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
+254 -139

No files matched your search

+99 -76
View File
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Download, RotateCcw, Trash2, Upload } from "lucide-react";
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";
const typeTone = { manual: "accent", scheduled: "blue", action: "gray" } as const;
const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const;
export function BackupsCard() {
export function BackupsSection() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["backups"], queryFn: () => api.get<{ config: BackupConfig; backups: Backup[] }>("/backups") });
@@ -68,93 +68,86 @@ export function BackupsCard() {
});
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 font-semibold">
<Archive className="size-4 text-accent" /> Backups
</h2>
<p className="text-sm text-muted">
Backups hold Playlink's database: settings, library pairs, scan results and history. They do <b>not</b> contain music files; file
changes are undone from History.
</p>
</div>
<>
<SettingsSection
icon={<Archive />}
title="Backups"
description={
<>
Copies of Playlink's database: settings, library pairs, scan results and history. Music files are not included; file changes are undone
from History.
</>
}
actions={
<Button size="sm" variant="primary" onClick={() => create.mutate()} loading={create.isPending}>
Back up now
<Archive className="size-3.5" /> Back up now
</Button>
}
>
{!cfg && (
<div className="px-5 py-4">
<Spinner />
</div>
{!cfg && <Spinner />}
)}
{cfg && (
<>
<div className="grid gap-4 md:grid-cols-3">
<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 })} />
</Field>
<Field label="Scheduled backups">
<select className={inputCls} value={cfg.interval_days} onChange={(e) => setCfg({ ...cfg, interval_days: Number(e.target.value) })}>
<SettingRow
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={1}>Every day</option>
<option value={3}>Every 3 days</option>
<option value={7}>Every 7 days</option>
<option value={14}>Every 14 days</option>
</select>
</Field>
<Field label="Retention (days)" hint="For scheduled backups. Manual ones are kept until deleted.">
<input className={inputCls} type="number" min={1} value={cfg.retention_days} onChange={(e) => setCfg({ ...cfg, retention_days: Number(e.target.value) })} />
</Field>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={cfg.before_actions}
onChange={(v) => setCfg({ ...cfg, before_actions: v })}
label={<span className="font-medium">Back up before every action in live mode</span>}
</SettingRow>
<SettingRow title="Keep scheduled backups for" description="Manual backups are kept until you delete them.">
<div className="flex items-center gap-2">
<input
className={inputCls + " w-20! text-right"}
type="number"
min={1}
value={cfg.retention_days}
onChange={(e) => setCfg({ ...cfg, retention_days: Number(e.target.value) })}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
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
per file. If the backup fails, the action doesn't run. The newest 10 are kept.
</p>
<span className="text-sm text-muted">days</span>
</div>
</SettingRow>
<SettingRow title="Backup folder" description="A folder inside the container.">
<input
className={inputCls + " mono sm:w-72! text-xs"}
value={cfg.folder}
onChange={(e) => setCfg({ ...cfg, folder: e.target.value })}
/>
</SettingRow>
{dirty && (
<div className="flex justify-end">
<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 backup settings
Save
</Button>
</div>
)}
</>
)}
<div className="overflow-hidden rounded-lg border border-line">
{data.data?.backups.length === 0 && <div className="p-4 text-center text-sm text-muted">No backups yet.</div>}
{data.data?.backups.map((b) => (
<div key={b.name} className="flex flex-wrap items-center gap-3 border-b border-line px-3 py-2 last:border-0">
<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="flex items-center justify-between px-5 pt-4 pb-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted">
Saved backups{data.data ? ` (${data.data.backups.length})` : ""}
</div>
<div className="text-xs text-muted">
{new Date(b.created * 1000).toLocaleString()} · {formatAgo(b.created)} · {formatBytes(b.size)}
</div>
</div>
<a
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-gray-300 hover:bg-panel-2"
href={`/api/backups/${encodeURIComponent(b.name)}/download`}
download
>
<Download className="size-3.5" /> Download
</a>
<Button size="sm" 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 className="flex items-center gap-2 text-xs text-muted">
<input
ref={fileRef}
type="file"
@@ -166,11 +159,43 @@ export function BackupsCard() {
e.target.value = "";
}}
/>
<Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()}>
<Upload className="size-3.5" /> Restore from a file…
<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>
a backup .zip downloaded earlier
</div>
{data.data?.backups.length === 0 && <div className="px-5 pb-5 text-sm text-muted">No backups yet.</div>}
<div className="max-h-96 overflow-y-auto px-2 pb-2">
{data.data?.backups.map((b) => (
<div key={b.name} className="group flex flex-wrap items-center gap-3 rounded-lg px-3 py-2 hover:bg-panel-2">
<Badge tone={typeTone[b.type]} className="w-24 justify-center">
{typeLabel[b.type]}
</Badge>
<div className="min-w-[12rem] flex-1">
<div className="text-sm text-gray-100">{new Date(b.created * 1000).toLocaleString()}</div>
<div className="truncate text-xs text-muted" title={b.name}>
{formatAgo(b.created)} · {formatBytes(b.size)}
</div>
</div>
<div className="ml-auto flex items-center gap-1 opacity-70 transition-opacity group-hover:opacity-100">
<a
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-gray-300 hover:bg-panel hover:text-white"
href={`/api/backups/${encodeURIComponent(b.name)}/download`}
download
>
<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?">
<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 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>
Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
</li>
<li>Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.</li>
</ul>
</div>
<div className="mt-5 flex justify-end gap-2">
@@ -210,7 +233,7 @@ export function BackupsCard() {
</div>
</Modal>
{restarting && <Restarting />}
</Card>
</>
);
}
@@ -240,8 +263,8 @@ function Restarting() {
{slow && (
<div className="mt-3">
<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
it starts.
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
starts.
</Alert>
</div>
)}
+99 -62
View File
@@ -1,10 +1,21 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CircleCheck, KeyRound, Server, ShieldCheck, Tv } from "lucide-react";
import { api } from "../api";
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 { 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() {
const info = useInfo();
@@ -37,86 +48,112 @@ export function SettingsPage() {
});
return (
<div className="max-w-3xl space-y-5">
<div className="mx-auto max-w-4xl space-y-10 pb-8">
<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">
<h2 className="font-semibold">Safety</h2>
<Toggle
checked={info.data?.dry_run ?? true}
onChange={(v) => dry.mutate(v)}
label={<span className="font-medium">Dry run</span>}
/>
<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>
<div className="text-xs text-muted">
<div className="mb-1 font-medium text-gray-300">Always on:</div>
<ul className="list-disc space-y-0.5 pl-5">
<li>Files must be unchanged since the scan, or the action is refused.</li>
<li>Identical files are compared byte for byte again right before linking.</li>
<li>Replacements are staged as a hardlink and atomically renamed into place, so a file is never missing, even after a crash.</li>
<li>Every new link is checked by inode after it is created.</li>
<li>Non-identical originals are quarantined, not deleted.</li>
<li>Symlinks are never followed, and every path is confined to the allowed roots.</li>
<SettingsSection icon={<ShieldCheck />} title="Safety" description="How Playlink treats your files.">
<SettingRow
title="Dry run"
description={
<>
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.
</>
}
>
<Toggle ariaLabel="Dry run" checked={info.data?.dry_run ?? true} onChange={(v) => dry.mutate(v)} />
</SettingRow>
<div className="px-5 py-4">
<div className="mb-2.5 text-xs font-medium uppercase tracking-wide text-muted">Always on</div>
<ul className="grid gap-x-6 gap-y-2 text-[13px] text-gray-300 md:grid-cols-2">
{ALWAYS_ON.map((t) => (
<li key={t} className="flex gap-2">
<CircleCheck className="mt-0.5 size-3.5 shrink-0 text-emerald-400" />
{t}
</li>
))}
</ul>
</div>
</Card>
</SettingsSection>
<Card className="space-y-3 p-5">
<h2 className="font-semibold">Media servers</h2>
<Toggle
checked={info.data?.plex_ignore ?? false}
onChange={(v) => plex.mutate(v)}
label={<span className="font-medium">Hide quarantine from Plex</span>}
/>
<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{" "}
<code className="mono">.plexignore</code> of your own is never changed.
</p>
</Card>
<SettingsSection icon={<Tv />} title="Media servers">
<SettingRow
title="Hide quarantine from Plex"
description={
<>
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.
</>
}
>
<Toggle ariaLabel="Hide quarantine from Plex" checked={info.data?.plex_ignore ?? false} onChange={(v) => plex.mutate(v)} />
</SettingRow>
</SettingsSection>
<BackupsCard />
<BackupsSection />
<Card className="space-y-2 p-5">
<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>
<SettingsSection icon={<KeyRound />} title="Account">
<form
className="space-y-3"
className="px-5 py-4"
onSubmit={(e) => {
e.preventDefault();
pw.mutate();
}}
>
<div className="mb-3">
<div className="text-sm font-medium text-gray-100">Change password</div>
<div className="mt-0.5 text-[13px] text-muted">Other sessions are signed out afterwards.</div>
</div>
<div className="grid items-end gap-3 sm:grid-cols-[1fr_1fr_auto]">
<Field label="Current password">
<input className={inputCls} type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} />
</Field>
<Field label="New password" hint="At least 10 characters">
<input className={inputCls} type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} />
<Field label="New password">
<input
className={inputCls}
type="password"
autoComplete="new-password"
placeholder="At least 10 characters"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</Field>
{pw.error && <Alert tone="red">{pw.error.message}</Alert>}
<Button type="submit" loading={pw.isPending}>
<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>
</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>
);
}
+56 -1
View File
@@ -125,12 +125,23 @@ export function Field({ label, hint, children }: { label: string; hint?: ReactNo
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";
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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
onClick={() => onChange(!checked)}
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>
);
}
/** 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>
);
}