diff --git a/angular.json b/angular.json index 59d55ea..8193d63 100644 --- a/angular.json +++ b/angular.json @@ -22,7 +22,9 @@ "base": "dist/wgenerator" }, "index": "src/index.html", - "polyfills": ["src/polyfills.ts"], + "polyfills": [ + "src/polyfills.ts" + ], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "less", "assets": [ @@ -38,9 +40,17 @@ "src/assets", "src/manifest.webmanifest" ], - "styles": ["src/custom-theme.scss", "src/styles/styles.less", "src/styles/shadow.less"], + "styles": [ + "src/custom-theme.scss", + "src/styles/styles.less", + "src/styles/shadow.less" + ], "scripts": [], - "allowedCommonJsDependencies": ["lodash", "docx", "qrcode"] + "allowedCommonJsDependencies": [ + "lodash", + "docx", + "qrcode" + ] }, "configurations": { "production": { @@ -58,8 +68,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumWarning": "40kB", + "maximumError": "80kB" } ], "outputHashing": "all" @@ -89,14 +99,19 @@ "options": { "runner": "vitest", "tsConfig": "tsconfig.spec.json", - "setupFiles": ["src/test-vitest.ts"], + "setupFiles": [ + "src/test-vitest.ts" + ], "runnerConfig": true } }, "lint": { "builder": "@angular-eslint/builder:lint", "options": { - "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] } } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4a4b340..1057368 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,8 @@ -import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, OnInit, inject} from '@angular/core'; import {fader} from './animations'; import {RouterOutlet} from '@angular/router'; import {NavigationComponent} from './widget-modules/components/application-frame/navigation/navigation.component'; +import {ThemeService} from './services/theme/theme.service'; @Component({ selector: 'app-root', @@ -12,6 +13,10 @@ import {NavigationComponent} from './widget-modules/components/application-frame imports: [RouterOutlet, NavigationComponent], }) export class AppComponent implements OnInit { + public constructor() { + inject(ThemeService); + } + public ngOnInit(): void { setTimeout(() => document.querySelector('#load-bg')?.classList.add('hidden'), 1000); setTimeout(() => document.querySelector('#load-bg')?.remove(), 5000); diff --git a/src/app/services/theme/theme.service.ts b/src/app/services/theme/theme.service.ts new file mode 100644 index 0000000..570018b --- /dev/null +++ b/src/app/services/theme/theme.service.ts @@ -0,0 +1,48 @@ +import {DOCUMENT} from '@angular/common'; +import {Injectable, inject, signal} from '@angular/core'; + +type ThemeMode = 'light' | 'dark'; + +@Injectable({providedIn: 'root'}) +export class ThemeService { + private readonly document = inject(DOCUMENT); + private readonly storageKey = 'wgenerator-theme'; + + public readonly theme = signal('light'); + public readonly isDarkMode = signal(false); + + public constructor() { + this.initializeTheme(); + } + + public toggleTheme(): void { + this.setTheme(this.isDarkMode() ? 'light' : 'dark'); + } + + public setTheme(theme: ThemeMode): void { + this.theme.set(theme); + this.isDarkMode.set(theme === 'dark'); + this.applyTheme(theme); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(this.storageKey, theme); + } + } + + private initializeTheme(): void { + const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem(this.storageKey) : null; + if (storedTheme === 'light' || storedTheme === 'dark') { + this.setTheme(storedTheme); + return; + } + + const prefersDarkMode = typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-color-scheme: dark)').matches; + this.setTheme(prefersDarkMode ? 'dark' : 'light'); + } + + private applyTheme(theme: ThemeMode): void { + this.document.body.classList.toggle('theme-dark', theme === 'dark'); + this.document.documentElement.style.colorScheme = theme; + } +} diff --git a/src/app/widget-modules/components/application-frame/navigation/navigation.component.html b/src/app/widget-modules/components/application-frame/navigation/navigation.component.html index a34a84f..0c45c12 100644 --- a/src/app/widget-modules/components/application-frame/navigation/navigation.component.html +++ b/src/app/widget-modules/components/application-frame/navigation/navigation.component.html @@ -6,7 +6,17 @@ -
+
+ +
+ diff --git a/src/app/widget-modules/components/application-frame/navigation/navigation.component.less b/src/app/widget-modules/components/application-frame/navigation/navigation.component.less index 4bd6d82..55046d5 100644 --- a/src/app/widget-modules/components/application-frame/navigation/navigation.component.less +++ b/src/app/widget-modules/components/application-frame/navigation/navigation.component.less @@ -32,9 +32,25 @@ nav { display: flex; height: 100%; align-items: center; + padding-right: 12px; +} + +.actions-search { padding-right: 20px; } +.theme-toggle { + color: var(--text-inverse); + transition: var(--transition-fast); + + &:hover { + background: var(--hover-background); + } +} + +.theme-toggle fa-icon { + font-size: 14px; +} .links { display: flex; diff --git a/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts b/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts index 98a1e66..9c3e3ad 100644 --- a/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts +++ b/src/app/widget-modules/components/application-frame/navigation/navigation.component.ts @@ -1,5 +1,7 @@ -import {Component} from '@angular/core'; -import {faChalkboard, faMusic, faPersonBooth, faUserCog} from '@fortawesome/free-solid-svg-icons'; +import {Component, inject} from '@angular/core'; +import {MatIconButton} from '@angular/material/button'; +import {FaIconComponent} from '@fortawesome/angular-fontawesome'; +import {faChalkboard, faMoon, faMusic, faPersonBooth, faSun, faUserCog} from '@fortawesome/free-solid-svg-icons'; import {fromEvent, Observable} from 'rxjs'; import {distinctUntilChanged, map, shareReplay, startWith} from 'rxjs/operators'; import {BrandComponent} from './brand/brand.component'; @@ -8,18 +10,22 @@ import {RoleDirective} from '../../../../services/user/role.directive'; import {LinkComponent} from './link/link.component'; import {FilterComponent} from './filter/filter.component'; import {AsyncPipe} from '@angular/common'; +import {ThemeService} from '../../../../services/theme/theme.service'; @Component({ selector: 'app-navigation', templateUrl: './navigation.component.html', styleUrls: ['./navigation.component.less'], - imports: [BrandComponent, RouterLink, RoleDirective, LinkComponent, FilterComponent, AsyncPipe], + imports: [BrandComponent, RouterLink, RoleDirective, LinkComponent, FilterComponent, AsyncPipe, MatIconButton, FaIconComponent], }) export class NavigationComponent { + public readonly themeService = inject(ThemeService); public faSongs = faMusic; public faShows = faPersonBooth; public faUser = faUserCog; public faPresentation = faChalkboard; + public faDarkMode = faMoon; + public faLightMode = faSun; public readonly windowScroll$: Observable = fromEvent(window, 'scroll').pipe( map(() => window.scrollY), diff --git a/src/custom-theme.scss b/src/custom-theme.scss index 86dd12e..8f9b702 100644 --- a/src/custom-theme.scss +++ b/src/custom-theme.scss @@ -5,18 +5,123 @@ @include mat.elevation-classes(); @include mat.app-background(); -$wgenerator-primary: mat.m2-define-palette(mat.$m2-indigo-palette); -$wgenerator-accent: mat.m2-define-palette(mat.$m2-pink-palette, A200, A100, A400); -$wgenerator-warn: mat.m2-define-palette(mat.$m2-red-palette); - -$wgenerator-theme: mat.m2-define-light-theme(( +$wgenerator-material-theme: mat.define-theme(( color: ( - primary: $wgenerator-primary, - accent: $wgenerator-accent, - warn: $wgenerator-warn, + theme-type: light, + primary: mat.$azure-palette, + tertiary: mat.$blue-palette, + use-system-variables: true, + ), + typography: ( + use-system-variables: true, + ), + density: ( + scale: -2, ), - typography: mat.m2-define-typography-config(), - density: -2, )); -@include mat.all-component-themes($wgenerator-theme); +@include mat.all-component-themes($wgenerator-material-theme); + +:root { + @include mat.theme(( + color: ( + theme-type: light, + primary: mat.$azure-palette, + tertiary: mat.$blue-palette, + ), + typography: Roboto, + density: -2, + )); + + @include mat.theme-overrides(( + background: var(--surface), + error: var(--danger), + error-container: color-mix(in srgb, var(--danger) 18%, var(--surface-strong)), + inverse-on-surface: var(--text-inverse), + inverse-primary: var(--primary-hover), + inverse-surface: var(--bg-deep), + on-background: var(--text), + on-error: var(--text-inverse), + on-error-container: var(--text), + on-primary: var(--text-inverse), + on-primary-container: var(--text), + on-secondary: var(--text-inverse), + on-secondary-container: var(--text), + on-surface: var(--text), + on-surface-variant: var(--text-soft), + on-tertiary: var(--text-inverse), + on-tertiary-container: var(--text), + outline: var(--surface-border), + outline-variant: var(--divider), + primary: var(--primary-color), + primary-container: color-mix(in srgb, var(--primary-color) 22%, var(--surface-strong)), + secondary: var(--primary-hover), + secondary-container: color-mix(in srgb, var(--primary-hover) 20%, var(--surface-strong)), + scrim: rgba(18, 24, 37, 0.62), + shadow: rgba(18, 24, 37, 0.28), + surface: var(--surface), + surface-bright: var(--surface-strong), + surface-container: color-mix(in srgb, var(--surface) 88%, white), + surface-container-high: color-mix(in srgb, var(--surface-strong) 92%, var(--primary-hover)), + surface-container-highest: color-mix(in srgb, var(--surface-strong) 84%, var(--primary-hover)), + surface-container-low: color-mix(in srgb, var(--surface) 96%, white), + surface-container-lowest: var(--surface-strong), + surface-dim: color-mix(in srgb, var(--surface) 78%, var(--surface-dark)), + surface-tint: var(--primary-color), + surface-variant: color-mix(in srgb, var(--surface) 72%, var(--primary-hover)), + tertiary: var(--accent-color), + tertiary-container: color-mix(in srgb, var(--accent-color) 18%, var(--surface-strong)), + )); +} + +body.theme-dark { + @include mat.theme(( + color: ( + theme-type: dark, + primary: mat.$azure-palette, + tertiary: mat.$blue-palette, + ), + typography: Roboto, + density: -2, + )); + + @include mat.theme-overrides(( + background: var(--surface-dark), + error: var(--danger), + error-container: color-mix(in srgb, var(--danger) 24%, rgba(255, 255, 255, 0.18)), + inverse-on-surface: var(--text), + inverse-primary: var(--primary-color), + inverse-surface: var(--text-inverse), + on-background: var(--text), + on-error: var(--text-inverse), + on-error-container: var(--text), + on-primary: #07111b, + on-primary-container: var(--text), + on-secondary: #07111b, + on-secondary-container: var(--text), + on-surface: var(--text), + on-surface-variant: color-mix(in srgb, var(--text) 78%, white), + on-tertiary: #07111b, + on-tertiary-container: var(--text), + outline: rgba(206, 223, 229, 0.34), + outline-variant: rgba(206, 223, 229, 0.22), + primary: var(--primary-hover), + primary-container: color-mix(in srgb, var(--primary-color) 36%, rgba(255, 255, 255, 0.18)), + secondary: color-mix(in srgb, var(--primary-hover) 92%, white), + secondary-container: color-mix(in srgb, var(--primary-active) 32%, rgba(255, 255, 255, 0.18)), + scrim: rgba(4, 10, 18, 0.82), + shadow: rgba(0, 0, 0, 0.45), + surface: rgba(36, 54, 72, 0.96), + surface-bright: rgba(46, 68, 89, 0.98), + surface-container: rgba(41, 61, 80, 0.97), + surface-container-high: rgba(48, 71, 93, 0.98), + surface-container-highest: rgba(57, 82, 106, 0.98), + surface-container-low: rgba(32, 48, 63, 0.94), + surface-container-lowest: rgba(25, 38, 50, 0.94), + surface-dim: rgba(20, 31, 42, 0.92), + surface-tint: var(--primary-hover), + surface-variant: rgba(52, 75, 96, 0.95), + tertiary: var(--accent-color), + tertiary-container: color-mix(in srgb, var(--accent-color) 26%, rgba(255, 255, 255, 0.18)), + )); +} diff --git a/src/styles/styles.less b/src/styles/styles.less index 9cf467d..a277992 100644 --- a/src/styles/styles.less +++ b/src/styles/styles.less @@ -50,6 +50,150 @@ --gap-s: calc(var(--gap-m) / 1.618); } +body.theme-dark { + --bg-deep: #07111b; + --bg-mid: #102435; + --bg-soft: #1d3d4a; + + --surface: rgba(13, 22, 34, 0.82); + --surface-strong: rgba(18, 31, 46, 0.94); + --surface-dark: rgba(4, 10, 18, 0.88); + --surface-border: rgba(167, 198, 206, 0.24); + --surface-subtle: rgba(255, 255, 255, 0.06); + --surface-muted: rgba(167, 198, 206, 0.08); + + --text: #e8f0f4; + --text-soft: #b7c9cf; + --text-inverse: #f7fbff; + + --color-primary-dark: #89acb5; + --color-primary: #7ea2ab; + --color-primary-light: #a7c6ce; + --primary-color-sat: #73bbc7; + --primary-color: #7ea2ab; + --primary-hover: #a7c6ce; + --primary-active: #638892; + --accent-color: #8ce3ca; + + --navigation-background: rgba(4, 10, 18, 0.82); + --hover-background: rgba(126, 162, 171, 0.18); + --overlay: rgba(4, 10, 18, 0.68); + --overlay-strong: rgba(4, 10, 18, 0.9); + --divider: rgba(232, 240, 244, 0.12); + --link-color: var(--primary-hover); + --focus-ring: 0 0 0 2px rgba(126, 162, 171, 0.34); + --icon-button-color: var(--primary-hover); + --icon-button-hover-color: var(--text-inverse); + + --mat-button-text-label-text-color: var(--text); + --mat-option-label-text-color: var(--text); + --mat-option-selected-state-label-text-color: var(--primary-hover); + --mat-optgroup-label-text-color: var(--text-soft); + --mat-select-enabled-trigger-text-color: var(--text); + --mat-select-disabled-trigger-text-color: rgba(232, 240, 244, 0.38); + --mat-select-placeholder-text-color: var(--text-soft); + --mat-select-enabled-arrow-color: var(--text-soft); + --mat-select-focused-arrow-color: var(--primary-hover); + --mat-form-field-enabled-select-arrow-color: var(--text-soft); + --mat-form-field-focus-select-arrow-color: var(--primary-hover); + --mat-form-field-outlined-input-text-color: var(--text); + --mat-form-field-outlined-label-text-color: var(--text-soft); + --mat-form-field-outlined-hover-label-text-color: var(--text); + --mat-form-field-outlined-focus-label-text-color: var(--primary-hover); + --mat-form-field-outlined-outline-color: rgba(206, 223, 229, 0.22); + --mat-form-field-outlined-hover-outline-color: rgba(206, 223, 229, 0.42); + --mat-form-field-outlined-focus-outline-color: var(--primary-hover); + --mat-form-field-outlined-caret-color: var(--primary-hover); + --mat-form-field-filled-input-text-color: var(--text); + --mat-form-field-filled-label-text-color: var(--text-soft); + --mat-form-field-filled-hover-label-text-color: var(--text); + --mat-form-field-filled-focus-label-text-color: var(--primary-hover); + --mat-form-field-filled-container-color: rgba(36, 54, 72, 0.96); + --mat-form-field-filled-active-indicator-color: rgba(206, 223, 229, 0.28); + --mat-form-field-filled-hover-active-indicator-color: rgba(206, 223, 229, 0.48); + --mat-form-field-filled-focus-active-indicator-color: var(--primary-hover); + --mat-form-field-input-text-placeholder-color: var(--text-soft); + --mat-form-field-filled-input-text-placeholder-color: var(--text-soft); + --mat-form-field-outlined-input-text-placeholder-color: var(--text-soft); + --mat-dialog-container-color: rgba(41, 61, 80, 0.98); + --mat-dialog-subhead-color: var(--text); + --mat-dialog-supporting-text-color: var(--text); + --mat-select-panel-background-color: rgba(41, 61, 80, 0.98); + --mat-autocomplete-background-color: rgba(41, 61, 80, 0.98); + --mat-menu-container-color: rgba(41, 61, 80, 0.98); + --mat-menu-item-label-text-color: var(--text); + --mat-menu-item-icon-color: var(--text-soft); + --mat-menu-item-hover-state-layer-color: rgba(255, 255, 255, 0.08); + --mat-menu-item-focus-state-layer-color: rgba(255, 255, 255, 0.12); + --mat-menu-divider-color: rgba(206, 223, 229, 0.16); +} + +body.theme-dark .mat-mdc-text-field-wrapper { + background-color: rgba(36, 54, 72, 0.96); +} + +body.theme-dark .mdc-notched-outline__leading, +body.theme-dark .mdc-notched-outline__notch, +body.theme-dark .mdc-notched-outline__trailing { + border-color: rgba(206, 223, 229, 0.22) !important; +} + +body.theme-dark .mat-mdc-form-field:hover .mdc-notched-outline__leading, +body.theme-dark .mat-mdc-form-field:hover .mdc-notched-outline__notch, +body.theme-dark .mat-mdc-form-field:hover .mdc-notched-outline__trailing { + border-color: rgba(206, 223, 229, 0.42) !important; +} + +body.theme-dark .mat-mdc-form-field.mat-focused .mdc-notched-outline__leading, +body.theme-dark .mat-mdc-form-field.mat-focused .mdc-notched-outline__notch, +body.theme-dark .mat-mdc-form-field.mat-focused .mdc-notched-outline__trailing { + border-color: var(--primary-hover) !important; +} + +body.theme-dark .mat-mdc-input-element, +body.theme-dark .mat-mdc-select-value, +body.theme-dark .mat-mdc-select-min-line { + color: var(--text) !important; +} + +body.theme-dark .mat-mdc-form-field .mat-mdc-floating-label, +body.theme-dark .mat-mdc-form-field .mat-mdc-select-placeholder { + color: var(--text-soft) !important; +} + +body.theme-dark .mat-mdc-select-arrow svg { + fill: var(--text-soft) !important; +} + +body.theme-dark .cdk-overlay-container .mat-mdc-select-panel, +body.theme-dark .cdk-overlay-container .mat-mdc-autocomplete-panel, +body.theme-dark .cdk-overlay-container .mat-mdc-menu-panel { + background: rgba(41, 61, 80, 0.98) !important; + color: var(--text) !important; +} + +body.theme-dark .cdk-overlay-container .mat-mdc-select-panel, +body.theme-dark .cdk-overlay-container .mat-mdc-autocomplete-panel { + --mat-select-panel-background-color: rgba(41, 61, 80, 0.98); + --mat-autocomplete-background-color: rgba(41, 61, 80, 0.98); +} + +body.theme-dark .cdk-overlay-container .mat-mdc-option .mdc-list-item__primary-text, +body.theme-dark .cdk-overlay-container .mat-mdc-option .mat-pseudo-checkbox, +body.theme-dark .cdk-overlay-container .mat-mdc-menu-item .mat-mdc-menu-item-text { + color: var(--text) !important; +} + +body.theme-dark .cdk-overlay-container .mat-mdc-option.mdc-list-item--selected .mdc-list-item__primary-text { + color: var(--primary-hover) !important; +} + +body.theme-dark .cdk-overlay-container .mat-mdc-option:hover:not(.mdc-list-item--disabled), +body.theme-dark .cdk-overlay-container .mat-mdc-option.mdc-list-item--selected:not(.mdc-list-item--disabled), +body.theme-dark .cdk-overlay-container .mat-mdc-menu-item:hover:not([disabled]) { + background: rgba(255, 255, 255, 0.08) !important; +} + html { scroll-behavior: auto; }