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:
1 parent
c98eb2f41d
commit
074123a1c7
3 files changed
+254
-139
No files matched your search
+99
-76
@@ -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,93 +68,86 @@ 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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!cfg && (
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{!cfg && <Spinner />}
|
|
||||||
{cfg && (
|
{cfg && (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<SettingRow
|
||||||
<Field label="Backup folder" hint="Inside the container, e.g. /config/backups">
|
title="Back up before every action"
|
||||||
<input className={inputCls + " mono text-xs"} value={cfg.folder} onChange={(e) => setCfg({ ...cfg, folder: e.target.value })} />
|
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."
|
||||||
</Field>
|
>
|
||||||
<Field label="Scheduled backups">
|
<Toggle ariaLabel="Back up before every action" checked={cfg.before_actions} onChange={(v) => setCfg({ ...cfg, before_actions: v })} />
|
||||||
<select className={inputCls} value={cfg.interval_days} onChange={(e) => setCfg({ ...cfg, interval_days: Number(e.target.value) })}>
|
</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>}
|
|
||||||
/>
|
/>
|
||||||
<p className="mt-1.5 pl-11.5 text-xs text-muted">
|
<span className="text-sm text-muted">days</span>
|
||||||
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>
|
|
||||||
</div>
|
</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 && (
|
{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}>
|
<Button size="sm" variant="primary" onClick={() => saveCfg.mutate()} loading={saveCfg.isPending}>
|
||||||
Save backup settings
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
<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
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -166,11 +159,43 @@ export function BackupsCard() {
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()}>
|
<Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()} title="Restore a backup .zip downloaded earlier">
|
||||||
<Upload className="size-3.5" /> Restore from a file…
|
<Upload className="size-3.5" /> Restore from file…
|
||||||
</Button>
|
</Button>
|
||||||
a backup .zip downloaded earlier
|
|
||||||
</div>
|
</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?">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+99
-62
@@ -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">
|
||||||
|
<header className="px-1">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
<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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<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">
|
<Field label="Current password">
|
||||||
<input className={inputCls} type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} />
|
<input className={inputCls} type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="New password" hint="At least 10 characters">
|
<Field label="New password">
|
||||||
<input className={inputCls} type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} />
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="At least 10 characters"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{pw.error && <Alert tone="red">{pw.error.message}</Alert>}
|
<Button type="submit" loading={pw.isPending} disabled={!cur || !next}>
|
||||||
<Button type="submit" loading={pw.isPending}>
|
|
||||||
Update password
|
Update password
|
||||||
</Button>
|
</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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user