Add option to follow system preferences for dark mode (#834)

This commit is contained in:
Flaminel authored and GitHub committed 2026-10-12 22:15:22 +03:00
1 parent 9265ea8e31
commit 87b7a621dd
16 files changed
+511 -48

No files matched your search

+2
View File
@@ -15,6 +15,7 @@ import {
tablerLayoutSidebarLeftCollapse,
tablerSun,
tablerMoon,
tablerDeviceDesktop,
tablerCheck,
tablerBrandGithub,
tablerBrandDiscord,
@@ -68,6 +69,7 @@ export const APP_ICONS = {
tablerLayoutSidebarLeftCollapse,
tablerSun,
tablerMoon,
tablerDeviceDesktop,
tablerCheck,
tablerBrandGithub,
tablerBrandDiscord,
@@ -51,4 +51,5 @@ export const NEW_FEATURES: NewFeature[] = [
{ id: 'force-import' },
{ id: 'usenet-queue-cleaner' },
{ id: 'qbit-api-key' },
{ id: 'theme-follow-system' },
];
@@ -1,18 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';
import { RootDataKey, ThemeService } from './theme.service';
const BRAND_SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
const DATA_ATTRIBUTES = ['data-theme', 'data-performance-mode', 'data-full-width', 'data-accent'];
describe('ThemeService', () => {
const root = document.documentElement;
let darkMatches = false;
let darkListeners: ((event: MediaQueryListEvent) => void)[];
function setup(): ThemeService {
function setup(initialSystemDark = false): ThemeService {
darkMatches = initialSystemDark;
const service = TestBed.inject(ThemeService);
TestBed.tick();
return service;
}
function fireSystemThemeChange(matches: boolean): void {
darkMatches = matches;
for (const listener of darkListeners) {
listener({ matches } as MediaQueryListEvent);
}
TestBed.tick();
}
function brandStop(shade: number): string {
return root.style.getPropertyValue(`--brand-${shade}`).trim();
}
@@ -29,14 +39,29 @@ describe('ThemeService', () => {
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('matchMedia', () => ({ matches: false }));
darkMatches = false;
darkListeners = [];
vi.stubGlobal('matchMedia', (query: string) => ({
get matches(): boolean {
return query === '(prefers-color-scheme: dark)' ? darkMatches : false;
},
addEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => {
if (type === 'change' && query === '(prefers-color-scheme: dark)')
{
darkListeners.push(listener);
}
},
removeEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => {
darkListeners = darkListeners.filter((registered) => registered !== listener);
},
}));
});
afterEach(() => {
localStorage.clear();
vi.unstubAllGlobals();
for (const attribute of DATA_ATTRIBUTES) {
root.removeAttribute(attribute);
for (const key of Object.values(RootDataKey)) {
delete root.dataset[key];
}
for (const shade of BRAND_SHADES) {
root.style.removeProperty(`--brand-${shade}`);
@@ -44,6 +69,15 @@ describe('ThemeService', () => {
root.style.removeProperty('--accent-rgb');
});
it('removes its system theme listener when the injector is destroyed', () => {
setup();
expect(darkListeners.length).toBe(1);
TestBed.resetTestingModule();
expect(darkListeners.length).toBe(0);
});
it('round-trips the picked hex back through the mid brand stop and publishes its rgb triple', () => {
const service = setup();
@@ -131,7 +165,7 @@ describe('ThemeService', () => {
expect(service.customAccent()).toBe('#8b5cf6');
expect(service.accent()).toBe('default');
expect(root.getAttribute('data-accent')).toBe('default');
expect(root.dataset[RootDataKey.Accent]).toBe('default');
expect(brandStop(500)).toBe('');
expect(localStorage.getItem('cleanuparr-custom-accent')).toBeNull();
});
@@ -143,13 +177,13 @@ describe('ThemeService', () => {
TestBed.tick();
expect(service.accent()).toBe('custom');
expect(root.getAttribute('data-accent')).toBe('custom');
expect(root.dataset[RootDataKey.Accent]).toBe('custom');
expect(brandStop(500)).toBe('#f43f5e');
service.setAccent('teal');
TestBed.tick();
expect(root.getAttribute('data-accent')).toBe('teal');
expect(root.dataset[RootDataKey.Accent]).toBe('teal');
expect(brandStop(500)).toBe('');
expect(root.style.getPropertyValue('--accent-rgb')).toBe('');
expect(service.customAccent()).toBe('#f43f5e');
@@ -162,10 +196,89 @@ describe('ThemeService', () => {
const service = setup();
expect(service.theme()).toBe('light');
expect(root.getAttribute('data-theme')).toBe('light');
expect(root.dataset[RootDataKey.Theme]).toBe('light');
expect(service.accent()).toBe('default');
});
it('resolves to dark from the system preference when nothing is stored', () => {
const service = setup(true);
expect(service.preference()).toBe('system');
expect(service.theme()).toBe('dark');
expect(root.dataset[RootDataKey.Theme]).toBe('dark');
});
it('resolves to light from the system preference when nothing is stored', () => {
const service = setup(false);
expect(service.preference()).toBe('system');
expect(service.theme()).toBe('light');
expect(root.dataset[RootDataKey.Theme]).toBe('light');
});
it('restores a stored system preference', () => {
localStorage.setItem('cleanuparr-theme', 'system');
const service = setup(true);
expect(service.preference()).toBe('system');
expect(service.theme()).toBe('dark');
});
it('restores a legacy stored dark or light theme as an explicit preference', () => {
localStorage.setItem('cleanuparr-theme', 'dark');
const service = setup(true);
expect(service.preference()).toBe('dark');
expect(service.theme()).toBe('dark');
});
it('follows a system theme change while the preference is system', () => {
const service = setup(false);
expect(service.theme()).toBe('light');
fireSystemThemeChange(true);
expect(service.theme()).toBe('dark');
expect(root.dataset[RootDataKey.Theme]).toBe('dark');
});
it('ignores a system theme change while the preference is explicit', () => {
const service = setup(false);
service.setTheme('light');
TestBed.tick();
fireSystemThemeChange(true);
expect(service.theme()).toBe('light');
expect(root.dataset[RootDataKey.Theme]).toBe('light');
});
it('cycles dark to light to system to dark, persisting each step', () => {
const service = setup(false);
service.setTheme('dark');
TestBed.tick();
service.cycleTheme();
TestBed.tick();
expect(service.preference()).toBe('light');
expect(localStorage.getItem('cleanuparr-theme')).toBe('light');
service.cycleTheme();
TestBed.tick();
expect(service.preference()).toBe('system');
expect(localStorage.getItem('cleanuparr-theme')).toBe('system');
service.cycleTheme();
TestBed.tick();
expect(service.preference()).toBe('dark');
expect(localStorage.getItem('cleanuparr-theme')).toBe('dark');
});
it('falls back to the default accent when the stored accent or custom hex is unusable', () => {
localStorage.setItem('cleanuparr-accent', 'chartreuse');
localStorage.setItem('cleanuparr-custom-accent', 'not-a-color');
@@ -1,6 +1,16 @@
import { Injectable, signal, effect } from '@angular/core';
import { DestroyRef, Injectable, inject, signal, computed, effect } from '@angular/core';
export type Theme = 'dark' | 'light';
export type ThemePreference = Theme | 'system';
export enum RootDataKey {
Theme = 'theme',
PerformanceMode = 'performanceMode',
FullWidth = 'fullWidth',
Accent = 'accent',
}
const THEME_PREFERENCE_ORDER: readonly ThemePreference[] = ['dark', 'light', 'system'];
export const ACCENT_PRESETS = [
'default',
@@ -25,6 +35,7 @@ export const ACCENT_PRESET_HEX: Record<AccentPreset, string> = {
teal: '#14b8a6',
};
// index.html reads this key before Angular boots.
const THEME_KEY = 'cleanuparr-theme';
const PERFORMANCE_MODE_KEY = 'cleanuparr-performance-mode';
const FULL_WIDTH_KEY = 'cleanuparr-full-width';
@@ -53,13 +64,23 @@ const LIGHTNESS_STOPS: Record<(typeof BRAND_SHADES)[number], number> = {
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly root = document.documentElement;
private readonly _theme = signal<Theme>('dark');
private readonly destroyRef = inject(DestroyRef);
private readonly _preference = signal<ThemePreference>('system');
private readonly _systemDark = signal(false);
private readonly _performanceMode = signal(false);
private readonly _fullWidth = signal(false);
private readonly _accent = signal<Accent>('default');
private readonly _customAccent = signal<string>(DEFAULT_CUSTOM_ACCENT);
readonly theme = this._theme.asReadonly();
readonly theme = computed<Theme>(() => {
const preference = this._preference();
if (preference === 'dark' || preference === 'light')
{
return preference;
}
return this._systemDark() ? 'dark' : 'light';
});
readonly preference = this._preference.asReadonly();
readonly performanceMode = this._performanceMode.asReadonly();
readonly fullWidth = this._fullWidth.asReadonly();
readonly accent = this._accent.asReadonly();
@@ -67,19 +88,20 @@ export class ThemeService {
constructor() {
this.restoreFromStorage();
this.watchSystemTheme();
this.detectSystemPreferences();
this.bindToDom();
}
toggleTheme(): void {
const next = this._theme() === 'dark' ? 'light' : 'dark';
this._theme.set(next);
localStorage.setItem(THEME_KEY, next);
cycleTheme(): void {
const index = THEME_PREFERENCE_ORDER.indexOf(this._preference());
const next = THEME_PREFERENCE_ORDER[(index + 1) % THEME_PREFERENCE_ORDER.length];
this.setTheme(next);
}
setTheme(theme: Theme): void {
this._theme.set(theme);
localStorage.setItem(THEME_KEY, theme);
setTheme(preference: ThemePreference): void {
this._preference.set(preference);
localStorage.setItem(THEME_KEY, preference);
}
togglePerformanceMode(): void {
@@ -105,7 +127,7 @@ export class ThemeService {
* `*-dark.svg` (designed for light backgrounds) convention.
*/
themedIconSrc(src: string): string {
if (this._theme() === 'dark')
if (this.theme() === 'dark')
{
return src;
}
@@ -128,8 +150,8 @@ export class ThemeService {
private restoreFromStorage(): void {
const savedTheme = localStorage.getItem(THEME_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') {
this._theme.set(savedTheme);
if (savedTheme === 'light' || savedTheme === 'dark' || savedTheme === 'system') {
this._preference.set(savedTheme);
}
const saved = localStorage.getItem(PERFORMANCE_MODE_KEY);
@@ -162,22 +184,32 @@ export class ThemeService {
}
}
private watchSystemTheme(): void {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
this._systemDark.set(prefersDark.matches);
const onChange = (event: MediaQueryListEvent): void => {
this._systemDark.set(event.matches);
};
prefersDark.addEventListener('change', onChange);
this.destroyRef.onDestroy(() => prefersDark.removeEventListener('change', onChange));
}
private bindToDom(): void {
effect(() => {
this.root.setAttribute('data-theme', this._theme());
this.root.dataset[RootDataKey.Theme] = this.theme();
});
effect(() => {
this.root.setAttribute('data-performance-mode', String(this._performanceMode()));
this.root.dataset[RootDataKey.PerformanceMode] = String(this._performanceMode());
});
effect(() => {
this.root.setAttribute('data-full-width', String(this._fullWidth()));
this.root.dataset[RootDataKey.FullWidth] = String(this._fullWidth());
});
effect(() => {
const accent = this._accent();
this.root.setAttribute('data-accent', accent);
this.root.dataset[RootDataKey.Accent] = accent;
if (accent === 'custom')
{
@@ -5,12 +5,13 @@
<div class="settings-form">
<app-card header="Theme">
<p class="section-hint">Choose between dark and light color modes.</p>
<p class="section-hint">Choose dark, light, or follow your system setting.</p>
<div class="theme-options">
<button
type="button"
class="theme-option"
[class.theme-option--active]="theme() === 'dark'"
[class.theme-option--active]="preference() === 'dark'"
[attr.aria-pressed]="preference() === 'dark'"
(click)="selectTheme('dark')"
>
<span class="theme-option__preview theme-option__preview--dark"></span>
@@ -19,12 +20,26 @@
<button
type="button"
class="theme-option"
[class.theme-option--active]="theme() === 'light'"
[class.theme-option--active]="preference() === 'light'"
[attr.aria-pressed]="preference() === 'light'"
(click)="selectTheme('light')"
>
<span class="theme-option__preview theme-option__preview--light"></span>
<span class="theme-option__label">Light</span>
</button>
<button
type="button"
class="theme-option"
[class.theme-option--active]="preference() === 'system'"
[attr.aria-pressed]="preference() === 'system'"
(click)="selectTheme('system')"
>
<span class="theme-option__preview theme-option__preview--system"></span>
<span class="theme-option__label">
System
<app-new-badge variant="inline" featureId="theme-follow-system" />
</span>
</button>
</div>
</app-card>
@@ -14,7 +14,7 @@
.theme-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
@@ -62,9 +62,34 @@
&--light {
background: linear-gradient(135deg, #ffffff 0%, var(--brand-100) 100%);
}
&--system {
position: relative;
overflow: hidden;
&::before,
&::after {
content: '';
position: absolute;
inset: 0;
}
&::before {
background: linear-gradient(135deg, var(--brand-950) 0%, #0c0614 100%);
clip-path: polygon(0 0, 100% 0, 0 100%);
}
&::after {
background: linear-gradient(135deg, #ffffff 0%, var(--brand-100) 100%);
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
@@ -0,0 +1,136 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import {
ACCENT_PRESETS,
ACCENT_PRESET_HEX,
Accent,
ThemePreference,
ThemeService,
} from '@core/services/theme.service';
import { FeatureBadgeService } from '@core/feature-badges/feature-badge.service';
import { AppearanceSettingsComponent } from './appearance-settings.component';
function createThemeService() {
return {
preference: signal<ThemePreference>('system'),
accent: signal<Accent>('default'),
customAccent: signal<string>('#8b5cf6'),
setTheme: vi.fn(),
setAccent: vi.fn(),
setCustomAccent: vi.fn(),
};
}
interface Setup {
fixture: ComponentFixture<AppearanceSettingsComponent>;
themeService: ReturnType<typeof createThemeService>;
}
function setup(): Setup {
const themeService = createThemeService();
TestBed.configureTestingModule({
providers: [
{ provide: ThemeService, useValue: themeService },
{ provide: FeatureBadgeService, useValue: { isNew: vi.fn(() => false) } },
],
});
const fixture = TestBed.createComponent(AppearanceSettingsComponent);
fixture.detectChanges();
return { fixture, themeService };
}
function themeOptions(fixture: ComponentFixture<AppearanceSettingsComponent>): HTMLButtonElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('.theme-option'));
}
describe('AppearanceSettingsComponent', () => {
it('renders dark, light and system theme options', () => {
const { fixture } = setup();
const labels = themeOptions(fixture).map((button) => button.textContent?.trim());
expect(labels.length).toBe(3);
expect(labels[0]).toContain('Dark');
expect(labels[1]).toContain('Light');
expect(labels[2]).toContain('System');
});
it('marks the option matching the current preference as active', () => {
const { fixture, themeService } = setup();
const [dark, light, system] = themeOptions(fixture);
expect(system.classList).toContain('theme-option--active');
expect(dark.classList).not.toContain('theme-option--active');
expect(light.classList).not.toContain('theme-option--active');
themeService.preference.set('dark');
fixture.detectChanges();
expect(dark.classList).toContain('theme-option--active');
expect(system.classList).not.toContain('theme-option--active');
});
it('exposes the current preference through aria-pressed', () => {
const { fixture, themeService } = setup();
const [dark, light, system] = themeOptions(fixture);
expect(system.getAttribute('aria-pressed')).toBe('true');
expect(dark.getAttribute('aria-pressed')).toBe('false');
expect(light.getAttribute('aria-pressed')).toBe('false');
themeService.preference.set('light');
fixture.detectChanges();
expect(light.getAttribute('aria-pressed')).toBe('true');
expect(system.getAttribute('aria-pressed')).toBe('false');
});
it('calls setTheme with the clicked preference', () => {
const { fixture, themeService } = setup();
const [dark, light, system] = themeOptions(fixture);
system.click();
fixture.detectChanges();
expect(themeService.setTheme).toHaveBeenCalledWith('system');
dark.click();
fixture.detectChanges();
expect(themeService.setTheme).toHaveBeenCalledWith('dark');
light.click();
fixture.detectChanges();
expect(themeService.setTheme).toHaveBeenCalledWith('light');
});
it('marks the active accent swatch and switches on click', () => {
const { fixture, themeService } = setup();
const swatches = Array.from<HTMLButtonElement>(
fixture.nativeElement.querySelectorAll('.swatch:not(.swatch--custom)'),
);
expect(swatches.length).toBe(ACCENT_PRESETS.length);
expect(swatches[0].classList).toContain('swatch--active');
const greenIndex = ACCENT_PRESETS.indexOf('green');
swatches[greenIndex].click();
fixture.detectChanges();
expect(themeService.setAccent).toHaveBeenCalledWith('green');
});
it('uses the registered preset hex colors for the swatch dots', () => {
const { fixture } = setup();
const dots = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.swatch__dot'));
const probe = document.createElement('div');
ACCENT_PRESETS.forEach((preset, index) => {
probe.style.background = ACCENT_PRESET_HEX[preset];
expect(dots[index].style.background).toBe(probe.style.background);
});
});
});
@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import { CardComponent } from '@ui';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import {
ACCENT_PRESETS,
ACCENT_PRESET_HEX,
Accent,
Theme,
ThemePreference,
ThemeService,
} from '@core/services/theme.service';
@@ -18,7 +19,7 @@ interface AccentSwatch {
@Component({
selector: 'app-appearance-settings',
standalone: true,
imports: [PageHeaderComponent, CardComponent],
imports: [PageHeaderComponent, CardComponent, NewBadgeComponent],
templateUrl: './appearance-settings.component.html',
styleUrl: './appearance-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -26,7 +27,7 @@ interface AccentSwatch {
export class AppearanceSettingsComponent {
private readonly themeService = inject(ThemeService);
readonly theme = this.themeService.theme;
readonly preference = this.themeService.preference;
readonly accent = this.themeService.accent;
readonly customAccent = this.themeService.customAccent;
@@ -36,7 +37,7 @@ export class AppearanceSettingsComponent {
color: ACCENT_PRESET_HEX[value],
}));
selectTheme(theme: Theme): void {
selectTheme(theme: ThemePreference): void {
this.themeService.setTheme(theme);
}
@@ -71,7 +71,7 @@ interface Setup {
describe('NotificationsComponent', () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', () => ({ matches: false }));
vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined }));
});
afterEach(() => {
@@ -19,7 +19,7 @@ describe('NavSidebarComponent', () => {
});
function setup(status: AppStatus | null = null): Harness {
vi.stubGlobal('matchMedia', () => ({ matches: false }));
vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined }));
const appStatus = signal(status);
const logouts: number[] = [];
@@ -25,7 +25,7 @@ describe('ShellComponent', () => {
}
function setup(width = DESKTOP_WIDTH): Harness {
vi.stubGlobal('matchMedia', () => ({ matches: false }));
vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined }));
setWidth(width);
const hubCalls: string[] = [];
@@ -6,13 +6,15 @@
</div>
<div class="toolbar__right">
<button
class="toolbar__icon-btn"
(click)="onToggleTheme()"
[attr.aria-label]="theme() === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
>
<ng-icon [name]="theme() === 'dark' ? 'tablerSun' : 'tablerMoon'" />
</button>
<app-tooltip [text]="themeTooltip()">
<button
class="toolbar__icon-btn"
(click)="onToggleTheme()"
[attr.aria-label]="themeTooltip()"
>
<ng-icon [name]="themeIcon()" />
</button>
</app-tooltip>
<app-tooltip text="Expands page content edge-to-edge on wide monitors, removing the default width cap.">
<app-toggle
@@ -0,0 +1,96 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NgIcon } from '@ng-icons/core';
import { ThemeService, ThemePreference, Theme } from '@core/services/theme.service';
import { ToolbarComponent } from './toolbar.component';
interface Harness {
fixture: ComponentFixture<ToolbarComponent>;
preference: WritableSignal<ThemePreference>;
theme: WritableSignal<Theme>;
cycleTheme: () => void;
}
describe('ToolbarComponent', () => {
function setup(): Harness {
const preference = signal<ThemePreference>('dark');
const theme = signal<Theme>('dark');
const cycleTheme = vi.fn();
TestBed.configureTestingModule({
providers: [
{
provide: ThemeService,
useValue: {
preference,
theme,
performanceMode: signal(false),
fullWidth: signal(false),
cycleTheme,
togglePerformanceMode: vi.fn(),
toggleFullWidth: vi.fn(),
},
},
],
});
const fixture = TestBed.createComponent(ToolbarComponent);
fixture.detectChanges();
return { fixture, preference, theme, cycleTheme };
}
function themeButton(fixture: ComponentFixture<ToolbarComponent>): HTMLButtonElement {
return fixture.nativeElement.querySelector('.toolbar__icon-btn') as HTMLButtonElement;
}
function themeIconName(fixture: ComponentFixture<ToolbarComponent>): unknown {
const icon = fixture.debugElement
.query(By.css('.toolbar__icon-btn'))
.query(By.directive(NgIcon));
return icon.componentInstance.name();
}
it('shows the moon icon and dark tooltip for the dark preference', () => {
const { fixture } = setup();
expect(themeIconName(fixture)).toBe('tablerMoon');
expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: Dark. Click for light.');
});
it('shows the sun icon and light tooltip for the light preference', () => {
const { fixture, preference, theme } = setup();
preference.set('light');
theme.set('light');
fixture.detectChanges();
expect(themeIconName(fixture)).toBe('tablerSun');
expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: Light. Click to follow system.');
});
it('shows the desktop icon and resolved theme for the system preference', () => {
const { fixture, preference, theme } = setup();
preference.set('system');
theme.set('dark');
fixture.detectChanges();
expect(themeIconName(fixture)).toBe('tablerDeviceDesktop');
expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: System (dark). Click for dark.');
theme.set('light');
fixture.detectChanges();
expect(themeButton(fixture).getAttribute('aria-label')).toBe('Theme: System (light). Click for dark.');
});
it('cycles the theme preference on click', () => {
const { fixture, cycleTheme } = setup();
themeButton(fixture).click();
fixture.detectChanges();
expect(cycleTheme).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy, computed, input, output, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ThemeService } from '@core/services/theme.service';
import { ToggleComponent, TooltipComponent } from '@ui';
@@ -18,11 +18,36 @@ export class ToolbarComponent {
private themeService = inject(ThemeService);
theme = this.themeService.theme;
preference = this.themeService.preference;
performanceMode = this.themeService.performanceMode;
fullWidth = this.themeService.fullWidth;
themeIcon = computed(() => {
switch (this.preference()) {
case 'dark':
return 'tablerMoon';
case 'light':
return 'tablerSun';
default:
return 'tablerDeviceDesktop';
}
});
themeTooltip = computed(() => {
const preference = this.preference();
if (preference === 'dark')
{
return 'Theme: Dark. Click for light.';
}
if (preference === 'light')
{
return 'Theme: Light. Click to follow system.';
}
return `Theme: System (${this.theme()}). Click for dark.`;
});
onToggleTheme(): void {
this.themeService.toggleTheme();
this.themeService.cycleTheme();
}
onTogglePerformanceMode(): void {
@@ -10,6 +10,7 @@ import {
inject,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { RootDataKey } from '../../core/services/theme.service';
@Component({
selector: 'app-animated-counter',
@@ -46,7 +47,7 @@ export class AnimatedCounterComponent implements OnDestroy {
}
// Skip animation in performance mode
if (document.documentElement.dataset['performanceMode'] === 'true') {
if (document.documentElement.dataset[RootDataKey.PerformanceMode] === 'true') {
this.displayValue.set(target);
return;
}
+14
View File
@@ -18,6 +18,20 @@
document.head.appendChild(baseTag);
})();
</script>
<script>
(function() {
let theme = null;
try {
theme = localStorage.getItem('cleanuparr-theme');
} catch (e) {}
if (theme !== 'dark' && theme !== 'light') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.dataset.theme = theme;
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico">
<link rel="icon" type="image/x-icon" href="favicon.ico">