Redesign Settings as centered sections with one row per setting

Settings is now a centered page of titled sections (Safety, Media
servers, Backups, Account, Environment). Each setting is a row with its
name and a short description on the left and its control on the right;
the safety guarantees are a checklist. Backups get a save/discard bar for
unsaved changes and a cleaner list of saved backups that wraps on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 16:04:40 -07:00
1 parent c98eb2f41d
commit 074123a1c7
3 files changed
+286 -171

No files matched your search

+124 -101
View File
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Download, RotateCcw, Trash2, Upload } from "lucide-react"; import { Archive, Download, RotateCcw, Trash2, Upload } from "lucide-react";
import { api, type Backup, type BackupConfig } from "../api"; import { api, type Backup, type BackupConfig } from "../api";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui"; import { Alert, Badge, Button, Modal, SettingRow, SettingsSection, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
const typeTone = { manual: "accent", scheduled: "blue", action: "gray" } as const; const typeTone = { manual: "accent", scheduled: "blue", action: "gray" } as const;
const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const; const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const;
export function BackupsCard() { export function BackupsSection() {
const qc = useQueryClient(); const qc = useQueryClient();
const toast = useToast(); const toast = useToast();
const data = useQuery({ queryKey: ["backups"], queryFn: () => api.get<{ config: BackupConfig; backups: Backup[] }>("/backups") }); const data = useQuery({ queryKey: ["backups"], queryFn: () => api.get<{ config: BackupConfig; backups: Backup[] }>("/backups") });
@@ -68,109 +68,134 @@ export function BackupsCard() {
}); });
return ( return (
<Card className="space-y-4 p-5"> <>
<div className="flex items-start justify-between gap-3"> <SettingsSection
<div> icon={<Archive />}
<h2 className="flex items-center gap-2 font-semibold"> title="Backups"
<Archive className="size-4 text-accent" /> Backups description={
</h2> <>
<p className="text-sm text-muted"> Copies of Playlink's database: settings, library pairs, scan results and history. Music files are not included; file changes are undone
Backups hold Playlink's database: settings, library pairs, scan results and history. They do <b>not</b> contain music files; file from History.
changes are undone from History. </>
</p> }
</div> actions={
<Button size="sm" variant="primary" onClick={() => create.mutate()} loading={create.isPending}> <Button size="sm" variant="primary" onClick={() => create.mutate()} loading={create.isPending}>
Back up now <Archive className="size-3.5" /> Back up now
</Button> </Button>
</div> }
>
{!cfg && <Spinner />} {!cfg && (
{cfg && ( <div className="px-5 py-4">
<> <Spinner />
<div className="grid gap-4 md:grid-cols-3"> </div>
<Field label="Backup folder" hint="Inside the container, e.g. /config/backups"> )}
<input className={inputCls + " mono text-xs"} value={cfg.folder} onChange={(e) => setCfg({ ...cfg, folder: e.target.value })} /> {cfg && (
</Field> <>
<Field label="Scheduled backups"> <SettingRow
<select className={inputCls} value={cfg.interval_days} onChange={(e) => setCfg({ ...cfg, interval_days: Number(e.target.value) })}> title="Back up before every action"
description="In live mode: one backup per action you start, not per file. If it fails, the action doesn't run. The newest 10 are kept."
>
<Toggle ariaLabel="Back up before every action" checked={cfg.before_actions} onChange={(v) => setCfg({ ...cfg, before_actions: v })} />
</SettingRow>
<SettingRow title="Scheduled backups" description="Checked once an hour.">
<select
className={inputCls + " sm:w-44!"}
value={cfg.interval_days}
onChange={(e) => setCfg({ ...cfg, interval_days: Number(e.target.value) })}
>
<option value={0}>Off</option> <option value={0}>Off</option>
<option value={1}>Every day</option> <option value={1}>Every day</option>
<option value={3}>Every 3 days</option> <option value={3}>Every 3 days</option>
<option value={7}>Every 7 days</option> <option value={7}>Every 7 days</option>
<option value={14}>Every 14 days</option> <option value={14}>Every 14 days</option>
</select> </select>
</Field> </SettingRow>
<Field label="Retention (days)" hint="For scheduled backups. Manual ones are kept until deleted."> <SettingRow title="Keep scheduled backups for" description="Manual backups are kept until you delete them.">
<input className={inputCls} type="number" min={1} value={cfg.retention_days} onChange={(e) => setCfg({ ...cfg, retention_days: Number(e.target.value) })} /> <div className="flex items-center gap-2">
</Field> <input
</div> className={inputCls + " w-20! text-right"}
<div className="rounded-lg border border-line bg-panel-2 p-3.5"> type="number"
<Toggle min={1}
checked={cfg.before_actions} value={cfg.retention_days}
onChange={(v) => setCfg({ ...cfg, before_actions: v })} onChange={(e) => setCfg({ ...cfg, retention_days: Number(e.target.value) })}
label={<span className="font-medium">Back up before every action in live mode</span>} />
/> <span className="text-sm text-muted">days</span>
<p className="mt-1.5 pl-11.5 text-xs text-muted"> </div>
One backup per action you start (a link, a bulk action, a tag edit, renaming folders, an undo…) and before scans change files, not one </SettingRow>
per file. If the backup fails, the action doesn't run. The newest 10 are kept. <SettingRow title="Backup folder" description="A folder inside the container.">
</p> <input
</div> className={inputCls + " mono sm:w-72! text-xs"}
{dirty && ( value={cfg.folder}
<div className="flex justify-end"> onChange={(e) => setCfg({ ...cfg, folder: e.target.value })}
<Button size="sm" variant="primary" onClick={() => saveCfg.mutate()} loading={saveCfg.isPending}> />
Save backup settings </SettingRow>
</Button> {dirty && (
</div> <div className="flex items-center justify-end gap-2 bg-accent/5 px-5 py-3">
)} <span className="mr-auto text-xs text-muted">Unsaved changes</span>
</> <Button size="sm" variant="ghost" onClick={() => saved && setCfg(saved)}>
)} Discard
</Button>
<Button size="sm" variant="primary" onClick={() => saveCfg.mutate()} loading={saveCfg.isPending}>
Save
</Button>
</div>
)}
</>
)}
<div className="overflow-hidden rounded-lg border border-line"> <div>
{data.data?.backups.length === 0 && <div className="p-4 text-center text-sm text-muted">No backups yet.</div>} <div className="flex items-center justify-between px-5 pt-4 pb-2">
{data.data?.backups.map((b) => ( <div className="text-xs font-medium uppercase tracking-wide text-muted">
<div key={b.name} className="flex flex-wrap items-center gap-3 border-b border-line px-3 py-2 last:border-0"> Saved backups{data.data ? ` (${data.data.backups.length})` : ""}
<Badge tone={typeTone[b.type]}>{typeLabel[b.type]}</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm mono" title={b.name}>
{b.name}
</div>
<div className="text-xs text-muted">
{new Date(b.created * 1000).toLocaleString()} · {formatAgo(b.created)} · {formatBytes(b.size)}
</div>
</div> </div>
<a <input
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-gray-300 hover:bg-panel-2" ref={fileRef}
href={`/api/backups/${encodeURIComponent(b.name)}/download`} type="file"
download accept=".zip,application/zip"
> className="hidden"
<Download className="size-3.5" /> Download onChange={(e) => {
</a> const f = e.target.files?.[0];
<Button size="sm" onClick={() => setRestore({ name: b.name })}> if (f) setRestore({ file: f });
<RotateCcw className="size-3.5" /> Restore e.target.value = "";
</Button> }}
<Button size="sm" variant="ghost" onClick={() => setDel(b)} title="Delete backup"> />
<Trash2 className="size-3.5" /> <Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()} title="Restore a backup .zip downloaded earlier">
<Upload className="size-3.5" /> Restore from file…
</Button> </Button>
</div> </div>
))} {data.data?.backups.length === 0 && <div className="px-5 pb-5 text-sm text-muted">No backups yet.</div>}
</div> <div className="max-h-96 overflow-y-auto px-2 pb-2">
<div className="flex items-center gap-2 text-xs text-muted"> {data.data?.backups.map((b) => (
<input <div key={b.name} className="group flex flex-wrap items-center gap-3 rounded-lg px-3 py-2 hover:bg-panel-2">
ref={fileRef} <Badge tone={typeTone[b.type]} className="w-24 justify-center">
type="file" {typeLabel[b.type]}
accept=".zip,application/zip" </Badge>
className="hidden" <div className="min-w-[12rem] flex-1">
onChange={(e) => { <div className="text-sm text-gray-100">{new Date(b.created * 1000).toLocaleString()}</div>
const f = e.target.files?.[0]; <div className="truncate text-xs text-muted" title={b.name}>
if (f) setRestore({ file: f }); {formatAgo(b.created)} · {formatBytes(b.size)}
e.target.value = ""; </div>
}} </div>
/> <div className="ml-auto flex items-center gap-1 opacity-70 transition-opacity group-hover:opacity-100">
<Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()}> <a
<Upload className="size-3.5" /> Restore from a file… className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-gray-300 hover:bg-panel hover:text-white"
</Button> href={`/api/backups/${encodeURIComponent(b.name)}/download`}
a backup .zip downloaded earlier download
</div> >
<Download className="size-3.5" /> Download
</a>
<Button size="sm" variant="ghost" onClick={() => setRestore({ name: b.name })}>
<RotateCcw className="size-3.5" /> Restore
</Button>
<Button size="sm" variant="ghost" onClick={() => setDel(b)} title="Delete backup">
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
</SettingsSection>
<Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?"> <Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?">
<div className="space-y-3 text-sm text-gray-300"> <div className="space-y-3 text-sm text-gray-300">
@@ -182,9 +207,7 @@ export function BackupsCard() {
<li>The backup is checked first; a damaged backup or one from a newer Playlink version is refused.</li> <li>The backup is checked first; a damaged backup or one from a newer Playlink version is refused.</li>
<li>The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.</li> <li>The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.</li>
<li>Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.</li> <li>Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.</li>
<li> <li>Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.</li>
Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
</li>
</ul> </ul>
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
@@ -210,7 +233,7 @@ export function BackupsCard() {
</div> </div>
</Modal> </Modal>
{restarting && <Restarting />} {restarting && <Restarting />}
</Card> </>
); );
} }
@@ -240,8 +263,8 @@ function Restarting() {
{slow && ( {slow && (
<div className="mt-3"> <div className="mt-3">
<Alert tone="amber"> <Alert tone="amber">
Playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when Playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when it
it starts. starts.
</Alert> </Alert>
</div> </div>
)} )}
+106 -69
View File
@@ -1,10 +1,21 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CircleCheck, KeyRound, Server, ShieldCheck, Tv } from "lucide-react";
import { api } from "../api"; import { api } from "../api";
import { useInfo } from "../App"; import { useInfo } from "../App";
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui"; import { Alert, Button, Field, SettingRow, SettingsSection, Toggle, inputCls } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { BackupsCard } from "./Backups"; import { BackupsSection } from "./Backups";
// Guarantees that apply whatever the settings are.
const ALWAYS_ON = [
"Files must be unchanged since the scan, or the action is refused",
"Identical files are compared byte for byte again right before linking",
"Replacements are staged and renamed into place atomically, so a file is never missing",
"Every new link is checked by inode after it is created",
"Non-identical originals are quarantined, never deleted",
"Symlinks are never followed; every path stays inside the allowed roots",
];
export function SettingsPage() { export function SettingsPage() {
const info = useInfo(); const info = useInfo();
@@ -37,86 +48,112 @@ export function SettingsPage() {
}); });
return ( return (
<div className="max-w-3xl space-y-5"> <div className="mx-auto max-w-4xl space-y-10 pb-8">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1> <header className="px-1">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<p className="mt-1 text-sm text-muted">These apply to every library pair. Each pair's own options are under Edit on its page.</p>
</header>
<Card className="space-y-3 p-5"> <SettingsSection icon={<ShieldCheck />} title="Safety" description="How Playlink treats your files.">
<h2 className="font-semibold">Safety</h2> <SettingRow
<Toggle title="Dry run"
checked={info.data?.dry_run ?? true} description={
onChange={(v) => dry.mutate(v)} <>
label={<span className="font-medium">Dry run</span>} Link, promote, build and remove actions are only recorded in Activity as <i>planned</i>; nothing on disk changes. Scanning works
/> normally. Turn it off once the planned actions look right.
<p className="text-sm text-muted"> </>
While dry run is on, every link, promote, build and remove action is recorded in Activity as <i>planned</i>, and nothing on disk changes. }
Scanning and comparing work normally. Turn it off once the planned actions look right. >
</p> <Toggle ariaLabel="Dry run" checked={info.data?.dry_run ?? true} onChange={(v) => dry.mutate(v)} />
<div className="text-xs text-muted"> </SettingRow>
<div className="mb-1 font-medium text-gray-300">Always on:</div> <div className="px-5 py-4">
<ul className="list-disc space-y-0.5 pl-5"> <div className="mb-2.5 text-xs font-medium uppercase tracking-wide text-muted">Always on</div>
<li>Files must be unchanged since the scan, or the action is refused.</li> <ul className="grid gap-x-6 gap-y-2 text-[13px] text-gray-300 md:grid-cols-2">
<li>Identical files are compared byte for byte again right before linking.</li> {ALWAYS_ON.map((t) => (
<li>Replacements are staged as a hardlink and atomically renamed into place, so a file is never missing, even after a crash.</li> <li key={t} className="flex gap-2">
<li>Every new link is checked by inode after it is created.</li> <CircleCheck className="mt-0.5 size-3.5 shrink-0 text-emerald-400" />
<li>Non-identical originals are quarantined, not deleted.</li> {t}
<li>Symlinks are never followed, and every path is confined to the allowed roots.</li> </li>
))}
</ul> </ul>
</div> </div>
</Card> </SettingsSection>
<Card className="space-y-3 p-5"> <SettingsSection icon={<Tv />} title="Media servers">
<h2 className="font-semibold">Media servers</h2> <SettingRow
<Toggle title="Hide quarantine from Plex"
checked={info.data?.plex_ignore ?? false} description={
onChange={(v) => plex.mutate(v)} <>
label={<span className="font-medium">Hide quarantine from Plex</span>} Adds a <code className="mono">.plexignore</code> to each pair's quarantine folder so Plex doesn't show quarantined songs as duplicates.
/> Turning it off removes only the files Playlink created.
<p className="text-sm text-muted"> </>
Adds a <code className="mono">.plexignore</code> file to each library pair's quarantine folder (by default{" "} }
<code className="mono">.playlink-trash</code> in the slave library), so Plex doesn't pick up quarantined songs as duplicates. New >
quarantine folders get it automatically. Turning this off removes the file again, but only if Playlink created it: a{" "} <Toggle ariaLabel="Hide quarantine from Plex" checked={info.data?.plex_ignore ?? false} onChange={(v) => plex.mutate(v)} />
<code className="mono">.plexignore</code> of your own is never changed. </SettingRow>
</p> </SettingsSection>
</Card>
<BackupsCard /> <BackupsSection />
<Card className="space-y-2 p-5"> <SettingsSection icon={<KeyRound />} title="Account">
<h2 className="font-semibold">Environment</h2>
<div className="text-sm">
<span className="text-muted">Allowed roots: </span>
{info.data?.allowed_roots.map((r) => (
<code key={r} className="mono mr-2 rounded bg-panel-2 px-1.5 py-0.5 text-xs">
{r}
</code>
))}
</div>
<p className="text-xs text-muted">
Set with <code className="mono">PLAYLINK_ALLOWED_ROOTS</code>. Libraries and quarantine folders must be inside these roots.
</p>
</Card>
<Card className="p-5">
<h2 className="mb-3 font-semibold">Change password</h2>
<form <form
className="space-y-3" className="px-5 py-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
pw.mutate(); pw.mutate();
}} }}
> >
<Field label="Current password"> <div className="mb-3">
<input className={inputCls} type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} /> <div className="text-sm font-medium text-gray-100">Change password</div>
</Field> <div className="mt-0.5 text-[13px] text-muted">Other sessions are signed out afterwards.</div>
<Field label="New password" hint="At least 10 characters"> </div>
<input className={inputCls} type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} /> <div className="grid items-end gap-3 sm:grid-cols-[1fr_1fr_auto]">
</Field> <Field label="Current password">
{pw.error && <Alert tone="red">{pw.error.message}</Alert>} <input className={inputCls} type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} />
<Button type="submit" loading={pw.isPending}> </Field>
Update password <Field label="New password">
</Button> <input
className={inputCls}
type="password"
autoComplete="new-password"
placeholder="At least 10 characters"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</Field>
<Button type="submit" loading={pw.isPending} disabled={!cur || !next}>
Update password
</Button>
</div>
{pw.error && (
<div className="mt-3">
<Alert tone="red">{pw.error.message}</Alert>
</div>
)}
</form> </form>
</Card> </SettingsSection>
<SettingsSection icon={<Server />} title="Environment" description="Set when the container starts; shown here for reference.">
<SettingRow
title="Allowed roots"
description={
<>
Libraries and quarantine folders must be inside these. Set with <code className="mono">PLAYLINK_ALLOWED_ROOTS</code>.
</>
}
>
<div className="flex flex-wrap justify-end gap-1.5">
{info.data?.allowed_roots.map((r) => (
<code key={r} className="mono rounded-md border border-line bg-panel-2 px-2 py-1 text-xs">
{r}
</code>
))}
</div>
</SettingRow>
<SettingRow title="Version">
<code className="mono text-xs text-gray-300">{info.data?.version}</code>
</SettingRow>
</SettingsSection>
</div> </div>
); );
} }
+56 -1
View File
@@ -125,12 +125,23 @@ export function Field({ label, hint, children }: { label: string; hint?: ReactNo
export const inputCls = export const inputCls =
"w-full h-9 rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-gray-600"; "w-full h-9 rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-gray-600";
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode }) { export function Toggle({
checked,
onChange,
label,
ariaLabel,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label?: ReactNode;
ariaLabel?: string;
}) {
return ( return (
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label={ariaLabel}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className="inline-flex items-center gap-2.5 text-sm cursor-pointer" className="inline-flex items-center gap-2.5 text-sm cursor-pointer"
> >
@@ -195,3 +206,47 @@ export function Brand({ size = "md" }: { size?: "md" | "lg" }) {
</span> </span>
); );
} }
/** A titled group of settings: a heading, a short description, then a card of rows. */
export function SettingsSection({
icon,
title,
description,
actions,
children,
}: {
icon?: ReactNode;
title: string;
description?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<section className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3 px-1">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold">
{icon && <span className="text-accent [&>svg]:size-4">{icon}</span>}
{title}
</h2>
{description && <p className="mt-0.5 max-w-2xl text-sm text-muted">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
<Card className="divide-y divide-line">{children}</Card>
</section>
);
}
/** One setting: what it is on the left, its control on the right. */
export function SettingRow({ title, description, children }: { title: ReactNode; description?: ReactNode; children?: ReactNode }) {
return (
<div className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-100">{title}</div>
{description && <div className="mt-0.5 text-[13px] leading-relaxed text-muted">{description}</div>}
</div>
{children && <div className="shrink-0 sm:max-w-[55%]">{children}</div>}
</div>
);
}