Files
playlink/web/src/ui.tsx
T
cdnutterandClaude Opus 5.5 ac47e61b08 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>
2026-09-22 15:38:25 -07:00

186 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")}`;
}