From 074123a1c70b0c79de7894acbd2e09b58aebe9f7 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 16:04:40 -0700 Subject: [PATCH] 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 --- web/src/pages/Backups.tsx | 225 ++++++++++++++++++++----------------- web/src/pages/Settings.tsx | 175 +++++++++++++++++------------ web/src/ui.tsx | 57 +++++++++- 3 files changed, 286 insertions(+), 171 deletions(-) diff --git a/web/src/pages/Backups.tsx b/web/src/pages/Backups.tsx index b8aec79..810971b 100644 --- a/web/src/pages/Backups.tsx +++ b/web/src/pages/Backups.tsx @@ -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,109 +68,134 @@ export function BackupsCard() { }); return ( - -
-
-

- Backups -

-

- Backups hold Playlink's database: settings, library pairs, scan results and history. They do not contain music files; file - changes are undone from History. -

-
- -
- - {!cfg && } - {cfg && ( - <> -
- - setCfg({ ...cfg, folder: e.target.value })} /> - - - setCfg({ ...cfg, interval_days: Number(e.target.value) })} + > - - - setCfg({ ...cfg, retention_days: Number(e.target.value) })} /> - -
-
- setCfg({ ...cfg, before_actions: v })} - label={Back up before every action in live mode} - /> -

- 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. -

-
- {dirty && ( -
- -
- )} - - )} + + +
+ setCfg({ ...cfg, retention_days: Number(e.target.value) })} + /> + days +
+
+ + setCfg({ ...cfg, folder: e.target.value })} + /> + + {dirty && ( +
+ Unsaved changes + + +
+ )} + + )} -
- {data.data?.backups.length === 0 &&
No backups yet.
} - {data.data?.backups.map((b) => ( -
- {typeLabel[b.type]} -
-
- {b.name} -
-
- {new Date(b.created * 1000).toLocaleString()} · {formatAgo(b.created)} · {formatBytes(b.size)} -
+
+
+
+ Saved backups{data.data ? ` (${data.data.backups.length})` : ""}
- - Download - - -
- ))} -
-
- { - const f = e.target.files?.[0]; - if (f) setRestore({ file: f }); - e.target.value = ""; - }} - /> - - a backup .zip downloaded earlier -
+ {data.data?.backups.length === 0 &&
No backups yet.
} +
+ {data.data?.backups.map((b) => ( +
+ + {typeLabel[b.type]} + +
+
{new Date(b.created * 1000).toLocaleString()}
+
+ {formatAgo(b.created)} · {formatBytes(b.size)} +
+
+
+ + Download + + + +
+
+ ))} +
+
+ setRestore(null)} title="Restore this backup?">
@@ -182,9 +207,7 @@ export function BackupsCard() {
  • The backup is checked first; a damaged backup or one from a newer Playlink version is refused.
  • The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.
  • Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.
  • -
  • - Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again. -
  • +
  • Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
  • @@ -210,7 +233,7 @@ export function BackupsCard() {
    {restarting && } - + ); } @@ -240,8 +263,8 @@ function Restarting() { {slow && (
    - 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.
    )} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index c11af7e..b87c964 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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 ( -
    -

    Settings

    +
    +
    +

    Settings

    +

    These apply to every library pair. Each pair's own options are under Edit on its page.

    +
    - -

    Safety

    - dry.mutate(v)} - label={Dry run} - /> -

    - While dry run is on, every link, promote, build and remove action is recorded in Activity as planned, and nothing on disk changes. - Scanning and comparing work normally. Turn it off once the planned actions look right. -

    -
    -
    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 as a hardlink and atomically renamed into place, so a file is never missing, even after a crash.
    • -
    • Every new link is checked by inode after it is created.
    • -
    • Non-identical originals are quarantined, not deleted.
    • -
    • Symlinks are never followed, and every path is confined to the allowed roots.
    • + } title="Safety" description="How Playlink treats your files."> + + Link, promote, build and remove actions are only recorded in Activity as planned; nothing on disk changes. Scanning works + normally. Turn it off once the planned actions look right. + + } + > + dry.mutate(v)} /> + +
      +
      Always on
      +
        + {ALWAYS_ON.map((t) => ( +
      • + + {t} +
      • + ))}
      - +
      - -

      Media servers

      - plex.mutate(v)} - label={Hide quarantine from Plex} - /> -

      - Adds a .plexignore file to each library pair's quarantine folder (by default{" "} - .playlink-trash 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{" "} - .plexignore of your own is never changed. -

      -
      + } title="Media servers"> + + Adds a .plexignore to each pair's quarantine folder so Plex doesn't show quarantined songs as duplicates. + Turning it off removes only the files Playlink created. + + } + > + plex.mutate(v)} /> + + - + - -

      Environment

      -
      - Allowed roots: - {info.data?.allowed_roots.map((r) => ( - - {r} - - ))} -
      -

      - Set with PLAYLINK_ALLOWED_ROOTS. Libraries and quarantine folders must be inside these roots. -

      -
      - - -

      Change password

      + } title="Account">
      { e.preventDefault(); pw.mutate(); }} > - - setCur(e.target.value)} /> - - - setNext(e.target.value)} /> - - {pw.error && {pw.error.message}} - +
      +
      Change password
      +
      Other sessions are signed out afterwards.
      +
      +
      + + setCur(e.target.value)} /> + + + setNext(e.target.value)} + /> + + +
      + {pw.error && ( +
      + {pw.error.message} +
      + )}
      -
      + + + } title="Environment" description="Set when the container starts; shown here for reference."> + + Libraries and quarantine folders must be inside these. Set with PLAYLINK_ALLOWED_ROOTS. + + } + > +
      + {info.data?.allowed_roots.map((r) => ( + + {r} + + ))} +
      +
      + + {info.data?.version} + +
    ); } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 86f1fd4..a5eb675 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -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 (