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>
186 lines
6.5 KiB
TypeScript
186 lines
6.5 KiB
TypeScript
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")}`;
|
||
}
|