From dbfe5b38abf59256e2407f99254153754ff14738 Mon Sep 17 00:00:00 2001 From: Teriuihi Date: Thu, 3 Apr 2025 22:21:21 +0200 Subject: [PATCH] Add theme switcher component with dark and light mode support Extracted theme toggle functionality into a new `app-theme` component to improve modularity and reusability. Integrated theme settings with a service using cookies for persistence and introduced the `THEME_MODE` enum. Updated header component to utilize the new theme switcher and removed inline implementation. --- frontend/package.json | 1 + frontend/src/app/app.module.ts | 15 ++-- frontend/src/app/constant.ts | 4 + frontend/src/app/header/header.component.html | 7 +- frontend/src/app/header/header.component.scss | 80 ------------------- frontend/src/app/theme/theme.component.html | 6 ++ frontend/src/app/theme/theme.component.scss | 75 +++++++++++++++++ .../src/app/theme/theme.component.spec.ts | 23 ++++++ frontend/src/app/theme/theme.component.ts | 35 ++++++++ frontend/src/app/theme/theme.service.spec.ts | 16 ++++ frontend/src/app/theme/theme.service.ts | 44 ++++++++++ 11 files changed, 214 insertions(+), 92 deletions(-) create mode 100644 frontend/src/app/theme/theme.component.html create mode 100644 frontend/src/app/theme/theme.component.scss create mode 100644 frontend/src/app/theme/theme.component.spec.ts create mode 100644 frontend/src/app/theme/theme.component.ts create mode 100644 frontend/src/app/theme/theme.service.spec.ts create mode 100644 frontend/src/app/theme/theme.service.ts diff --git a/frontend/package.json b/frontend/package.json index fdc61cc..eae7c8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", + "ngx-cookie-service": "^19.1.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 8148fa7..2c12a4f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -5,23 +5,26 @@ import {RouterModule, Routes} from '@angular/router'; import { HeaderComponent } from './header/header.component'; import { HomeComponent } from './home/home.component'; import {NgOptimizedImage} from '@angular/common'; +import {ThemeComponent} from "./theme/theme.component"; +import {CookieService} from 'ngx-cookie-service'; const routes: Routes = [ { path: '', component: HomeComponent } ]; @NgModule({ - declarations: [ - AppComponent, - HeaderComponent, - HomeComponent - ], + declarations: [ + AppComponent, + HeaderComponent, + HomeComponent, + ThemeComponent + ], imports: [ BrowserModule, RouterModule.forRoot(routes), NgOptimizedImage, ], - providers: [], + providers: [CookieService], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/frontend/src/app/constant.ts b/frontend/src/app/constant.ts index 6fb8a8c..9fa462d 100644 --- a/frontend/src/app/constant.ts +++ b/frontend/src/app/constant.ts @@ -1 +1,5 @@ export const ALTITUDE_VERSION = "1.21" +export const enum THEME_MODE { + LIGHT= 'theme-light', + DARK = 'theme-dark' +} diff --git a/frontend/src/app/header/header.component.html b/frontend/src/app/header/header.component.html index 4a92abb..42c9ebd 100644 --- a/frontend/src/app/header/header.component.html +++ b/frontend/src/app/header/header.component.html @@ -126,12 +126,7 @@ -
- -
+ diff --git a/frontend/src/app/header/header.component.scss b/frontend/src/app/header/header.component.scss index e653188..ed04791 100644 --- a/frontend/src/app/header/header.component.scss +++ b/frontend/src/app/header/header.component.scss @@ -251,86 +251,6 @@ nav img { display: none; } -/* darkmode switch start */ - -/* The switch - the box around the slider */ -.switch { - position: relative; - display: inline-block; - width: 60px; - height: 25px; -} - -.switch-div{ - position: absolute; - right: 40px; -} - -/* Hide default HTML checkbox */ -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -/* The slider */ -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - -webkit-transition: 0.4s; - transition: 0.4s; -} - -.slider:before { - position: absolute; - content: ""; - height: 37px; - width: 37px; - left: 0; - top: 0; - bottom: 0; - margin: auto 0; - -webkit-transition: 0.4s; - transition: 0.4s; - box-shadow: 0 0 15px #2020203d; - background: var(--switch) url('https://i.ibb.co/7JfqXxB/sunny.png'); - background-repeat: no-repeat; - background-position: center; -} - -input:checked + .slider { - background-color: var(--link-color); -} - -input:focus + .slider { - box-shadow: 0 0 1px var(--link-color); -} - -input:checked + .slider:before { - -webkit-transform: translateX(24px); - -ms-transform: translateX(24px); - transform: translateX(24px); - background: var(--switch) url('https://i.ibb.co/FxzBYR9/night.png'); - background-repeat: no-repeat; - background-position: center; -} - -/* Rounded sliders */ -.slider.round { - border-radius: 34px; -} - -.slider.round:before { - border-radius: 50%; -} - -/* darkmode switch end */ - @-moz-document url-prefix() { .dropdown2{ top: 12px; diff --git a/frontend/src/app/theme/theme.component.html b/frontend/src/app/theme/theme.component.html new file mode 100644 index 0000000..8ae21cf --- /dev/null +++ b/frontend/src/app/theme/theme.component.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/frontend/src/app/theme/theme.component.scss b/frontend/src/app/theme/theme.component.scss new file mode 100644 index 0000000..dce2175 --- /dev/null +++ b/frontend/src/app/theme/theme.component.scss @@ -0,0 +1,75 @@ + +.switch { + position: relative; + display: inline-block; + width: 60px; + height: 25px; +} + +.switch-div{ + position: absolute; + right: 40px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: 0.4s; + transition: 0.4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 37px; + width: 37px; + left: 0; + top: 0; + bottom: 0; + margin: auto 0; + -webkit-transition: 0.4s; + transition: 0.4s; + box-shadow: 0 0 15px #2020203d; + background: var(--switch) url('https://i.ibb.co/7JfqXxB/sunny.png'); + background-repeat: no-repeat; + background-position: center; +} + +input:checked + .slider { + background-color: var(--link-color); +} + +input:focus + .slider { + box-shadow: 0 0 1px var(--link-color); +} + +input:checked + .slider:before { + -webkit-transform: translateX(24px); + -ms-transform: translateX(24px); + transform: translateX(24px); + background: var(--switch) url('https://i.ibb.co/FxzBYR9/night.png'); + background-repeat: no-repeat; + background-position: center; +} + +/* Rounded sliders */ +.slider.round { + border-radius: 34px; +} + +.slider.round:before { + border-radius: 50%; +} diff --git a/frontend/src/app/theme/theme.component.spec.ts b/frontend/src/app/theme/theme.component.spec.ts new file mode 100644 index 0000000..7472879 --- /dev/null +++ b/frontend/src/app/theme/theme.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ThemeComponent } from './theme.component'; + +describe('ThemeComponent', () => { + let component: ThemeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ThemeComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ThemeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/theme/theme.component.ts b/frontend/src/app/theme/theme.component.ts new file mode 100644 index 0000000..97feab4 --- /dev/null +++ b/frontend/src/app/theme/theme.component.ts @@ -0,0 +1,35 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {ThemeService} from './theme.service'; +import {THEME_MODE} from '../constant'; + +@Component({ + standalone: false, + selector: 'app-theme', + templateUrl: './theme.component.html', + styleUrl: './theme.component.scss' +}) +export class ThemeComponent implements OnInit, OnDestroy { + isDarkMode: boolean = false; + private themeSubscription: Subscription | null = null; + + constructor(private themeService: ThemeService) {} + + ngOnInit(): void { + this.themeSubscription = this.themeService.theme$.subscribe(theme => { + this.isDarkMode = theme === THEME_MODE.DARK; + }); + } + + toggleTheme(): void { + this.isDarkMode = this.themeService.toggleTheme() === THEME_MODE.DARK; + } + + ngOnDestroy(): void { + if (this.themeSubscription) { + this.themeSubscription.unsubscribe(); + } + } + + +} diff --git a/frontend/src/app/theme/theme.service.spec.ts b/frontend/src/app/theme/theme.service.spec.ts new file mode 100644 index 0000000..1c2957b --- /dev/null +++ b/frontend/src/app/theme/theme.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ThemeService } from './theme.service'; + +describe('ThemeService', () => { + let service: ThemeService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ThemeService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/theme/theme.service.ts b/frontend/src/app/theme/theme.service.ts new file mode 100644 index 0000000..a5c0e34 --- /dev/null +++ b/frontend/src/app/theme/theme.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { CookieService } from 'ngx-cookie-service'; +import { BehaviorSubject } from 'rxjs'; +import {THEME_MODE} from '../constant'; + +@Injectable({ + providedIn: 'root' +}) +export class ThemeService { + private readonly THEME_KEY = 'theme'; + + private themeSubject: BehaviorSubject = new BehaviorSubject(THEME_MODE.DARK); + public theme$ = this.themeSubject.asObservable(); + + constructor(private cookieService: CookieService) { + this.initializeTheme(); + } + + public initializeTheme(): void { + const savedTheme = this.cookieService.check(this.THEME_KEY) + ? this.cookieService.get(this.THEME_KEY) + : THEME_MODE.DARK; + let currentTheme: THEME_MODE; + switch (savedTheme) { + case THEME_MODE.LIGHT: currentTheme = THEME_MODE.LIGHT; break; + case THEME_MODE.DARK: + default: currentTheme = THEME_MODE.DARK; + } + this.setTheme(currentTheme); + } + + public setTheme(themeName: THEME_MODE): void { + this.cookieService.set(this.THEME_KEY, themeName, 365); + document.body.className = themeName; + this.themeSubject.next(themeName); + } + + public toggleTheme(): THEME_MODE { + const currentTheme = this.themeSubject.getValue(); + const newTheme = currentTheme === THEME_MODE.LIGHT ? THEME_MODE.DARK : THEME_MODE.LIGHT; + this.setTheme(newTheme); + return newTheme + } +}