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

+10 -10
View File
@@ -1,6 +1,6 @@
# playlink # Playlink
Deduplicate music libraries with hardlinks. playlink finds songs that exist in both a **master** library (e.g. your full `Albums`) and a Deduplicate music libraries with hardlinks. Playlink finds songs that exist in both a **master** library (e.g. your full `Albums`) and a
**slave** library (e.g. a curated `Singles` library) and replaces the slave copy with a hardlink to the master copy. Each song then takes **slave** library (e.g. a curated `Singles` library) and replaces the slave copy with a hardlink to the master copy. Each song then takes
up disk space only once, while both libraries keep looking exactly the same to Plex, Navidrome, Jellyfin and other media servers. up disk space only once, while both libraries keep looking exactly the same to Plex, Navidrome, Jellyfin and other media servers.
@@ -13,7 +13,7 @@ Files are matched by their **path relative to each library root**. Any folder la
`Genre/Artist/Title.ext`, flat, …) as long as both libraries use the same one, so that a song sits at the same relative `Genre/Artist/Title.ext`, flat, …) as long as both libraries use the same one, so that a song sits at the same relative
path on both sides. The fallbacks below catch small differences, and a scan warns when the two layouts don't line up. path on both sides. The fallbacks below catch small differences, and a scan warns when the two layouts don't line up.
| Situation | What playlink does | | Situation | What Playlink does |
|---|---| |---|---|
| Same relative path, **byte-identical** | Status `identical`. Link with one click, in bulk, or automatically after each scan. | | Same relative path, **byte-identical** | Status `identical`. Link with one click, in bulk, or automatically after each scan. |
| Same path, **different bytes** (re-encode, different tags) | Status `different`. Manual review with a codec/bitrate/bit-depth/duration comparison. | | Same path, **different bytes** (re-encode, different tags) | Status `different`. Manual review with a codec/bitrate/bit-depth/duration comparison. |
@@ -51,7 +51,7 @@ Safeguards:
codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then have three choices: codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then have three choices:
- **Upgrade / link to master copy.** The default. When the master copy is better, the match is flagged as an **upgrade** and listed - **Upgrade / link to master copy.** The default. When the master copy is better, the match is flagged as an **upgrade** and listed
under the *Upgrades* filter. The master is kept because it belongs to a complete album. If the master is *lower* quality, playlink under the *Upgrades* filter. The master is kept because it belongs to a complete album. If the master is *lower* quality, Playlink
warns you and asks for explicit confirmation. warns you and asks for explicit confirmation.
- **Use slave copy as master.** The override. The slave's better file replaces the master file, the old master file is quarantined, and both - **Use slave copy as master.** The override. The slave's better file replaces the master file, the old master file is quarantined, and both
libraries share the better file. libraries share the better file.
@@ -76,14 +76,14 @@ Each row has the match status and type, a recommendation, both paths, where link
bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
(UTF-8, and cells a spreadsheet would treat as formulas are escaped). (UTF-8, and cells a spreadsheet would treat as formulas are escaped).
**Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: playlink lists the top-most **Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: Playlink lists the top-most
folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or
ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are
not. **Remove** asks for confirmation (and an extra acknowledgement when the folders still hold files), re-checks each folder for music not. **Remove** asks for confirmation (and an extra acknowledgement when the folders still hold files), re-checks each folder for music
right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention
period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies. period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies.
**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and **Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of Playlink's database (settings, library pairs, scan results and
history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7
or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be
downloaded, deleted, or restored, also from an uploaded zip. A restore is validated first (damaged backups and ones from a newer version downloaded, deleted, or restored, also from an uploaded zip. A restore is validated first (damaged backups and ones from a newer version
@@ -92,12 +92,12 @@ policy brings it back automatically. Optionally, **back up before every action i
before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept. before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept.
**Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the master library, tick the **Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the master library, tick the
folders or songs you want, and playlink hardlinks them in at the same relative path. **Remove** deletes slave files, but only ones that are hardlinks of the folders or songs you want, and Playlink hardlinks them in at the same relative path. **Remove** deletes slave files, but only ones that are hardlinks of the
master file. master file.
## Safety model ## Safety model
playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation: Playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation:
- **Dry run is on by default.** Every action is recorded in Activity as *planned* until you switch to live mode. - **Dry run is on by default.** Every action is recorded in Activity as *planned* until you switch to live mode.
- **Files are re-verified before any change.** Both files must still be plain regular files with the same inode, size and mtime that - **Files are re-verified before any change.** Both files must still be plain regular files with the same inode, size and mtime that
@@ -159,7 +159,7 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions
- **Both libraries must be visible through one bind mount.** Docker bind mounts are separate mount points, and `link(2)` fails across - **Both libraries must be visible through one bind mount.** Docker bind mounts are separate mount points, and `link(2)` fails across
them (`EXDEV`) even when they are on the same disk. Mount a common parent folder, as shown above. them (`EXDEV`) even when they are on the same disk. Mount a common parent folder, as shown above.
- **Both libraries must be on the same filesystem.** playlink checks this when you save a pair and before every operation. - **Both libraries must be on the same filesystem.** Playlink checks this when you save a pair and before every operation.
- **Unraid:** hardlinks through `/mnt/user` require *Settings → Global Share Settings → Tunable (support Hard Links)* to be *Yes* - **Unraid:** hardlinks through `/mnt/user` require *Settings → Global Share Settings → Tunable (support Hard Links)* to be *Yes*
(the default). If the share uses a cache pool, make sure your mover preserves hardlinks. Every link is verified by inode, and a (the default). If the share uses a cache pool, make sure your mover preserves hardlinks. Every link is verified by inode, and a
failed link aborts safely with the original untouched. failed link aborts safely with the original untouched.
@@ -176,7 +176,7 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions
| `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. | | `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. |
**Hide quarantine from Plex** (Settings → Media servers) writes a `.plexignore` into each pair's quarantine folder so Plex does not **Hide quarantine from Plex** (Settings → Media servers) writes a `.plexignore` into each pair's quarantine folder so Plex does not
pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if playlink pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if Playlink
created it. created it.
Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder, Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder,
+5 -5
View File
@@ -41,7 +41,7 @@ const (
var nameRe = regexp.MustCompile(`^playlink_backup_(manual|scheduled|action)_\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}(_\d+)?\.zip$`) var nameRe = regexp.MustCompile(`^playlink_backup_(manual|scheduled|action)_\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}(_\d+)?\.zip$`)
var ErrBadName = errors.New("not a playlink backup file name") var ErrBadName = errors.New("not a Playlink backup file name")
// Settings stored in the settings table. // Settings stored in the settings table.
const ( const (
@@ -341,7 +341,7 @@ func (m *Manager) Stage(ctx context.Context, zipPath string, maxSchema int) erro
} }
} }
if dbf == nil { if dbf == nil {
return errors.New("the zip does not contain a playlink database") return errors.New("the zip does not contain a Playlink database")
} }
tmp := filepath.Join(m.dataDir, stagedName+".tmp") tmp := filepath.Join(m.dataDir, stagedName+".tmp")
_ = os.Remove(tmp) _ = os.Remove(tmp)
@@ -395,14 +395,14 @@ func validate(ctx context.Context, path string, maxSchema int) error {
} }
var schema int var schema int
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema); err != nil { if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema); err != nil {
return errors.New("the backup is not a playlink database") return errors.New("the backup is not a Playlink database")
} }
if schema < 1 || schema > maxSchema { if schema < 1 || schema > maxSchema {
return fmt.Errorf("the backup is from a newer version of playlink (schema %d, this version supports up to %d)", schema, maxSchema) return fmt.Errorf("the backup is from a newer version of Playlink (schema %d, this version supports up to %d)", schema, maxSchema)
} }
var n int var n int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('pairs', 'users', 'operations')`).Scan(&n); err != nil || n != 3 { if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('pairs', 'users', 'operations')`).Scan(&n); err != nil || n != 3 {
return errors.New("the backup is not a playlink database") return errors.New("the backup is not a Playlink database")
} }
return nil return nil
} }
+1 -1
View File
@@ -626,7 +626,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
default: default:
// History can hold operations of features that were removed (tag // History can hold operations of features that were removed (tag
// edits, file moves); undoing them as a link would be wrong. // edits, file moves); undoing them as a link would be wrong.
return fmt.Errorf("%q operations can no longer be undone from playlink; their originals stay in the quarantine folder", o.Action) return fmt.Errorf("%q operations can no longer be undone from Playlink; their originals stay in the quarantine folder", o.Action)
} }
l, err := e.loadPair(ctx, o.PairID) l, err := e.loadPair(ctx, o.PairID)
if err != nil { if err != nil {
+1 -1
View File
@@ -345,7 +345,7 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
} }
if ms.symlinks+ss.symlinks > 0 { if ms.symlinks+ss.symlinks > 0 {
warns = append(warns, store.Warning{Title: fmt.Sprintf("Ignored %d symlinks", ms.symlinks+ss.symlinks), warns = append(warns, store.Warning{Title: fmt.Sprintf("Ignored %d symlinks", ms.symlinks+ss.symlinks),
Detail: "playlink never follows or replaces symlinks."}) Detail: "Playlink never follows or replaces symlinks."})
} }
if e.prober.Bin == "" { if e.prober.Bin == "" {
warns = append(warns, store.Warning{Title: "ffprobe was not found", warns = append(warns, store.Warning{Title: "ffprobe was not found",
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>playlink</title> <title>Playlink</title>
<meta name="theme-color" content="#0d0b12" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+10
View File
@@ -8,6 +8,7 @@
"name": "playlink-web", "name": "playlink-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"lucide-react": "^1.47.0", "lucide-react": "^1.47.0",
"react": "^19.3.0", "react": "^19.3.0",
@@ -23,6 +24,15 @@
"vite": "^8.3.0" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -9,6 +9,7 @@
"typecheck": "tsc -b" "typecheck": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"lucide-react": "^1.47.0", "lucide-react": "^1.47.0",
"react": "^19.3.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 { PairPage } from "./pages/PairPage";
import { ActivityPage } from "./pages/Activity"; import { ActivityPage } from "./pages/Activity";
import { SettingsPage } from "./pages/Settings"; import { SettingsPage } from "./pages/Settings";
import { Spinner, cx } from "./ui"; import { Spinner, cx, Brand } from "./ui";
import { useToast } from "./toast"; import { useToast } from "./toast";
export function useRoute() { export function useRoute() {
@@ -74,9 +74,8 @@ function Shell({ username }: { username: string }) {
<div className="flex min-h-full flex-col"> <div className="flex min-h-full flex-col">
<header className="sticky top-0 z-40 border-b border-line bg-bg/85 backdrop-blur"> <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"> <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"> <a href="#/" className="mr-2">
<img src="/favicon.svg" className="size-6" alt="" /> <Brand />
playlink
</a> </a>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
{nav.map((n) => ( {nav.map((n) => (
@@ -85,10 +84,10 @@ function Shell({ username }: { username: string }) {
href={`#${n.to}`} href={`#${n.to}`}
className={cx( className={cx(
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm", "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> <span className="hidden sm:inline">{n.label}</span>
</a> </a>
))} ))}
@@ -118,7 +117,7 @@ function Shell({ username }: { username: string }) {
</div> </div>
)} )}
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6">{page}</main> <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> </div>
); );
} }
+76 -9
View File
@@ -1,14 +1,81 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@fontsource-variable/inter";
/* Playlink "sunset" theme: plum-black surfaces with a coral-to-pink accent. */
@theme { @theme {
--color-bg: #0b0c10; --font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--color-panel: #13151b;
--color-panel-2: #1a1d25; --color-bg: #0d0b12;
--color-line: #262a35; --color-panel: #15121c;
--color-muted: #8a91a3; --color-panel-2: #1d1926;
--color-accent: #8b5cf6; --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%; } html,
body { background: var(--color-bg); color: #e6e8ee; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } body,
code, .mono { font-family: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace; } #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 className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1> <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>
<div className="flex rounded-lg border border-line p-0.5"> <div className="flex rounded-lg border border-line p-0.5">
{(["all", "quarantine"] as const).map((v) => ( {(["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 { Alert, Badge, Button, Card, Field, Modal, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast"; 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; const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const;
export function BackupsCard() { export function BackupsCard() {
@@ -75,7 +75,7 @@ export function BackupsCard() {
<Archive className="size-4 text-accent" /> Backups <Archive className="size-4 text-accent" /> Backups
</h2> </h2>
<p className="text-sm text-muted"> <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. changes are undone from History.
</p> </p>
</div> </div>
@@ -175,15 +175,15 @@ export function BackupsCard() {
<Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?"> <Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?">
<div className="space-y-3 text-sm text-gray-300"> <div className="space-y-3 text-sm text-gray-300">
<p> <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. pairs, scan results and history go back to how they were then.
</p> </p>
<ul className="list-disc space-y-1 pl-5 text-xs text-muted"> <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>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>Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.</li>
<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> </li>
</ul> </ul>
</div> </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() { function Restarting() {
const [slow, setSlow] = useState(false); const [slow, setSlow] = useState(false);
useEffect(() => { useEffect(() => {
@@ -235,12 +235,12 @@ function Restarting() {
return ( return (
<Modal open onClose={() => {}} title="Restoring backup…"> <Modal open onClose={() => {}} title="Restoring backup…">
<div className="flex items-center gap-3 text-sm text-gray-300"> <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> </div>
{slow && ( {slow && (
<div className="mt-3"> <div className="mt-3">
<Alert tone="amber"> <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. it starts.
</Alert> </Alert>
</div> </div>
+3 -3
View File
@@ -39,7 +39,7 @@ export function Build({ pair }: { pair: Pair }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Alert tone="blue"> <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 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. overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the master file, so the master copy is always kept.
</Alert> </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"> <label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<input <input
type="checkbox" type="checkbox"
className="accent-violet-500" className=""
checked={allSelected} checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(all.map((e) => e.rel)))} 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) => ( {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")}> <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 ? <Folder className="size-4 shrink-0 text-accent" /> : <FileAudio className="size-4 shrink-0 text-muted" />}
{e.dir ? ( {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()); }}> <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} ref={ref}
type="checkbox" type="checkbox"
aria-label={label} aria-label={label}
className="size-4 shrink-0 accent-violet-500 cursor-pointer" className="size-4 shrink-0 cursor-pointer"
checked={checked} checked={checked}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -150,7 +150,7 @@ function AlbumRow({ pair, g, sel, setSel }: { pair: Pair; g: AlbumGroup; sel: Se
</Badge> </Badge>
)} )}
{g.identical > 0 && <Badge tone="blue">{g.identical} identical</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.approximate > 0 && <Badge>{g.approximate} approximate</Badge>}
{g.other > 0 && <Badge>{g.other} other</Badge>} {g.other > 0 && <Badge>{g.other} other</Badge>}
</div> </div>
@@ -349,7 +349,7 @@ function BulkDialog({ pair, ids, action, onClose, onDone }: { pair: Pair; ids: n
)} )}
{action === "link" && (p.moves_folder > 0 || keepFolder) && ( {action === "link" && (p.moves_folder > 0 || keepFolder) && (
<label className="flex items-center gap-2 text-xs cursor-pointer"> <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 Keep songs in their current slave folders instead
</label> </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"> <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 <input
type="checkbox" type="checkbox"
className="accent-violet-500" className=""
checked={allOn} checked={allOn}
onChange={() => setSel(allOn ? new Set() : new Set(folders.map((f) => f.rel)))} 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"> <label key={f.rel} className="flex items-start gap-3 px-4 py-2 hover:bg-panel-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
className="mt-1 accent-violet-500" className="mt-1"
checked={sel.has(f.rel)} checked={sel.has(f.rel)}
onChange={() => onChange={() =>
setSel((s) => { setSel((s) => {
+1 -1
View File
@@ -30,7 +30,7 @@ export function Dashboard() {
{pairs.data?.length === 0 && ( {pairs.data?.length === 0 && (
<Card> <Card>
<Empty icon={<Library className="size-10" />} title="No library pairs yet"> <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. both and replaces the slave copy with a hardlink to the master copy, so each song is stored only once.
<div className="mt-4"> <div className="mt-4">
<Button variant="primary" onClick={() => setCreating(true)}> <Button variant="primary" onClick={() => setCreating(true)}>
+4 -7
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api"; 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 }) { export function Login({ setup }: { setup: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -18,12 +18,9 @@ export function Login({ setup }: { setup: boolean }) {
return ( return (
<div className="grid min-h-full place-items-center p-4"> <div className="grid min-h-full place-items-center p-4">
<Card className="w-full max-w-sm p-6"> <Card className="w-full max-w-sm p-6">
<div className="mb-6 flex items-center gap-2.5"> <div className="mb-6 space-y-1.5">
<img src="/favicon.svg" className="size-8" alt="" /> <Brand size="lg" />
<div> <div className="text-sm text-muted">{setup ? "Create your admin account" : "Sign in to your music libraries"}</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> </div>
<form <form
className="space-y-4" className="space-y-4"
+1 -1
View File
@@ -178,7 +178,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
step={1} step={1}
value={f.approx_threshold} value={f.approx_threshold}
onChange={(e) => set("approx_threshold", Number(e.target.value))} 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> <span className="w-10 text-sm tabular-nums">{f.approx_threshold}%</span>
</div> </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> <span className="tabular-nums text-muted">{job.total > 0 ? `${job.done} / ${job.total}` : "…"}</span>
</div> </div>
<div className="h-1.5 overflow-hidden rounded-full bg-line"> <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>
</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> <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"> <div className="mt-1 flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-4">
<span className="truncate"> <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>
<span className="truncate"> <span className="truncate">
<Badge>slave</Badge> <code className="mono">{pair.slave_root}</code> <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)} /> <PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
<Modal open={deleting} onClose={() => setDeleting(false)} title="Remove library pair?"> <Modal open={deleting} onClose={() => setDeleting(false)} title="Remove library pair?">
<p className="text-sm text-gray-300"> <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. 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> </p>
<div className="mt-5 flex justify-end gap-2"> <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"> <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 <input
type="checkbox" type="checkbox"
className="accent-violet-500" className=""
checked={all} checked={all}
onChange={(e) => setPicked(e.target.checked ? new Set(ready.map((r) => r.slave)) : new Set())} 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")}> <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 <input
type="checkbox" type="checkbox"
className="mt-0.5 accent-violet-500" className="mt-0.5"
checked={picked.has(r.slave)} checked={picked.has(r.slave)}
onChange={(e) => onChange={(e) =>
setPicked((s) => { setPicked((s) => {
+3 -3
View File
@@ -297,7 +297,7 @@ export function MatchRow({ m, selected, onToggle }: { m: Match; selected?: boole
<input <input
type="checkbox" type="checkbox"
aria-label={`Select ${m.rel_slave}`} 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} checked={!!selected}
onChange={(e) => onToggle(e.target.checked)} 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"> <tr className="text-left text-xs text-muted">
<th className="py-1 pr-4 font-normal"></th> <th className="py-1 pr-4 font-normal"></th>
<th className="py-1 pr-4 font-medium"> <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>
<th className="py-1 font-medium"> <th className="py-1 font-medium">
<Badge>slave</Badge> {better(v?.better === "slave")} <Badge>slave</Badge> {better(v?.better === "slave")}
@@ -474,7 +474,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
</div> </div>
{movesFolder && ( {movesFolder && (
<label className="mt-2 flex items-center gap-2 text-xs cursor-pointer"> <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> Keep it in the slave's current folder instead: <code className="mono">{dir(m.rel_slave) || "library root"}</code>
</label> </label>
)} )}
+2 -2
View File
@@ -23,7 +23,7 @@ export function SettingsPage() {
qc.invalidateQueries({ queryKey: ["info"] }); qc.invalidateQueries({ queryKey: ["info"] });
const failed = (res.results ?? []).filter((r) => r.error); const failed = (res.results ?? []).filter((r) => r.error);
if (failed.length > 0) failed.forEach((r) => toast("err", `${r.pair}: ${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), onError: (e) => toast("err", e.message),
}); });
@@ -74,7 +74,7 @@ export function SettingsPage() {
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Adds a <code className="mono">.plexignore</code> file to each library pair's quarantine folder (by default{" "} 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 <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. <code className="mono">.plexignore</code> of your own is never changed.
</p> </p>
</Card> </Card>
+18 -6
View File
@@ -8,8 +8,8 @@ export function cx(...c: (string | false | null | undefined)[]) {
type Variant = "primary" | "secondary" | "ghost" | "danger" | "warn"; type Variant = "primary" | "secondary" | "ghost" | "danger" | "warn";
const variants: Record<Variant, string> = { const variants: Record<Variant, string> = {
primary: "bg-accent text-white hover:bg-violet-500", 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-[#222633] text-gray-100", secondary: "bg-panel-2 border border-line hover:bg-[#262131] text-gray-100",
ghost: "hover:bg-panel-2 text-gray-300", ghost: "hover:bg-panel-2 text-gray-300",
danger: "bg-red-600/90 text-white hover:bg-red-500", danger: "bg-red-600/90 text-white hover:bg-red-500",
warn: "bg-amber-500/90 text-black hover:bg-amber-400", 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 }) { 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 = { const tones = {
@@ -50,7 +50,7 @@ const tones = {
blue: "bg-sky-500/15 text-sky-300 border-sky-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", amber: "bg-amber-500/15 text-amber-300 border-amber-500/25",
red: "bg-red-500/15 text-red-300 border-red-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 }) { 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", linked: "green",
identical: "blue", identical: "blue",
different: "amber", different: "amber",
similar: "violet", similar: "accent",
unmatched: "gray", unmatched: "gray",
error: "red", error: "red",
}; };
@@ -134,7 +134,7 @@ export function Toggle({ checked, onChange, label }: { checked: boolean; onChang
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className="inline-flex items-center gap-2.5 text-sm cursor-pointer" 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 className={cx("absolute top-0.5 size-4 rounded-full bg-white transition-all", checked ? "left-4.5" : "left-0.5")} />
</span> </span>
{label} {label}
@@ -183,3 +183,15 @@ export function formatDuration(sec: number) {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
return `${m}:${String(Math.round(sec % 60)).padStart(2, "0")}`; 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>
);
}