theme defaults in general settings save and apply correctly with auth enabled

This commit is contained in:
Jarek Krochmalski committed 2026-09-09 09:37:26 +05:30
1 parent 2e6d3ed46c
commit c14b6df1df
8 files changed
+139 -18

No files matched your search

+6 -3
View File
@@ -3,22 +3,25 @@
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { resolveToggleValue, shouldSkipApply } from '$lib/utils/theme-preference';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
// When set, the toggle reflects and saves this global default instead of the profile.
globalValue?: boolean;
}
let { userId }: Props = $props();
let { userId, globalValue }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't toggle the live document
// when the admin is editing the global default while logged in (their own
// per-user preference still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
const skipApply = $derived(shouldSkipApply($authStore.loading, $authStore.authEnabled, userId));
let checked = $state(true);
$effect(() => {
checked = $themeStore.animateIcons;
checked = resolveToggleValue(globalValue, $themeStore.animateIcons);
});
function onToggle(value: boolean) {
@@ -3,19 +3,23 @@
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { resolveToggleValue, shouldSkipApply } from '$lib/utils/theme-preference';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
// When set, the toggle reflects and saves this global default (the General settings
// editor) instead of the current user's profile preference. Omit elsewhere.
globalValue?: boolean;
}
let { userId }: Props = $props();
let { userId, globalValue }: Props = $props();
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
const skipApply = $derived(shouldSkipApply($authStore.loading, $authStore.authEnabled, userId));
let checked = $state(false);
$effect(() => {
checked = $themeStore.coloredActionButtons;
checked = resolveToggleValue(globalValue, $themeStore.coloredActionButtons);
});
function onToggle(value: boolean) {
+6 -3
View File
@@ -3,22 +3,25 @@
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { resolveToggleValue, shouldSkipApply } from '$lib/utils/theme-preference';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
// When set, the toggle reflects and saves this global default instead of the profile.
globalValue?: boolean;
}
let { userId }: Props = $props();
let { userId, globalValue }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't touch the live editor when the
// admin is editing the global default while logged in (their own per-user preference
// still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
const skipApply = $derived(shouldSkipApply($authStore.loading, $authStore.authEnabled, userId));
let checked = $state(false);
$effect(() => {
checked = $themeStore.editorIndentGuides;
checked = resolveToggleValue(globalValue, $themeStore.editorIndentGuides);
});
function onToggle(value: boolean) {
+13
View File
@@ -50,6 +50,12 @@
let selectedTerminalFont = $state('system-mono');
let selectedEditorFont = $state('system-mono');
// The global-scope values are fetched on mount; render waits on this so the selects
// don't briefly show the hardcoded defaults before the global settings arrive. Only
// the global editor (no userId) fetches; the user scope reads the store synchronously.
let valuesLoaded = $state(false);
const awaitingGlobal = $derived(!userId && !valuesLoaded);
onMount(async () => {
// Load bundled monospace fonts for dropdown previews
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
@@ -96,6 +102,7 @@
// Use defaults on error
}
}
valuesLoaded = true;
});
// Sync with themeStore changes only when editing user profile
@@ -163,6 +170,11 @@
</script>
<div class="space-y-4">
{#if awaitingGlobal}
<!-- Editing the global defaults: wait for the fetch so the selects don't flash the
hardcoded defaults before the real global values arrive. -->
<div class="h-10 animate-pulse rounded-md bg-muted/50"></div>
{:else}
<!-- Light Theme -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
@@ -378,4 +390,5 @@
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
+3 -5
View File
@@ -9,6 +9,7 @@
import { writable, get } from 'svelte/store';
import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes';
import { preferenceTarget } from '$lib/utils/theme-preference';
export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge';
export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
@@ -161,12 +162,9 @@ function createThemeStore() {
// Save to database (async, non-blocking)
try {
const url = userId
? `/api/profile/preferences`
: `/api/settings/general`;
const { url, method } = preferenceTarget(userId);
await fetch(url, {
method: userId ? 'PUT' : 'POST',
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value })
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Pure decisions behind the theme toggles. Kept out of theme.ts and the .svelte
* components (which touch localStorage/document/fetch) so they are unit-testable.
*/
/**
* The value a toggle should display: the global default when one is provided (the
* General settings editor), otherwise the personal value from the theme store.
*/
export function resolveToggleValue(globalValue: boolean | undefined, storeValue: boolean): boolean {
return globalValue !== undefined ? globalValue : storeValue;
}
/**
* Where setPreference persists: a per-user PUT to the profile when a userId is given,
* otherwise a POST to the global settings default (the default for new users).
*/
export function preferenceTarget(userId?: number): { url: string; method: 'PUT' | 'POST' } {
return userId
? { url: '/api/profile/preferences', method: 'PUT' }
: { url: '/api/settings/general', method: 'POST' };
}
/**
* Whether to skip applying the change to the live document. Editing the global default
* (no userId) with auth on persists without changing the current admin's own view, whose
* per-user preference drives their session. Auth state still loading also skips.
*/
export function shouldSkipApply(authLoading: boolean, authEnabled: boolean, userId?: number): boolean {
if (authLoading) return true;
return authEnabled && !userId;
}
+30 -4
View File
@@ -306,6 +306,15 @@ services:
let semverIncludePrerelease = $state(false);
let semverLoaded = $state(false);
// The global theme defaults (what a new user starts with). With auth on the theme
// toggles here edit these, not the admin's own profile. Rendering waits on
// globalThemeLoaded: until the fetch fills these, a toggle handed globalValue=undefined
// would fall back to the admin's personal store value and show it.
let globalColoredActions = $state<boolean | undefined>(undefined);
let globalAnimateIcons = $state<boolean | undefined>(undefined);
let globalIndentGuides = $state<boolean | undefined>(undefined);
let globalThemeLoaded = $state(false);
onMount(async () => {
try {
const res = await fetch('/api/settings/semver');
@@ -318,6 +327,17 @@ services:
}
} catch { /* keep defaults */ }
semverLoaded = true;
try {
const res = await fetch('/api/settings/general');
if (res.ok) {
const g = await res.json();
globalColoredActions = !!g.coloredActionButtons;
globalAnimateIcons = g.animateIcons ?? true;
globalIndentGuides = !!g.editorIndentGuides;
}
} catch { /* toggles fall back to store when global value is unknown */ }
globalThemeLoaded = true;
});
async function saveSemverConfig() {
@@ -593,14 +613,20 @@ services:
<!-- Right column: Theme settings (always shown, with hint when auth enabled) -->
<div class="space-y-4">
<ThemeSelector />
<ColoredActionsToggle />
<AnimateIconsToggle />
<IndentGuidesToggle />
<!-- With auth on the toggles edit the GLOBAL defaults, so they wait for
those to load; binding globalValue=undefined first would show the
admin's own profile value. With auth off the store IS the global
value, so they render immediately. -->
{#if !$authStore.authEnabled || globalThemeLoaded}
<ColoredActionsToggle globalValue={$authStore.authEnabled ? globalColoredActions : undefined} />
<AnimateIconsToggle globalValue={$authStore.authEnabled ? globalAnimateIcons : undefined} />
<IndentGuidesToggle globalValue={$authStore.authEnabled ? globalIndentGuides : undefined} />
{/if}
{#if $authStore.authEnabled}
<div class="text-xs text-muted-foreground flex items-start gap-1.5 mt-2 p-2 bg-muted/50 rounded-md">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div>
<p>Personal theme preferences can be configured in your <a href="/profile" class="text-primary hover:underline">profile</a>.</p>
<p>These are the <strong>defaults for new users</strong> - they don't change your own view. To customise how <em>you</em> see the app, use the theme settings in your <a href="/profile" class="text-primary hover:underline">profile</a>.</p>
</div>
</div>
{/if}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'bun:test';
import { resolveToggleValue, preferenceTarget, shouldSkipApply } from '../src/lib/utils/theme-preference';
describe('resolveToggleValue', () => {
it('shows the global default when one is provided (General editor)', () => {
expect(resolveToggleValue(true, false)).toBe(true);
expect(resolveToggleValue(false, true)).toBe(false);
});
it('shows the store (personal) value when no global is provided', () => {
expect(resolveToggleValue(undefined, true)).toBe(true);
expect(resolveToggleValue(undefined, false)).toBe(false);
});
it('a global value of false is respected, not treated as "unset"', () => {
// The guard is `!== undefined`, so `false` is a real global default, not a fallthrough.
expect(resolveToggleValue(false, true)).toBe(false);
});
});
describe('preferenceTarget', () => {
it('saves to the profile (PUT) for a per-user preference', () => {
expect(preferenceTarget(42)).toEqual({ url: '/api/profile/preferences', method: 'PUT' });
});
it('saves to the global default (POST) when no user id is given', () => {
expect(preferenceTarget(undefined)).toEqual({ url: '/api/settings/general', method: 'POST' });
});
});
describe('shouldSkipApply', () => {
it('skips while auth state is still loading', () => {
expect(shouldSkipApply(true, false, undefined)).toBe(true);
expect(shouldSkipApply(true, true, 42)).toBe(true);
});
it('skips a global edit when auth is on (admin keeps their own view)', () => {
expect(shouldSkipApply(false, true, undefined)).toBe(true);
});
it('applies a per-user edit even with auth on', () => {
expect(shouldSkipApply(false, true, 42)).toBe(false);
});
it('applies a global edit when auth is off (store is the global value)', () => {
expect(shouldSkipApply(false, false, undefined)).toBe(false);
});
});