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:
cdnutterandClaude Opus 5.5 committed 2026-09-30 15:49:21 -07:00
1 parent 30fab3a208
commit c98eb2f41d
23 files changed
+164 -77

No files matched your search

+2 -1
View File
@@ -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>
+10
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1 -1
View File
@@ -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) => (
+8 -8
View File
@@ -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>
+3 -3
View File
@@ -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()); }}>
+3 -3
View File
@@ -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>
)}
+2 -2
View File
@@ -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) => {
+1 -1
View File
@@ -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)}>
+4 -7
View File
@@ -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"
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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">
+2 -2
View File
@@ -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) => {
+3 -3
View File
@@ -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>
)}
+2 -2
View File
@@ -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
View File
@@ -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>
);
}