Initial commit: playlink music library hardlink deduplicator

Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds
songs present in both a host and a slave music library and replaces the
slave copy with a hardlink to the host copy.

- Path-first matching with format/title/album-folder fallbacks
- Auto-linking of byte-identical files, re-verified with cmp before linking
- Manual review with ffprobe quality comparison, downgrade guard and
  promote-to-host override
- Build mode to populate an empty slave library from the host
- Dry run by default, atomic stage+rename replacement, quarantine with
  retention, undo, path confinement and authenticated UI
- Dockerfile and compose file for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 15:38:25 -07:00
commit ac47e61b08
42 files changed
+8892

No files matched your search

View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
// Package web embeds the built single-page app.
package web
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// Dist returns the built UI rooted at dist/.
func Dist() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err)
}
return sub
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>playlink</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1949
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "playlink-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b"
},
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"lucide-react": "^1.47.0",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#7c3aed"/><path d="M12 22V9l11-2v13" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/><circle cx="9.5" cy="22" r="2.8" fill="#fff"/><circle cx="20.5" cy="20" r="2.8" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 332 B

+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, FlaskConical, LayoutGrid, LogOut, Settings as SettingsIcon, ShieldCheck } from "lucide-react";
import { api, type AuthState, type Info } from "./api";
import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard";
import { PairPage } from "./pages/PairPage";
import { ActivityPage } from "./pages/Activity";
import { SettingsPage } from "./pages/Settings";
import { Spinner, cx } from "./ui";
import { useToast } from "./toast";
export function useRoute() {
const [hash, setHash] = useState(() => window.location.hash.slice(1) || "/");
useEffect(() => {
const h = () => setHash(window.location.hash.slice(1) || "/");
window.addEventListener("hashchange", h);
return () => window.removeEventListener("hashchange", h);
}, []);
return hash.split("?")[0].split("/").filter(Boolean);
}
export const go = (path: string) => (window.location.hash = path);
export function useInfo() {
return useQuery({ queryKey: ["info"], queryFn: () => api.get<Info>("/info") });
}
export function App() {
const auth = useQuery({ queryKey: ["auth"], queryFn: () => api.get<AuthState>("/auth/state") });
if (auth.isLoading) {
return (
<div className="grid h-full place-items-center">
<Spinner />
</div>
);
}
if (!auth.data?.authenticated) return <Login setup={!!auth.data?.setup_required} />;
return <Shell username={auth.data.username ?? ""} />;
}
function Shell({ username }: { username: string }) {
const route = useRoute();
const qc = useQueryClient();
const toast = useToast();
const info = useInfo();
const dry = info.data?.dry_run ?? true;
const setDry = useMutation({
mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }),
onSuccess: (_, v) => {
qc.invalidateQueries({ queryKey: ["info"] });
toast("ok", v ? "Dry run enabled: no files will be changed." : "Dry run disabled: operations will now change files.");
},
});
const logout = useMutation({
mutationFn: () => api.post("/auth/logout"),
onSuccess: () => qc.invalidateQueries(),
});
const nav = [
{ to: "/", label: "Libraries", icon: LayoutGrid, active: route.length === 0 || route[0] === "pair" },
{ to: "/activity", label: "Activity", icon: Activity, active: route[0] === "activity" },
{ to: "/settings", label: "Settings", icon: SettingsIcon, active: route[0] === "settings" },
];
let page;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "review"} />;
else if (route[0] === "activity") page = <ActivityPage />;
else if (route[0] === "settings") page = <SettingsPage />;
else page = <Dashboard />;
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-40 border-b border-line bg-bg/85 backdrop-blur">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4">
<a href="#/" className="flex items-center gap-2 font-semibold tracking-tight">
<img src="/favicon.svg" className="size-6" alt="" />
playlink
</a>
<nav className="flex items-center gap-1">
{nav.map((n) => (
<a
key={n.to}
href={`#${n.to}`}
className={cx(
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm",
n.active ? "bg-panel-2 text-white" : "text-muted hover:text-white",
)}
>
<n.icon className="size-4" />
<span className="hidden sm:inline">{n.label}</span>
</a>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setDry.mutate(!dry)}
title="Toggle dry run"
className={cx(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium cursor-pointer",
dry ? "border-sky-500/40 bg-sky-500/10 text-sky-300" : "border-amber-500/40 bg-amber-500/10 text-amber-300",
)}
>
{dry ? <FlaskConical className="size-3.5" /> : <ShieldCheck className="size-3.5" />}
{dry ? "Dry run" : "Live mode"}
</button>
<span className="hidden text-sm text-muted md:inline">{username}</span>
<button onClick={() => logout.mutate()} className="rounded-md p-1.5 text-muted hover:bg-panel-2 hover:text-white cursor-pointer" title="Sign out">
<LogOut className="size-4" />
</button>
</div>
</div>
</header>
{dry && (
<div className="border-b border-sky-500/20 bg-sky-500/5 text-center text-xs text-sky-200 py-1.5 px-4">
Dry run is on. Scans and reviews work normally, but link, promote, build and remove actions are only recorded as planned in Activity. Nothing on disk changes.
</div>
)}
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6">{page}</main>
<footer className="py-4 text-center text-xs text-gray-600">playlink {info.data?.version}</footer>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, {
method,
credentials: "same-origin",
headers: {
"X-Playlink-Request": "1",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, data.error ?? res.statusText);
return data as T;
}
export const api = {
get: <T>(p: string) => request<T>("GET", p),
post: <T>(p: string, b: unknown = {}) => request<T>("POST", p, b),
put: <T>(p: string, b: unknown) => request<T>("PUT", p, b),
del: <T>(p: string) => request<T>("DELETE", p),
};
export type AuthState = { setup_required: boolean; authenticated: boolean; username?: string };
export type Info = {
version: string;
allowed_roots: string[];
default_extensions: string[];
dry_run: boolean;
};
export type Job = {
id: number;
pair_id: number;
kind: string;
state: "running" | "done" | "failed" | "cancelled";
phase: string;
done: number;
total: number;
message: string;
error: string;
results: Record<string, number>;
skipped: string[];
started_at: number;
finished_at?: number;
};
export type Summary = {
counts: Record<string, number>;
review: number;
ignored: number;
reclaimable_bytes: number;
linked_bytes: number;
};
export type Pair = {
id: number;
name: string;
host_root: string;
slave_root: string;
quarantine_dir: string;
extensions: string[];
auto_link_identical: boolean;
scan_interval_minutes: number;
retention_days: number;
settle_minutes: number;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: string[];
summary: Summary;
job: Job | null;
};
export type PairInput = Pick<
Pair,
| "name"
| "host_root"
| "slave_root"
| "quarantine_dir"
| "extensions"
| "auto_link_identical"
| "scan_interval_minutes"
| "retention_days"
| "settle_minutes"
>;
export type ProbeInfo = {
codec: string;
codec_long?: string;
format?: string;
lossless: boolean;
bitrate: number;
sample_rate: number;
bit_depth?: number;
channels: number;
duration: number;
title?: string;
artist?: string;
album?: string;
error?: string;
};
export type Verdict = { better: "host" | "slave" | "equal" | "unknown"; duration_delta: number; notes: string[] | null };
export type Status = "linked" | "identical" | "different" | "similar" | "unmatched" | "error";
export type Match = {
id: number;
pair_id: number;
rel_slave: string;
rel_host: string;
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "none";
status: Status;
host_size: number;
host_mtime: number;
slave_size: number;
slave_mtime: number;
host_probe?: ProbeInfo;
slave_probe?: ProbeInfo;
verdict?: Verdict;
note: string;
ignored: boolean;
};
export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove";
rel_host: string;
rel_slave: string;
rel_new: string;
quarantine_path: string;
quarantine_side: string;
bytes: number;
dry_run: boolean;
trigger: string;
status: "running" | "done" | "failed" | "planned" | "interrupted";
error: string;
created_at: number;
finished_at: number | null;
undone_at: number | null;
purged_at: number | null;
};
export type TreeEntry = {
name: string;
rel: string;
dir: boolean;
size?: number;
state?: "linked" | "conflict" | "missing";
linked?: number;
};
export type Page<T> = { items: T[]; total: number };
+14
View File
@@ -0,0 +1,14 @@
@import "tailwindcss";
@theme {
--color-bg: #0b0c10;
--color-panel: #13151b;
--color-panel-2: #1a1d25;
--color-line: #262a35;
--color-muted: #8a91a3;
--color-accent: #8b5cf6;
}
html, body, #root { height: 100%; }
body { background: var(--color-bg); color: #e6e8ee; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
code, .mono { font-family: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace; }
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
import { ToastProvider } from "./toast";
import "./index.css";
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: true, staleTime: 2000 } },
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={qc}>
<ToastProvider>
<App />
</ToastProvider>
</QueryClientProvider>
</StrictMode>,
);
+181
View File
@@ -0,0 +1,181 @@
import { useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, History, RotateCcw, Trash2 } from "lucide-react";
import { api, type Operation, type Page, type Pair } from "../api";
import { Badge, Button, Card, Empty, Modal, Spinner, cx, formatAgo, formatBytes } from "../ui";
import { useToast } from "../toast";
export function ActivityPage() {
const [view, setView] = useState<"all" | "quarantine">("all");
return (
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
<p className="text-sm text-muted">Every change playlink makes is recorded here and can be undone.</p>
</div>
<div className="flex rounded-lg border border-line p-0.5">
{(["all", "quarantine"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", view === v ? "bg-panel-2 text-white" : "text-muted")}
>
{v === "all" ? "History" : "Quarantine"}
</button>
))}
</div>
</div>
<OpsTable quarantine={view === "quarantine"} />
</div>
);
}
const actionLabel: Record<Operation["action"], string> = {
link: "Linked slave → host",
promote: "Promoted slave → host",
build: "Built into slave",
remove: "Removed from slave",
};
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
done: "green",
failed: "red",
planned: "blue",
interrupted: "amber",
running: "gray",
};
export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: boolean }) {
const [page, setPage] = useState(0);
const [confirm, setConfirm] = useState<null | { op: Operation; kind: "undo" | "purge" }>(null);
const qc = useQueryClient();
const toast = useToast();
const pairs = useQuery({ queryKey: ["pairs"], queryFn: () => api.get<Pair[]>("/pairs") });
const names = Object.fromEntries((pairs.data ?? []).map((p) => [p.id, p.name]));
const ops = useQuery({
queryKey: ["ops", pairId, quarantine, page],
queryFn: () => api.get<Page<Operation>>(`/operations?limit=50&offset=${page * 50}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`),
placeholderData: keepPreviousData,
});
const act = useMutation({
mutationFn: ({ op, kind }: { op: Operation; kind: "undo" | "purge" }) => api.post(`/operations/${op.id}/${kind}`),
onSuccess: (_, { kind }) => {
toast("ok", kind === "undo" ? "Operation undone" : "Quarantined file permanently deleted");
setConfirm(null);
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["matches"] });
qc.invalidateQueries({ queryKey: ["pair"] });
},
onError: (e) => toast("err", e.message),
});
if (ops.isLoading) return <Spinner />;
const items = ops.data?.items ?? [];
return (
<>
<Card className="overflow-hidden">
{items.length === 0 && (
<Empty icon={quarantine ? <Archive className="size-8" /> : <History className="size-8" />} title={quarantine ? "Quarantine is empty" : "No activity yet"}>
{quarantine ? "Originals replaced by a non-identical file show up here until they are purged." : undefined}
</Empty>
)}
{items.map((o) => {
const canUndo = o.status === "done" && !o.undone_at && !o.purged_at;
const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted");
return (
<div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0">
<div className="w-40 shrink-0">
<div className="text-sm">{actionLabel[o.action]}</div>
<div className="text-[11px] text-muted">
{formatAgo(o.created_at)} · {o.trigger}
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave}>
{o.rel_slave}
</div>
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_host && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
)}
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
{inQuarantine && (
<div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}>
{o.quarantine_side} original kept in quarantine
</div>
)}
</div>
<div className="flex items-center gap-1.5">
{o.bytes > 0 && o.status === "done" && !o.undone_at && <Badge tone="green">−{formatBytes(o.bytes)}</Badge>}
<Badge tone={statusTone[o.status]}>{o.dry_run ? "dry run" : o.status}</Badge>
{o.undone_at && <Badge>undone</Badge>}
{o.purged_at && <Badge>purged</Badge>}
</div>
<div className="flex gap-1.5">
{canUndo && (
<Button size="sm" onClick={() => setConfirm({ op: o, kind: "undo" })}>
<RotateCcw className="size-3.5" /> {o.quarantine_path ? "Restore" : "Undo"}
</Button>
)}
{inQuarantine && (
<Button size="sm" variant="ghost" onClick={() => setConfirm({ op: o, kind: "purge" })} title="Delete quarantined original">
<Trash2 className="size-3.5" />
</Button>
)}
</div>
</div>
);
})}
</Card>
{ops.data && ops.data.total > 50 && (
<div className="mt-3 flex justify-end gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * 50 >= ops.data.total} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
)}
<Modal open={!!confirm} onClose={() => setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}>
{confirm && (
<p className="text-sm text-gray-300">
{confirm.kind === "purge" ? (
<>
The quarantined {confirm.op.quarantine_side} original of <code className="mono break-all">{confirm.op.rel_slave}</code> will be deleted.{" "}
<b>This cannot be undone.</b>
</>
) : (
undoText(confirm.op)
)}
</p>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant={confirm?.kind === "purge" ? "danger" : "primary"} loading={act.isPending} onClick={() => confirm && act.mutate(confirm)}>
{confirm?.kind === "undo" ? "Undo" : "Delete forever"}
</Button>
</div>
</Modal>
</>
);
}
function undoText(o: Operation) {
switch (o.action) {
case "link":
return o.quarantine_path
? "The original slave file is restored from quarantine and the hardlink is removed."
: "The slave gets its own independent copy of the file again, which uses disk space.";
case "promote":
return "The original host file is restored from quarantine. The slave keeps its own file.";
case "build":
return "The hardlink is removed from the slave library. The host file is not affected.";
case "remove":
return "The hardlink is recreated in the slave library.";
}
}
+136
View File
@@ -0,0 +1,136 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Disc3, FileAudio, Folder, Link2, Unlink } from "lucide-react";
import { api, type Pair, type TreeEntry } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
import { useToast } from "../toast";
export function Build({ pair }: { pair: Pair }) {
const [path, setPath] = useState("");
const [sel, setSel] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<null | "build" | "remove">(null);
const qc = useQueryClient();
const toast = useToast();
const tree = useQuery({
queryKey: ["tree", pair.id, path],
queryFn: () => api.get<TreeEntry[]>(`/pairs/${pair.id}/tree?path=${encodeURIComponent(path)}`),
});
const run = useMutation({
mutationFn: (kind: "build" | "remove") => api.post(`/pairs/${pair.id}/${kind}`, { paths: [...sel] }),
onSuccess: () => {
setConfirm(null);
setSel(new Set());
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
},
onError: (e) => toast("err", e.message),
});
const running = pair.job?.state === "running";
const crumbs = path ? path.split("/") : [];
const toggle = (rel: string) =>
setSel((s) => {
const n = new Set(s);
n.has(rel) ? n.delete(rel) : n.add(rel);
return n;
});
const all = tree.data ?? [];
const allSelected = all.length > 0 && all.every((e) => sel.has(e.rel));
return (
<div className="space-y-4">
<Alert tone="blue">
Build the slave library from the host. Browse the host library, pick artists, albums or songs, and playlink hardlinks them into the slave
library at the same <code className="mono">Artist/Album/Title.ext</code> path. No extra space is used. Existing slave files are never
overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the host file, so the host copy is always kept.
</Alert>
<div className="flex flex-wrap items-center justify-between gap-3">
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
host
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="size-3.5 text-muted" />
<button className="truncate hover:underline cursor-pointer" onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}>
{c}
</button>
</span>
))}
</nav>
<div className="flex items-center gap-2">
{sel.size > 0 && <span className="text-xs text-muted">{sel.size} selected</span>}
<Button size="sm" disabled={!sel.size || running} onClick={() => setConfirm("remove")}>
<Unlink className="size-3.5" /> Remove from slave
</Button>
<Button size="sm" variant="primary" disabled={!sel.size || running} onClick={() => setConfirm("build")}>
<Link2 className="size-3.5" /> Link into slave
</Button>
</div>
</div>
<Card className="overflow-hidden">
{tree.isLoading && (
<div className="p-8">
<Spinner />
</div>
)}
{tree.error && <div className="p-4"><Alert tone="red">{tree.error.message}</Alert></div>}
{tree.data?.length === 0 && <Empty icon={<Disc3 className="size-8" />} title="Empty folder" />}
{all.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<input
type="checkbox"
className="accent-violet-500"
checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(all.map((e) => e.rel)))}
/>
Select all in this folder
</label>
)}
{all.map((e) => (
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
<input type="checkbox" className="accent-violet-500" checked={sel.has(e.rel)} onChange={() => toggle(e.rel)} />
{e.dir ? <Folder className="size-4 shrink-0 text-accent" /> : <FileAudio className="size-4 shrink-0 text-muted" />}
{e.dir ? (
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => { setPath(e.rel); setSel(new Set()); }}>
{e.name}
</button>
) : (
<span className="min-w-0 flex-1 truncate text-sm">{e.name}</span>
)}
{e.dir && !!e.linked && <Badge tone="green">{e.linked} linked</Badge>}
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
{e.state === "conflict" && <Badge tone="amber">different file in slave</Badge>}
{!e.dir && <span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>}
</div>
))}
</Card>
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
<Modal open={confirm !== null} onClose={() => setConfirm(null)} title={confirm === "build" ? "Link into slave library?" : "Remove from slave library?"}>
<p className="text-sm text-gray-300">
{confirm === "build" ? (
<>
Every audio file in the {sel.size} selected item(s) will be hardlinked into <code className="mono">{pair.slave_root}</code> at the same
relative path. Songs that already exist in the slave are skipped.
</>
) : (
<>
Slave files in the {sel.size} selected item(s) that are <b>hardlinks of the host file</b> will be deleted from{" "}
<code className="mono">{pair.slave_root}</code>. The host copy is kept. Any other slave file is left untouched.
</>
)}
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant={confirm === "build" ? "primary" : "danger"} onClick={() => confirm && run.mutate(confirm)} loading={run.isPending}>
{confirm === "build" ? "Link" : "Remove"}
</Button>
</div>
</Modal>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, Library, Plus } from "lucide-react";
import { api, type Pair } from "../api";
import { Badge, Button, Card, Empty, Spinner, formatAgo, formatBytes, statusTone } from "../ui";
import { PairForm } from "./PairForm";
import { JobBar, ScanButton } from "./PairPage";
export function Dashboard() {
const [creating, setCreating] = useState(false);
const pairs = useQuery({
queryKey: ["pairs"],
queryFn: () => api.get<Pair[]>("/pairs"),
refetchInterval: (q) => (q.state.data?.some((p) => p.job?.state === "running") ? 1000 : 15000),
});
return (
<div className="space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Libraries</h1>
<p className="text-sm text-muted">Each pair links matching songs from a slave library to the host library's copy.</p>
</div>
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="size-4" /> New pair
</Button>
</div>
{pairs.isLoading && <Spinner />}
{pairs.data?.length === 0 && (
<Card>
<Empty icon={<Library className="size-10" />} title="No library pairs yet">
Create a pair with a <b>host</b> library (e.g. Albums) and a <b>slave</b> library (e.g. your curated Singles). playlink finds songs in
both and replaces the slave copy with a hardlink to the host copy, so each song is stored only once.
<div className="mt-4">
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="size-4" /> Create your first pair
</Button>
</div>
</Empty>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-2">
{pairs.data?.map((p) => <PairCard key={p.id} pair={p} />)}
</div>
<PairForm open={creating} onClose={() => setCreating(false)} />
</div>
);
}
function PairCard({ pair }: { pair: Pair }) {
const c = pair.summary.counts;
const stats: [string, number][] = [
["linked", c.linked ?? 0],
["identical", c.identical ?? 0],
["review", pair.summary.review],
["unmatched", c.unmatched ?? 0],
];
return (
<Card className="flex flex-col p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<a href={`#/pair/${pair.id}`} className="text-lg font-semibold hover:text-accent">
{pair.name}
</a>
<div className="mt-1 space-y-0.5 text-xs">
<div className="truncate">
<span className="text-muted">host</span> <code className="mono">{pair.host_root}</code>
</div>
<div className="truncate">
<span className="text-muted">slave</span> <code className="mono">{pair.slave_root}</code>
</div>
</div>
</div>
<ScanButton pair={pair} />
</div>
<div className="mt-4 grid grid-cols-4 gap-2">
{stats.map(([k, v]) => (
<a
key={k}
href={`#/pair/${pair.id}/review?status=${k}`}
className="rounded-lg border border-line bg-panel-2 px-3 py-2 hover:border-accent/50"
>
<div className="text-xl font-semibold tabular-nums">{v}</div>
<Badge tone={k === "review" ? "amber" : statusTone[k]}>{k}</Badge>
</a>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<span>Reclaimable: <b className="text-sky-300">{formatBytes(pair.summary.reclaimable_bytes)}</b></span>
<span>Deduplicated: <b className="text-emerald-300">{formatBytes(pair.summary.linked_bytes)}</b></span>
<span>Last scan: {formatAgo(pair.last_scan_at)}</span>
{pair.auto_link_identical && <Badge tone="blue">auto-link</Badge>}
{pair.scan_interval_minutes > 0 && <Badge>every {pair.scan_interval_minutes}m</Badge>}
</div>
{pair.job?.state === "running" && <JobBar job={pair.job} className="mt-4" />}
{pair.last_scan_error && (
<div className="mt-3 flex gap-2 text-xs text-red-300">
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_error}
</div>
)}
{pair.last_scan_warnings.length > 0 && (
<div className="mt-3 flex gap-2 text-xs text-amber-300">
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_warnings.length} layout warning{pair.last_scan_warnings.length > 1 && "s"}
</div>
)}
<a href={`#/pair/${pair.id}`} className="mt-4 flex items-center gap-1 self-end text-sm text-accent hover:underline">
Open <ArrowRight className="size-3.5" />
</a>
</Card>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { Alert, Button, Card, Field, inputCls } from "../ui";
export function Login({ setup }: { setup: boolean }) {
const qc = useQueryClient();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const m = useMutation({
mutationFn: () => {
if (setup && password !== confirm) throw new Error("Passwords do not match");
return api.post(setup ? "/auth/setup" : "/auth/login", { username, password });
},
onSuccess: () => qc.invalidateQueries(),
});
return (
<div className="grid min-h-full place-items-center p-4">
<Card className="w-full max-w-sm p-6">
<div className="mb-6 flex items-center gap-2.5">
<img src="/favicon.svg" className="size-8" alt="" />
<div>
<div className="text-lg font-semibold">playlink</div>
<div className="text-xs text-muted">{setup ? "Create your admin account" : "Sign in"}</div>
</div>
</div>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
m.mutate();
}}
>
<Field label="Username">
<input className={inputCls} autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</Field>
<Field label="Password" hint={setup ? "At least 10 characters." : undefined}>
<input
className={inputCls}
type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
{setup && (
<Field label="Confirm password">
<input className={inputCls} type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
</Field>
)}
{m.error && <Alert tone="red">{m.error.message}</Alert>}
<Button variant="primary" className="w-full" loading={m.isPending} type="submit">
{setup ? "Create account" : "Sign in"}
</Button>
</form>
</Card>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type Pair, type PairInput } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
type Validation = { ok: boolean; error?: string; warnings: string[] };
export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) {
const info = useInfo();
const qc = useQueryClient();
const toast = useToast();
const blank: PairInput = {
name: "Music",
host_root: "",
slave_root: "",
quarantine_dir: "",
extensions: [],
auto_link_identical: false,
scan_interval_minutes: 0,
retention_days: 30,
settle_minutes: 10,
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
const [picker, setPicker] = useState<null | "host_root" | "slave_root" | "quarantine_dir">(null);
const [validation, setValidation] = useState<Validation | null>(null);
useEffect(() => {
if (open) {
setF(pair ?? blank);
setExts((pair?.extensions ?? info.data?.default_extensions ?? []).join(", "));
setValidation(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, pair?.id]);
// Send only editable fields: the API rejects unknown keys.
const body = (): PairInput => ({
name: f.name,
host_root: f.host_root,
slave_root: f.slave_root,
quarantine_dir: f.quarantine_dir,
auto_link_identical: f.auto_link_identical,
scan_interval_minutes: f.scan_interval_minutes,
retention_days: f.retention_days,
settle_minutes: f.settle_minutes,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
setF((p) => ({ ...p, [k]: v }));
setValidation(null);
};
const validate = useMutation({
mutationFn: () => api.post<Validation>("/pairs/validate", body()),
onSuccess: setValidation,
});
const save = useMutation({
mutationFn: () => (pair ? api.put<{ warnings: string[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: string[] }>("/pairs", body())),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair"] });
toast("ok", pair ? "Library pair updated" : "Library pair created. Run a scan to find matches.");
onClose();
},
});
return (
<Modal open={open} onClose={onClose} title={pair ? "Edit library pair" : "New library pair"} wide>
<div className="space-y-4">
<Field label="Name">
<input className={inputCls} value={f.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Host library (keeps the files)"
hint="Example: Albums. Its files are the source of every hardlink."
value={f.host_root}
onChange={(v) => set("host_root", v)}
onBrowse={() => setPicker("host_root")}
/>
<PathField
label="Slave library (gets hardlinks)"
hint="Example: your curated Singles library. Matching files here are replaced with hardlinks."
value={f.slave_root}
onChange={(v) => set("slave_root", v)}
onBrowse={() => setPicker("slave_root")}
/>
</div>
<Alert tone="blue">
Both libraries should use the same <code className="mono">Artist/Album/Title.ext</code> layout. Files are matched by relative path first.
Both must be on the same filesystem. On Unraid, mount one common parent folder into the container instead of two separate volumes.
</Alert>
<PathField
label="Quarantine folder"
hint="Originals replaced by a non-identical file are moved here so you can restore them. Default: <slave>/.playlink-trash"
value={f.quarantine_dir}
placeholder="(default)"
onChange={(v) => set("quarantine_dir", v)}
onBrowse={() => setPicker("quarantine_dir")}
/>
<Field label="File extensions" hint="Comma-separated. Other files (covers, lyrics, playlists) are never touched.">
<input className={inputCls} value={exts} onChange={(e) => setExts(e.target.value)} />
</Field>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Scan interval (minutes)" hint="0 = manual only, otherwise at least 5">
<input className={inputCls} type="number" min={0} value={f.scan_interval_minutes} onChange={(e) => set("scan_interval_minutes", Number(e.target.value))} />
</Field>
<Field label="Quarantine retention (days)" hint="0 = keep until purged manually">
<input className={inputCls} type="number" min={0} value={f.retention_days} onChange={(e) => set("retention_days", Number(e.target.value))} />
</Field>
<Field label="Settle time (minutes)" hint="Auto-link skips files modified more recently than this">
<input className={inputCls} type="number" min={0} value={f.settle_minutes} onChange={(e) => set("settle_minutes", Number(e.target.value))} />
</Field>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.auto_link_identical}
onChange={(v) => set("auto_link_identical", v)}
label={<span className="font-medium">Automatically link byte-identical files after each scan</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
Only files whose relative paths match and whose bytes are identical (verified with a full compare right before linking).
Anything else always waits for manual review. Dry run still applies.
</p>
</div>
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
{validation?.warnings.map((w) => (
<Alert key={w} tone="amber" icon={<AlertTriangle className="size-4 shrink-0 mt-0.5" />}>
{w}
</Alert>
))}
{save.error && <Alert tone="red">{save.error.message}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button onClick={onClose} variant="ghost">
Cancel
</Button>
<Button onClick={() => validate.mutate()} loading={validate.isPending}>
Validate
</Button>
<Button variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
{pair ? "Save" : "Create"}
</Button>
</div>
</div>
{picker && (
<FolderPicker
start={f[picker] || ""}
onPick={(p) => {
set(picker, p);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
</Modal>
);
}
function PathField(props: { label: string; hint: string; value: string; placeholder?: string; onChange: (v: string) => void; onBrowse: () => void }) {
return (
<Field label={props.label} hint={props.hint}>
<div className="flex gap-2">
<input className={inputCls + " mono text-xs"} value={props.value} placeholder={props.placeholder ?? "/data/..."} onChange={(e) => props.onChange(e.target.value)} />
<Button type="button" onClick={props.onBrowse} title="Browse">
<FolderOpen className="size-4" />
</Button>
</div>
</Field>
);
}
function FolderPicker({ start, onPick, onClose }: { start: string; onPick: (p: string) => void; onClose: () => void }) {
const [path, setPath] = useState(start);
const q = useQuery({
queryKey: ["browse", path],
queryFn: () => api.get<{ path: string; parent: string; dirs: string[] }>(`/browse?path=${encodeURIComponent(path)}`),
retry: false,
});
return (
<Modal open onClose={onClose} title="Choose folder">
<div className="mb-3 flex items-center gap-2">
<Button size="sm" disabled={!path} onClick={() => setPath(q.data?.parent ?? "")}>
<ChevronLeft className="size-3.5" />
</Button>
<code className="mono truncate text-xs text-muted">{q.data?.path || "Allowed roots"}</code>
</div>
{q.error && (
<Alert tone="red">
{q.error.message}{" "}
<button className="underline cursor-pointer" onClick={() => setPath("")}>
Back to roots
</button>
</Alert>
)}
<div className="max-h-80 overflow-y-auto rounded-lg border border-line">
{q.data?.dirs.map((d) => (
<button key={d} onClick={() => setPath(d)} className="flex w-full items-center gap-2 border-b border-line px-3 py-2 text-left text-sm last:border-0 hover:bg-panel-2 cursor-pointer">
<Folder className="size-4 text-accent" />
<span className="truncate">{q.data.path ? d.split("/").pop() : d}</span>
</button>
))}
{q.data && q.data.dirs.length === 0 && <div className="p-4 text-center text-sm text-muted">No subfolders</div>}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!q.data?.path} onClick={() => onPick(q.data!.path)}>
Use this folder
</Button>
</div>
</Modal>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronLeft, Pencil, RefreshCw, Square, Trash2 } from "lucide-react";
import { api, type Job, type Pair } from "../api";
import { Alert, Badge, Button, Card, Modal, Spinner, cx, formatAgo } from "../ui";
import { useToast } from "../toast";
import { go } from "../App";
import { PairForm } from "./PairForm";
import { Review } from "./Review";
import { Build } from "./Build";
import { OpsTable } from "./Activity";
export function usePair(id: number) {
return useQuery({
queryKey: ["pair", id],
queryFn: () => api.get<Pair>(`/pairs/${id}`),
refetchInterval: (q) => (q.state.data?.job?.state === "running" ? 1000 : 15000),
});
}
// useJobFinished invalidates dependent data when a pair's job completes.
function useJobFinished(pair: Pair | undefined) {
const qc = useQueryClient();
const toast = useToast();
const prev = useRef<string | undefined>(undefined);
useEffect(() => {
const st = pair?.job?.state;
if (prev.current === "running" && st && st !== "running") {
qc.invalidateQueries({ queryKey: ["matches"] });
qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
const j = pair!.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
}
prev.current = st;
}, [pair?.job?.state]); // eslint-disable-line react-hooks/exhaustive-deps
}
export function ScanButton({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const running = pair.job?.state === "running";
const scan = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/scan`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
},
onError: (e) => toast("err", e.message),
});
const cancel = useMutation({ mutationFn: () => api.post(`/pairs/${pair.id}/job/cancel`) });
if (running)
return (
<Button size="sm" onClick={() => cancel.mutate()} loading={cancel.isPending}>
<Square className="size-3" /> Stop
</Button>
);
return (
<Button size="sm" variant="primary" onClick={() => scan.mutate()} loading={scan.isPending}>
<RefreshCw className="size-3.5" /> Scan
</Button>
);
}
export function JobBar({ job, className }: { job: Job; className?: string }) {
const pct = job.total > 0 ? Math.round((job.done / job.total) * 100) : null;
return (
<div className={cx("space-y-1.5", className)}>
<div className="flex justify-between text-xs">
<span className="flex items-center gap-1.5 text-gray-300">
<Spinner className="size-3" /> {job.phase || job.kind}
</span>
<span className="tabular-nums text-muted">{job.total > 0 ? `${job.done} / ${job.total}` : "…"}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-line">
<div className={cx("h-full bg-accent transition-all", pct === null && "w-1/3 animate-pulse")} style={pct !== null ? { width: `${pct}%` } : undefined} />
</div>
</div>
);
}
function LastJob({ job }: { job: Job }) {
const [open, setOpen] = useState(false);
if (job.state === "running") return <JobBar job={job} />;
const results = Object.entries(job.results).filter(([, v]) => v > 0);
return (
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge tone={job.state === "done" ? "green" : "red"}>{job.kind}: {job.state}</Badge>
<span className="text-muted">{job.message || job.error}</span>
{results.map(([k, v]) => (
<Badge key={k}>
{k} {v}
</Badge>
))}
{(job.skipped?.length ?? 0) > 0 && (
<button className="text-amber-300 underline cursor-pointer" onClick={() => setOpen(true)}>
view skipped
</button>
)}
<Modal open={open} onClose={() => setOpen(false)} title="Skipped items" wide>
<ul className="max-h-[60vh] space-y-1 overflow-y-auto text-xs mono">
{(job.skipped ?? []).map((s, i) => (
<li key={i} className="border-b border-line pb-1">
{s}
</li>
))}
</ul>
</Modal>
</div>
);
}
const tabs = [
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "history", label: "History" },
];
export function PairPage({ id, tab }: { id: number; tab: string }) {
const q = usePair(id);
const qc = useQueryClient();
const toast = useToast();
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
useJobFinished(q.data);
const del = useMutation({
mutationFn: () => api.del(`/pairs/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
toast("ok", "Pair removed. No files were touched.");
go("/");
},
onError: (e) => toast("err", e.message),
});
if (q.isLoading) return <Spinner />;
if (q.error || !q.data) return <Alert tone="red">{q.error?.message ?? "Not found"}</Alert>;
const pair = q.data;
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<a href="#/" className="mb-1 inline-flex items-center gap-1 text-xs text-muted hover:text-white">
<ChevronLeft className="size-3.5" /> Libraries
</a>
<h1 className="text-2xl font-semibold tracking-tight">{pair.name}</h1>
<div className="mt-1 flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-4">
<span className="truncate">
<Badge tone="violet">host</Badge> <code className="mono">{pair.host_root}</code>
</span>
<span className="truncate">
<Badge>slave</Badge> <code className="mono">{pair.slave_root}</code>
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Scanned {formatAgo(pair.last_scan_at)}</span>
<Button size="sm" onClick={() => setEditing(true)}>
<Pencil className="size-3.5" /> Edit
</Button>
<Button size="sm" variant="ghost" onClick={() => setDeleting(true)} title="Remove pair">
<Trash2 className="size-3.5" />
</Button>
<ScanButton pair={pair} />
</div>
</div>
{pair.job && (
<Card className="px-4 py-3">
<LastJob job={pair.job} />
</Card>
)}
{pair.last_scan_error && <Alert tone="red">Last scan failed: {pair.last_scan_error}</Alert>}
{pair.last_scan_warnings.map((w) => (
<Alert key={w} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{w}
</Alert>
))}
<div className="flex gap-1 border-b border-line">
{tabs.map((t) => (
<a
key={t.id}
href={`#/pair/${id}/${t.id}`}
className={cx("-mb-px border-b-2 px-3.5 py-2 text-sm", tab === t.id ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
>
{t.label}
{t.id === "review" && pair.summary.review > 0 && (
<Badge tone="amber" className="ml-1.5">
{pair.summary.review}
</Badge>
)}
</a>
))}
</div>
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
<Modal open={deleting} onClose={() => setDeleting(false)} title="Remove library pair?">
<p className="text-sm text-gray-300">
This deletes the pair's configuration, scan results and history from playlink. <b>No files on disk are changed</b>, and existing
hardlinks stay in place. Quarantined files stay in <code className="mono">{pair.quarantine_dir}</code>, but you can no longer restore them from the UI.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDeleting(false)}>
Cancel
</Button>
<Button variant="danger" onClick={() => del.mutate()} loading={del.isPending}>
Remove pair
</Button>
</div>
</Modal>
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
const filters = [
{ id: "review", label: "Needs review" },
{ id: "identical", label: "Identical" },
{ id: "linked", label: "Linked" },
{ id: "unmatched", label: "Slave only" },
{ id: "error", label: "Errors" },
{ id: "ignored", label: "Ignored" },
{ id: "", label: "All" },
];
const kindLabel: Record<Match["kind"], string> = {
path: "same path",
path_case: "path differs in case",
extension: "other format",
fuzzy: "similar title",
elsewhere: "other album folder",
none: "no match",
};
function hashParam(name: string) {
const q = window.location.hash.split("?")[1];
return new URLSearchParams(q ?? "").get(name);
}
const PAGE = 50;
export function Review({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical"));
const [q, setQ] = useState("");
const [debounced, setDebounced] = useState("");
const [page, setPage] = useState(0);
const [confirmBulk, setConfirmBulk] = useState(false);
useEffect(() => {
const t = setTimeout(() => setDebounced(q), 250);
return () => clearTimeout(t);
}, [q]);
useEffect(() => setPage(0), [status, debounced]);
const list = useQuery({
queryKey: ["matches", pair.id, status, debounced, page],
queryFn: () =>
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${page * PAGE}`),
placeholderData: keepPreviousData,
});
const bulk = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
onSuccess: () => {
setConfirmBulk(false);
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
},
onError: (e) => toast("err", e.message),
});
const c = pair.summary.counts;
const count = (id: string) =>
id === "review" ? pair.summary.review : id === "ignored" ? pair.summary.ignored : id === "" ? undefined : c[id] ?? 0;
const identical = c.identical ?? 0;
const running = pair.job?.state === "running";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{filters.map((f) => (
<button
key={f.id}
onClick={() => setStatus(f.id)}
className={cx(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs cursor-pointer",
status === f.id ? "border-accent bg-accent/15 text-white" : "border-line text-muted hover:text-white",
)}
>
{f.label}
{count(f.id) !== undefined && <span className="tabular-nums opacity-70">{count(f.id)}</span>}
</button>
))}
<div className="relative ml-auto w-full sm:w-64">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
</div>
{status === "identical" && identical > 0 && (
<Card className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="text-sm">
<b>{identical}</b> byte-identical files ({formatBytes(pair.summary.reclaimable_bytes)}) can be replaced with hardlinks.
<div className="text-xs text-muted">Each one is compared byte for byte again right before linking. No quarantine is needed because the data stays in the host.</div>
</div>
<Button variant="primary" onClick={() => setConfirmBulk(true)} disabled={running}>
<CopyCheck className="size-4" /> Link all identical
</Button>
</Card>
)}
<Card className="overflow-hidden">
{list.isLoading && (
<div className="p-8">
<Spinner />
</div>
)}
{list.data?.items.length === 0 && (
<Empty icon={<CopyCheck className="size-8" />} title={pair.last_scan_at ? "Nothing here" : "Not scanned yet"}>
{pair.last_scan_at ? "No files match this filter." : "Run a scan to compare the two libraries."}
</Empty>
)}
{list.data?.items.map((m) => <MatchRow key={m.id} m={m} />)}
</Card>
{list.data && list.data.total > PAGE && (
<div className="flex items-center justify-between text-sm text-muted">
<span>
{page * PAGE + 1}–{Math.min((page + 1) * PAGE, list.data.total)} of {list.data.total}
</span>
<div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * PAGE >= list.data.total} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</div>
)}
<Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?">
<p className="text-sm text-gray-300">
Each of the <b>{identical}</b> slave files will be compared byte for byte with its host counterpart once more, then atomically
replaced with a hardlink to the host file. Files whose paths differ only by letter case are included.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirmBulk(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => bulk.mutate()} loading={bulk.isPending}>
Link {identical} files
</Button>
</div>
</Modal>
</div>
);
}
function PathLine({ m }: { m: Match }) {
const same = m.rel_host === m.rel_slave || !m.rel_host;
return (
<div className="min-w-0 text-sm">
<div className="truncate mono text-[13px]" title={m.rel_slave}>
{m.rel_slave}
</div>
{!same && (
<div className="flex min-w-0 items-center gap-1 text-xs text-muted" title={m.rel_host}>
<ArrowRight className="size-3 shrink-0" /> <span className="truncate mono">{m.rel_host}</span>
</div>
)}
</div>
);
}
function qualityLabel(p?: ProbeInfo) {
if (!p || p.error) return "?";
if (p.lossless) return `${p.codec.toUpperCase()} ${p.bit_depth || "?"}/${(p.sample_rate / 1000).toFixed(1)}`;
return `${p.codec.toUpperCase()} ${Math.round(p.bitrate / 1000)}k`;
}
function MatchRow({ m }: { m: Match }) {
const reviewable = m.status === "different" || m.status === "similar";
const [open, setOpen] = useState(false);
const qc = useQueryClient();
const toast = useToast();
const refresh = () => {
qc.invalidateQueries({ queryKey: ["matches"] });
qc.invalidateQueries({ queryKey: ["pair", m.pair_id] });
};
const link = useMutation({
mutationFn: () => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, {}),
onSuccess: (op) => {
toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Linked");
refresh();
},
onError: (e) => toast("err", e.message),
});
const ignore = useMutation({
mutationFn: () => api.post(`/matches/${m.id}/ignore`, { ignored: !m.ignored }),
onSuccess: refresh,
});
const better = m.verdict?.better;
return (
<div className="border-b border-line last:border-0">
<div className="flex items-center gap-3 px-4 py-2.5">
<button
className={cx("text-muted", reviewable ? "cursor-pointer hover:text-white" : "invisible")}
onClick={() => setOpen(!open)}
aria-label="Expand"
>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
<div className="flex w-28 shrink-0 flex-col items-start gap-1">
<Badge tone={statusTone[m.status]}>{m.status}</Badge>
{m.kind !== "path" && m.kind !== "none" && <span className="text-[10px] text-muted">{kindLabel[m.kind]}</span>}
</div>
<div className="min-w-0 flex-1" onClick={() => reviewable && setOpen(!open)}>
<PathLine m={m} />
{m.note && <div className="text-xs text-red-300">{m.note}</div>}
</div>
{reviewable && (
<div className="hidden items-center gap-1.5 text-xs md:flex">
<span className="text-muted">{qualityLabel(m.host_probe)}</span>
<span className="text-gray-600">vs</span>
<span className="text-muted">{qualityLabel(m.slave_probe)}</span>
{better === "slave" && (
<Badge tone="amber">
<AlertTriangle className="size-3" /> host lower
</Badge>
)}
{better === "host" && <Badge tone="green">host better</Badge>}
{better === "equal" && <Badge>equal</Badge>}
</div>
)}
<div className="hidden w-20 text-right text-xs tabular-nums text-muted sm:block">{formatBytes(m.slave_size)}</div>
<div className="flex shrink-0 gap-1.5">
{m.status === "identical" && !m.ignored && (
<Button size="sm" variant="primary" onClick={() => link.mutate()} loading={link.isPending}>
<Link2 className="size-3.5" /> Link
</Button>
)}
{reviewable && !m.ignored && (
<Button size="sm" onClick={() => setOpen(!open)}>
Review
</Button>
)}
{(reviewable || m.status === "identical") && (
<Button size="sm" variant="ghost" onClick={() => ignore.mutate()} title={m.ignored ? "Un-ignore" : "Ignore"}>
{m.ignored ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</Button>
)}
</div>
</div>
{open && reviewable && <ComparePanel m={m} onDone={refresh} />}
</div>
);
}
const rows: [string, (p: ProbeInfo, size: number) => string][] = [
["Codec", (p) => `${p.codec}${p.lossless ? " (lossless)" : ""}`],
["Bitrate", (p) => (p.bitrate ? `${Math.round(p.bitrate / 1000)} kbps` : "–")],
["Sample rate", (p) => (p.sample_rate ? `${(p.sample_rate / 1000).toFixed(1)} kHz` : "–")],
["Bit depth", (p) => (p.bit_depth ? `${p.bit_depth}-bit` : "–")],
["Channels", (p) => String(p.channels || "–")],
["Duration", (p) => formatDuration(p.duration)],
["Size", (_, s) => formatBytes(s)],
["Title tag", (p) => p.title || "–"],
["Album tag", (p) => p.album || "–"],
];
function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
const toast = useToast();
const [confirm, setConfirm] = useState<null | "downgrade" | "promote">(null);
const hp = m.host_probe;
const sp = m.slave_probe;
const v = m.verdict;
const renamed = m.rel_host.split("/").pop() !== m.rel_slave.split("/").pop();
const link = useMutation({
mutationFn: (allow: boolean) => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, { allow_downgrade: allow }),
onSuccess: (op) => {
setConfirm(null);
toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Linked. The original slave file is in quarantine.");
onDone();
},
onError: (e) => {
if (e instanceof ApiError && e.status === 409 && e.message.includes("lower quality")) setConfirm("downgrade");
else toast("err", e.message);
},
});
const promote = useMutation({
mutationFn: () => api.post<{ dry_run: boolean }>(`/matches/${m.id}/promote`, { confirm: true }),
onSuccess: (op) => {
setConfirm(null);
toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Slave file promoted to host. The old host file is in quarantine.");
onDone();
},
onError: (e) => toast("err", e.message),
});
return (
<div className="border-t border-line bg-panel-2/50 px-4 py-4 md:pl-14">
{(!hp || !sp) && <Alert tone="amber">No codec details available (ffprobe missing or failed). Compare the files yourself before linking.</Alert>}
{hp && sp && (
<div className="overflow-x-auto">
<table className="w-full max-w-2xl text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1 pr-4 font-normal"></th>
<th className="py-1 pr-4 font-medium">
<Badge tone="violet">host</Badge> {better(v?.better === "host")}
</th>
<th className="py-1 font-medium">
<Badge>slave</Badge> {better(v?.better === "slave")}
</th>
</tr>
</thead>
<tbody>
{rows.map(([label, f]) => {
const a = hp.error ? "–" : f(hp, m.host_size);
const b = sp.error ? "–" : f(sp, m.slave_size);
return (
<tr key={label} className="border-t border-line/60">
<td className="py-1 pr-4 text-xs text-muted">{label}</td>
<td className={cx("py-1 pr-4", a !== b && "text-white")}>{a}</td>
<td className={cx("py-1", a !== b && "text-white")}>{b}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="mt-3 space-y-2">
{v?.notes?.map((n) => (
<Alert key={n} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{n}
</Alert>
))}
{v?.better === "slave" && (
<Alert tone="amber" icon={<Crown className="mt-0.5 size-4 shrink-0" />}>
The host copy is lower quality. The host is normally kept because it belongs to a complete album. You can still link to it (the
slave's better copy goes to quarantine), or override and make the slave's copy the host file.
</Alert>
)}
{renamed && (
<Alert tone="blue">
File names differ. Linking puts <code className="mono">{m.rel_host.split("/").pop()}</code> into the slave folder in place of{" "}
<code className="mono">{m.rel_slave.split("/").pop()}</code>, so playlists that point at the old name will need updating.
</Alert>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="primary" onClick={() => (v?.better === "slave" ? setConfirm("downgrade") : link.mutate(false))} loading={link.isPending && !confirm}>
<Link2 className="size-4" /> Link to host copy
</Button>
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
<Crown className="size-4" /> Use slave copy as host
</Button>
</div>
<p className="mt-2 text-xs text-muted">Whatever gets replaced is kept in quarantine and can be restored from History.</p>
<Modal open={confirm === "downgrade"} onClose={() => setConfirm(null)} title="Link to a lower-quality host file?">
<p className="text-sm text-gray-300">
The slave has the better copy ({qualityLabel(sp)} vs {qualityLabel(hp)}). Linking replaces it with the host's{" "}
{qualityLabel(hp)} file. The slave's original goes to quarantine and can be restored until it is purged.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant="warn" onClick={() => link.mutate(true)} loading={link.isPending}>
Link anyway
</Button>
</div>
</Modal>
<Modal open={confirm === "promote"} onClose={() => setConfirm(null)} title="Replace the host file with the slave copy?">
<p className="text-sm text-gray-300">
This <b>changes the host library</b>. The host file <code className="mono break-all">{m.rel_host}</code> goes to quarantine, and the
slave's file (<code className="mono">{qualityLabel(sp)}</code>) is hardlinked into the host album in its place
{renamed && (
<>
{" "}as <code className="mono">{m.rel_slave.split("/").pop()}</code>
</>
)}
. Both libraries then share one file.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant="warn" onClick={() => promote.mutate()} loading={promote.isPending}>
Promote slave copy
</Button>
</div>
</Modal>
</div>
);
}
function better(yes: boolean) {
return yes ? <span className="text-[11px] text-emerald-300">★ better</span> : null;
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
export function SettingsPage() {
const info = useInfo();
const qc = useQueryClient();
const toast = useToast();
const [cur, setCur] = useState("");
const [next, setNext] = useState("");
const dry = useMutation({
mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["info"] }),
});
const pw = useMutation({
mutationFn: () => api.post("/auth/password", { current: cur, new: next }),
onSuccess: () => {
setCur("");
setNext("");
toast("ok", "Password changed. Other sessions were signed out.");
},
});
return (
<div className="max-w-2xl space-y-5">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<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>
</ul>
</div>
</Card>
<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>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
pw.mutate();
}}
>
<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>
{pw.error && <Alert tone="red">{pw.error.message}</Alert>}
<Button type="submit" loading={pw.isPending}>
Update password
</Button>
</form>
</Card>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import { cx } from "./ui";
type Toast = { id: number; tone: "ok" | "err"; text: string };
const Ctx = createContext<(tone: Toast["tone"], text: string) => void>(() => {});
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<Toast[]>([]);
const push = useCallback((tone: Toast["tone"], text: string) => {
const id = Date.now() + Math.random();
setItems((t) => [...t, { id, tone, text }]);
setTimeout(() => setItems((t) => t.filter((x) => x.id !== id)), tone === "err" ? 8000 : 4000);
}, []);
return (
<Ctx.Provider value={push}>
{children}
<div className="fixed bottom-4 right-4 z-[60] flex w-96 max-w-[calc(100vw-2rem)] flex-col gap-2">
{items.map((t) => (
<div
key={t.id}
className={cx(
"flex items-start gap-2 rounded-lg border px-3.5 py-2.5 text-sm shadow-xl",
t.tone === "ok" ? "border-emerald-500/30 bg-[#0f1f19] text-emerald-100" : "border-red-500/30 bg-[#241314] text-red-100",
)}
>
{t.tone === "ok" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0" /> : <AlertTriangle className="mt-0.5 size-4 shrink-0" />}
<span className="break-words">{t.text}</span>
</div>
))}
</div>
</Ctx.Provider>
);
}
export const useToast = () => useContext(Ctx);
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Loader2, X } from "lucide-react";
export function cx(...c: (string | false | null | undefined)[]) {
return c.filter(Boolean).join(" ");
}
type Variant = "primary" | "secondary" | "ghost" | "danger" | "warn";
const variants: Record<Variant, string> = {
primary: "bg-accent text-white hover:bg-violet-500",
secondary: "bg-panel-2 border border-line hover:bg-[#222633] text-gray-100",
ghost: "hover:bg-panel-2 text-gray-300",
danger: "bg-red-600/90 text-white hover:bg-red-500",
warn: "bg-amber-500/90 text-black hover:bg-amber-400",
};
export function Button({
variant = "secondary",
loading,
className,
children,
size = "md",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; loading?: boolean; size?: "sm" | "md" }) {
return (
<button
{...props}
disabled={props.disabled || loading}
className={cx(
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap",
size === "sm" ? "h-7 px-2.5 text-xs" : "h-9 px-3.5 text-sm",
variants[variant],
className,
)}
>
{loading && <Loader2 className="size-3.5 animate-spin" />}
{children}
</button>
);
}
export function Card({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cx("rounded-xl border border-line bg-panel", className)}>{children}</div>;
}
const tones = {
gray: "bg-gray-500/15 text-gray-300 border-gray-500/25",
green: "bg-emerald-500/15 text-emerald-300 border-emerald-500/25",
blue: "bg-sky-500/15 text-sky-300 border-sky-500/25",
amber: "bg-amber-500/15 text-amber-300 border-amber-500/25",
red: "bg-red-500/15 text-red-300 border-red-500/25",
violet: "bg-violet-500/15 text-violet-300 border-violet-500/25",
};
export function Badge({ tone = "gray", children, className }: { tone?: keyof typeof tones; children: ReactNode; className?: string }) {
return (
<span className={cx("inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium", tones[tone], className)}>
{children}
</span>
);
}
export const statusTone: Record<string, keyof typeof tones> = {
linked: "green",
identical: "blue",
different: "amber",
similar: "violet",
unmatched: "gray",
error: "red",
};
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cx("animate-spin text-muted", className ?? "size-5")} />;
}
export function Modal({
open,
onClose,
title,
children,
wide,
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[8vh]" onMouseDown={onClose}>
<div
className={cx("w-full rounded-xl border border-line bg-panel shadow-2xl", wide ? "max-w-3xl" : "max-w-lg")}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-line px-5 py-3.5">
<h2 className="font-semibold">{title}</h2>
<button onClick={onClose} className="text-muted hover:text-white cursor-pointer" aria-label="Close">
<X className="size-4" />
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
}
export function Field({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) {
return (
<label className="block space-y-1.5">
<span className="text-sm font-medium text-gray-200">{label}</span>
{children}
{hint && <span className="block text-xs text-muted">{hint}</span>}
</label>
);
}
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 }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="inline-flex items-center gap-2.5 text-sm cursor-pointer"
>
<span className={cx("relative h-5 w-9 rounded-full transition-colors", checked ? "bg-accent" : "bg-line")}>
<span className={cx("absolute top-0.5 size-4 rounded-full bg-white transition-all", checked ? "left-4.5" : "left-0.5")} />
</span>
{label}
</button>
);
}
export function Alert({ tone, children, icon }: { tone: "amber" | "red" | "blue" | "green"; children: ReactNode; icon?: ReactNode }) {
const c = {
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
red: "border-red-500/30 bg-red-500/10 text-red-200",
blue: "border-sky-500/30 bg-sky-500/10 text-sky-200",
green: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
}[tone];
return <div className={cx("flex gap-2.5 rounded-lg border px-3.5 py-2.5 text-sm", c)}>{icon}<div className="min-w-0 flex-1">{children}</div></div>;
}
export function Empty({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<div className="text-muted">{icon}</div>
<div className="font-medium">{title}</div>
{children && <div className="max-w-md text-sm text-muted">{children}</div>}
</div>
);
}
export function formatBytes(n: number) {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
}
export function formatAgo(unix: number | null | undefined) {
if (!unix) return "never";
const s = Math.floor(Date.now() / 1000 - unix);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export function formatDuration(sec: number) {
if (!sec) return "–";
const m = Math.floor(sec / 60);
return `${m}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
build: { outDir: "dist", emptyOutDir: true },
server: { proxy: { "/api": "http://localhost:7480" } },
});