Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/server/src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ const DEFAULTS: Record<string, string> = {
// 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 {
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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');
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/uiStyle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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');
});
4 changes: 2 additions & 2 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<html lang="en" style="background:#09090b">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Sakuya</title>
<link rel="icon" href="/icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand All @@ -40,8 +46,14 @@ export function App() {
return (
<JobsProvider>
<DownloaderProvider>
<div className="relative min-h-screen bg-zinc-950 text-zinc-100">
<ReleaseNotesDialog />
{/* The bottom padding keeps the last row clear of the phone dock. It lives here rather than
on <body> 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. */}
<div className="relative min-h-dvh pb-(--dock-h) text-zinc-100">
{/* 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' ? <UiStylePicker current={settings.ui_style} /> : <ReleaseNotesDialog />)}
<UpdateToast />
<Navbar />
<Routes>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/BulkConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function BulkConfirmDialog({
onClick={busy ? undefined : onCancel}
>
<div
className="flex max-h-[80vh] w-full max-w-[560px] flex-col rounded-xl border border-zinc-800 bg-[#111113] p-5"
className="flex max-h-[80vh] w-full max-w-[560px] flex-col rounded-xl border border-line bg-surface p-5"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-2 text-[15px] font-bold">{title}</div>
Expand All @@ -64,19 +64,19 @@ export function BulkConfirmDialog({
</div>
)}

<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-line bg-zinc-900/40">
{shown.map((row) => (
<div
key={row.id}
className={`flex items-center gap-2.5 border-b border-zinc-800/60 px-2.5 py-1.5 last:border-b-0 ${
className={`flex items-center gap-2.5 border-b border-line/60 px-2.5 py-1.5 last:border-b-0 ${
row.warning ? 'bg-rose-500/5' : ''
}`}
>
<img
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"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-semibold text-zinc-200" title={row.label}>
Expand All @@ -101,14 +101,14 @@ export function BulkConfirmDialog({
<button
disabled={busy}
onClick={onCancel}
className="cursor-pointer rounded-[7px] border border-zinc-800 px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100 disabled:opacity-40"
className="cursor-pointer rounded-btn border border-line px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100 disabled:opacity-40"
>
Cancel
</button>
<button
disabled={busy || rows.length === blocking}
onClick={onConfirm}
className={`cursor-pointer rounded-[7px] px-3.5 py-1.5 text-[12.5px] font-semibold text-white disabled:opacity-40 ${
className={`cursor-pointer rounded-btn px-3.5 py-1.5 text-[12.5px] font-semibold text-white disabled:opacity-40 ${
danger ? 'bg-rose-600 hover:bg-rose-500' : 'bg-accent hover:opacity-90'
}`}
>
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/components/BulkRenameDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function segStyle(active: boolean): string {
}

const inputClass =
'w-full rounded-lg border border-zinc-800 bg-zinc-900 px-2.5 py-2 text-[12.5px] text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-zinc-600';
'w-full rounded-lg border border-line bg-zinc-900 px-2.5 py-2 text-[12.5px] text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-line-hover';

/**
* Bulk rename with a live preview. The preview rows and the payload come from the same
Expand Down Expand Up @@ -79,12 +79,12 @@ export function BulkRenameDialog({
onClick={onCancel}
>
<div
className="flex max-h-[85vh] w-full max-w-[600px] flex-col rounded-xl border border-zinc-800 bg-[#111113] p-5"
className="flex max-h-[85vh] w-full max-w-[600px] flex-col rounded-xl border border-line bg-surface p-5"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 text-[15px] font-bold">Rename {selected.length} files</div>

<div className="mb-3 flex w-fit rounded-lg border border-zinc-800 bg-zinc-900 p-0.5">
<div className="mb-3 flex w-fit rounded-lg border border-line bg-zinc-900 p-0.5">
<div className={segStyle(opts.mode === 'pattern')} onClick={() => set({ mode: 'pattern' })}>
Pattern
</div>
Expand All @@ -110,7 +110,7 @@ export function BulkRenameDialog({
min={0}
value={opts.startAt}
onChange={(e) => set({ startAt: Number(e.target.value) || 0 })}
className="w-[68px] rounded-lg border border-zinc-800 bg-zinc-900 px-2 py-2 text-[12.5px] text-zinc-100 outline-none focus:border-zinc-600"
className="w-[68px] rounded-lg border border-line bg-zinc-900 px-2 py-2 text-[12.5px] text-zinc-100 outline-none focus:border-line-hover"
/>
</div>
</div>
Expand Down Expand Up @@ -158,11 +158,11 @@ export function BulkRenameDialog({
{stats.errors > 0 && <span className="text-rose-400"> · {stats.errors} blocked</span>}
</div>
</div>
<div className="min-h-[120px] flex-1 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="min-h-[120px] flex-1 overflow-y-auto rounded-lg border border-line bg-zinc-900/40">
{plan.map((row) => (
<div
key={row.id}
className={`flex items-center gap-2 border-b border-zinc-800/60 px-2.5 py-1.5 text-[11.5px] last:border-b-0 ${
className={`flex items-center gap-2 border-b border-line/60 px-2.5 py-1.5 text-[11.5px] last:border-b-0 ${
row.error ? 'bg-rose-500/5' : ''
}`}
>
Expand All @@ -183,14 +183,14 @@ export function BulkRenameDialog({
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onCancel}
className="cursor-pointer rounded-[7px] border border-zinc-800 px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100"
className="cursor-pointer rounded-btn border border-line px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100"
>
Cancel
</button>
<button
disabled={stats.changed === 0}
onClick={() => setConfirming(true)}
className="cursor-pointer rounded-[7px] bg-accent px-3.5 py-1.5 text-[12.5px] font-semibold text-white hover:opacity-90 disabled:opacity-40"
className="cursor-pointer rounded-btn bg-accent px-3.5 py-1.5 text-[12.5px] font-semibold text-white hover:opacity-90 disabled:opacity-40"
>
Review changes
</button>
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/BulkTagDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function BulkTagDialog({
onClick={onCancel}
>
<div
className="flex max-h-[80vh] w-full max-w-[520px] flex-col rounded-xl border border-zinc-800 bg-[#111113] p-5"
className="flex max-h-[80vh] w-full max-w-[520px] flex-col rounded-xl border border-line bg-surface p-5"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-1 text-[15px] font-bold">Edit tags on {ids.length} files</div>
Expand Down Expand Up @@ -125,7 +125,7 @@ export function BulkTagDialog({
/>

<div className="mb-1.5 mt-5 text-xs font-bold tracking-[0.4px] text-zinc-500">REMOVE</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900/40 p-2">
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-line bg-zinc-900/40 p-2">
{(present ?? []).length === 0 && (
<div className="px-1 py-2 text-[11.5px] text-zinc-600">No tags on the selected files.</div>
)}
Expand All @@ -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'
}`}
>
<span>{tag.name}</span>
Expand All @@ -156,14 +156,14 @@ export function BulkTagDialog({
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onCancel}
className="cursor-pointer rounded-[7px] border border-zinc-800 px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100"
className="cursor-pointer rounded-btn border border-line px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100"
>
Cancel
</button>
<button
disabled={!hasChanges}
onClick={() => setConfirming(true)}
className="cursor-pointer rounded-[7px] bg-accent px-3.5 py-1.5 text-[12.5px] font-semibold text-white hover:opacity-90 disabled:opacity-40"
className="cursor-pointer rounded-btn bg-accent px-3.5 py-1.5 text-[12.5px] font-semibold text-white hover:opacity-90 disabled:opacity-40"
>
Review changes
</button>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,21 @@ export function ConfirmDialog({
onClick={onCancel}
>
<div
className="w-full max-w-[420px] rounded-xl border border-zinc-800 bg-[#111113] p-5"
className="w-full max-w-[420px] rounded-xl border border-line bg-surface p-5"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-2 text-[15px] font-bold">{title}</div>
<div className="mb-4 text-[12.5px] leading-relaxed text-zinc-400">{body}</div>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="cursor-pointer rounded-[7px] border border-zinc-800 px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100"
className="cursor-pointer rounded-btn border border-line px-3.5 py-1.5 text-[12.5px] font-semibold text-zinc-300 hover:text-zinc-100"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`cursor-pointer rounded-[7px] px-3.5 py-1.5 text-[12.5px] font-semibold text-white ${
className={`cursor-pointer rounded-btn px-3.5 py-1.5 text-[12.5px] font-semibold text-white ${
danger ? 'bg-rose-600 hover:bg-rose-500' : 'bg-accent hover:opacity-90'
}`}
>
Expand Down
Loading
Loading