Rebrand as Playlink with a new sunset theme
- Name: Playlink in the UI, page title, footer, messages and README (file names like playlink.db and .playlink-trash are unchanged). - Theme: plum-black surfaces with a coral-to-pink accent, a soft glow behind the page, gradient primary buttons, toggles and progress bars, accent focus rings and slim scrollbars. - New logo (beamed notes on the brand gradient) and a Play/link wordmark. - Inter variable font, bundled so it works under the strict CSP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30fab3a208
commit
c98eb2f41d
23 files changed
+164
-77
No files matched your search
+2
-1
@@ -4,7 +4,8 @@
|
||||
<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>
|
||||
<title>Playlink</title>
|
||||
<meta name="theme-color" content="#0d0b12" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "playlink-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.3.0",
|
||||
@@ -23,6 +24,15 @@
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.3.0",
|
||||
|
||||
@@ -1 +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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ff8a5c"/><stop offset="1" stop-color="#ff4f9a"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><path d="M12 22V10.5l11-2.5v12" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><circle cx="9.4" cy="22" r="3" fill="#fff"/><circle cx="20.4" cy="20" r="3" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 492 B |
+6
-7
@@ -7,7 +7,7 @@ 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 { Spinner, cx, Brand } from "./ui";
|
||||
import { useToast } from "./toast";
|
||||
|
||||
export function useRoute() {
|
||||
@@ -74,9 +74,8 @@ function Shell({ username }: { username: string }) {
|
||||
<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 href="#/" className="mr-2">
|
||||
<Brand />
|
||||
</a>
|
||||
<nav className="flex items-center gap-1">
|
||||
{nav.map((n) => (
|
||||
@@ -85,10 +84,10 @@ function Shell({ username }: { username: string }) {
|
||||
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.active ? "bg-accent/12 text-white" : "text-muted hover:bg-panel-2/60 hover:text-white",
|
||||
)}
|
||||
>
|
||||
<n.icon className="size-4" />
|
||||
<n.icon className={cx("size-4", n.active && "text-accent")} />
|
||||
<span className="hidden sm:inline">{n.label}</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -118,7 +117,7 @@ function Shell({ username }: { username: string }) {
|
||||
</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>
|
||||
<footer className="py-4 text-center text-xs text-gray-600">Playlink {info.data?.version}</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+76
-9
@@ -1,14 +1,81 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/inter";
|
||||
|
||||
/* Playlink "sunset" theme: plum-black surfaces with a coral-to-pink accent. */
|
||||
@theme {
|
||||
--color-bg: #0b0c10;
|
||||
--color-panel: #13151b;
|
||||
--color-panel-2: #1a1d25;
|
||||
--color-line: #262a35;
|
||||
--color-muted: #8a91a3;
|
||||
--color-accent: #8b5cf6;
|
||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
|
||||
--color-bg: #0d0b12;
|
||||
--color-panel: #15121c;
|
||||
--color-panel-2: #1d1926;
|
||||
--color-line: #2b2637;
|
||||
--color-muted: #9690a8;
|
||||
|
||||
--color-accent: #ff7a59; /* coral */
|
||||
--color-accent-strong: #ff9275; /* hover */
|
||||
--color-accent-2: #ff4f9a; /* pink, the other end of the brand gradient */
|
||||
--color-on-accent: #1f0c06; /* text on coral: dark reads better than white */
|
||||
}
|
||||
|
||||
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; }
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
/* A soft sunset glow behind the page. */
|
||||
background-image:
|
||||
radial-gradient(60rem 28rem at 12% -8rem, rgb(255 122 89 / 0.09), transparent 70%),
|
||||
radial-gradient(50rem 26rem at 95% -10rem, rgb(255 79 154 / 0.08), transparent 70%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
color: #ece9f3;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "cv11", "ss01"; /* Inter: single-storey a, open digits */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
code,
|
||||
.mono {
|
||||
font-family: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(255 122 89 / 0.35);
|
||||
}
|
||||
|
||||
/* The brand gradient, for the wordmark and small highlights. */
|
||||
.brand-gradient {
|
||||
background-image: linear-gradient(100deg, var(--color-accent), var(--color-accent-2));
|
||||
}
|
||||
.brand-text {
|
||||
background-image: linear-gradient(100deg, var(--color-accent), var(--color-accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(255 122 89 / 0.7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Slim scrollbars that match the panels. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-line) transparent;
|
||||
}
|
||||
|
||||
/* Fields show their own accent ring when focused. */
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function ActivityPage() {
|
||||
<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>
|
||||
<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) => (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api, type Backup, type BackupConfig } from "../api";
|
||||
import { Alert, Badge, Button, Card, Field, Modal, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
const typeTone = { manual: "violet", scheduled: "blue", action: "gray" } as const;
|
||||
const typeTone = { manual: "accent", scheduled: "blue", action: "gray" } as const;
|
||||
const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const;
|
||||
|
||||
export function BackupsCard() {
|
||||
@@ -75,7 +75,7 @@ export function BackupsCard() {
|
||||
<Archive className="size-4 text-accent" /> Backups
|
||||
</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Backups hold playlink's database: settings, library pairs, scan results and history. They do <b>not</b> contain music files; file
|
||||
Backups hold Playlink's database: settings, library pairs, scan results and history. They do <b>not</b> contain music files; file
|
||||
changes are undone from History.
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,15 +175,15 @@ export function BackupsCard() {
|
||||
<Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?">
|
||||
<div className="space-y-3 text-sm text-gray-300">
|
||||
<p>
|
||||
playlink's database is replaced with <code className="mono break-all">{restore?.name ?? restore?.file?.name}</code>: settings, library
|
||||
Playlink's database is replaced with <code className="mono break-all">{restore?.name ?? restore?.file?.name}</code>: settings, library
|
||||
pairs, scan results and history go back to how they were then.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-muted">
|
||||
<li>The backup is checked first; a damaged backup or one from a newer playlink version is refused.</li>
|
||||
<li>The backup is checked first; a damaged backup or one from a newer Playlink version is refused.</li>
|
||||
<li>The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.</li>
|
||||
<li>Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.</li>
|
||||
<li>
|
||||
playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
|
||||
Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export function BackupsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for playlink to come back after a restore, then reloads the page. */
|
||||
/** Waits for Playlink to come back after a restore, then reloads the page. */
|
||||
function Restarting() {
|
||||
const [slow, setSlow] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -235,12 +235,12 @@ function Restarting() {
|
||||
return (
|
||||
<Modal open onClose={() => {}} title="Restoring backup…">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-300">
|
||||
<Spinner /> playlink is restarting with the restored database. This page reloads when it is back.
|
||||
<Spinner /> Playlink is restarting with the restored database. This page reloads when it is back.
|
||||
</div>
|
||||
{slow && (
|
||||
<div className="mt-3">
|
||||
<Alert tone="amber">
|
||||
playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when
|
||||
Playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when
|
||||
it starts.
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Build({ pair }: { pair: Pair }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert tone="blue">
|
||||
Build the slave library from the master. Browse the master library, pick folders or songs, and playlink hardlinks them into the slave
|
||||
Build the slave library from the master. Browse the master library, pick folders or songs, and Playlink hardlinks them into the slave
|
||||
library at the same relative 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 master file, so the master copy is always kept.
|
||||
</Alert>
|
||||
@@ -81,7 +81,7 @@ export function Build({ pair }: { pair: Pair }) {
|
||||
<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"
|
||||
className=""
|
||||
checked={allSelected}
|
||||
onChange={() => setSel(allSelected ? new Set() : new Set(all.map((e) => e.rel)))}
|
||||
/>
|
||||
@@ -90,7 +90,7 @@ export function Build({ pair }: { pair: Pair }) {
|
||||
)}
|
||||
{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)} />
|
||||
<input type="checkbox" className="" 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()); }}>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function TriCheckbox({ checked, partial, onChange, label }: { checked: bo
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
className="size-4 shrink-0 accent-violet-500 cursor-pointer"
|
||||
className="size-4 shrink-0 cursor-pointer"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -150,7 +150,7 @@ function AlbumRow({ pair, g, sel, setSel }: { pair: Pair; g: AlbumGroup; sel: Se
|
||||
</Badge>
|
||||
)}
|
||||
{g.identical > 0 && <Badge tone="blue">{g.identical} identical</Badge>}
|
||||
{g.review - g.upgrades - g.downgrades > 0 && <Badge tone="violet">{g.review - g.upgrades - g.downgrades} to review</Badge>}
|
||||
{g.review - g.upgrades - g.downgrades > 0 && <Badge tone="accent">{g.review - g.upgrades - g.downgrades} to review</Badge>}
|
||||
{g.approximate > 0 && <Badge>{g.approximate} approximate</Badge>}
|
||||
{g.other > 0 && <Badge>{g.other} other</Badge>}
|
||||
</div>
|
||||
@@ -349,7 +349,7 @@ function BulkDialog({ pair, ids, action, onClose, onDone }: { pair: Pair; ids: n
|
||||
)}
|
||||
{action === "link" && (p.moves_folder > 0 || keepFolder) && (
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input type="checkbox" className="accent-violet-500" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
|
||||
<input type="checkbox" className="" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
|
||||
Keep songs in their current slave folders instead
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function Cleanup({ pair }: { pair: Pair }) {
|
||||
<label className="flex items-center gap-2 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
className=""
|
||||
checked={allOn}
|
||||
onChange={() => setSel(allOn ? new Set() : new Set(folders.map((f) => f.rel)))}
|
||||
/>
|
||||
@@ -99,7 +99,7 @@ export function Cleanup({ pair }: { pair: Pair }) {
|
||||
<label key={f.rel} className="flex items-start gap-3 px-4 py-2 hover:bg-panel-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 accent-violet-500"
|
||||
className="mt-1"
|
||||
checked={sel.has(f.rel)}
|
||||
onChange={() =>
|
||||
setSel((s) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function Dashboard() {
|
||||
{pairs.data?.length === 0 && (
|
||||
<Card>
|
||||
<Empty icon={<Library className="size-10" />} title="No library pairs yet">
|
||||
Create a pair with a <b>master</b> library (e.g. Albums) and a <b>slave</b> library (e.g. your curated Singles). playlink finds songs in
|
||||
Create a pair with a <b>master</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 master copy, so each song is stored only once.
|
||||
<div className="mt-4">
|
||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { Alert, Button, Card, Field, inputCls } from "../ui";
|
||||
import { Alert, Button, Card, Field, inputCls, Brand } from "../ui";
|
||||
|
||||
export function Login({ setup }: { setup: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
@@ -18,12 +18,9 @@ export function Login({ setup }: { setup: boolean }) {
|
||||
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 className="mb-6 space-y-1.5">
|
||||
<Brand size="lg" />
|
||||
<div className="text-sm text-muted">{setup ? "Create your admin account" : "Sign in to your music libraries"}</div>
|
||||
</div>
|
||||
<form
|
||||
className="space-y-4"
|
||||
|
||||
@@ -178,7 +178,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
step={1}
|
||||
value={f.approx_threshold}
|
||||
onChange={(e) => set("approx_threshold", Number(e.target.value))}
|
||||
className="w-40 accent-violet-500"
|
||||
className="w-40"
|
||||
/>
|
||||
<span className="w-10 text-sm tabular-nums">{f.approx_threshold}%</span>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function JobBar({ job, className }: { job: Job; className?: string }) {
|
||||
<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 className={cx("h-full brand-gradient transition-all", pct === null && "w-1/3 animate-pulse")} style={pct !== null ? { width: `${pct}%` } : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -159,7 +159,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
||||
<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">master</Badge> <code className="mono">{pair.master_root}</code>
|
||||
<Badge tone="accent">master</Badge> <code className="mono">{pair.master_root}</code>
|
||||
</span>
|
||||
<span className="truncate">
|
||||
<Badge>slave</Badge> <code className="mono">{pair.slave_root}</code>
|
||||
@@ -211,7 +211,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
||||
<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
|
||||
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">
|
||||
|
||||
@@ -83,7 +83,7 @@ function RenameDialog({ pair, onClose }: { pair: Pair; onClose: () => void }) {
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
className=""
|
||||
checked={all}
|
||||
onChange={(e) => setPicked(e.target.checked ? new Set(ready.map((r) => r.slave)) : new Set())}
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ function RenameDialog({ pair, onClose }: { pair: Pair; onClose: () => void }) {
|
||||
<label key={r.slave} className={cx("flex items-start gap-3 border-b border-line px-3 py-2 last:border-0 cursor-pointer", !picked.has(r.slave) && "opacity-50")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-violet-500"
|
||||
className="mt-0.5"
|
||||
checked={picked.has(r.slave)}
|
||||
onChange={(e) =>
|
||||
setPicked((s) => {
|
||||
|
||||
@@ -297,7 +297,7 @@ export function MatchRow({ m, selected, onToggle }: { m: Match; selected?: boole
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${m.rel_slave}`}
|
||||
className="size-4 shrink-0 accent-violet-500 cursor-pointer"
|
||||
className="size-4 shrink-0 cursor-pointer"
|
||||
checked={!!selected}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
/>
|
||||
@@ -418,7 +418,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
<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">master</Badge> {better(v?.better === "master")}
|
||||
<Badge tone="accent">master</Badge> {better(v?.better === "master")}
|
||||
</th>
|
||||
<th className="py-1 font-medium">
|
||||
<Badge>slave</Badge> {better(v?.better === "slave")}
|
||||
@@ -474,7 +474,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
</div>
|
||||
{movesFolder && (
|
||||
<label className="mt-2 flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input type="checkbox" className="accent-violet-500" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
|
||||
<input type="checkbox" className="" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
|
||||
Keep it in the slave's current folder instead: <code className="mono">{dir(m.rel_slave) || "library root"}</code>
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SettingsPage() {
|
||||
qc.invalidateQueries({ queryKey: ["info"] });
|
||||
const failed = (res.results ?? []).filter((r) => r.error);
|
||||
if (failed.length > 0) failed.forEach((r) => toast("err", `${r.pair}: ${r.error}`));
|
||||
else toast("ok", res.plex_ignore ? "Added .plexignore to every quarantine folder." : "Removed playlink's .plexignore files.");
|
||||
else toast("ok", res.plex_ignore ? "Added .plexignore to every quarantine folder." : "Removed Playlink's .plexignore files.");
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
@@ -74,7 +74,7 @@ export function SettingsPage() {
|
||||
<p className="text-sm text-muted">
|
||||
Adds a <code className="mono">.plexignore</code> file to each library pair's quarantine folder (by default{" "}
|
||||
<code className="mono">.playlink-trash</code> in the slave library), so Plex doesn't pick up quarantined songs as duplicates. New
|
||||
quarantine folders get it automatically. Turning this off removes the file again, but only if playlink created it: a{" "}
|
||||
quarantine folders get it automatically. Turning this off removes the file again, but only if Playlink created it: a{" "}
|
||||
<code className="mono">.plexignore</code> of your own is never changed.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
+18
-6
@@ -8,8 +8,8 @@ export function cx(...c: (string | false | null | undefined)[]) {
|
||||
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",
|
||||
primary: "brand-gradient text-on-accent font-semibold shadow-sm shadow-accent/20 hover:brightness-110",
|
||||
secondary: "bg-panel-2 border border-line hover:bg-[#262131] 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",
|
||||
@@ -41,7 +41,7 @@ export function Button({
|
||||
}
|
||||
|
||||
export function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cx("rounded-xl border border-line bg-panel", className)}>{children}</div>;
|
||||
return <div className={cx("rounded-xl border border-line bg-panel shadow-lg shadow-black/20", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
const tones = {
|
||||
@@ -50,7 +50,7 @@ const tones = {
|
||||
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",
|
||||
accent: "bg-accent/15 text-accent-strong border-accent/30",
|
||||
};
|
||||
|
||||
export function Badge({ tone = "gray", children, className }: { tone?: keyof typeof tones; children: ReactNode; className?: string }) {
|
||||
@@ -65,7 +65,7 @@ export const statusTone: Record<string, keyof typeof tones> = {
|
||||
linked: "green",
|
||||
identical: "blue",
|
||||
different: "amber",
|
||||
similar: "violet",
|
||||
similar: "accent",
|
||||
unmatched: "gray",
|
||||
error: "red",
|
||||
};
|
||||
@@ -134,7 +134,7 @@ export function Toggle({ checked, onChange, label }: { checked: boolean; onChang
|
||||
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("relative h-5 w-9 rounded-full transition-colors", checked ? "brand-gradient" : "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}
|
||||
@@ -183,3 +183,15 @@ export function formatDuration(sec: number) {
|
||||
const m = Math.floor(sec / 60);
|
||||
return `${m}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The Playlink logo and wordmark: "Play" plus "link" in the brand gradient. */
|
||||
export function Brand({ size = "md" }: { size?: "md" | "lg" }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-semibold tracking-tight">
|
||||
<img src="/favicon.svg" className={size === "lg" ? "size-9" : "size-7"} alt="" />
|
||||
<span className={size === "lg" ? "text-xl" : "text-[17px]"}>
|
||||
Play<span className="brand-text">link</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user