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:
commit
ac47e61b08
42 files changed
+8892
No files matched your search
Vendored
Whitespace-only changes.
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+1949
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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; }
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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")}`;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" } },
|
||||
});
|
||||
Reference in new issue
Block a user