theme defaults in general settings save and apply correctly with auth enabled
This commit is contained in:
8 files changed
+139
-18
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user