From 9f48d3565aff3af66266aaa174b8bf16fe0fa013 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 26 Apr 2026 01:28:07 +0300 Subject: [PATCH] Add customizable theme color (#582) --- Logo/logo.svg | 21 +- .../public/icons/ext/apprise-dark.svg | 1 + .../public/icons/ext/discord-dark.svg | 1 + .../frontend/public/icons/ext/gotify-dark.svg | 1 + .../frontend/public/icons/ext/lidarr-dark.svg | 1 + .../public/icons/ext/notifiarr-dark.svg | 1 + code/frontend/public/icons/ext/ntfy-dark.svg | 1 + .../public/icons/ext/pushover-dark.svg | 1 + .../frontend/public/icons/ext/radarr-dark.svg | 1 + .../public/icons/ext/readarr-dark.svg | 1 + .../frontend/public/icons/ext/sonarr-dark.svg | 1 + .../public/icons/ext/telegram-dark.svg | 1 + .../public/icons/ext/whisparr-dark.svg | 1 + code/frontend/src/app/app.config.ts | 2 + code/frontend/src/app/app.routes.ts | 7 + .../src/app/core/services/theme.service.ts | 195 +++++++++++++++++- .../features/auth/login/login.component.scss | 8 +- .../features/auth/setup/setup.component.scss | 6 +- .../dashboard/dashboard.component.scss | 14 +- .../app/features/events/events.component.scss | 6 +- .../logs-component/logs.component.scss | 6 +- .../quality-tab/quality-tab.component.scss | 2 +- .../searches-tab/searches-tab.component.scss | 4 +- .../upgrades-tab/upgrades-tab.component.scss | 2 +- .../appearance-settings.component.html | 65 ++++++ .../appearance-settings.component.scss | 132 ++++++++++++ .../appearance-settings.component.ts | 51 +++++ .../notifications.component.html | 4 +- .../notifications/notifications.component.ts | 4 + .../features/strikes/strikes.component.scss | 2 +- .../auth-layout/auth-layout.component.html | 2 +- .../auth-layout/auth-layout.component.scss | 2 +- .../auth-layout/auth-layout.component.ts | 3 +- .../nav-sidebar/nav-sidebar.component.html | 4 +- .../nav-sidebar/nav-sidebar.component.scss | 24 +-- .../nav-sidebar/nav-sidebar.component.ts | 8 +- .../app/layout/toolbar/toolbar.component.scss | 4 +- .../src/app/ui/badge/badge.component.scss | 4 +- .../src/app/ui/button/button.component.scss | 8 +- .../src/app/ui/card/card.component.scss | 4 +- .../ui/chip-input/chip-input.component.scss | 4 +- code/frontend/src/app/ui/index.ts | 1 + .../src/app/ui/logo/logo.component.html | 12 ++ .../src/app/ui/logo/logo.component.scss | 11 + .../src/app/ui/logo/logo.component.ts | 12 ++ .../src/app/ui/modal/modal.component.scss | 2 +- .../number-input/number-input.component.scss | 2 +- .../ui/size-input/size-input.component.scss | 2 +- code/frontend/src/styles.scss | 5 +- code/frontend/src/styles/_accents.scss | 94 +++++++++ code/frontend/src/styles/_animations.scss | 6 +- code/frontend/src/styles/_glass.scss | 4 +- code/frontend/src/styles/_list-layout.scss | 2 +- code/frontend/src/styles/_themes.scss | 96 ++++++--- code/frontend/src/styles/_tokens.scss | 5 + code/frontend/src/styles/_variables.scss | 24 +-- docs/static/img/cleanuparr.svg | 21 +- 57 files changed, 774 insertions(+), 135 deletions(-) create mode 100644 code/frontend/public/icons/ext/apprise-dark.svg create mode 100644 code/frontend/public/icons/ext/discord-dark.svg create mode 100644 code/frontend/public/icons/ext/gotify-dark.svg create mode 100644 code/frontend/public/icons/ext/lidarr-dark.svg create mode 100644 code/frontend/public/icons/ext/notifiarr-dark.svg create mode 100644 code/frontend/public/icons/ext/ntfy-dark.svg create mode 100644 code/frontend/public/icons/ext/pushover-dark.svg create mode 100644 code/frontend/public/icons/ext/radarr-dark.svg create mode 100644 code/frontend/public/icons/ext/readarr-dark.svg create mode 100644 code/frontend/public/icons/ext/sonarr-dark.svg create mode 100644 code/frontend/public/icons/ext/telegram-dark.svg create mode 100644 code/frontend/public/icons/ext/whisparr-dark.svg create mode 100644 code/frontend/src/app/features/settings/appearance/appearance-settings.component.html create mode 100644 code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss create mode 100644 code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts create mode 100644 code/frontend/src/app/ui/logo/logo.component.html create mode 100644 code/frontend/src/app/ui/logo/logo.component.scss create mode 100644 code/frontend/src/app/ui/logo/logo.component.ts create mode 100644 code/frontend/src/styles/_accents.scss diff --git a/Logo/logo.svg b/Logo/logo.svg index 22cfc2c6..fa442ca2 100644 --- a/Logo/logo.svg +++ b/Logo/logo.svg @@ -1,10 +1,13 @@ - - - - - - - - - + + + + + + + + + + + + diff --git a/code/frontend/public/icons/ext/apprise-dark.svg b/code/frontend/public/icons/ext/apprise-dark.svg new file mode 100644 index 00000000..9d06b8fe --- /dev/null +++ b/code/frontend/public/icons/ext/apprise-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/discord-dark.svg b/code/frontend/public/icons/ext/discord-dark.svg new file mode 100644 index 00000000..e0e26269 --- /dev/null +++ b/code/frontend/public/icons/ext/discord-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/gotify-dark.svg b/code/frontend/public/icons/ext/gotify-dark.svg new file mode 100644 index 00000000..d14e9fcb --- /dev/null +++ b/code/frontend/public/icons/ext/gotify-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/lidarr-dark.svg b/code/frontend/public/icons/ext/lidarr-dark.svg new file mode 100644 index 00000000..e869266d --- /dev/null +++ b/code/frontend/public/icons/ext/lidarr-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/notifiarr-dark.svg b/code/frontend/public/icons/ext/notifiarr-dark.svg new file mode 100644 index 00000000..3d1e6917 --- /dev/null +++ b/code/frontend/public/icons/ext/notifiarr-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/ntfy-dark.svg b/code/frontend/public/icons/ext/ntfy-dark.svg new file mode 100644 index 00000000..4aee2f14 --- /dev/null +++ b/code/frontend/public/icons/ext/ntfy-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/pushover-dark.svg b/code/frontend/public/icons/ext/pushover-dark.svg new file mode 100644 index 00000000..0f60a3f4 --- /dev/null +++ b/code/frontend/public/icons/ext/pushover-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/radarr-dark.svg b/code/frontend/public/icons/ext/radarr-dark.svg new file mode 100644 index 00000000..91357d73 --- /dev/null +++ b/code/frontend/public/icons/ext/radarr-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/readarr-dark.svg b/code/frontend/public/icons/ext/readarr-dark.svg new file mode 100644 index 00000000..0a98a66d --- /dev/null +++ b/code/frontend/public/icons/ext/readarr-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/sonarr-dark.svg b/code/frontend/public/icons/ext/sonarr-dark.svg new file mode 100644 index 00000000..5ab7abc8 --- /dev/null +++ b/code/frontend/public/icons/ext/sonarr-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/telegram-dark.svg b/code/frontend/public/icons/ext/telegram-dark.svg new file mode 100644 index 00000000..75dd0463 --- /dev/null +++ b/code/frontend/public/icons/ext/telegram-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/whisparr-dark.svg b/code/frontend/public/icons/ext/whisparr-dark.svg new file mode 100644 index 00000000..6c5d0d59 --- /dev/null +++ b/code/frontend/public/icons/ext/whisparr-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/src/app/app.config.ts b/code/frontend/src/app/app.config.ts index 0ab4b938..1b1459df 100644 --- a/code/frontend/src/app/app.config.ts +++ b/code/frontend/src/app/app.config.ts @@ -52,6 +52,7 @@ import { tablerHistory, tablerGripVertical, tablerFilter, + tablerPalette, } from '@ng-icons/tabler-icons'; import { routes } from './app.routes'; @@ -114,6 +115,7 @@ export const appConfig: ApplicationConfig = { tablerHistory, tablerGripVertical, tablerFilter, + tablerPalette, }), ], }; diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index 65e55806..e9d48f31 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -128,6 +128,13 @@ export const routes: Routes = [ '@features/settings/account/account-settings.component' ).then((m) => m.AccountSettingsComponent), }, + { + path: 'appearance', + loadComponent: () => + import( + '@features/settings/appearance/appearance-settings.component' + ).then((m) => m.AppearanceSettingsComponent), + }, ], }, ], diff --git a/code/frontend/src/app/core/services/theme.service.ts b/code/frontend/src/app/core/services/theme.service.ts index 3014e5bf..20bebe67 100644 --- a/code/frontend/src/app/core/services/theme.service.ts +++ b/code/frontend/src/app/core/services/theme.service.ts @@ -2,19 +2,68 @@ import { Injectable, signal, effect } from '@angular/core'; export type Theme = 'dark' | 'light'; +export const ACCENT_PRESETS = [ + 'default', + 'blue', + 'green', + 'rose', + 'amber', + 'teal', +] as const; +export type AccentPreset = (typeof ACCENT_PRESETS)[number]; +export type Accent = AccentPreset | 'custom'; + +// Preview swatch colors for each preset. These mirror the --brand-500 stop +// declared in styles/_accents.scss (and styles/_tokens.scss for 'default'). +// Keep the two in sync — there is no SCSS-from-TS import path. +export const ACCENT_PRESET_HEX: Record = { + default: '#8b5cf6', + blue: '#3b82f6', + green: '#10b981', + rose: '#f43f5e', + amber: '#f59e0b', + teal: '#14b8a6', +}; + const THEME_KEY = 'cleanuparr-theme'; const PERFORMANCE_MODE_KEY = 'cleanuparr-performance-mode'; const FULL_WIDTH_KEY = 'cleanuparr-full-width'; +const ACCENT_KEY = 'cleanuparr-accent'; +const CUSTOM_ACCENT_KEY = 'cleanuparr-custom-accent'; + +const DEFAULT_CUSTOM_ACCENT = '#8b5cf6'; +const HEX_COLOR_REGEX = /^#[0-9a-f]{6}$/i; +const BRAND_SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +// Lightness stops per shade, tuned to match the visual weight of the default purple scale. +const LIGHTNESS_STOPS: Record<(typeof BRAND_SHADES)[number], number> = { + 50: 97, + 100: 93, + 200: 86, + 300: 75, + 400: 62, + 500: 50, + 600: 42, + 700: 34, + 800: 27, + 900: 20, + 950: 12, +}; @Injectable({ providedIn: 'root' }) export class ThemeService { + private readonly root = document.documentElement; private readonly _theme = signal('dark'); private readonly _performanceMode = signal(false); private readonly _fullWidth = signal(false); + private readonly _accent = signal('default'); + private readonly _customAccent = signal(DEFAULT_CUSTOM_ACCENT); readonly theme = this._theme.asReadonly(); readonly performanceMode = this._performanceMode.asReadonly(); readonly fullWidth = this._fullWidth.asReadonly(); + readonly accent = this._accent.asReadonly(); + readonly customAccent = this._customAccent.asReadonly(); constructor() { this.restoreFromStorage(); @@ -55,6 +104,38 @@ export class ThemeService { localStorage.setItem(FULL_WIDTH_KEY, String(value)); } + setAccent(accent: Accent): void { + this._accent.set(accent); + localStorage.setItem(ACCENT_KEY, accent); + } + + /** + * Picks the right icon variant for the active theme. Asset filenames must + * follow the `*-light.svg` (designed for dark backgrounds) / + * `*-dark.svg` (designed for light backgrounds) convention. + */ + themedIconSrc(src: string): string { + if (this._theme() === 'dark') + { + return src; + } + return src.replace('-light.svg', '-dark.svg'); + } + + setCustomAccent(hex: string): void { + const normalized = hex.trim().toLowerCase(); + if (!HEX_COLOR_REGEX.test(normalized)) + { + return; + } + this._customAccent.set(normalized); + localStorage.setItem(CUSTOM_ACCENT_KEY, normalized); + if (this._accent() !== 'custom') + { + this.setAccent('custom'); + } + } + private restoreFromStorage(): void { const savedTheme = localStorage.getItem(THEME_KEY); if (savedTheme === 'light' || savedTheme === 'dark') { @@ -70,6 +151,18 @@ export class ThemeService { if (savedFullWidth === 'true') { this._fullWidth.set(true); } + + const savedAccent = localStorage.getItem(ACCENT_KEY); + const migratedAccent = savedAccent === 'purple' ? 'default' : savedAccent; + if (migratedAccent && this.isAccent(migratedAccent)) { + this._accent.set(migratedAccent); + } + + const savedCustom = localStorage.getItem(CUSTOM_ACCENT_KEY); + if (savedCustom && HEX_COLOR_REGEX.test(savedCustom)) + { + this._customAccent.set(savedCustom); + } } private detectSystemPreferences(): void { @@ -81,15 +174,111 @@ export class ThemeService { private bindToDom(): void { effect(() => { - document.documentElement.setAttribute('data-theme', this._theme()); + this.root.setAttribute('data-theme', this._theme()); }); effect(() => { - document.documentElement.setAttribute('data-performance-mode', String(this._performanceMode())); + this.root.setAttribute('data-performance-mode', String(this._performanceMode())); }); effect(() => { - document.documentElement.setAttribute('data-full-width', String(this._fullWidth())); + this.root.setAttribute('data-full-width', String(this._fullWidth())); + }); + + effect(() => { + const accent = this._accent(); + this.root.setAttribute('data-accent', accent); + + if (accent === 'custom') + { + this.applyCustomAccent(this._customAccent()); + } + else + { + this.clearInlineAccent(); + } }); } + + private isAccent(value: string): value is Accent { + return value === 'custom' || (ACCENT_PRESETS as readonly string[]).includes(value); + } + + private applyCustomAccent(hex: string): void { + const rgb = hexToRgb(hex); + if (!rgb) + { + return; + } + + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + // Keep some chroma even when the user picks a near-gray, otherwise the whole + // brand scale collapses to shades of gray and active states become invisible. + const s = Math.max(hsl.s, 15); + + for (const shade of BRAND_SHADES) + { + const l = shade === 500 ? hsl.l : LIGHTNESS_STOPS[shade]; + const { r, g, b } = hslToRgb(hsl.h, s, l); + this.root.style.setProperty(`--brand-${shade}`, rgbToHex(r, g, b)); + } + + this.root.style.setProperty('--accent-rgb', `${rgb.r}, ${rgb.g}, ${rgb.b}`); + } + + private clearInlineAccent(): void { + for (const shade of BRAND_SHADES) + { + this.root.style.removeProperty(`--brand-${shade}`); + } + this.root.style.removeProperty('--accent-rgb'); + } +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) return null; + const n = parseInt(match[1], 16); + return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }; +} + +function rgbToHex(r: number, g: number, b: number): string { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + const hex = (v: number) => clamp(v).toString(16).padStart(2, '0'); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255, gn = g / 255, bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + let h = 0, s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break; + case gn: h = (bn - rn) / d + 2; break; + case bn: h = (rn - gn) / d + 4; break; + } + h *= 60; + } + return { h, s: s * 100, l: l * 100 }; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + const sn = s / 100, ln = l / 100; + const c = (1 - Math.abs(2 * ln - 1)) * sn; + const hp = h / 60; + const x = c * (1 - Math.abs((hp % 2) - 1)); + let r1 = 0, g1 = 0, b1 = 0; + if (hp >= 0 && hp < 1) [r1, g1, b1] = [c, x, 0]; + else if (hp < 2) [r1, g1, b1] = [x, c, 0]; + else if (hp < 3) [r1, g1, b1] = [0, c, x]; + else if (hp < 4) [r1, g1, b1] = [0, x, c]; + else if (hp < 5) [r1, g1, b1] = [x, 0, c]; + else [r1, g1, b1] = [c, 0, x]; + const m = ln - c / 2; + return { r: (r1 + m) * 255, g: (g1 + m) * 255, b: (b1 + m) * 255 }; } diff --git a/code/frontend/src/app/features/auth/login/login.component.scss b/code/frontend/src/app/features/auth/login/login.component.scss index 59b8d628..39746fbe 100644 --- a/code/frontend/src/app/features/auth/login/login.component.scss +++ b/code/frontend/src/app/features/auth/login/login.component.scss @@ -130,16 +130,16 @@ font-family: var(--font-family); font-size: var(--font-size-sm); font-weight: 500; - color: #ffffff; - background: #7E57C2; + color: var(--color-primary-text); + background: var(--color-primary); border: 1px solid transparent; border-radius: var(--radius-lg); cursor: pointer; transition: all var(--duration-fast) var(--ease-default); &:hover:not(:disabled) { - background: #6D28D9; - box-shadow: 0 0 20px rgba(126, 87, 194, 0.4); + background: var(--color-primary-hover); + box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.4); } &:active:not(:disabled) { diff --git a/code/frontend/src/app/features/auth/setup/setup.component.scss b/code/frontend/src/app/features/auth/setup/setup.component.scss index b79d9352..c23249cb 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.scss +++ b/code/frontend/src/app/features/auth/setup/setup.component.scss @@ -47,15 +47,15 @@ transition: all var(--duration-normal) var(--ease-default); .step-group.active & { - background: rgba(126, 87, 194, 0.15); + background: rgba(var(--accent-rgb), 0.15); color: var(--color-primary); - box-shadow: 0 0 8px rgba(126, 87, 194, 0.25); + box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.25); } .step-group.completed & { background: var(--color-primary); color: #ffffff; - box-shadow: 0 0 12px rgba(126, 87, 194, 0.35); + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.35); } } diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.scss b/code/frontend/src/app/features/dashboard/dashboard.component.scss index 1df23d48..20d702b8 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.scss +++ b/code/frontend/src/app/features/dashboard/dashboard.component.scss @@ -30,7 +30,7 @@ &:hover { background: var(--glass-bg-hover); transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(126, 87, 194, 0.12); + box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.12); } // GitHub — monochrome grey/white @@ -278,8 +278,8 @@ } &--important { - background: rgba(126, 87, 194, 0.08); - border-color: rgba(126, 87, 194, 0.25); + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.25); animation: slide-up var(--duration-normal) var(--ease-default), glow-pulse-important 3s ease-in-out infinite; animation-delay: 0s, var(--duration-normal); } @@ -533,9 +533,9 @@ box-shadow: 0 0 8px rgba(34, 197, 94, 0.25); } &--primary { - background: rgba(126, 87, 194, 0.15); + background: rgba(var(--accent-rgb), 0.15); color: var(--color-primary); - box-shadow: 0 0 8px rgba(126, 87, 194, 0.25); + box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.25); } &--default { background: rgba(148, 163, 184, 0.15); @@ -726,10 +726,10 @@ @keyframes glow-pulse-important { 0%, 100% { - box-shadow: 0 0 12px rgba(126, 87, 194, 0.1), 0 0 24px rgba(126, 87, 194, 0.05); + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.1), 0 0 24px rgba(var(--accent-rgb), 0.05); } 50% { - box-shadow: 0 0 20px rgba(126, 87, 194, 0.25), 0 0 40px rgba(126, 87, 194, 0.1); + box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.25), 0 0 40px rgba(var(--accent-rgb), 0.1); } } diff --git a/code/frontend/src/app/features/events/events.component.scss b/code/frontend/src/app/features/events/events.component.scss index 0c499ae2..644ab1a6 100644 --- a/code/frontend/src/app/features/events/events.component.scss +++ b/code/frontend/src/app/features/events/events.component.scss @@ -42,7 +42,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent); transform: translateX(-100%); transition: transform var(--duration-normal) var(--ease-default); pointer-events: none; @@ -233,8 +233,8 @@ gap: var(--space-2); padding: var(--space-2) var(--space-3); margin-bottom: var(--space-3); - background: rgba(126, 87, 194, 0.1); - border: 1px solid rgba(126, 87, 194, 0.2); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); border-radius: var(--radius-md); font-size: var(--font-size-sm); color: var(--color-primary); diff --git a/code/frontend/src/app/features/logs-component/logs.component.scss b/code/frontend/src/app/features/logs-component/logs.component.scss index 4a2dda15..cf546605 100644 --- a/code/frontend/src/app/features/logs-component/logs.component.scss +++ b/code/frontend/src/app/features/logs-component/logs.component.scss @@ -44,7 +44,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent); transform: translateX(-100%); transition: transform var(--duration-normal) var(--ease-default); pointer-events: none; @@ -202,8 +202,8 @@ gap: var(--space-2); padding: var(--space-2) var(--space-3); margin-bottom: var(--space-3); - background: rgba(126, 87, 194, 0.1); - border: 1px solid rgba(126, 87, 194, 0.2); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); border-radius: var(--radius-md); font-size: var(--font-size-sm); color: var(--color-primary); diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss index 57596791..b09af680 100644 --- a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss @@ -58,7 +58,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent); transform: translateX(-100%); transition: transform var(--duration-normal) var(--ease-default); pointer-events: none; diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss index 8bef8dcd..7253df0d 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss @@ -135,7 +135,7 @@ &__progress-fill { height: 100%; - background: linear-gradient(90deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 75%, #c084fc)); + background: linear-gradient(90deg, var(--color-primary), var(--brand-300)); border-radius: var(--radius-full); transition: width var(--duration-normal) var(--ease-default); min-width: 0; @@ -307,7 +307,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent); transform: translateX(-100%); transition: transform var(--duration-normal) var(--ease-default); pointer-events: none; diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss index 2860b12c..f87ccc97 100644 --- a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss @@ -49,7 +49,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent); transform: translateX(-100%); transition: transform var(--duration-normal) var(--ease-default); pointer-events: none; diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html new file mode 100644 index 00000000..c74b66a8 --- /dev/null +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.html @@ -0,0 +1,65 @@ + + +
+ +

Choose between dark and light color modes.

+
+ + +
+
+ + +

Pick a preset or choose a custom color. Changes apply instantly and persist across sessions.

+
+ @for (swatch of presetSwatches; track swatch.value) { + + } + + +
+
+
diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss new file mode 100644 index 00000000..62a954cd --- /dev/null +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.scss @@ -0,0 +1,132 @@ +@use 'settings-layout' as *; + +:host { @include settings-page; } + +.settings-form { @include settings-form; } + +.section-hint { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.5; + margin: 0 0 var(--space-3); +} + +.theme-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin-top: var(--space-2); + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } +} + +.theme-option { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border-radius: var(--radius-lg); + border: 1px solid var(--glass-border); + background: var(--glass-bg); + color: var(--text-primary); + cursor: pointer; + text-align: left; + transition: border-color var(--duration-fast) var(--ease-default), + background var(--duration-fast) var(--ease-default), + box-shadow var(--duration-fast) var(--ease-default); + + &:hover { + border-color: var(--glass-border-hover); + background: var(--glass-bg-hover); + } + + &--active { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-subtle); + } + + &__preview { + width: 36px; + height: 36px; + border-radius: var(--radius-md); + border: 1px solid var(--glass-border); + flex-shrink: 0; + + &--dark { + background: linear-gradient(135deg, var(--brand-950) 0%, #0c0614 100%); + } + + &--light { + background: linear-gradient(135deg, #ffffff 0%, var(--brand-100) 100%); + } + } + + &__label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } +} + +.swatch-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: var(--space-2); + margin-top: var(--space-2); +} + +.swatch { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--glass-border); + background: var(--glass-bg); + color: var(--text-primary); + cursor: pointer; + text-align: left; + transition: border-color var(--duration-fast) var(--ease-default), + background var(--duration-fast) var(--ease-default), + box-shadow var(--duration-fast) var(--ease-default); + + &:hover { + border-color: var(--glass-border-hover); + background: var(--glass-bg-hover); + } + + &--active { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-subtle); + } + + &__dot { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.15); + flex-shrink: 0; + + &--custom { + background-image: conic-gradient(from 180deg, #f43f5e, #f59e0b, #10b981, #3b82f6, #7e57c2, #f43f5e); + } + } + + &__label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } + + &__input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + border: 0; + padding: 0; + } +} diff --git a/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts new file mode 100644 index 00000000..4afaedb6 --- /dev/null +++ b/code/frontend/src/app/features/settings/appearance/appearance-settings.component.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { PageHeaderComponent } from '@layout/page-header/page-header.component'; +import { CardComponent } from '@ui'; +import { + ACCENT_PRESETS, + ACCENT_PRESET_HEX, + Accent, + Theme, + ThemeService, +} from '@core/services/theme.service'; + +interface AccentSwatch { + readonly value: Accent; + readonly label: string; + readonly color: string; +} + +@Component({ + selector: 'app-appearance-settings', + standalone: true, + imports: [PageHeaderComponent, CardComponent], + templateUrl: './appearance-settings.component.html', + styleUrl: './appearance-settings.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppearanceSettingsComponent { + private readonly themeService = inject(ThemeService); + + readonly theme = this.themeService.theme; + readonly accent = this.themeService.accent; + readonly customAccent = this.themeService.customAccent; + + readonly presetSwatches: AccentSwatch[] = ACCENT_PRESETS.map((value) => ({ + value, + label: value.charAt(0).toUpperCase() + value.slice(1), + color: ACCENT_PRESET_HEX[value], + })); + + selectTheme(theme: Theme): void { + this.themeService.setTheme(theme); + } + + selectAccent(accent: Accent): void { + this.themeService.setAccent(accent); + } + + onCustomColorChange(event: Event): void { + const { value } = event.target as HTMLInputElement; + this.themeService.setCustomAccent(value); + } +} diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.html b/code/frontend/src/app/features/settings/notifications/notifications.component.html index 7604b771..eb34cca0 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.html +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.html @@ -84,8 +84,8 @@ @for (provider of availableProviders; track provider.type) {