Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6a81396
feat: add a themeable appearance system
Punnawit9285 Sep 5, 2026
3e6e9a6
feat: add theme shades, saved colours and smooth transitions
Punnawit9285 Sep 5, 2026
5ce1072
feat: sync the theme across devices, except the background picture
Punnawit9285 Oct 5, 2026
e8a9b9b
perf: hold study sync to one write per 30 minutes per device
Punnawit9285 Oct 5, 2026
a2940d6
feat: return the theme to plain light when defaults are restored
Punnawit9285 Oct 5, 2026
e5e764e
feat: keep every background picture and add a slideshow
Punnawit9285 Oct 5, 2026
6356f43
fix: let the theme editor scroll to its last control
Punnawit9285 Oct 6, 2026
7525f55
feat: give each theme colour one job and simplify the editor
Punnawit9285 Oct 6, 2026
bd00a27
feat: move custom Light/Dark under Primary and add seven colour templ…
Punnawit9285 Oct 6, 2026
1934875
refactor(theme): update presets and editor settings
Punnawit9285 Oct 6, 2026
04efe24
Merge remote-tracking branch 'upstream/master' into themes-split
Punnawit9285 Oct 6, 2026
12c12b9
test: expect year colours from the theme
Punnawit9285 Oct 6, 2026
ce0e041
refactor(theme): align template and style edit anchors with settings …
Punnawit9285 Oct 6, 2026
654162b
refactor(theme): add card container and inset padding to background c…
Punnawit9285 Oct 6, 2026
eac0f9e
refactor(theme): sync through FlickMan instead of Firestore
Punnawit9285 Oct 7, 2026
1fc150f
Merge remote-tracking branch 'upstream/master' into themes-split
Punnawit9285 Oct 10, 2026
c11e3a7
Merge remote-tracking branch 'upstream/master' into themes-split
Punnawit9285 Oct 10, 2026
09041c7
Merge remote-tracking branch 'origin/master' into themes
keenthekeen Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ npm-debug.log*

# Local editor and tool settings
.claude/
/.agents
AGENTS.md
CLAUDE.md
*.local.json
3 changes: 3 additions & 0 deletions src/app/home/course/course.page.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<ion-header>
<ion-toolbar>
<ion-back-button slot="start" [defaultHref]="'/home/'+year"></ion-back-button>
<ion-buttons slot="end">
<app-theme-menu></app-theme-menu>
</ion-buttons>
<ion-title>
{{ course ?? 'Course' }}
@if (courseProgress.duration > 0) {
Expand Down
19 changes: 5 additions & 14 deletions src/app/home/course/course.page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ span a {
}

ion-title small {
color: gray;
color: var(--flick-muted-text);
}

.e-learning-tag {
Expand All @@ -12,30 +12,21 @@ ion-title small {

span.date {
font-family: monospace, monospace;
color: darkblue;
@media (prefers-color-scheme: dark) {
color: lightblue;
}
color: var(--flick-companion-text);
}

span.date-divider {
margin: 0 0.2rem 0 0.2rem;
opacity: 0.5;
color: darkblue;
@media (prefers-color-scheme: dark) {
color: lightblue;
}
color: var(--flick-companion-text);
}

.time-info {
color: darkgoldenrod;
@media (prefers-color-scheme: dark) {
color: yellow;
}
color: var(--flick-warning-text);
}

.ion-color .check-icon {
background-color: white;
background-color: var(--ion-color-contrast);
}

.set-playback-speed-button {
Expand Down
4 changes: 4 additions & 0 deletions src/app/home/course/course.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AlertController,
IonBackButton,
IonButton,
IonButtons,
IonCard,
IonCardContent,
IonCardHeader,
Expand Down Expand Up @@ -41,6 +42,7 @@ import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common';
import {ModalEvaluationComponent} from './modal-evaluation.component';
import {confirmAiDisclaimer, ModalDocumentComponent} from './modal-document.component';
import {PomodoroTimerComponent} from '../../shared/pomodoro-timer.component';
import {ThemeMenuComponent} from '../../shared/theme-menu.component';
import {Analytics, logEvent} from '@angular/fire/analytics';
import {ConsentService} from '../../consent.service';

Expand All @@ -59,6 +61,7 @@ interface SeekBarPointerHandlers {
IonHeader,
IonToolbar,
IonBackButton,
IonButtons,
IonTitle,
IonContent,
IonGrid,
Expand All @@ -76,6 +79,7 @@ interface SeekBarPointerHandlers {
IonItem,
IonIcon,
IonSearchbar,
ThemeMenuComponent,
NgClass,
IonProgressBar,
AsyncPipe,
Expand Down
2 changes: 1 addition & 1 deletion src/app/home/course/modal-evaluation.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</ion-header>
<ion-content class="ion-padding">
@if (video) {
<div style="border-left: 4px solid #1976d2; padding-left: 1rem;">
<div style="border-left: 4px solid var(--ion-color-primary); padding-left: 1rem;">
<span style="font-weight: bold">Lecture:</span> {{ video.title }}<br/>
<span style="font-weight: bold">Instructor:</span> {{ video.lecturer }}
</div>
Expand Down
8 changes: 6 additions & 2 deletions src/app/home/home.page.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<ion-header [translucent]="true">
<ion-toolbar>
<ion-buttons slot="end">
<app-theme-menu></app-theme-menu>
</ion-buttons>
<ion-title>
MDCU Recorded Lecture
</ion-title>
Expand Down Expand Up @@ -115,7 +118,7 @@ <h2 class="video-title">{{ result.title }}</h2>
<span class="duration-badge">{{ formatDuration(result.duration) }}</span>
}
@if (result.courseYear) {
<span class="course-year-badge" [ngStyle]="{'background-color': colorByFolderName(result.courseYear)}">{{ result.courseYear }}</span>
<span class="course-year-badge" [ngStyle]="{'background-color': colorByFolderName(result.courseYear), 'color': contrastByFolderName(result.courseYear)}">{{ result.courseYear }}</span>
}
</p>
</ion-label>
Expand Down Expand Up @@ -145,7 +148,8 @@ <h2>{{ response.last_played.video.course.name }}</h2>
<ion-row>
@for (folder of Object.keys(response.years); track folder) {
<ion-col size-xs="12" size-sm="6" size-md="4" size-lg="3" size-xl="2">
<ion-card [routerLink]="folder" [ngStyle]="{'background-color': colorByFolderName(folder)}"
<ion-card [routerLink]="folder"
[ngStyle]="{'background-color': colorByFolderName(folder), 'color': contrastByFolderName(folder)}"
class="ion-text-center hover-card">
<ion-card-header>
<ion-card-title>{{ folder }}</ion-card-title>
Expand Down
41 changes: 11 additions & 30 deletions src/app/home/home.page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,15 @@ ion-card {
min-height: 4rem;
}

ion-card-title {
color: white;
/* Ionic gives the header and title a colour of their own, so both have to defer to the
card, which carries the group's readable label colour. */
.hover-card ion-card-header,
.hover-card ion-card-title {
color: inherit;
}

.course-name {
color: #222222;
}

@media (prefers-color-scheme: dark) {
.course-name {
color: #cccccc;
}
color: var(--ion-text-color);
}

.center-text {
Expand All @@ -36,18 +33,10 @@ ion-card-title {
}

.small-text {
color: #aaaaaa;
color: var(--flick-muted-text);

a {
color: #bbbbbb;
}

@media (prefers-color-scheme: dark) {
color: #555555;

a {
color: #666666;
}
color: var(--flick-muted-text);
}
}

Expand All @@ -62,12 +51,12 @@ ion-card-title {

p {
padding: 0 1.5rem 0 1.5rem;
color: gray;
color: var(--flick-muted-text);
text-align: center;

ion-icon {
margin: 0 0.6em;
color: gray;
color: var(--flick-muted-text);
}

a {
Expand Down Expand Up @@ -195,10 +184,6 @@ p {
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 {
Expand All @@ -219,21 +204,17 @@ p {
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;
}

.heading-path {
font-size: 0.8rem;
color: var(--ion-color-medium) !important;
Expand Down
5 changes: 4 additions & 1 deletion src/app/home/home.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Observable, of, Subject, timer} from 'rxjs';
import {CourseListResponse, DocumentSearchHit, Lecture, ManService, PlayStats, SearchVideoResult} from '../man.service';
import {Router, RouterLink} from '@angular/router';
import {AuthService} from '../auth.service';
import {colorByFolderName, formatDuration, snippetToHtml} from '../../helpers';
import {colorByFolderName, contrastByFolderName, formatDuration, snippetToHtml} from '../../helpers';
import {addIcons} from "ionicons";
import {documentTextOutline, filmOutline, logOutOutline, playOutline, searchOutline} from "ionicons/icons";
import {catchError, debounce, distinctUntilChanged, map, switchMap, tap} from 'rxjs/operators';
Expand Down Expand Up @@ -34,6 +34,7 @@ import {
ModalController,
} from '@ionic/angular';
import {AsyncPipe, DatePipe, NgStyle} from '@angular/common';
import {ThemeMenuComponent} from '../shared/theme-menu.component';
import {Analytics, logEvent} from '@angular/fire/analytics';
import {ConsentService} from '../consent.service';
import {confirmAiDisclaimer, ModalDocumentComponent} from './course/modal-document.component';
Expand Down Expand Up @@ -66,6 +67,7 @@ export type SearchResults =
IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon,
IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle,
IonCardHeader, IonCardTitle, AsyncPipe, DatePipe, IonCardContent, IonItem,
ThemeMenuComponent,
IonLabel, IonText, IonSpinner, IonSearchbar, IonList, IonSegment, IonSegmentButton,
]
})
Expand Down Expand Up @@ -223,6 +225,7 @@ export class HomePage implements OnInit {
}

protected readonly colorByFolderName = colorByFolderName;
protected readonly contrastByFolderName = contrastByFolderName;
protected readonly formatDay = formatDay;
protected readonly Object = Object;

Expand Down
7 changes: 5 additions & 2 deletions src/app/home/list/list.page.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-back-button slot="start" defaultHref="/home"></ion-back-button>
<ion-buttons slot="end">
<app-theme-menu></app-theme-menu>
</ion-buttons>
<ion-title>
<span [ngStyle]="{'background-color': colorByFolderName(year)}">{{ year || 'Loading'}}</span>
<span [ngStyle]="{'background-color': colorByFolderName(year), 'color': contrastByFolderName(year)}">{{ year || 'Loading'}}</span>
</ion-title>
</ion-toolbar>
<ion-toolbar>
Expand Down Expand Up @@ -47,7 +50,7 @@ <h2 class="video-title">{{ result.title }}</h2>
<span class="duration-badge">{{ formatDuration(result.duration) }}</span>
}
@if (result.courseYear) {
<span class="course-year-badge" [ngStyle]="{'background-color': colorByFolderName(result.courseYear)}">{{ result.courseYear }}</span>
<span class="course-year-badge" [ngStyle]="{'background-color': colorByFolderName(result.courseYear), 'color': contrastByFolderName(result.courseYear)}">{{ result.courseYear }}</span>
}
</p>
</ion-label>
Expand Down
10 changes: 0 additions & 10 deletions src/app/home/list/list.page.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
ion-title>span {
padding: 0.2em 0.5em;
color: white;
}

ion-label {
Expand Down Expand Up @@ -94,10 +93,6 @@ ion-label {
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 {
Expand All @@ -118,16 +113,11 @@ ion-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

@media (prefers-color-scheme: dark) {
color: var(--ion-color-primary-tint);
}
}

.course-year-badge {
grid-column: 4;
flex-shrink: 0;
color: #fff;
border-radius: 4px;
padding: 0.05rem 0.4rem;
font-size: 0.7rem;
Expand Down
7 changes: 6 additions & 1 deletion src/app/home/list/list.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {EMPTY, Observable, of, Subject} from 'rxjs';
import {debounceTime, distinctUntilChanged, map, switchMap, tap} from 'rxjs/operators';
import {ManService, SearchVideoResult} from '../../man.service';
import {colorByFolderName} from '../../../helpers';
import {colorByFolderName, contrastByFolderName} from '../../../helpers';
import {
IonBackButton,
IonButtons,
IonContent,
IonHeader,
IonItem,
Expand All @@ -20,6 +21,7 @@ import {
import {AsyncPipe, NgStyle} from '@angular/common';
import {Analytics, logEvent} from '@angular/fire/analytics';
import {ConsentService} from '../../consent.service';
import {ThemeMenuComponent} from '../../shared/theme-menu.component';

export interface EnrichedSearchResult extends SearchVideoResult {
courseName?: string;
Expand All @@ -32,7 +34,9 @@ export interface EnrichedSearchResult extends SearchVideoResult {
styleUrls: ['./list.page.scss'],
imports: [
IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle,
IonButtons,
IonContent, IonList, IonListHeader, IonItem, IonLabel, AsyncPipe,
ThemeMenuComponent,
IonSearchbar, IonSpinner,
]
})
Expand Down Expand Up @@ -129,6 +133,7 @@ export class ListPage implements OnInit {
}

protected readonly colorByFolderName = colorByFolderName;
protected readonly contrastByFolderName = contrastByFolderName;

private groupByAcademicYear(courses: { name: string, is_remote: boolean, id: number, link: string[] }[]) {
const groups = new Map<string, typeof courses>();
Expand Down
26 changes: 26 additions & 0 deletions src/app/man.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,21 @@ export class ManService {
return this.get<object>('v1/auth_check').pipe(timeout(8000), map(a => a.hasOwnProperty('success')));
}

/** The settings synced across the user's devices (see UserSyncService), or null when they can't be fetched. */
getUserSettings(): Observable<object | null> {
return this.get<JSend<object>>('v1/user_settings').pipe(map(response => response?.data ?? null));
}

/** Merge a change into the synced settings. `keepalive` lets the request finish while the page closes. */
saveUserSettings(patch: object): Observable<JSend<null>> {
return this.post<JSend<null>>('v1/user_settings', patch, {keepalive: true}).pipe(map(response => {
if (!response) {
throw new Error('ManService could not send the user settings.');
}
return response;
}));
}

changeEndpoint() {
this.endpoint.push(this.endpoint.shift());
}
Expand All @@ -204,6 +219,17 @@ export class ManService {
return of(null);
}

post<T>(path: string, body: object, options?: object): Observable<T> {
if (this.httpOptions.headers.get('Authorization').length < 30) {
console.error('ManService ID token is not set.');
} else if (!this.getEndpointLocation()) {
console.error('ManService endpoint is not set.');
} else {
return this.http.post<T>(this.getEndpointLocation() + path, body, {...this.httpOptions, ...options});
}
return of(null);
}

/**
* Runs a GraphQL operation. Emits null when no ID token is set, and errors when the response has errors and no data.
*/
Expand Down
Loading
Loading