From 359d476eaeba8b4ea63be4aca386555c6795a5d4 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 21 Jul 2026 11:38:25 +0700 Subject: [PATCH 1/3] Pink 1st --- pnpm-workspace.yaml | 8 + src/app/home/course/course.page.scss | 45 +++++ src/app/home/home.page.html | 5 +- src/app/home/home.page.scss | 65 +++++++ src/app/home/home.page.ts | 17 +- src/app/home/list/list.page.scss | 25 +++ src/app/theme.service.ts | 43 +++++ src/app/welcome/welcome.page.scss | 23 +++ src/global.scss | 19 +++ src/helpers.ts | 15 ++ src/theme/variables.scss | 245 +++++++++++++++++++++++++++ 11 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 src/app/theme.service.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d5cb679 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +allowBuilds: + '@firebase/util': set this to true or false + '@parcel/watcher': set this to true or false + core-js: set this to true or false + esbuild: set this to true or false + lmdb: set this to true or false + msgpackr-extract: set this to true or false + protobufjs: set this to true or false diff --git a/src/app/home/course/course.page.scss b/src/app/home/course/course.page.scss index cd800bf..3e82f67 100644 --- a/src/app/home/course/course.page.scss +++ b/src/app/home/course/course.page.scss @@ -50,3 +50,48 @@ span.date-divider { } } } + +/* Pink Theme overrides */ + +:host-context(body.pink-theme) { + span.date { + color: #AD1457; + } + + span.date-divider { + color: #AD1457; + } + + .time-info { + color: #E91E90; + } + + ion-title small { + color: #B07090; + } + + .set-playback-speed-button { + --ion-color-primary: #E91E90; + } +} + +@media (prefers-color-scheme: dark) { + :host-context(body.pink-theme) { + span.date { + color: #F48FB1; + } + + span.date-divider { + color: #F48FB1; + } + + .time-info { + color: #F06292; + } + + ion-title small { + color: #C48BA0; + } + } +} + diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 00a3e5b..2e44eca 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -4,6 +4,9 @@ MDCU Recorded Lecture + + + Sign out @@ -34,7 +37,7 @@

{{ response.last_played.video.course.name }}

@for (folder of Object.keys(response.years); track folder) { - {{ folder }} diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 9879ce2..0c17234 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -72,4 +72,69 @@ p { .text-sign-out { margin-right: 0.5rem; +} + +/* Pink Theme overrides */ + +:host-context(body.pink-theme) { + ion-card-title { + color: white; + text-shadow: 0 1px 3px rgba(136, 14, 79, 0.3); + } + + .hover-card { + box-shadow: 0 2px 12px rgba(233, 30, 144, 0.2); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-0.5rem) scale(1.02); + box-shadow: 0 6px 20px rgba(233, 30, 144, 0.35); + } + } + + .center-spinner { + color: #E91E90; + } + + p { + color: #B07090; + + ion-icon { + color: #B07090; + } + } + + .small-text { + color: #C48BA0; + a { + color: #D4A0B5; + } + } +} + +@media (prefers-color-scheme: dark) { + :host-context(body.pink-theme) { + .small-text { + color: #7B4560; + a { + color: #8E5170; + } + } + + p { + color: #8E5170; + + ion-icon { + color: #8E5170; + } + } + + .hover-card { + box-shadow: 0 2px 12px rgba(244, 143, 177, 0.15); + + &:hover { + box-shadow: 0 6px 20px rgba(244, 143, 177, 0.25); + } + } + } } \ No newline at end of file diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 4a3b9c4..0827dfb 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -3,9 +3,10 @@ import {Observable} from 'rxjs'; import {CourseListResponse, Lecture, ManService} from '../man.service'; import {Router, RouterLink} from '@angular/router'; import {AuthService} from '../auth.service'; -import {colorByFolderName} from '../../helpers'; +import {ThemeService} from '../theme.service'; +import {colorByFolderName, colorByFolderNamePink} from '../../helpers'; import {addIcons} from "ionicons"; -import {logOutOutline} from "ionicons/icons"; +import {heartOutline, heart, logOutOutline} from "ionicons/icons"; import { IonButton, IonButtons, @@ -38,11 +39,12 @@ export class HomePage implements OnInit { private manService = inject(ManService); private router = inject(Router); private authService = inject(AuthService); + themeService = inject(ThemeService); response$: Observable; constructor() { - addIcons({logOutOutline}); + addIcons({logOutOutline, heartOutline, heart}); } logout() { @@ -56,6 +58,15 @@ export class HomePage implements OnInit { } protected readonly colorByFolderName = colorByFolderName; + protected readonly colorByFolderNamePink = colorByFolderNamePink; + + getCardColor(folder: string): string { + return this.themeService.isPinkMode ? colorByFolderNamePink(folder) : colorByFolderName(folder); + } + + togglePink() { + this.themeService.toggle(); + } goToLastVideo(lastVideo: Lecture) { return this.router.navigate(['home', 'course', lastVideo.course.id]); diff --git a/src/app/home/list/list.page.scss b/src/app/home/list/list.page.scss index 17dd012..14b5fe9 100644 --- a/src/app/home/list/list.page.scss +++ b/src/app/home/list/list.page.scss @@ -7,3 +7,28 @@ ion-label { text-overflow: initial !important; white-space: normal !important; } + +/* Pink Theme overrides */ + +:host-context(body.pink-theme) { + ion-title > span { + text-shadow: 0 1px 3px rgba(136, 14, 79, 0.4); + } + + ion-item { + --ion-item-border-color: #F48FB1; + } +} + +@media (prefers-color-scheme: dark) { + :host-context(body.pink-theme) { + ion-title > span { + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + } + + ion-item { + --ion-item-border-color: #5C1038; + } + } +} + diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts new file mode 100644 index 0000000..c7cd5b0 --- /dev/null +++ b/src/app/theme.service.ts @@ -0,0 +1,43 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ThemeService { + private readonly STORAGE_KEY = 'pinkMode'; + private pinkMode = new BehaviorSubject(this.loadPreference()); + + isPinkMode$ = this.pinkMode.asObservable(); + + constructor() { + this.applyTheme(this.pinkMode.value); + } + + toggle(): void { + const newValue = !this.pinkMode.value; + this.pinkMode.next(newValue); + this.applyTheme(newValue); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(newValue)); + } + + get isPinkMode(): boolean { + return this.pinkMode.value; + } + + private loadPreference(): boolean { + try { + return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) === true; + } catch { + return false; + } + } + + private applyTheme(isPink: boolean): void { + if (isPink) { + document.body.classList.add('pink-theme'); + } else { + document.body.classList.remove('pink-theme'); + } + } +} diff --git a/src/app/welcome/welcome.page.scss b/src/app/welcome/welcome.page.scss index fd92e92..117e0e8 100644 --- a/src/app/welcome/welcome.page.scss +++ b/src/app/welcome/welcome.page.scss @@ -17,3 +17,26 @@ ion-content { font-size: 0.75rem; } } + +:host-context(body.pink-theme) { + ion-content { + #mainCard { + box-shadow: 0 4px 24px rgba(233, 30, 144, 0.18), + 0 1.5px 8px rgba(244, 143, 177, 0.12); + border: 1px solid rgba(244, 143, 177, 0.3); + } + } +} + +@media (prefers-color-scheme: dark) { + :host-context(body.pink-theme) { + ion-content { + #mainCard { + box-shadow: 0 4px 24px rgba(244, 143, 177, 0.15), + 0 1.5px 8px rgba(233, 30, 144, 0.1); + border: 1px solid rgba(244, 143, 177, 0.15); + } + } + } +} + diff --git a/src/global.scss b/src/global.scss index 906b180..2f96a09 100644 --- a/src/global.scss +++ b/src/global.scss @@ -49,3 +49,22 @@ color: #15803D; } } + +body { + transition: background-color 0.3s ease, color 0.3s ease; +} + +body.pink-theme .e-learning-tag { + border: #E91E90 solid 1px; + background-color: #FCE4EC; + color: #E91E90; +} + +@media (prefers-color-scheme: dark) { + body.pink-theme .e-learning-tag { + border: #F48FB1 solid 1px; + background-color: #3A0420; + color: #F48FB1; + } +} + diff --git a/src/helpers.ts b/src/helpers.ts index e07f4e0..9f04787 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -11,3 +11,18 @@ export function colorByFolderName(name: string) { }; return colorMap[name] || 'gray'; } + +export function colorByFolderNamePink(name: string) { + const colorMap = { + '1st year': '#F48FB1', + '2nd year': '#E91E90', + '3rd year': '#AD1457', + '4th year': '#C2185B', + '5th year': '#D81B60', + '6th year': '#880E4F', + 'NLE1': '#EC407A', + 'NLE2': '#F06292' + }; + return colorMap[name] || '#B07090'; +} + diff --git a/src/theme/variables.scss b/src/theme/variables.scss index e111d61..2f78374 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -227,3 +227,248 @@ --color: white; } } + +/* + * Pink Theme + * ------------------------------------------- + * Toggled via body.pink-theme class + */ + +body.pink-theme { + /** primary — hot pink **/ + --ion-color-primary: #E91E90; + --ion-color-primary-rgb: 233, 30, 144; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255, 255, 255; + --ion-color-primary-shade: #CD1A7F; + --ion-color-primary-tint: #EB359C; + + /** secondary — soft rose **/ + --ion-color-secondary: #F06292; + --ion-color-secondary-rgb: 240, 98, 146; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #D35680; + --ion-color-secondary-tint: #F2729D; + + /** tertiary — deep magenta **/ + --ion-color-tertiary: #AD1457; + --ion-color-tertiary-rgb: 173, 20, 87; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255, 255, 255; + --ion-color-tertiary-shade: #98124D; + --ion-color-tertiary-tint: #B52C68; + + /** success — pink-tinted green **/ + --ion-color-success: #E91E63; + --ion-color-success-rgb: 233, 30, 99; + --ion-color-success-contrast: #ffffff; + --ion-color-success-contrast-rgb: 255, 255, 255; + --ion-color-success-shade: #CD1A57; + --ion-color-success-tint: #EB3573; + + /** warning — warm peach **/ + --ion-color-warning: #FF8A80; + --ion-color-warning-rgb: 255, 138, 128; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0, 0, 0; + --ion-color-warning-shade: #E07970; + --ion-color-warning-tint: #FF968D; + + /** danger — deep crimson **/ + --ion-color-danger: #C2185B; + --ion-color-danger-rgb: 194, 24, 91; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255, 255, 255; + --ion-color-danger-shade: #AB1550; + --ion-color-danger-tint: #C82F6B; + + /** dark **/ + --ion-color-dark: #4A0E2B; + --ion-color-dark-rgb: 74, 14, 43; + --ion-color-dark-contrast: #ffffff; + --ion-color-dark-contrast-rgb: 255, 255, 255; + --ion-color-dark-shade: #410C26; + --ion-color-dark-tint: #5C2640; + + /** medium **/ + --ion-color-medium: #B07090; + --ion-color-medium-rgb: 176, 112, 144; + --ion-color-medium-contrast: #ffffff; + --ion-color-medium-contrast-rgb: 255, 255, 255; + --ion-color-medium-shade: #9B637F; + --ion-color-medium-tint: #B87E9B; + + /** light **/ + --ion-color-light: #FFF0F5; + --ion-color-light-rgb: 255, 240, 245; + --ion-color-light-contrast: #4A0E2B; + --ion-color-light-contrast-rgb: 74, 14, 43; + --ion-color-light-shade: #E0D3D8; + --ion-color-light-tint: #FFF2F6; + + --ion-background-color: #FFF0F5; + --ion-background-color-rgb: 255, 240, 245; + + --ion-text-color: #4A0E2B; + --ion-text-color-rgb: 74, 14, 43; + + --ion-toolbar-background: #F8BBD0; + --ion-toolbar-color: #4A0E2B; + + --ion-item-background: #FCE4EC; + --ion-item-background-activated: #F8BBD0; + + --ion-border-color: #F48FB1; + + --ion-color-step-50: #FCE4EC; + --ion-color-step-100: #F8BBD0; + --ion-color-step-150: #F48FB1; + --ion-color-step-200: #F06292; + --ion-color-step-250: #EC407A; + --ion-color-step-300: #E91E63; + --ion-color-step-350: #D81B60; + --ion-color-step-400: #C2185B; + --ion-color-step-450: #AD1457; + --ion-color-step-500: #880E4F; + --ion-color-step-550: #7B0C47; + --ion-color-step-600: #6E0A3F; + --ion-color-step-650: #610937; + --ion-color-step-700: #54072F; + --ion-color-step-750: #470627; + --ion-color-step-800: #3A041F; + --ion-color-step-850: #2D0317; + --ion-color-step-900: #20020F; + --ion-color-step-950: #130107; + + --ion-card-background: #FCE4EC; + --ion-tab-bar-background: #F8BBD0; +} + +/* + * Pink Theme — Dark Mode + * ------------------------------------------- + */ + +@media (prefers-color-scheme: dark) { + body.pink-theme { + --ion-color-primary: #F48FB1; + --ion-color-primary-rgb: 244, 143, 177; + --ion-color-primary-contrast: #000000; + --ion-color-primary-contrast-rgb: 0, 0, 0; + --ion-color-primary-shade: #D77E9C; + --ion-color-primary-tint: #F59AB9; + + --ion-color-secondary: #F06292; + --ion-color-secondary-rgb: 240, 98, 146; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #D35680; + --ion-color-secondary-tint: #F2729D; + + --ion-color-tertiary: #E91E90; + --ion-color-tertiary-rgb: 233, 30, 144; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255, 255, 255; + --ion-color-tertiary-shade: #CD1A7F; + --ion-color-tertiary-tint: #EB359C; + + --ion-color-success: #F48FB1; + --ion-color-success-rgb: 244, 143, 177; + --ion-color-success-contrast: #000000; + --ion-color-success-contrast-rgb: 0, 0, 0; + --ion-color-success-shade: #D77E9C; + --ion-color-success-tint: #F59AB9; + + --ion-color-warning: #FF8A80; + --ion-color-warning-rgb: 255, 138, 128; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0, 0, 0; + --ion-color-warning-shade: #E07970; + --ion-color-warning-tint: #FF968D; + + --ion-color-danger: #FF5252; + --ion-color-danger-rgb: 255, 82, 82; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255, 255, 255; + --ion-color-danger-shade: #E04848; + --ion-color-danger-tint: #FF6363; + + --ion-color-dark: #FCE4EC; + --ion-color-dark-rgb: 252, 228, 236; + --ion-color-dark-contrast: #000000; + --ion-color-dark-contrast-rgb: 0, 0, 0; + --ion-color-dark-shade: #DEC9D0; + --ion-color-dark-tint: #FCE7EE; + + --ion-color-medium: #C48BA0; + --ion-color-medium-rgb: 196, 139, 160; + --ion-color-medium-contrast: #000000; + --ion-color-medium-contrast-rgb: 0, 0, 0; + --ion-color-medium-shade: #AC7A8D; + --ion-color-medium-tint: #CA97AA; + + --ion-color-light: #2D0317; + --ion-color-light-rgb: 45, 3, 23; + --ion-color-light-contrast: #ffffff; + --ion-color-light-contrast-rgb: 255, 255, 255; + --ion-color-light-shade: #280314; + --ion-color-light-tint: #421C2E; + + --ion-background-color: #1A0A10; + --ion-background-color-rgb: 26, 10, 16; + + --ion-text-color: #F8E0E8; + --ion-text-color-rgb: 248, 224, 232; + + --ion-toolbar-background: #2D0317; + --ion-toolbar-color: #F8E0E8; + + --ion-item-background: #3A0420; + --ion-item-background-activated: #4D0A2E; + + --ion-border-color: #5C1038; + + --ion-color-step-50: #231018; + --ion-color-step-100: #2D1520; + --ion-color-step-150: #361B28; + --ion-color-step-200: #402130; + --ion-color-step-250: #4A2738; + --ion-color-step-300: #542D40; + --ion-color-step-350: #5D3348; + --ion-color-step-400: #673950; + --ion-color-step-450: #713F58; + --ion-color-step-500: #7B4560; + --ion-color-step-550: #854B68; + --ion-color-step-600: #8E5170; + --ion-color-step-650: #985778; + --ion-color-step-700: #A25D80; + --ion-color-step-750: #AC6388; + --ion-color-step-800: #B56990; + --ion-color-step-850: #BF6F98; + --ion-color-step-900: #C975A0; + --ion-color-step-950: #D37BA8; + + --ion-card-background: #2D0317; + --ion-tab-bar-background: #2D0317; + } + + .ios body.pink-theme { + --ion-background-color: #1A0A10; + --ion-background-color-rgb: 26, 10, 16; + --ion-toolbar-background: #1A0A10; + --ion-item-background: #2D0317; + --ion-item-background-activated: #3A0420; + } + + .md body.pink-theme { + --ion-background-color: #1A0A10; + --ion-background-color-rgb: 26, 10, 16; + --ion-border-color: #3A0420; + --ion-item-background: #2D0317; + } + + body.pink-theme ion-title.title-large { + --color: #F8E0E8; + } +} From d41d1d26ca3ab71d40c47d18cc6f07373893015d Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 21 Jul 2026 12:04:08 +0700 Subject: [PATCH 2/3] feat: add pink theme toggle button to all pages (welcome, list, course) --- src/app/home/course/course.page.html | 5 +++++ src/app/home/course/course.page.ts | 12 ++++++++++-- src/app/home/list/list.page.html | 7 ++++++- src/app/home/list/list.page.ts | 22 +++++++++++++++++++--- src/app/welcome/welcome.page.html | 3 +++ src/app/welcome/welcome.page.scss | 7 +++++++ src/app/welcome/welcome.page.ts | 16 ++++++++++++++++ 7 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index 6e41f71..5ade09d 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -8,6 +8,11 @@ {{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left } + + + + + diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index ca13bf6..299735b 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -10,6 +10,7 @@ import { AlertController, IonBackButton, IonButton, + IonButtons, IonCard, IonCardContent, IonCardHeader, @@ -34,11 +35,12 @@ import { 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 {checkmarkOutline, closeOutline, documentAttachOutline, download, heart, heartOutline, 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 {ThemeService} from '../../theme.service'; @Component({ selector: 'app-course', @@ -70,6 +72,7 @@ import {ModalEvaluationComponent} from './modal-evaluation.component'; AsyncPipe, DecimalPipe, DatePipe, + IonButtons, ] }) export class CoursePage implements OnInit, AfterViewInit, OnDestroy { @@ -79,6 +82,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { private alertController = inject(AlertController); private sanitizer = inject(DomSanitizer); private modalCtrl = inject(ModalController); + themeService = inject(ThemeService); @ViewChild('videoPlayer') videoPlayerElement: ElementRef; videoPlayer: Player; @@ -108,7 +112,11 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { expectVideoTimeJump = false; constructor() { - addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline }); + addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline, heart, heartOutline }); + } + + togglePink() { + this.themeService.toggle(); } ngOnInit() { diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index 4a3a5df..ca90fa5 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -2,8 +2,13 @@ - {{ year || 'Loading'}} + {{ year || 'Loading'}} + + + + + diff --git a/src/app/home/list/list.page.ts b/src/app/home/list/list.page.ts index 1662678..5c0ba04 100644 --- a/src/app/home/list/list.page.ts +++ b/src/app/home/list/list.page.ts @@ -3,24 +3,32 @@ 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 {colorByFolderName, colorByFolderNamePink} from '../../../helpers'; +import {ThemeService} from '../../theme.service'; +import {addIcons} from 'ionicons'; +import {heart, heartOutline} from 'ionicons/icons'; +import {IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonItem, IonLabel, IonList, IonTitle, IonToolbar} from '@ionic/angular/standalone'; import {AsyncPipe, NgStyle} from '@angular/common'; @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, IonItem, IonLabel, AsyncPipe, IonButtons, IonButton, IonIcon] }) export class ListPage implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private manService = inject(ManService); + themeService = inject(ThemeService); year: string; list$: Observable<{ name: string, is_remote: boolean, id: number, link: string[] }[]>; + constructor() { + addIcons({heart, heartOutline}); + } + ngOnInit() { this.list$ = this.route.paramMap.pipe( switchMap(s => { @@ -40,5 +48,13 @@ export class ListPage implements OnInit { ); } + getYearColor(): string { + return this.themeService.isPinkMode ? colorByFolderNamePink(this.year) : colorByFolderName(this.year); + } + + togglePink() { + this.themeService.toggle(); + } + protected readonly colorByFolderName = colorByFolderName; } diff --git a/src/app/welcome/welcome.page.html b/src/app/welcome/welcome.page.html index 9b5b603..2b3b217 100644 --- a/src/app/welcome/welcome.page.html +++ b/src/app/welcome/welcome.page.html @@ -1,4 +1,7 @@ + + + diff --git a/src/app/welcome/welcome.page.scss b/src/app/welcome/welcome.page.scss index 117e0e8..f9316b6 100644 --- a/src/app/welcome/welcome.page.scss +++ b/src/app/welcome/welcome.page.scss @@ -18,6 +18,13 @@ ion-content { } } +.theme-toggle-fab { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 10; +} + :host-context(body.pink-theme) { ion-content { #mainCard { diff --git a/src/app/welcome/welcome.page.ts b/src/app/welcome/welcome.page.ts index 3b61c06..2cdfe0f 100644 --- a/src/app/welcome/welcome.page.ts +++ b/src/app/welcome/welcome.page.ts @@ -7,6 +7,7 @@ import { IonCardHeader, IonCardTitle, IonContent, + IonIcon, IonText, LoadingController, } from "@ionic/angular/standalone"; @@ -16,6 +17,10 @@ import {HttpErrorResponse} from "@angular/common/http"; import {Subscription} from "rxjs"; import {User} from "@angular/fire/auth"; import {AuthService} from "../auth.service"; +import {ThemeService} from "../theme.service"; +import {addIcons} from "ionicons"; +import {heart, heartOutline} from "ionicons/icons"; +import {AsyncPipe} from "@angular/common"; @Component({ selector: "app-welcome", @@ -28,7 +33,9 @@ import {AuthService} from "../auth.service"; IonCardTitle, IonCardContent, IonButton, + IonIcon, IonText, + AsyncPipe, ], }) export class WelcomePage implements OnInit, OnDestroy { @@ -37,6 +44,15 @@ export class WelcomePage implements OnInit, OnDestroy { private manService = inject(ManService); private loadingCtrl = inject(LoadingController); alertCtrl = inject(AlertController); + themeService = inject(ThemeService); + + constructor() { + addIcons({heart, heartOutline}); + } + + togglePink() { + this.themeService.toggle(); + } authStateSubscription: Subscription; isAuthChecked: boolean; From 9760c41a02ad91e62a49e75ee9562c56e9a97450 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Thu, 23 Jul 2026 23:38:27 +0700 Subject: [PATCH 3/3] feat: theme dropdown redesign with smooth transitions and per-user persistence --- .agents/AGENTS.md | 3 + src/app/home/course/course.page.html | 4 +- src/app/home/course/course.page.scss | 62 +-- src/app/home/course/course.page.ts | 10 +- src/app/home/home.page.html | 6 +- src/app/home/home.page.scss | 74 +--- src/app/home/home.page.ts | 11 +- src/app/home/list/list.page.html | 4 +- src/app/home/list/list.page.scss | 10 - src/app/home/list/list.page.ts | 10 +- src/app/shared/theme-dropdown.component.ts | 127 ++++++ src/app/theme.service.ts | 170 +++++++- src/app/welcome/welcome.page.html | 6 +- src/app/welcome/welcome.page.scss | 31 +- src/app/welcome/welcome.page.ts | 18 +- src/global.scss | 43 +- src/theme/variables.scss | 444 ++++++++------------- 17 files changed, 517 insertions(+), 516 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 src/app/shared/theme-dropdown.component.ts diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..3fb18d6 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,3 @@ +# Project Rules + +- **Git Operations**: Always ask the user for explicit confirmation/permission before executing any `git push` command (including pushes to remote branches or pull requests). diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index 5ade09d..a2636f7 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -9,9 +9,7 @@ } - - - + diff --git a/src/app/home/course/course.page.scss b/src/app/home/course/course.page.scss index 3e82f67..7eab2e3 100644 --- a/src/app/home/course/course.page.scss +++ b/src/app/home/course/course.page.scss @@ -3,7 +3,7 @@ span a { } ion-title small { - color: gray; + color: var(--flick-subtext-color, gray); } .e-learning-tag { @@ -12,26 +12,17 @@ ion-title small { span.date { font-family: monospace, monospace; - color: darkblue; - @media (prefers-color-scheme: dark) { - color: lightblue; - } + color: var(--flick-date-color, darkblue); } 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-date-divider-color, darkblue); } .time-info { - color: darkgoldenrod; - @media (prefers-color-scheme: dark) { - color: yellow; - } + color: var(--flick-time-info-color, darkgoldenrod); } .ion-color .check-icon { @@ -50,48 +41,3 @@ span.date-divider { } } } - -/* Pink Theme overrides */ - -:host-context(body.pink-theme) { - span.date { - color: #AD1457; - } - - span.date-divider { - color: #AD1457; - } - - .time-info { - color: #E91E90; - } - - ion-title small { - color: #B07090; - } - - .set-playback-speed-button { - --ion-color-primary: #E91E90; - } -} - -@media (prefers-color-scheme: dark) { - :host-context(body.pink-theme) { - span.date { - color: #F48FB1; - } - - span.date-divider { - color: #F48FB1; - } - - .time-info { - color: #F06292; - } - - ion-title small { - color: #C48BA0; - } - } -} - diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index 299735b..ff615d3 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -35,12 +35,13 @@ import { import {DomSanitizer} from '@angular/platform-browser'; import {PlayHistory} from '../../play-tracker.service'; import {addIcons} from "ionicons"; -import {checkmarkOutline, closeOutline, documentAttachOutline, download, heart, heartOutline, pauseCircleOutline} from "ionicons/icons"; +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 {ThemeService} from '../../theme.service'; +import {ThemeDropdownComponent} from '../../shared/theme-dropdown.component'; @Component({ selector: 'app-course', @@ -73,6 +74,7 @@ import {ThemeService} from '../../theme.service'; DecimalPipe, DatePipe, IonButtons, + ThemeDropdownComponent, ] }) export class CoursePage implements OnInit, AfterViewInit, OnDestroy { @@ -112,11 +114,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { expectVideoTimeJump = false; constructor() { - addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline, heart, heartOutline }); - } - - togglePink() { - this.themeService.toggle(); + addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline }); } ngOnInit() { diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 2e44eca..83da16d 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -4,12 +4,10 @@ MDCU Recorded Lecture - - - + Sign out - + diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 0c17234..ed4e279 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -14,13 +14,7 @@ ion-card-title { } .course-name { - color: #222222; -} - -@media (prefers-color-scheme: dark) { - .course-name { - color: #cccccc; - } + color: var(--flick-card-title-color, #222222); } .center-text { @@ -33,21 +27,14 @@ ion-card-title { } .small-text { - color: #aaaaaa; + color: var(--flick-subtext-color, #aaaaaa); a { - color: #bbbbbb; - } - - @media (prefers-color-scheme: dark) { - color: #555555; - a { - color: #666666; - } + color: var(--flick-subtext-link-color, #bbbbbb); } } .hover-card { - transition: transform 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease; cursor: pointer; } @@ -57,12 +44,12 @@ ion-card-title { p { padding: 0 1.5rem 0 1.5rem; - color: gray; + color: var(--flick-subtext-color, gray); text-align: center; ion-icon { margin: 0 0.6em; - color: gray; + color: var(--flick-subtext-color, gray); } a { @@ -74,67 +61,20 @@ p { margin-right: 0.5rem; } -/* Pink Theme overrides */ - :host-context(body.pink-theme) { ion-card-title { - color: white; text-shadow: 0 1px 3px rgba(136, 14, 79, 0.3); } .hover-card { box-shadow: 0 2px 12px rgba(233, 30, 144, 0.2); - transition: transform 0.2s ease, box-shadow 0.2s ease; &:hover { - transform: translateY(-0.5rem) scale(1.02); box-shadow: 0 6px 20px rgba(233, 30, 144, 0.35); } } .center-spinner { - color: #E91E90; - } - - p { - color: #B07090; - - ion-icon { - color: #B07090; - } - } - - .small-text { - color: #C48BA0; - a { - color: #D4A0B5; - } - } -} - -@media (prefers-color-scheme: dark) { - :host-context(body.pink-theme) { - .small-text { - color: #7B4560; - a { - color: #8E5170; - } - } - - p { - color: #8E5170; - - ion-icon { - color: #8E5170; - } - } - - .hover-card { - box-shadow: 0 2px 12px rgba(244, 143, 177, 0.15); - - &:hover { - box-shadow: 0 6px 20px rgba(244, 143, 177, 0.25); - } - } + color: var(--ion-color-primary); } } \ No newline at end of file diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 0827dfb..1c85241 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -6,7 +6,8 @@ import {AuthService} from '../auth.service'; import {ThemeService} from '../theme.service'; import {colorByFolderName, colorByFolderNamePink} from '../../helpers'; import {addIcons} from "ionicons"; -import {heartOutline, heart, logOutOutline} from "ionicons/icons"; +import {logOutOutline} from "ionicons/icons"; +import {ThemeDropdownComponent} from '../shared/theme-dropdown.component'; import { IonButton, IonButtons, @@ -33,7 +34,7 @@ import {AsyncPipe, NgStyle} from '@angular/common'; 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, ThemeDropdownComponent] }) export class HomePage implements OnInit { private manService = inject(ManService); @@ -44,7 +45,7 @@ export class HomePage implements OnInit { response$: Observable; constructor() { - addIcons({logOutOutline, heartOutline, heart}); + addIcons({logOutOutline}); } logout() { @@ -64,9 +65,7 @@ export class HomePage implements OnInit { return this.themeService.isPinkMode ? colorByFolderNamePink(folder) : colorByFolderName(folder); } - togglePink() { - this.themeService.toggle(); - } + goToLastVideo(lastVideo: Lecture) { return this.router.navigate(['home', 'course', lastVideo.course.id]); diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index ca90fa5..fa42bdb 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -5,9 +5,7 @@ {{ year || 'Loading'}} - - - + diff --git a/src/app/home/list/list.page.scss b/src/app/home/list/list.page.scss index 14b5fe9..ddfaa5c 100644 --- a/src/app/home/list/list.page.scss +++ b/src/app/home/list/list.page.scss @@ -20,15 +20,5 @@ ion-label { } } -@media (prefers-color-scheme: dark) { - :host-context(body.pink-theme) { - ion-title > span { - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); - } - ion-item { - --ion-item-border-color: #5C1038; - } - } -} diff --git a/src/app/home/list/list.page.ts b/src/app/home/list/list.page.ts index 5c0ba04..23e4978 100644 --- a/src/app/home/list/list.page.ts +++ b/src/app/home/list/list.page.ts @@ -5,8 +5,7 @@ import {map, switchMap} from 'rxjs/operators'; import {ManService} from '../../man.service'; import {colorByFolderName, colorByFolderNamePink} from '../../../helpers'; import {ThemeService} from '../../theme.service'; -import {addIcons} from 'ionicons'; -import {heart, heartOutline} from 'ionicons/icons'; +import {ThemeDropdownComponent} from '../../shared/theme-dropdown.component'; import {IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonItem, IonLabel, IonList, IonTitle, IonToolbar} from '@ionic/angular/standalone'; import {AsyncPipe, NgStyle} from '@angular/common'; @@ -14,7 +13,7 @@ import {AsyncPipe, NgStyle} from '@angular/common'; selector: 'app-list', templateUrl: './list.page.html', styleUrls: ['./list.page.scss'], - imports: [IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, IonContent, IonList, IonItem, IonLabel, AsyncPipe, IonButtons, IonButton, IonIcon] + imports: [IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, IonContent, IonList, IonItem, IonLabel, AsyncPipe, IonButtons, ThemeDropdownComponent] }) export class ListPage implements OnInit { private route = inject(ActivatedRoute); @@ -26,7 +25,6 @@ export class ListPage implements OnInit { list$: Observable<{ name: string, is_remote: boolean, id: number, link: string[] }[]>; constructor() { - addIcons({heart, heartOutline}); } ngOnInit() { @@ -52,9 +50,7 @@ export class ListPage implements OnInit { return this.themeService.isPinkMode ? colorByFolderNamePink(this.year) : colorByFolderName(this.year); } - togglePink() { - this.themeService.toggle(); - } + protected readonly colorByFolderName = colorByFolderName; } diff --git a/src/app/shared/theme-dropdown.component.ts b/src/app/shared/theme-dropdown.component.ts new file mode 100644 index 0000000..ecb17bf --- /dev/null +++ b/src/app/shared/theme-dropdown.component.ts @@ -0,0 +1,127 @@ +import {Component, inject} from '@angular/core'; +import { + IonButton, + IonIcon, + IonItem, + IonLabel, + IonList, + PopoverController, +} from '@ionic/angular/standalone'; +import {ThemeService, THEME_OPTIONS, ThemeMode} from '../theme.service'; +import {AsyncPipe, NgClass} from '@angular/common'; +import {addIcons} from 'ionicons'; +import {sunny, moon, heart, phonePortrait, checkmark} from 'ionicons/icons'; + +/** + * Popover content showing all theme options. + * This is presented inside the popover triggered by ThemeDropdownComponent. + */ +@Component({ + selector: 'app-theme-popover', + template: ` + + @for (option of themeOptions; track option.mode) { + + + {{ option.label }} + @if (option.mode === (themeService.currentTheme$ | async)) { + + } + + } + + `, + styles: [` + .theme-popover-list { + padding: 0.25rem 0; + } + + .theme-popover-list ion-item { + --min-height: 44px; + cursor: pointer; + } + + .theme-option-icon { + font-size: 1.2rem; + margin-inline-end: 0.75rem; + } + + .active-theme { + --background: var(--ion-color-primary-tint, rgba(56, 128, 255, 0.08)); + font-weight: 600; + } + `], + imports: [IonList, IonItem, IonIcon, IonLabel, AsyncPipe], +}) +export class ThemePopoverComponent { + themeService = inject(ThemeService); + private popoverCtrl = inject(PopoverController); + + /** Expose the theme options config to the template */ + themeOptions = THEME_OPTIONS; + + selectTheme(mode: ThemeMode): void { + this.themeService.setTheme(mode); + this.popoverCtrl.dismiss(); + } +} + +/** + * Reusable theme dropdown trigger button. + * Place this in any toolbar or container — it renders a single icon button + * that opens a popover with all theme options. + */ +@Component({ + selector: 'app-theme-dropdown', + template: ` + + + + `, + styles: [` + :host { + display: inline-flex; + align-items: center; + } + `], + imports: [IonButton, IonIcon], +}) +export class ThemeDropdownComponent { + themeService = inject(ThemeService); + private popoverCtrl = inject(PopoverController); + + constructor() { + addIcons({sunny, moon, heart, phonePortrait, checkmark}); + } + + /** Map the current theme to the appropriate trigger icon */ + getIcon(): string { + const option = THEME_OPTIONS.find(o => o.mode === this.themeService.currentTheme); + return option?.icon ?? 'sunny'; + } + + /** Map the current theme to icon color */ + getIconColor(): string { + const option = THEME_OPTIONS.find(o => o.mode === this.themeService.currentTheme); + return option?.iconColor ?? 'warning'; + } + + async openPopover(event: Event): Promise { + const popover = await this.popoverCtrl.create({ + component: ThemePopoverComponent, + event, + translucent: true, + showBackdrop: false, + dismissOnSelect: true, + }); + await popover.present(); + } +} diff --git a/src/app/theme.service.ts b/src/app/theme.service.ts index c7cd5b0..84e5b32 100644 --- a/src/app/theme.service.ts +++ b/src/app/theme.service.ts @@ -1,43 +1,177 @@ import {Injectable} from '@angular/core'; -import {BehaviorSubject} from 'rxjs'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +/** The user-selectable theme modes */ +export type ThemeMode = 'light' | 'dark' | 'pink' | 'device'; + +/** Config entry for each theme option (used by the dropdown component) */ +export interface ThemeOption { + mode: ThemeMode; + label: string; + icon: string; + iconColor: string; +} + +/** All available theme options — single source of truth for the dropdown */ +export const THEME_OPTIONS: ThemeOption[] = [ + {mode: 'light', label: 'Light theme', icon: 'sunny', iconColor: 'warning'}, + {mode: 'dark', label: 'Dark theme', icon: 'moon', iconColor: 'medium'}, + {mode: 'pink', label: 'Pink theme', icon: 'heart', iconColor: 'danger'}, + {mode: 'device', label: 'Device default', icon: 'phone-portrait', iconColor: 'medium'}, +]; @Injectable({ providedIn: 'root', }) export class ThemeService { - private readonly STORAGE_KEY = 'pinkMode'; - private pinkMode = new BehaviorSubject(this.loadPreference()); + private readonly STORAGE_KEY_PREFIX = 'themePrefs_'; + private currentUserId = 'guest'; + + private themeMode = new BehaviorSubject('device'); + + /** Observable: the user-selected theme mode */ + currentTheme$ = this.themeMode.asObservable(); + + /** Observable: the resolved effective theme (device → actual light/dark) */ + effectiveTheme$: Observable<'light' | 'dark' | 'pink'> = this.themeMode.pipe( + map(mode => this.resolveEffective(mode)), + ); + + /** Backward-compatible observables for CSS class logic */ + isDarkMode$ = this.effectiveTheme$.pipe(map(t => t === 'dark')); + isPinkMode$ = this.effectiveTheme$.pipe(map(t => t === 'pink')); - isPinkMode$ = this.pinkMode.asObservable(); + private systemDarkMediaQuery: MediaQueryList | null = null; + private systemDarkListener: ((e: MediaQueryListEvent) => void) | null = null; constructor() { - this.applyTheme(this.pinkMode.value); + this.setupSystemMediaListener(); + this.initTheme(); } - toggle(): void { - const newValue = !this.pinkMode.value; - this.pinkMode.next(newValue); - this.applyTheme(newValue); - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(newValue)); + /** Set up listener for system dark mode changes */ + private setupSystemMediaListener(): void { + if (typeof window !== 'undefined' && window.matchMedia) { + this.systemDarkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + this.systemDarkListener = () => { + // Re-apply theme if in device mode so CSS classes update + if (this.themeMode.value === 'device') { + this.applyTheme(); + // Re-emit so derived observables update + this.themeMode.next('device'); + } + }; + this.systemDarkMediaQuery.addEventListener('change', this.systemDarkListener); + } + } + + /** Initialize theme for current state/guest */ + private initTheme(): void { + const mode = this.loadPreference(this.currentUserId); + this.themeMode.next(mode); + this.applyTheme(); + } + + /** Load preferences for a specific user (call on login) */ + loadForUser(uid: string): void { + this.currentUserId = uid || 'guest'; + const mode = this.loadPreference(this.currentUserId); + this.themeMode.next(mode); + this.applyTheme(); + } + + /** Clear preferences from active state (call on logout) */ + clearForUser(): void { + this.currentUserId = 'guest'; + const mode = this.loadPreference('guest'); + this.themeMode.next(mode); + this.applyTheme(); + } + + /** Set the active theme mode */ + setTheme(mode: ThemeMode): void { + this.themeMode.next(mode); + this.applyTheme(); + this.savePreference(); + } + + /** Synchronous getter for backward compatibility */ + get isDarkMode(): boolean { + return this.resolveEffective(this.themeMode.value) === 'dark'; } get isPinkMode(): boolean { - return this.pinkMode.value; + return this.resolveEffective(this.themeMode.value) === 'pink'; + } + + get currentTheme(): ThemeMode { + return this.themeMode.value; + } + + /** Resolve 'device' to actual light/dark based on system preference */ + private resolveEffective(mode: ThemeMode): 'light' | 'dark' | 'pink' { + if (mode === 'device') { + const systemDark = this.systemDarkMediaQuery?.matches ?? false; + return systemDark ? 'dark' : 'light'; + } + return mode; } - private loadPreference(): boolean { + /** Load preference from localStorage with migration from old format */ + private loadPreference(uid: string): ThemeMode { try { - return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) === true; + const raw = localStorage.getItem(this.STORAGE_KEY_PREFIX + uid); + if (raw) { + const parsed = JSON.parse(raw); + + // New format: { theme: 'light' | 'dark' | 'pink' | 'device' } + const validModes = THEME_OPTIONS.map(o => o.mode); + if (typeof parsed.theme === 'string' && validModes.includes(parsed.theme as ThemeMode)) { + return parsed.theme as ThemeMode; + } + + // Old format migration: { darkMode: boolean, pinkMode: boolean } + if (typeof parsed.darkMode === 'boolean' || typeof parsed.pinkMode === 'boolean') { + if (parsed.pinkMode === true) { + return 'pink'; + } + if (parsed.darkMode === true) { + return 'dark'; + } + return 'light'; + } + } } catch { - return false; + // Ignore parse errors } + + // Default: follow device + return 'device'; } - private applyTheme(isPink: boolean): void { - if (isPink) { - document.body.classList.add('pink-theme'); + private savePreference(): void { + localStorage.setItem( + this.STORAGE_KEY_PREFIX + this.currentUserId, + JSON.stringify({theme: this.themeMode.value}), + ); + } + + private applyTheme(): void { + const updateClasses = () => { + const body = document.body; + const effective = this.resolveEffective(this.themeMode.value); + + body.classList.toggle('dark-theme', effective === 'dark'); + body.classList.toggle('pink-theme', effective === 'pink'); + }; + + if (typeof document !== 'undefined' && 'startViewTransition' in document) { + (document as any).startViewTransition(() => { + updateClasses(); + }); } else { - document.body.classList.remove('pink-theme'); + updateClasses(); } } } diff --git a/src/app/welcome/welcome.page.html b/src/app/welcome/welcome.page.html index 2b3b217..f69bbce 100644 --- a/src/app/welcome/welcome.page.html +++ b/src/app/welcome/welcome.page.html @@ -1,7 +1,7 @@ - - - +
+ +
diff --git a/src/app/welcome/welcome.page.scss b/src/app/welcome/welcome.page.scss index f9316b6..080d8a3 100644 --- a/src/app/welcome/welcome.page.scss +++ b/src/app/welcome/welcome.page.scss @@ -7,6 +7,8 @@ ion-content { #mainCard { margin: 20vh auto 0.4rem auto; border-radius: 1rem; + box-shadow: var(--flick-welcome-card-shadow, 0 4px 24px rgba(0, 0, 0, 0.08)); + border: var(--flick-welcome-card-border, none); } .browserCard { @@ -15,35 +17,16 @@ ion-content { .small-text { font-size: 0.75rem; + color: var(--flick-subtext-color, #aaaaaa); } } -.theme-toggle-fab { +.theme-toggle-container { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 10; + display: flex; + align-items: center; + gap: 0.25rem; } - -:host-context(body.pink-theme) { - ion-content { - #mainCard { - box-shadow: 0 4px 24px rgba(233, 30, 144, 0.18), - 0 1.5px 8px rgba(244, 143, 177, 0.12); - border: 1px solid rgba(244, 143, 177, 0.3); - } - } -} - -@media (prefers-color-scheme: dark) { - :host-context(body.pink-theme) { - ion-content { - #mainCard { - box-shadow: 0 4px 24px rgba(244, 143, 177, 0.15), - 0 1.5px 8px rgba(233, 30, 144, 0.1); - border: 1px solid rgba(244, 143, 177, 0.15); - } - } - } -} - diff --git a/src/app/welcome/welcome.page.ts b/src/app/welcome/welcome.page.ts index 2cdfe0f..fe8a81d 100644 --- a/src/app/welcome/welcome.page.ts +++ b/src/app/welcome/welcome.page.ts @@ -7,7 +7,6 @@ import { IonCardHeader, IonCardTitle, IonContent, - IonIcon, IonText, LoadingController, } from "@ionic/angular/standalone"; @@ -18,9 +17,7 @@ import {Subscription} from "rxjs"; import {User} from "@angular/fire/auth"; import {AuthService} from "../auth.service"; import {ThemeService} from "../theme.service"; -import {addIcons} from "ionicons"; -import {heart, heartOutline} from "ionicons/icons"; -import {AsyncPipe} from "@angular/common"; +import {ThemeDropdownComponent} from "../shared/theme-dropdown.component"; @Component({ selector: "app-welcome", @@ -33,9 +30,8 @@ import {AsyncPipe} from "@angular/common"; IonCardTitle, IonCardContent, IonButton, - IonIcon, IonText, - AsyncPipe, + ThemeDropdownComponent, ], }) export class WelcomePage implements OnInit, OnDestroy { @@ -46,14 +42,6 @@ export class WelcomePage implements OnInit, OnDestroy { alertCtrl = inject(AlertController); themeService = inject(ThemeService); - constructor() { - addIcons({heart, heartOutline}); - } - - togglePink() { - this.themeService.toggle(); - } - authStateSubscription: Subscription; isAuthChecked: boolean; user: User; @@ -75,6 +63,7 @@ export class WelcomePage implements OnInit, OnDestroy { this.authStateSubscription = this.authService.user.subscribe((user) => { this.user = user; if (user) { + this.themeService.loadForUser(user.uid); // User is signed in. this.goToHome().then(() => { loading.dismiss(); @@ -113,6 +102,7 @@ export class WelcomePage implements OnInit, OnDestroy { } logout() { + this.themeService.clearForUser(); this.authService .signOut() .then() diff --git a/src/global.scss b/src/global.scss index 2f96a09..5d417d6 100644 --- a/src/global.scss +++ b/src/global.scss @@ -42,16 +42,35 @@ display: inline-block; } -@media (prefers-color-scheme: dark) { - .e-learning-tag { - border: #15803D solid 1px; - background-color: #052E16; - color: #15803D; - } +body.dark-theme .e-learning-tag { + border: #15803D solid 1px; + background-color: #052E16; + color: #15803D; } -body { - transition: background-color 0.3s ease, color 0.3s ease; +$theme-transition-duration: 0.3s !default; +$theme-transition-timing: ease !default; + +@mixin theme-transition { + transition: background-color $theme-transition-duration $theme-transition-timing, + color $theme-transition-duration $theme-transition-timing, + border-color $theme-transition-duration $theme-transition-timing, + box-shadow $theme-transition-duration $theme-transition-timing, + fill $theme-transition-duration $theme-transition-timing; +} + +body, +ion-content, +ion-toolbar, +ion-card, +ion-item, +ion-header, +ion-footer, +ion-title, +ion-button, +ion-list, +.e-learning-tag { + @include theme-transition; } body.pink-theme .e-learning-tag { @@ -60,11 +79,3 @@ body.pink-theme .e-learning-tag { color: #E91E90; } -@media (prefers-color-scheme: dark) { - body.pink-theme .e-learning-tag { - border: #F48FB1 solid 1px; - background-color: #3A0420; - color: #F48FB1; - } -} - diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 2f78374..5c45561 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -74,158 +74,166 @@ --ion-color-light-contrast-rgb: 0, 0, 0; --ion-color-light-shade: #d7d8da; --ion-color-light-tint: #f5f6f9; + + /** Custom FlickPlayer Design Tokens **/ + --flick-date-color: darkblue; + --flick-date-divider-color: darkblue; + --flick-time-info-color: darkgoldenrod; + --flick-subtext-color: #aaaaaa; + --flick-subtext-link-color: #bbbbbb; + --flick-card-title-color: #222222; + --flick-welcome-card-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + --flick-welcome-card-border: none; +} + +/* + * Dark Theme + * ------------------------------------------- + * Toggled via body.dark-theme class + */ + +body.dark-theme { + --ion-color-primary: #428cff; + --ion-color-primary-rgb: 66,140,255; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255,255,255; + --ion-color-primary-shade: #3a7be0; + --ion-color-primary-tint: #5598ff; + + --ion-color-secondary: #50c8ff; + --ion-color-secondary-rgb: 80,200,255; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255,255,255; + --ion-color-secondary-shade: #46b0e0; + --ion-color-secondary-tint: #62ceff; + + --ion-color-tertiary: #6a64ff; + --ion-color-tertiary-rgb: 106,100,255; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255,255,255; + --ion-color-tertiary-shade: #5d58e0; + --ion-color-tertiary-tint: #7974ff; + + --ion-color-success: #2fdf75; + --ion-color-success-rgb: 47,223,117; + --ion-color-success-contrast: #000000; + --ion-color-success-contrast-rgb: 0,0,0; + --ion-color-success-shade: #29c467; + --ion-color-success-tint: #44e283; + + --ion-color-warning: #ffd534; + --ion-color-warning-rgb: 255,213,52; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0,0,0; + --ion-color-warning-shade: #e0bb2e; + --ion-color-warning-tint: #ffd948; + + --ion-color-danger: #ff4961; + --ion-color-danger-rgb: 255,73,97; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255,255,255; + --ion-color-danger-shade: #e04055; + --ion-color-danger-tint: #ff5b71; + + --ion-color-dark: #f4f5f8; + --ion-color-dark-rgb: 244,245,248; + --ion-color-dark-contrast: #000000; + --ion-color-dark-contrast-rgb: 0,0,0; + --ion-color-dark-shade: #d7d8da; + --ion-color-dark-tint: #f5f6f9; + + --ion-color-medium: #989aa2; + --ion-color-medium-rgb: 152,154,162; + --ion-color-medium-contrast: #000000; + --ion-color-medium-contrast-rgb: 0,0,0; + --ion-color-medium-shade: #86888f; + --ion-color-medium-tint: #a2a4ab; + + --ion-color-light: #222428; + --ion-color-light-rgb: 34,36,40; + --ion-color-light-contrast: #ffffff; + --ion-color-light-contrast-rgb: 255,255,255; + --ion-color-light-shade: #1e2023; + --ion-color-light-tint: #383a3e; + + /** Custom FlickPlayer Design Tokens **/ + --flick-date-color: lightblue; + --flick-date-divider-color: lightblue; + --flick-time-info-color: yellow; + --flick-subtext-color: #555555; + --flick-subtext-link-color: #666666; + --flick-card-title-color: #cccccc; + --flick-welcome-card-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 1.5px 8px rgba(0, 0, 0, 0.2); + --flick-welcome-card-border: 1px solid rgba(255, 255, 255, 0.1); +} + +.ios body.dark-theme { + --ion-background-color: #000000; + --ion-background-color-rgb: 0,0,0; + + --ion-text-color: #ffffff; + --ion-text-color-rgb: 255,255,255; + + --ion-color-step-50: #0d0d0d; + --ion-color-step-100: #1a1a1a; + --ion-color-step-150: #262626; + --ion-color-step-200: #333333; + --ion-color-step-250: #404040; + --ion-color-step-300: #4d4d4d; + --ion-color-step-350: #595959; + --ion-color-step-400: #666666; + --ion-color-step-450: #737373; + --ion-color-step-500: #808080; + --ion-color-step-550: #8c8c8c; + --ion-color-step-600: #999999; + --ion-color-step-650: #a6a6a6; + --ion-color-step-700: #b3b3b3; + --ion-color-step-750: #bfbfbf; + --ion-color-step-800: #cccccc; + --ion-color-step-850: #d9d9d9; + --ion-color-step-900: #e6e6e6; + --ion-color-step-950: #f2f2f2; + + --ion-toolbar-background: #0d0d0d; + + --ion-item-background: #1c1c1c; + --ion-item-background-activated: #313131; +} + +.md body.dark-theme { + --ion-background-color: #121212; + --ion-background-color-rgb: 18,18,18; + + --ion-text-color: #ffffff; + --ion-text-color-rgb: 255,255,255; + + --ion-border-color: #222222; + + --ion-color-step-50: #1e1e1e; + --ion-color-step-100: #2a2a2a; + --ion-color-step-150: #363636; + --ion-color-step-200: #414141; + --ion-color-step-250: #4d4d4d; + --ion-color-step-300: #595959; + --ion-color-step-350: #656565; + --ion-color-step-400: #717171; + --ion-color-step-450: #7d7d7d; + --ion-color-step-500: #898989; + --ion-color-step-550: #949494; + --ion-color-step-600: #a0a0a0; + --ion-color-step-650: #acacac; + --ion-color-step-700: #b8b8b8; + --ion-color-step-750: #c4c4c4; + --ion-color-step-800: #d0d0d0; + --ion-color-step-850: #dbdbdb; + --ion-color-step-900: #e7e7e7; + --ion-color-step-950: #f3f3f3; + + --ion-item-background: #1A1B1E; } -@media (prefers-color-scheme: dark) { - /* - * Dark Colors - * ------------------------------------------- - */ - - body { - --ion-color-primary: #428cff; - --ion-color-primary-rgb: 66,140,255; - --ion-color-primary-contrast: #ffffff; - --ion-color-primary-contrast-rgb: 255,255,255; - --ion-color-primary-shade: #3a7be0; - --ion-color-primary-tint: #5598ff; - - --ion-color-secondary: #50c8ff; - --ion-color-secondary-rgb: 80,200,255; - --ion-color-secondary-contrast: #ffffff; - --ion-color-secondary-contrast-rgb: 255,255,255; - --ion-color-secondary-shade: #46b0e0; - --ion-color-secondary-tint: #62ceff; - - --ion-color-tertiary: #6a64ff; - --ion-color-tertiary-rgb: 106,100,255; - --ion-color-tertiary-contrast: #ffffff; - --ion-color-tertiary-contrast-rgb: 255,255,255; - --ion-color-tertiary-shade: #5d58e0; - --ion-color-tertiary-tint: #7974ff; - - --ion-color-success: #2fdf75; - --ion-color-success-rgb: 47,223,117; - --ion-color-success-contrast: #000000; - --ion-color-success-contrast-rgb: 0,0,0; - --ion-color-success-shade: #29c467; - --ion-color-success-tint: #44e283; - - --ion-color-warning: #ffd534; - --ion-color-warning-rgb: 255,213,52; - --ion-color-warning-contrast: #000000; - --ion-color-warning-contrast-rgb: 0,0,0; - --ion-color-warning-shade: #e0bb2e; - --ion-color-warning-tint: #ffd948; - - --ion-color-danger: #ff4961; - --ion-color-danger-rgb: 255,73,97; - --ion-color-danger-contrast: #ffffff; - --ion-color-danger-contrast-rgb: 255,255,255; - --ion-color-danger-shade: #e04055; - --ion-color-danger-tint: #ff5b71; - - --ion-color-dark: #f4f5f8; - --ion-color-dark-rgb: 244,245,248; - --ion-color-dark-contrast: #000000; - --ion-color-dark-contrast-rgb: 0,0,0; - --ion-color-dark-shade: #d7d8da; - --ion-color-dark-tint: #f5f6f9; - - --ion-color-medium: #989aa2; - --ion-color-medium-rgb: 152,154,162; - --ion-color-medium-contrast: #000000; - --ion-color-medium-contrast-rgb: 0,0,0; - --ion-color-medium-shade: #86888f; - --ion-color-medium-tint: #a2a4ab; - - --ion-color-light: #222428; - --ion-color-light-rgb: 34,36,40; - --ion-color-light-contrast: #ffffff; - --ion-color-light-contrast-rgb: 255,255,255; - --ion-color-light-shade: #1e2023; - --ion-color-light-tint: #383a3e; - } - - /* - * iOS Dark Theme - * ------------------------------------------- - */ - - .ios body { - --ion-background-color: #000000; - --ion-background-color-rgb: 0,0,0; - - --ion-text-color: #ffffff; - --ion-text-color-rgb: 255,255,255; - - --ion-color-step-50: #0d0d0d; - --ion-color-step-100: #1a1a1a; - --ion-color-step-150: #262626; - --ion-color-step-200: #333333; - --ion-color-step-250: #404040; - --ion-color-step-300: #4d4d4d; - --ion-color-step-350: #595959; - --ion-color-step-400: #666666; - --ion-color-step-450: #737373; - --ion-color-step-500: #808080; - --ion-color-step-550: #8c8c8c; - --ion-color-step-600: #999999; - --ion-color-step-650: #a6a6a6; - --ion-color-step-700: #b3b3b3; - --ion-color-step-750: #bfbfbf; - --ion-color-step-800: #cccccc; - --ion-color-step-850: #d9d9d9; - --ion-color-step-900: #e6e6e6; - --ion-color-step-950: #f2f2f2; - - --ion-toolbar-background: #0d0d0d; - - --ion-item-background: #1c1c1c; - --ion-item-background-activated: #313131; - } - - - /* - * Material Design Dark Theme - * ------------------------------------------- - */ - - .md body { - --ion-background-color: #121212; - --ion-background-color-rgb: 18,18,18; - - --ion-text-color: #ffffff; - --ion-text-color-rgb: 255,255,255; - - --ion-border-color: #222222; - - --ion-color-step-50: #1e1e1e; - --ion-color-step-100: #2a2a2a; - --ion-color-step-150: #363636; - --ion-color-step-200: #414141; - --ion-color-step-250: #4d4d4d; - --ion-color-step-300: #595959; - --ion-color-step-350: #656565; - --ion-color-step-400: #717171; - --ion-color-step-450: #7d7d7d; - --ion-color-step-500: #898989; - --ion-color-step-550: #949494; - --ion-color-step-600: #a0a0a0; - --ion-color-step-650: #acacac; - --ion-color-step-700: #b8b8b8; - --ion-color-step-750: #c4c4c4; - --ion-color-step-800: #d0d0d0; - --ion-color-step-850: #dbdbdb; - --ion-color-step-900: #e7e7e7; - --ion-color-step-950: #f3f3f3; - - --ion-item-background: #1A1B1E; - } - - ion-title.title-large { - --color: white; - } +body.dark-theme ion-title.title-large { + --color: white; } /* @@ -343,132 +351,14 @@ body.pink-theme { --ion-card-background: #FCE4EC; --ion-tab-bar-background: #F8BBD0; -} - -/* - * Pink Theme — Dark Mode - * ------------------------------------------- - */ -@media (prefers-color-scheme: dark) { - body.pink-theme { - --ion-color-primary: #F48FB1; - --ion-color-primary-rgb: 244, 143, 177; - --ion-color-primary-contrast: #000000; - --ion-color-primary-contrast-rgb: 0, 0, 0; - --ion-color-primary-shade: #D77E9C; - --ion-color-primary-tint: #F59AB9; - - --ion-color-secondary: #F06292; - --ion-color-secondary-rgb: 240, 98, 146; - --ion-color-secondary-contrast: #ffffff; - --ion-color-secondary-contrast-rgb: 255, 255, 255; - --ion-color-secondary-shade: #D35680; - --ion-color-secondary-tint: #F2729D; - - --ion-color-tertiary: #E91E90; - --ion-color-tertiary-rgb: 233, 30, 144; - --ion-color-tertiary-contrast: #ffffff; - --ion-color-tertiary-contrast-rgb: 255, 255, 255; - --ion-color-tertiary-shade: #CD1A7F; - --ion-color-tertiary-tint: #EB359C; - - --ion-color-success: #F48FB1; - --ion-color-success-rgb: 244, 143, 177; - --ion-color-success-contrast: #000000; - --ion-color-success-contrast-rgb: 0, 0, 0; - --ion-color-success-shade: #D77E9C; - --ion-color-success-tint: #F59AB9; - - --ion-color-warning: #FF8A80; - --ion-color-warning-rgb: 255, 138, 128; - --ion-color-warning-contrast: #000000; - --ion-color-warning-contrast-rgb: 0, 0, 0; - --ion-color-warning-shade: #E07970; - --ion-color-warning-tint: #FF968D; - - --ion-color-danger: #FF5252; - --ion-color-danger-rgb: 255, 82, 82; - --ion-color-danger-contrast: #ffffff; - --ion-color-danger-contrast-rgb: 255, 255, 255; - --ion-color-danger-shade: #E04848; - --ion-color-danger-tint: #FF6363; - - --ion-color-dark: #FCE4EC; - --ion-color-dark-rgb: 252, 228, 236; - --ion-color-dark-contrast: #000000; - --ion-color-dark-contrast-rgb: 0, 0, 0; - --ion-color-dark-shade: #DEC9D0; - --ion-color-dark-tint: #FCE7EE; - - --ion-color-medium: #C48BA0; - --ion-color-medium-rgb: 196, 139, 160; - --ion-color-medium-contrast: #000000; - --ion-color-medium-contrast-rgb: 0, 0, 0; - --ion-color-medium-shade: #AC7A8D; - --ion-color-medium-tint: #CA97AA; - - --ion-color-light: #2D0317; - --ion-color-light-rgb: 45, 3, 23; - --ion-color-light-contrast: #ffffff; - --ion-color-light-contrast-rgb: 255, 255, 255; - --ion-color-light-shade: #280314; - --ion-color-light-tint: #421C2E; - - --ion-background-color: #1A0A10; - --ion-background-color-rgb: 26, 10, 16; - - --ion-text-color: #F8E0E8; - --ion-text-color-rgb: 248, 224, 232; - - --ion-toolbar-background: #2D0317; - --ion-toolbar-color: #F8E0E8; - - --ion-item-background: #3A0420; - --ion-item-background-activated: #4D0A2E; - - --ion-border-color: #5C1038; - - --ion-color-step-50: #231018; - --ion-color-step-100: #2D1520; - --ion-color-step-150: #361B28; - --ion-color-step-200: #402130; - --ion-color-step-250: #4A2738; - --ion-color-step-300: #542D40; - --ion-color-step-350: #5D3348; - --ion-color-step-400: #673950; - --ion-color-step-450: #713F58; - --ion-color-step-500: #7B4560; - --ion-color-step-550: #854B68; - --ion-color-step-600: #8E5170; - --ion-color-step-650: #985778; - --ion-color-step-700: #A25D80; - --ion-color-step-750: #AC6388; - --ion-color-step-800: #B56990; - --ion-color-step-850: #BF6F98; - --ion-color-step-900: #C975A0; - --ion-color-step-950: #D37BA8; - - --ion-card-background: #2D0317; - --ion-tab-bar-background: #2D0317; - } - - .ios body.pink-theme { - --ion-background-color: #1A0A10; - --ion-background-color-rgb: 26, 10, 16; - --ion-toolbar-background: #1A0A10; - --ion-item-background: #2D0317; - --ion-item-background-activated: #3A0420; - } - - .md body.pink-theme { - --ion-background-color: #1A0A10; - --ion-background-color-rgb: 26, 10, 16; - --ion-border-color: #3A0420; - --ion-item-background: #2D0317; - } - - body.pink-theme ion-title.title-large { - --color: #F8E0E8; - } + /** Custom FlickPlayer Design Tokens **/ + --flick-date-color: #AD1457; + --flick-date-divider-color: #AD1457; + --flick-time-info-color: #E91E90; + --flick-subtext-color: #C48BA0; + --flick-subtext-link-color: #D4A0B5; + --flick-card-title-color: white; + --flick-welcome-card-shadow: 0 4px 24px rgba(233, 30, 144, 0.18), 0 1.5px 8px rgba(244, 143, 177, 0.12); + --flick-welcome-card-border: 1px solid rgba(244, 143, 177, 0.3); }