From 25496403df7a2a5ee33abe0bcf446c4cd4c8dac3 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Mon, 10 Aug 2026 10:35:02 +0700 Subject: [PATCH 1/5] fix: update remaining time display threshold --- src/app/home/course/course.page.html | 273 +++++++++++++-------------- 1 file changed, 134 insertions(+), 139 deletions(-) diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index e1c740d..a78e69e 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -5,7 +5,7 @@ {{ course ?? 'Course' }} @if (courseProgress.duration > 0) { - {{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left + {{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left } @@ -16,167 +16,162 @@ @if (progressTimedOut) { - - - Session timed out. - - - Cannot save your progress. Please refresh the page to continue watching. - You're currently at {{ progressTimedOut }}. - - + + + Session timed out. + + + Cannot save your progress. Please refresh the page to continue watching. + You're currently at {{ progressTimedOut }}. + + } @if (progressNetworkError) { - - - Network Error. - - - Cannot save your progress. Please check your internet connection. - - + + + Network Error. + + + Cannot save your progress. Please check your internet connection. + + } @if (currentVideo) { - -

- {{ currentVideo.title }}@if (currentVideo.lecturer) { - - {{ currentVideo.lecturer }} - }
- - Set playback speed - - @if (currentVideo.sourceExternal?.includes('1.mp4')) { - - | + +

+ {{ currentVideo.title }}@if (currentVideo.lecturer) { + - {{ currentVideo.lecturer }} + }
+ + Set playback speed + + @if (currentVideo.sourceExternal?.includes('1.mp4')) { + + |
Play with Docchula Player - - } - @if (isAndroid && currentVideo.sourceExternal) { - - | Play with MX Player - - } - @if ((isIos || isAndroid) && currentVideo.sourceExternal) { - - | Play with VLC - - } -

-

- Hotkeys   Space: Pause, ▲/▼: Volume, ◄/►: Seek, F: Fullscreen -

-
- @if (currentVideo.sources.length === 0) { - - - Format not supported - - - Your browser can't play this video. Please try again using Google Chrome or Mozilla Firefox on Windows/Linux/macOS/Android. - - - } - @if (currentVideo.attachments.length !== 0) { - - - Attachments - - @for (attachment of currentVideo.attachments; track attachment.src) { - - {{ attachment.name }} - - - - - } - + + } + @if (isAndroid && currentVideo.sourceExternal) { + + | Play with MX Player + + } + @if ((isIos || isAndroid) && currentVideo.sourceExternal) { + + | Play with + VLC + + } +

+

+ Hotkeys   Space: Pause, ▲/▼: Volume, ◄/►: Seek, F: Fullscreen +

+ + @if (currentVideo.sources.length === 0) { + + + Format not supported + + + Your browser can't play this video. Please try again using Google Chrome or Mozilla Firefox on + Windows/Linux/macOS/Android. + + + } + @if (currentVideo.attachments.length !== 0) { + + + Attachments + + @for (attachment of currentVideo.attachments; track attachment.src) { + + {{ attachment.name }} + + + + } + + } }
- @if (filteredList$ | async; as list) { - @if (list.length === 0 && searchQuery) { -

No videos found matching "{{ searchQuery }}"

- } - - @for (lecture of list; track lecture.id) { - - - @if (lecture.date) { - {{ lecture.date | date:"dd MMM y" }} - } - @if (lecture.date) { - | - } - {{ lecture.title }} - - {{ lecture.lecturer }} - @if (lecture.durationInMin) { - - {{ lecture.durationInMin }} min - } - @if (lecture.history.end_time && lecture.history.end_time > 3) { - - @if (!lecture.duration) { - - - {{ (lecture.history.end_time) / 60 | number:'1.0-0' }} min played - - } - @if (lecture.duration && lecture.history.end_time / lecture.duration < 0.995) { - - - {{ (lecture.duration - lecture.history.end_time) / 60 | number:'1.0-0' }} min left - - } - - } - @if (lecture.attachments.length !== 0) { - - } - - @if (lecture.history.end_time && lecture.duration && lecture.history.end_time > 3 && (lecture.history.end_time / lecture.duration < 0.995)) { - + @if (list.length === 0 && searchQuery) { +

No videos found matching "{{ searchQuery }}"

+ } + + @for (lecture of list; track lecture.id) { + + + @if (lecture.date) { + {{ lecture.date | date:"dd MMM y" }} + } + @if (lecture.date) { + | + } + {{ lecture.title }} + + {{ lecture.lecturer }} + @if (lecture.durationInMin) { + - {{ lecture.durationInMin }} min + } + @if (lecture.history.end_time && lecture.history.end_time > 3) { + + @if (!lecture.duration) { + + - {{ (lecture.history.end_time) / 60 | number:'1.0-0' }} min played + } - - @if (lecture.history.end_time && lecture.duration && (lecture.history.end_time / lecture.duration >= 0.995)) { - + @if (lecture.duration && lecture.history.end_time / lecture.duration < 0.95) { + + - {{ (lecture.duration - lecture.history.end_time) / 60 | number:'1.0-0' }} min + left + } - @if (lecture.sources.length === 0) { - + } - @if (lastPlayedVideoKey === lecture.id) { - + @if (lecture.attachments.length !== 0) { + } - + + @if (lecture.history.end_time && lecture.duration && lecture.history.end_time > 3 && + (lecture.history.end_time / lecture.duration + < 0.995)) { + } +
+ @if (lecture.history.end_time && lecture.duration && (lecture.history.end_time / + lecture.duration >= 0.995)) { + } -
+ @if (lecture.sources.length === 0) { + + } + @if (lastPlayedVideoKey === lecture.id) { + + } + + } + }
- + \ No newline at end of file From 70aa0e07a18ca829c8603be5e382fa016ec3a2f1 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Mon, 10 Aug 2026 14:10:01 +0700 Subject: [PATCH 2/5] fix: update video completion and progress threshold to 95% --- src/app/home/course/course.page.html | 4 ++-- src/app/home/course/course.page.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index a78e69e..e61b9f7 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -152,13 +152,13 @@ @if (lecture.history.end_time && lecture.duration && lecture.history.end_time > 3 && (lecture.history.end_time / lecture.duration - < 0.995)) { } @if (lecture.history.end_time && lecture.duration && (lecture.history.end_time / - lecture.duration >= 0.995)) { + lecture.duration >= 0.95)) { } @if (lecture.sources.length === 0) { diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index e3fc7fd..0d64025 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -182,7 +182,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { // On video load, seek to last played position if (this.currentVideo.history.end_time && this.expectVideoTimeJump - && (!this.currentVideo.duration || (((this.currentVideo.history.end_time ?? 0) / this.currentVideo.duration) < 0.995))) { + && (!this.currentVideo.duration || (((this.currentVideo.history.end_time ?? 0) / this.currentVideo.duration) < 0.95))) { this.videoPlayer.currentTime(this.currentVideo.history.end_time); } this.expectVideoTimeJump = false; From f9e39359b8f672113ea898b8b48df78eb3ab10ba Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Mon, 10 Aug 2026 14:21:17 +0700 Subject: [PATCH 3/5] fix: keep seek-on-load threshold at 99.5% and code formatting --- src/app/home/course/course.page.ts | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index 0d64025..f8305c5 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -1,8 +1,8 @@ -import {AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, ViewChild} from '@angular/core'; -import {combineLatest, EMPTY, fromEvent, mergeAll, Observable, of, pairwise, startWith, Subject, takeUntil, throttleTime} from 'rxjs'; -import {ActivatedRoute, Router} from '@angular/router'; -import {CourseMembers, EvaluationRecord, Lecture, ManService} from '../../man.service'; -import {first, map, switchMap} from 'rxjs/operators'; +import { AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { combineLatest, EMPTY, fromEvent, mergeAll, Observable, of, pairwise, startWith, Subject, takeUntil, throttleTime } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CourseMembers, EvaluationRecord, Lecture, ManService } from '../../man.service'; +import { first, map, switchMap } from 'rxjs/operators'; import videojs from 'video.js'; import 'videojs-hotkeys'; import 'videojs-youtube'; @@ -31,15 +31,15 @@ import { IonToolbar, ModalController, } from '@ionic/angular/standalone'; -import {DomSanitizer} from '@angular/platform-browser'; -import {PlayHistory} from '../../play-tracker.service'; -import {addIcons} from "ionicons"; -import {checkmarkOutline, closeOutline, documentAttachOutline, download, pauseCircleOutline} from "ionicons/icons"; +import { DomSanitizer } from '@angular/platform-browser'; +import { PlayHistory } from '../../play-tracker.service'; +import { addIcons } from "ionicons"; +import { checkmarkOutline, closeOutline, documentAttachOutline, download, pauseCircleOutline } from "ionicons/icons"; import type Player from 'video.js/dist/types/player'; -import {ulid} from 'ulid'; -import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common'; -import {ModalEvaluationComponent} from './modal-evaluation.component'; -import {PomodoroTimerComponent} from '../../shared/pomodoro-timer.component'; +import { ulid } from 'ulid'; +import { AsyncPipe, DatePipe, DecimalPipe, NgClass } from '@angular/common'; +import { ModalEvaluationComponent } from './modal-evaluation.component'; +import { PomodoroTimerComponent } from '../../shared/pomodoro-timer.component'; @Component({ selector: 'app-course', @@ -182,7 +182,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { // On video load, seek to last played position if (this.currentVideo.history.end_time && this.expectVideoTimeJump - && (!this.currentVideo.duration || (((this.currentVideo.history.end_time ?? 0) / this.currentVideo.duration) < 0.95))) { + && (!this.currentVideo.duration || (((this.currentVideo.history.end_time ?? 0) / this.currentVideo.duration) < 0.995))) { this.videoPlayer.currentTime(this.currentVideo.history.end_time); } this.expectVideoTimeJump = false; @@ -274,7 +274,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { lecture.history = history[lecture.id] ?? ((lecture.id in evaluations && lecture.duration) ? { // If evaluation exists, treat as watched end_time: lecture.duration, played_at: null, - } : {end_time: null, played_at: null}); + } : { end_time: null, played_at: null }); if (lecture.duration) { progress.duration -= -lecture.duration; if (lecture.history.end_time) { @@ -370,7 +370,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { async openEvaluationModal() { const modal = await this.modalCtrl.create({ component: ModalEvaluationComponent, - componentProps: {video: this.currentVideo}, + componentProps: { video: this.currentVideo }, }); await modal.present(); } From 725905509ad5602127d13caadf8de3a7322a179c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 11 Aug 2026 18:56:55 +0700 Subject: [PATCH 4/5] feat: global search via GraphQL with course grouping by academic year - Add searchVideos() in ManService using GraphQL endpoint (POST /graphql) with variables to prevent injection and first:100 for full results - Add SearchVideoResult interface - Home page: global search across all courses with debounce (300ms), shows video title, lecturer, duration, course name, year badge - List page: scoped search within current year/NLE/conference only - Group courses on list page by academic year extracted from course name e.g. (2568), with Miscellaneous fallback for unlabelled courses - Course page: reads ?video= query param to auto-select video from search - Year badge color matches home page folder colors via colorByFolderName() - No eager loading - GraphQL fires only on user input --- src/app/home/course/course.page.ts | 20 ++++- src/app/home/home.page.html | 51 +++++++++++- src/app/home/home.page.scss | 115 +++++++++++++++++++++++++ src/app/home/home.page.ts | 84 +++++++++++++++++-- src/app/home/list/list.page.html | 80 +++++++++++++++--- src/app/home/list/list.page.scss | 129 ++++++++++++++++++++++++++++- src/app/home/list/list.page.ts | 124 ++++++++++++++++++++++++--- src/app/man.service.ts | 32 +++++++ 8 files changed, 600 insertions(+), 35 deletions(-) diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index f8305c5..5773431 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -2,7 +2,7 @@ import { AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, ViewCh import { combineLatest, EMPTY, fromEvent, mergeAll, Observable, of, pairwise, startWith, Subject, takeUntil, throttleTime } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { CourseMembers, EvaluationRecord, Lecture, ManService } from '../../man.service'; -import { first, map, switchMap } from 'rxjs/operators'; +import { first, map, switchMap, take } from 'rxjs/operators'; import videojs from 'video.js'; import 'videojs-hotkeys'; import 'videojs-youtube'; @@ -88,6 +88,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { year: string; course: string; courseId?: string; + videoIdFromSearch: string | null = null; list$: Observable; filteredList$: Observable; courseProgress = { @@ -114,6 +115,11 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { } ngOnInit() { + // Read optional `video` query param set by global search navigation + this.route.queryParamMap.pipe(take(1)).subscribe(qp => { + this.videoIdFromSearch = qp.get('video'); + }); + this.list$ = this.route.paramMap.pipe( first(), switchMap(s => { @@ -130,7 +136,17 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { } this.year = courseData.category; this.course = courseData.name; - return this.mergeVideoInfo(courseData.lectures, history?.records ?? {}, history?.evaluations ?? {}); + const lectures = this.mergeVideoInfo(courseData.lectures, history?.records ?? {}, history?.evaluations ?? {}); + // Auto-select video from global search navigation + if (this.videoIdFromSearch) { + const target = lectures.find(l => String(l.id) === this.videoIdFromSearch); + if (target) { + // Defer to allow AfterViewInit to complete first + setTimeout(() => this.viewVideo(target), 100); + this.videoIdFromSearch = null; + } + } + return lectures; })); } else if (this.year) { this.router.navigate(['home/' + this.year]); diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 00a3e5b..c57b461 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -10,11 +10,60 @@ + + +
- @if (response$ | async; as response) { + @if (searchQuery.trim()) { + +
+ @if (isSearching) { +
+ + Searching... +
+ } @else if (searchResults$ | async; as results) { + @if (results.length === 0) { +
+

No videos found for “{{ searchQuery }}”

+
+ } @else { +

{{ results.length }} result{{ results.length !== 1 ? 's' : '' }} for “{{ searchQuery }}”

+ + @for (result of results; track result.id) { + + +

{{ result.title }}

+

+ @if (result.lecturer) { + {{ result.lecturer }} + } + @if (result.courseName) { + {{ result.courseName }} + } + @if (result.duration) { + {{ formatDuration(result.duration) }} + } + @if (result.courseYear) { + {{ result.courseYear }} + } +

+
+
+ } +
+ } + } +
+ } @else if (response$ | async; as response) { @if (response.last_played) { diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 9879ce2..e1ad0f2 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -1,5 +1,6 @@ main { min-height: 60vh; + @media screen and (min-width: 768px) { min-height: 70vh; } @@ -34,12 +35,14 @@ ion-card-title { .small-text { color: #aaaaaa; + a { color: #bbbbbb; } @media (prefers-color-scheme: dark) { color: #555555; + a { color: #666666; } @@ -72,4 +75,116 @@ p { .text-sign-out { margin-right: 0.5rem; +} + +/* ── Global Search Results ── */ +.search-results-container { + padding-top: 0.5rem; +} + +.search-status { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem 1rem; + gap: 0.75rem; + color: var(--ion-color-medium); + + ion-spinner { + width: 2rem; + height: 2rem; + } + + p { + margin: 0; + } +} + +.result-count { + padding: 0 0.5rem 0.25rem !important; + font-size: 0.8rem; + color: var(--ion-color-medium) !important; + text-align: left !important; +} + +.search-list { + border-radius: 8px; + overflow: hidden; + + ion-item::part(detail-icon) { + align-self: bottom; + } +} + +.video-title { + font-weight: 500; + white-space: normal !important; + line-height: 1.4; +} + +.video-meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; + white-space: normal !important; +} + +.lecturer { + color: var(--ion-color-medium); + font-size: 0.85rem; + text-align: left; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.duration-badge { + flex-shrink: 0; + background: var(--ion-color-light); + color: var(--ion-color-medium); + border-radius: 4px; + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + + @media (prefers-color-scheme: dark) { + background: var(--ion-color-dark-tint); + } +} + +.course-info { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; + white-space: normal !important; +} + +.course-name-label { + font-size: 0.8rem; + color: var(--ion-color-primary); + font-weight: 500; + text-align: left; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (prefers-color-scheme: dark) { + color: var(--ion-color-primary-tint); + } +} + +.course-year-badge { + flex-shrink: 0; + color: #fff; + border-radius: 4px; + padding: 0.05rem 0.4rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; } \ No newline at end of file diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 4a3b9c4..d2e6b67 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -1,11 +1,12 @@ import {Component, inject, OnInit} from '@angular/core'; -import {Observable} from 'rxjs'; -import {CourseListResponse, Lecture, ManService} from '../man.service'; +import {Observable, Subject, combineLatest, of} from 'rxjs'; +import {CourseListResponse, Lecture, ManService, SearchVideoResult} from '../man.service'; import {Router, RouterLink} from '@angular/router'; import {AuthService} from '../auth.service'; import {colorByFolderName} from '../../helpers'; import {addIcons} from "ionicons"; -import {logOutOutline} from "ionicons/icons"; +import {logOutOutline, searchOutline} from "ionicons/icons"; +import {debounceTime, distinctUntilChanged, map, switchMap, tap} from 'rxjs/operators'; import { IonButton, IonButtons, @@ -20,7 +21,9 @@ import { IonIcon, IonItem, IonLabel, + IonList, IonRow, + IonSearchbar, IonSpinner, IonText, IonTitle, @@ -28,11 +31,21 @@ import { } from '@ionic/angular/standalone'; import {AsyncPipe, NgStyle} from '@angular/common'; +export interface EnrichedSearchResult extends SearchVideoResult { + courseName?: string; + courseYear?: string; +} + @Component({ selector: 'app-home', templateUrl: 'home.page.html', styleUrls: ['home.page.scss'], - imports: [IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, IonCardContent, IonItem, IonLabel, IonText, IonSpinner] + imports: [ + IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, + IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, + IonCardHeader, IonCardTitle, AsyncPipe, IonCardContent, IonItem, + IonLabel, IonText, IonSpinner, IonSearchbar, IonList, + ] }) export class HomePage implements OnInit { private manService = inject(ManService); @@ -40,9 +53,17 @@ export class HomePage implements OnInit { private authService = inject(AuthService); response$: Observable; + searchQuery = ''; + searchResults$: Observable = of([]); + isSearching = false; + + /** Map of course_id (string) → { name, year } built from video list */ + private courseLookup = new Map(); + + private searchInput$ = new Subject(); constructor() { - addIcons({logOutOutline}); + addIcons({logOutOutline, searchOutline}); } logout() { @@ -53,13 +74,64 @@ export class HomePage implements OnInit { ngOnInit() { this.response$ = this.manService.getVideoList(); + + // Build course lookup map once video list loads + this.response$.subscribe(response => { + if (!response?.years) return; + this.courseLookup.clear(); + for (const year of Object.keys(response.years)) { + for (const course of response.years[year]) { + this.courseLookup.set(String(course.id), {name: course.name, year}); + } + } + }); + + this.searchResults$ = this.searchInput$.pipe( + debounceTime(300), + distinctUntilChanged(), + tap(() => this.isSearching = true), + switchMap(query => { + if (!query.trim()) { + this.isSearching = false; + return of([]); + } + return this.manService.searchVideos(query).pipe( + map(results => results.map(r => ({ + ...r, + courseName: this.courseLookup.get(r.course_id)?.name, + courseYear: this.courseLookup.get(r.course_id)?.year, + }))) + ); + }), + tap(() => this.isSearching = false), + ); + } + + onSearchChange(event: Event) { + const target = event.target as HTMLIonSearchbarElement; + this.searchQuery = target.value ?? ''; + this.searchInput$.next(this.searchQuery); + } + + goToVideo(result: SearchVideoResult) { + this.router.navigate(['home', 'course', result.course_id], { + queryParams: {video: result.id} + }); } protected readonly colorByFolderName = colorByFolderName; + protected readonly Object = Object; goToLastVideo(lastVideo: Lecture) { return this.router.navigate(['home', 'course', lastVideo.course.id]); } - protected readonly Object = Object; + formatDuration(seconds: number): string { + if (!seconds) return ''; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + const rem = m % 60; + return rem > 0 ? `${h}h ${rem}m` : `${h}h`; + } } diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index 4a3a5df..e68cb09 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -5,23 +5,77 @@ {{ year || 'Loading'}} + + + - @if (list$ | async; as list) { - @if (list) { - - @for (course of list; track course.id) { - - - @if (course.is_remote) { - MDCU E-Learning - } - {{ course.name }} - - + @if (searchQuery.trim()) { + +
+ @if (isSearching) { +
+ + Searching... +
+ } @else if (searchResults$ | async; as results) { + @if (results.length === 0) { +
+

No videos found for “{{ searchQuery }}”

+
+ } @else { +

{{ results.length }} result{{ results.length !== 1 ? 's' : '' }} for “{{ searchQuery }}”

+ + @for (result of results; track result.id) { + + +

{{ result.title }}

+

+ @if (result.lecturer) { + {{ result.lecturer }} + } + @if (result.courseName) { + {{ result.courseName }} + } + @if (result.duration) { + {{ formatDuration(result.duration) }} + } + @if (result.courseYear) { + {{ result.courseYear }} + } +

+
+
+ } +
} - + } +
+ } @else if (groupedList$ | async; as groups) { + @if (groups.length) { + @for (group of groups; track group.year) { + + {{ group.year }} + + + @for (course of group.courses; track course.id) { + + + @if (course.is_remote) { + MDCU E-Learning + } + {{ course.name }} + + + } + + } } @else {

Empty.

} diff --git a/src/app/home/list/list.page.scss b/src/app/home/list/list.page.scss index 17dd012..edf08b3 100644 --- a/src/app/home/list/list.page.scss +++ b/src/app/home/list/list.page.scss @@ -1,4 +1,4 @@ -ion-title > span { +ion-title>span { padding: 0.2em 0.5em; color: white; } @@ -7,3 +7,130 @@ ion-label { text-overflow: initial !important; white-space: normal !important; } + +.e-learning-tag { + margin: 0 1rem; +} + +.group-header { + font-size: 1.1rem; + font-weight: 700; + color: var(--ion-color-dark); + margin-top: 0.5rem; + + @media (prefers-color-scheme: dark) { + color: var(--ion-color-light); + } +} + +/* ── Global Search Results ── */ +.search-results-container { + padding-top: 0.5rem; +} + +.search-status { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem 1rem; + gap: 0.75rem; + color: var(--ion-color-medium); + + ion-spinner { + width: 2rem; + height: 2rem; + } + + p { + margin: 0; + text-align: center; + } +} + +.result-count { + padding: 0.5rem 1rem 0.25rem; + font-size: 0.8rem; + color: var(--ion-color-medium); +} + +.search-list { + border-radius: 8px; + overflow: hidden; + + ion-item::part(detail-icon) { + align-self: bottom; + } +} + +.video-title { + font-weight: 500; + white-space: normal !important; + line-height: 1.4; +} + +.video-meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; + white-space: normal !important; +} + +.lecturer { + color: var(--ion-color-medium); + font-size: 0.85rem; + text-align: left; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.duration-badge { + flex-shrink: 0; + background: var(--ion-color-light); + color: var(--ion-color-medium); + border-radius: 4px; + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + + @media (prefers-color-scheme: dark) { + background: var(--ion-color-dark-tint); + } +} + +.course-info { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; + white-space: normal !important; +} + +.course-name-label { + font-size: 0.8rem; + color: var(--ion-color-primary); + font-weight: 500; + text-align: left; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (prefers-color-scheme: dark) { + color: var(--ion-color-primary-tint); + } +} + +.course-year-badge { + flex-shrink: 0; + color: #fff; + border-radius: 4px; + padding: 0.05rem 0.4rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; +} \ No newline at end of file diff --git a/src/app/home/list/list.page.ts b/src/app/home/list/list.page.ts index 1662678..6dde55c 100644 --- a/src/app/home/list/list.page.ts +++ b/src/app/home/list/list.page.ts @@ -1,17 +1,38 @@ -import {Component, inject, OnInit} from '@angular/core'; -import {ActivatedRoute, Router, RouterLink} from '@angular/router'; -import {EMPTY, Observable} from 'rxjs'; -import {map, switchMap} from 'rxjs/operators'; -import {ManService} from '../../man.service'; -import {colorByFolderName} from '../../../helpers'; -import {IonBackButton, IonContent, IonHeader, IonItem, IonLabel, IonList, IonTitle, IonToolbar} from '@ionic/angular/standalone'; -import {AsyncPipe, NgStyle} from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { EMPTY, Observable, Subject, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; +import { ManService, SearchVideoResult } from '../../man.service'; +import { colorByFolderName } from '../../../helpers'; +import { + IonBackButton, + IonContent, + IonHeader, + IonItem, + IonLabel, + IonList, + IonListHeader, + IonSearchbar, + IonSpinner, + IonTitle, + IonToolbar +} from '@ionic/angular/standalone'; +import { AsyncPipe, NgStyle } from '@angular/common'; + +export interface EnrichedSearchResult extends SearchVideoResult { + courseName?: string; + courseYear?: string; +} @Component({ selector: 'app-list', templateUrl: './list.page.html', styleUrls: ['./list.page.scss'], - imports: [IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, IonContent, IonList, IonItem, IonLabel, AsyncPipe] + imports: [ + IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, + IonContent, IonList, IonListHeader, IonItem, IonLabel, AsyncPipe, + IonSearchbar, IonSpinner, + ] }) export class ListPage implements OnInit { private route = inject(ActivatedRoute); @@ -19,10 +40,19 @@ export class ListPage implements OnInit { private manService = inject(ManService); year: string; - list$: Observable<{ name: string, is_remote: boolean, id: number, link: string[] }[]>; + groupedList$: Observable<{ year: string, courses: { name: string, is_remote: boolean, id: number, link: string[] }[] }[]>; + + searchQuery = ''; + searchResults$: Observable = of([]); + isSearching = false; + + /** Map of course_id (string) → { name, year } built from video list */ + private courseLookup = new Map(); + + private searchInput$ = new Subject(); ngOnInit() { - this.list$ = this.route.paramMap.pipe( + this.groupedList$ = this.route.paramMap.pipe( switchMap(s => { const year = s.get('year'); this.year = year; @@ -31,14 +61,84 @@ export class ListPage implements OnInit { return EMPTY; } return this.manService.getVideoList().pipe(map(list => { - return list?.years[year]?.map(course => ({ + // Build course lookup while we have the data + if (list?.years) { + this.courseLookup.clear(); + for (const y of Object.keys(list.years)) { + for (const course of list.years[y]) { + this.courseLookup.set(String(course.id), { name: course.name, year: y }); + } + } + } + const courses = list?.years[year]?.map(course => ({ ...course, link: ['/', 'home', 'course', String(course.id)] })) ?? []; + return this.groupByAcademicYear(courses); })); }) ); + + this.searchResults$ = this.searchInput$.pipe( + debounceTime(300), + distinctUntilChanged(), + tap(() => this.isSearching = true), + switchMap(query => { + if (!query.trim()) { + this.isSearching = false; + return of([]); + } + return this.manService.searchVideos(query).pipe( + map(results => results.map(r => ({ + ...r, + courseName: this.courseLookup.get(r.course_id)?.name, + courseYear: this.courseLookup.get(r.course_id)?.year, + })).filter(r => r.courseYear === this.year)) + ); + }), + tap(() => this.isSearching = false), + ); + } + + onSearchChange(event: Event) { + const target = event.target as HTMLIonSearchbarElement; + this.searchQuery = target.value ?? ''; + this.searchInput$.next(this.searchQuery); + } + + goToVideo(result: SearchVideoResult) { + this.router.navigate(['home', 'course', result.course_id], { + queryParams: { video: result.id } + }); + } + + formatDuration(seconds: number): string { + if (!seconds) return ''; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + const rem = m % 60; + return rem > 0 ? `${h}h ${rem}m` : `${h}h`; } protected readonly colorByFolderName = colorByFolderName; + + private groupByAcademicYear(courses: { name: string, is_remote: boolean, id: number, link: string[] }[]) { + const groups = new Map(); + for (const course of courses) { + const match = course.name.match(/\((25\d{2})\)/); + const yearKey = match ? match[1] : 'Miscellaneous'; + if (!groups.has(yearKey)) { + groups.set(yearKey, []); + } + groups.get(yearKey).push(course); + } + // Sort year keys descending (numeric years first, 'Miscellaneous' last) + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (a === 'Miscellaneous') return 1; + if (b === 'Miscellaneous') return -1; + return Number(b) - Number(a); + }); + return sortedKeys.map(year => ({ year, courses: groups.get(year) })); + } } diff --git a/src/app/man.service.ts b/src/app/man.service.ts index 9131224..5639d93 100644 --- a/src/app/man.service.ts +++ b/src/app/man.service.ts @@ -173,6 +173,30 @@ export class ManService { return of(null); } + searchVideos(query: string): Observable { + if (!query.trim()) { + return of([]); + } + const keyword = '%' + query.trim() + '%'; + const body = { + query: `query SearchVideos($title: String!) { + videos(title: $title, first: 100) { + data { id title lecturer duration course_id } + } + }`, + variables: { title: keyword } + }; + if (this.httpOptions.headers.get('Authorization').length < 30) { + console.error('ManService ID token is not set.'); + return of([]); + } + return this.http.post<{ data: { videos: { data: SearchVideoResult[] } } }>( + this.getEndpointLocation() + 'graphql', + body, + this.httpOptions + ).pipe(map(response => response?.data?.videos?.data ?? [])); + } + /*updateCurrentStudent(requestBody) { if (!this.email) { console.error('ManService user email is not set.'); @@ -259,3 +283,11 @@ export interface JSend { message?: string; data?: A; } + +export interface SearchVideoResult { + id: string; + title: string; + lecturer: string; + duration: number; + course_id: string; +} From 66950a065506784236e4ea2c7feb1d8bf2b3bb17 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Fri, 14 Aug 2026 13:36:55 +0700 Subject: [PATCH 5/5] fix: prevent scrollbar oscillation on 2K displays (#57) --- src/app/home/course/course.page.html | 1 - src/app/home/course/course.page.scss | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index 19db52f..bac1407 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -1,4 +1,3 @@ - diff --git a/src/app/home/course/course.page.scss b/src/app/home/course/course.page.scss index c0334b3..37c02b7 100644 --- a/src/app/home/course/course.page.scss +++ b/src/app/home/course/course.page.scss @@ -47,6 +47,7 @@ span.date-divider { .scroll-area { height: calc(100vh - 70px); overflow-y: auto; + scrollbar-gutter: stable; padding-right: 0.5rem; } }