diff --git a/apps/server/src/lib/settings.ts b/apps/server/src/lib/settings.ts index 5698785..1a30336 100644 --- a/apps/server/src/lib/settings.ts +++ b/apps/server/src/lib/settings.ts @@ -18,6 +18,13 @@ const DEFAULTS: Record = { // Off until switched on in Settings. Engagement tracking runs regardless, so the taste profile // is already warm the first time it's turned on. discover_enabled: '0', + // 'classic' (the original look) or 'glass' (Liquid Glass). Classic by default, so an update + // doesn't restyle anyone's install out from under them. + ui_style: 'classic', + // '1' once the one-time "pick your look" prompt has been answered. Server-wide, like ui_style. + ui_style_chosen: '0', + // The Liquid Glass dashboard banner; its close button turns this off. + dashboard_hero: '1', }; export function getSetting(key: string): string { diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts index 50af6c1..a60d1ca 100644 --- a/apps/server/src/routes/settings.ts +++ b/apps/server/src/routes/settings.ts @@ -30,8 +30,13 @@ const EDITABLE_KEYS = new Set([ 'gifs_as_videos', 'video_transcode_enabled', 'discover_enabled', + 'ui_style', + 'ui_style_chosen', + 'dashboard_hero', ]); +const UI_STYLES = new Set(['glass', 'classic']); + settingsRouter.get( '/api/settings', wrap(async (_req, res) => { @@ -47,6 +52,9 @@ settingsRouter.patch( // ahead of the bad one and then reporting failure. const invalid = Object.keys(body).find((key) => !EDITABLE_KEYS.has(key)); if (invalid) return res.status(400).json({ error: `Setting not editable: ${invalid}` }); + if (body.ui_style !== undefined && !UI_STYLES.has(body.ui_style)) { + return res.status(400).json({ error: `Unknown ui_style: ${body.ui_style}` }); + } for (const [key, value] of Object.entries(body)) { if (key === 'gifs_as_videos' && value !== getSetting(key)) { enqueueGifReclassifyJob(value === '1'); diff --git a/apps/server/src/uiStyle.test.ts b/apps/server/src/uiStyle.test.ts new file mode 100644 index 0000000..d150b23 --- /dev/null +++ b/apps/server/src/uiStyle.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'bun:test'; +import { useTestConfig } from './testConfig'; + +// Point the server at a throwaway data dir before anything opens the real database. +useTestConfig('uistyle', { port: 38781 }); +const BASE = 'http://localhost:38781'; + +await import('./index'); // starts listening on PORT + +const patch = (body: unknown) => + fetch(`${BASE}/api/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); +const settings = async () => (await (await fetch(`${BASE}/api/settings`)).json()) as Record; + +test('a fresh install starts on the original style and has not been asked yet', async () => { + const s = await settings(); + expect(s.ui_style).toBe('classic'); + expect(s.ui_style_chosen).toBe('0'); +}); + +test('answering the style prompt saves the style and marks it answered', async () => { + const res = await patch({ ui_style: 'glass', ui_style_chosen: '1' }); + expect(res.status).toBe(200); + const s = await settings(); + expect(s.ui_style).toBe('glass'); + expect(s.ui_style_chosen).toBe('1'); +}); + +test('an unknown style is rejected and saves nothing from the same request', async () => { + const res = await patch({ dashboard_hero: '0', ui_style: 'neon' }); + expect(res.status).toBe(400); + const s = await settings(); + expect(s.ui_style).toBe('glass'); + expect(s.dashboard_hero).toBe('1'); +}); diff --git a/apps/web/index.html b/apps/web/index.html index e1075de..5acf06c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,11 +2,11 @@ - + Sakuya - +
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a2448b3..bdd253d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,9 +4,11 @@ import { useQuery } from '@tanstack/react-query'; import { api } from './lib/api'; import { Navbar } from './components/Navbar'; import { ReleaseNotesDialog } from './components/ReleaseNotesDialog'; +import { UiStylePicker } from './components/UiStylePicker'; import { UpdateToast } from './components/UpdateToast'; import { LoginGate } from './components/LoginGate'; import { useAuth } from './hooks/useAuth'; +import { applyUiStyle } from './hooks/useUiStyle'; import { JobsProvider } from './hooks/useJobs'; import { DownloaderProvider } from './hooks/useDownloader'; import { Dashboard } from './routes/Dashboard'; @@ -32,6 +34,10 @@ export function App() { } }, [settings?.accent_color]); + useEffect(() => { + if (settings?.ui_style) applyUiStyle(settings.ui_style); + }, [settings?.ui_style]); + if (loading) return null; // Only gate when the server says login is on. An unreachable server has no auth status at all, // and showing a password prompt for it sends people hunting for a password that doesn't exist. @@ -40,8 +46,14 @@ export function App() { return ( -
- + {/* The bottom padding keeps the last row clear of the phone dock. It lives here rather than + on so the login screen, which has no dock, doesn't get it, and min-h-dvh counts it + so a page that fits the screen doesn't scroll by the dock's height. */} +
+ {/* The style question comes first and only once per install. "What's new" waits until it's + answered, so a first visit never opens two dialogs on top of each other. */} + {settings && + (settings.ui_style_chosen !== '1' ? : )} diff --git a/apps/web/src/components/BulkConfirmDialog.tsx b/apps/web/src/components/BulkConfirmDialog.tsx index f193dae..feafa9a 100644 --- a/apps/web/src/components/BulkConfirmDialog.tsx +++ b/apps/web/src/components/BulkConfirmDialog.tsx @@ -49,7 +49,7 @@ export function BulkConfirmDialog({ onClick={busy ? undefined : onCancel} >
e.stopPropagation()} >
{title}
@@ -64,11 +64,11 @@ export function BulkConfirmDialog({
)} -
+
{shown.map((row) => (
@@ -76,7 +76,7 @@ export function BulkConfirmDialog({ src={thumbUrl(row.id)} alt="" loading="lazy" - className="h-7 w-7 flex-none rounded border border-zinc-800 object-cover" + className="h-7 w-7 flex-none rounded border border-line object-cover" />
@@ -101,14 +101,14 @@ export function BulkConfirmDialog({
-
+
{plan.map((row) => (
@@ -183,14 +183,14 @@ export function BulkRenameDialog({
diff --git a/apps/web/src/components/BulkTagDialog.tsx b/apps/web/src/components/BulkTagDialog.tsx index 271fc26..23b07a3 100644 --- a/apps/web/src/components/BulkTagDialog.tsx +++ b/apps/web/src/components/BulkTagDialog.tsx @@ -90,7 +90,7 @@ export function BulkTagDialog({ onClick={onCancel} >
e.stopPropagation()} >
Edit tags on {ids.length} files
@@ -125,7 +125,7 @@ export function BulkTagDialog({ />
REMOVE
-
+
{(present ?? []).length === 0 && (
No tags on the selected files.
)} @@ -141,7 +141,7 @@ export function BulkTagDialog({ className={`flex cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-semibold ${ staged ? 'border-rose-500/50 bg-rose-500/15 text-rose-300 line-through' - : 'border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-600' + : 'border-line-strong bg-zinc-800 text-zinc-300 hover:border-line-hover' }`} > {tag.name} @@ -156,14 +156,14 @@ export function BulkTagDialog({
diff --git a/apps/web/src/components/ConfirmDialog.tsx b/apps/web/src/components/ConfirmDialog.tsx index 2063446..bbce3f6 100644 --- a/apps/web/src/components/ConfirmDialog.tsx +++ b/apps/web/src/components/ConfirmDialog.tsx @@ -24,7 +24,7 @@ export function ConfirmDialog({ onClick={onCancel} >
e.stopPropagation()} >
{title}
@@ -32,13 +32,13 @@ export function ConfirmDialog({