Rebuild the UI on Next.js, and give search real metadata
The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.
Search now carries what a music UI needs:
- year and genre, grafted on from /album/{id} concurrently, deduplicated by
album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
with no image proxy.
/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.
Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.
CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.
Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc46fd80b3
commit
3b4b68daaf
34 files changed
+10655
-281
No files matched your search
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
|
||||
export function AlbumView({
|
||||
id,
|
||||
onArtist,
|
||||
onQueued,
|
||||
}: {
|
||||
id: number;
|
||||
onArtist: (id: number) => void;
|
||||
onQueued?: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [data, setData] = React.useState<{ album: AlbumHeader; tracks: Track[] } | null>(null);
|
||||
const [err, setErr] = React.useState<string | null>(null);
|
||||
const [batch, setBatch] = React.useState<"idle" | "busy" | "done">("idle");
|
||||
const [queued, setQueued] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
setData(null);
|
||||
setErr(null);
|
||||
setBatch("idle");
|
||||
api
|
||||
.album(id)
|
||||
.then((d) => {
|
||||
if (!live) return;
|
||||
setData(d);
|
||||
void tint(d.album.cover_big ?? d.album.cover);
|
||||
})
|
||||
.catch((e) => live && setErr((e as Error).message));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
|
||||
const { album, tracks } = data;
|
||||
|
||||
const getAll = async () => {
|
||||
setBatch("busy");
|
||||
try {
|
||||
const d = await api.requestBatch(tracks);
|
||||
setQueued(d.queued);
|
||||
setBatch("done");
|
||||
toast(`Queued ${d.queued} tracks from ${album.title}`);
|
||||
onQueued?.();
|
||||
} catch (e) {
|
||||
setBatch("idle");
|
||||
toast(`Could not queue album: ${(e as Error).message}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero
|
||||
kind={album.record_type ?? "Album"}
|
||||
title={album.title}
|
||||
art={album.cover_big ?? album.cover}
|
||||
by={
|
||||
album.artist_id ? (
|
||||
<button
|
||||
onClick={() => onArtist(album.artist_id!)}
|
||||
className="border-b border-edge-hi hover:text-accent hover:border-accent-line cursor-pointer"
|
||||
>
|
||||
{album.artist}
|
||||
</button>
|
||||
) : (
|
||||
album.artist
|
||||
)
|
||||
}
|
||||
facts={[
|
||||
album.year,
|
||||
album.genres?.[0],
|
||||
album.label,
|
||||
album.nb_tracks ? `${album.nb_tracks} tracks` : null,
|
||||
album.duration ? mmss(album.duration * 1000) : null,
|
||||
]}
|
||||
actions={
|
||||
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||
{batch === "idle"
|
||||
? `Get all ${tracks.length} tracks`
|
||||
: batch === "busy"
|
||||
? "Queueing…"
|
||||
: `Queued ${queued}`}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
track={t}
|
||||
index={i}
|
||||
showNumber
|
||||
showArtist={false}
|
||||
showAlbum={false}
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
|
||||
export function ArtistView({
|
||||
id,
|
||||
onAlbum,
|
||||
onQueued,
|
||||
}: {
|
||||
id: number;
|
||||
onAlbum: (id: number) => void;
|
||||
onQueued?: () => void;
|
||||
}) {
|
||||
const [data, setData] = React.useState<{
|
||||
artist: ArtistHeader;
|
||||
tracks: Track[];
|
||||
albums: AlbumCard[];
|
||||
} | null>(null);
|
||||
const [err, setErr] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
setData(null);
|
||||
setErr(null);
|
||||
api
|
||||
.artist(id)
|
||||
.then((d) => {
|
||||
if (!live) return;
|
||||
setData(d);
|
||||
void tint(d.artist.picture);
|
||||
})
|
||||
.catch((e) => live && setErr((e as Error).message));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
|
||||
const { artist, tracks, albums } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero
|
||||
kind="Artist"
|
||||
title={artist.name}
|
||||
art={artist.picture}
|
||||
round
|
||||
facts={[
|
||||
artist.nb_fan ? `${artist.nb_fan.toLocaleString()} fans` : null,
|
||||
artist.nb_album ? `${artist.nb_album} releases` : null,
|
||||
]}
|
||||
/>
|
||||
|
||||
<SectionHead>Top tracks</SectionHead>
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
track={t}
|
||||
index={i}
|
||||
showArtist={false}
|
||||
onAlbum={onAlbum}
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<SectionHead>Releases</SectionHead>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(136px,1fr))] gap-3.5">
|
||||
{albums.map((a, i) => (
|
||||
<motion.button
|
||||
key={a.album_id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.025, 0.5) }}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => onAlbum(a.album_id)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={a.cover ?? ""}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="mb-2 aspect-square w-full rounded-[13px] bg-faint object-cover
|
||||
shadow-[0_8px_24px_rgb(0_0_0/0.35)]"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
<div className="text-[13.5px] font-medium clamp1">{a.title}</div>
|
||||
<div className="text-xs text-dim clamp1">
|
||||
{[a.year, a.record_type].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="mt-6 mb-3 text-xs font-bold uppercase tracking-[0.9px] text-dim">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
|
||||
* the artwork sets, so the page takes on the palette of whatever is on screen.
|
||||
*
|
||||
* Kept faint on purpose: at full strength the gradient competes with the album
|
||||
* art it is derived from, and the glass rows lose contrast against it.
|
||||
*/
|
||||
export function Aurora() {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
|
||||
<motion.i
|
||||
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
|
||||
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
|
||||
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
|
||||
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.i
|
||||
className="absolute block rounded-full opacity-30 blur-[110px]
|
||||
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
|
||||
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
|
||||
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
|
||||
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
{/* Channel-rotated so the palette has range rather than one flat wash,
|
||||
but faint — a full-strength complement fights the artwork. */}
|
||||
<motion.i
|
||||
className="absolute block rounded-full opacity-[0.14] blur-[110px]
|
||||
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
|
||||
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
|
||||
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Result = Awaited<ReturnType<typeof api.importList>>;
|
||||
|
||||
const PLACEHOLDER = `Radiohead - Creep
|
||||
Juice WRLD – Lucid Dreams
|
||||
3. Gorillaz - Feel Good Inc
|
||||
Black Hole Sun by Soundgarden
|
||||
https://deezer.com/album/14880711`;
|
||||
|
||||
export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
const toast = useToast();
|
||||
const [text, setText] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [out, setOut] = React.useState<Result | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
const t = text.trim();
|
||||
if (!t) {
|
||||
toast("Paste some songs first", true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setOut(null);
|
||||
try {
|
||||
const d = await api.importList(t);
|
||||
setOut(d);
|
||||
if (d.queued.length) {
|
||||
setText("");
|
||||
toast(`Queued ${d.queued.length} tracks`);
|
||||
onQueued?.();
|
||||
}
|
||||
} catch (e) {
|
||||
toast(`Import failed: ${(e as Error).message}`, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-5"
|
||||
>
|
||||
<h2 className="mb-1 text-[17px] font-bold tracking-[-0.3px]">Import a list</h2>
|
||||
<p className="mb-3.5 max-w-[62ch] text-[13px] text-dim">
|
||||
One song per line. Deezer album and playlist URLs are expanded into their tracks. The
|
||||
top match is taken automatically — every job shows what it resolved to, so a bad pick is
|
||||
visible rather than silent.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={PLACEHOLDER}
|
||||
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
||||
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||||
text-ink outline-none transition
|
||||
placeholder:text-dim/65
|
||||
focus:border-accent-line focus:shadow-[0_0_0_4px_var(--color-accent-soft)]"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Button onClick={run} disabled={busy}>
|
||||
{busy ? "Queueing…" : "Queue all"}
|
||||
</Button>
|
||||
<span className="text-[12.5px] text-dim">
|
||||
Top match taken automatically — check what each line resolved to.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{out && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3.5 overflow-hidden"
|
||||
>
|
||||
{out.queued.length > 0 && (
|
||||
<>
|
||||
<div className="mb-1 text-[12.5px] text-dim">Queued {out.queued.length}:</div>
|
||||
{out.queued.map((q, i) => (
|
||||
<motion.div
|
||||
key={q.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.03, 0.5) }}
|
||||
className="flex items-center gap-2.5 border-b border-edge py-1.5 text-[13px] last:border-0"
|
||||
>
|
||||
{q.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={q.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-[30px] w-[30px] shrink-0 rounded-md bg-faint object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="shrink-0 text-dim">{q.line}</span>
|
||||
<ArrowRight className="h-3 w-3 shrink-0 text-dim opacity-50" />
|
||||
<span className="clamp1">{q.matched}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{out.skipped.length > 0 && (
|
||||
<div className="mt-2.5 text-[12.5px] leading-[1.7] text-err">
|
||||
Skipped {out.skipped.length}:{" "}
|
||||
{out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Album/artist header. The artwork itself, blown up and blurred, is the
|
||||
* panel's own backdrop — cheaper and more faithful than trying to synthesise
|
||||
* a gradient from the extracted accent, and it reads as the record rather
|
||||
* than as a themed box.
|
||||
*/
|
||||
export function Hero({
|
||||
kind,
|
||||
title,
|
||||
art,
|
||||
round,
|
||||
by,
|
||||
facts,
|
||||
actions,
|
||||
}: {
|
||||
kind: string;
|
||||
title: string;
|
||||
art: string | null;
|
||||
round?: boolean;
|
||||
by?: React.ReactNode;
|
||||
facts?: (string | null | undefined)[];
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||
>
|
||||
{art && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={art}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute inset-0 z-0 h-full w-full scale-[1.35] object-cover
|
||||
opacity-40 blur-[46px] saturate-[1.8]"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 z-[1] bg-gradient-to-r from-[var(--scrim)] to-[var(--scrim-2)]" />
|
||||
|
||||
{art && (
|
||||
<motion.img
|
||||
layoutId={`art-${title}`}
|
||||
src={art}
|
||||
alt=""
|
||||
className={cn(
|
||||
"relative z-[2] w-[132px] h-[132px] sm:w-[150px] sm:h-[150px] shrink-0 object-cover",
|
||||
"bg-faint shadow-[0_14px_40px_rgb(0_0_0/0.5)]",
|
||||
round ? "rounded-full" : "rounded-2xl",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-[2] flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-[1px] text-accent">
|
||||
{kind}
|
||||
</div>
|
||||
<h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]">
|
||||
{title}
|
||||
</h2>
|
||||
{by && <div className="mb-2.5 text-[15px] font-semibold">{by}</div>}
|
||||
{facts && (
|
||||
<div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5">
|
||||
{facts.filter(Boolean).map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="glass-hi rounded-full px-2.5 py-0.5 text-[11.5px] text-dim"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{actions && (
|
||||
<div className="flex flex-wrap justify-center sm:justify-start gap-2.5">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Square } from "lucide-react";
|
||||
import { mmss, type Track } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 30-second Deezer preview.
|
||||
*
|
||||
* Deliberately NOT a library player. Playing your own music belongs in
|
||||
* Navidrome, which already does offline, CarPlay, gapless and scrobbling, and
|
||||
* has a dozen native clients. This exists for one job: confirm you picked the
|
||||
* right recording before a Soulseek search is committed to it — which is the
|
||||
* cheapest possible guard against importing the wrong take.
|
||||
*/
|
||||
|
||||
type Ctx = {
|
||||
playing: number | null;
|
||||
toggle: (t: Track) => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const PlayerCtx = React.createContext<Ctx>({
|
||||
playing: null,
|
||||
toggle: () => {},
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
export const usePlayer = () => React.useContext(PlayerCtx);
|
||||
|
||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
const [track, setTrack] = React.useState<Track | null>(null);
|
||||
const [pct, setPct] = React.useState(0);
|
||||
const [left, setLeft] = React.useState(30_000);
|
||||
|
||||
if (typeof window !== "undefined" && !audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.preload = "none";
|
||||
}
|
||||
|
||||
const stop = React.useCallback(() => {
|
||||
const a = audioRef.current;
|
||||
if (a) {
|
||||
a.pause();
|
||||
a.removeAttribute("src");
|
||||
a.load();
|
||||
}
|
||||
setTrack(null);
|
||||
setPct(0);
|
||||
}, []);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
(t: Track) => {
|
||||
const a = audioRef.current;
|
||||
if (!a || !t.preview) return;
|
||||
if (track?.deezer_id === t.deezer_id) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
a.pause();
|
||||
a.src = t.preview;
|
||||
void a.play().catch(() => undefined);
|
||||
setTrack(t);
|
||||
setPct(0);
|
||||
},
|
||||
[track, stop],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const a = audioRef.current;
|
||||
if (!a) return;
|
||||
const onTime = () => {
|
||||
const d = a.duration || 30;
|
||||
setPct((a.currentTime / d) * 100);
|
||||
setLeft((d - a.currentTime) * 1000);
|
||||
};
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
a.addEventListener("ended", stop);
|
||||
return () => {
|
||||
a.removeEventListener("timeupdate", onTime);
|
||||
a.removeEventListener("ended", stop);
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
// Stop playback when the tab is hidden — nobody wants a phantom 30s clip.
|
||||
React.useEffect(() => {
|
||||
const onHide = () => document.hidden && stop();
|
||||
document.addEventListener("visibilitychange", onHide);
|
||||
return () => document.removeEventListener("visibilitychange", onHide);
|
||||
}, [stop]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ playing: track?.deezer_id ?? null, toggle, stop }),
|
||||
[track, toggle, stop],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerCtx.Provider value={value}>
|
||||
{children}
|
||||
<AnimatePresence>
|
||||
{track && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22, x: "-50%" }}
|
||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||
exit={{ opacity: 0, y: 22, x: "-50%" }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||
className="sheet fixed bottom-4 left-1/2 z-70 flex items-center gap-3
|
||||
rounded-[20px] py-2 pl-2 pr-4 w-[min(560px,calc(100vw-28px))]"
|
||||
>
|
||||
{track.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={track.cover}
|
||||
alt=""
|
||||
className="w-[42px] h-[42px] rounded-[9px] object-cover bg-faint shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13.5px] font-semibold clamp1">{track.title}</div>
|
||||
<div className="text-xs text-dim clamp1">{track.artist}</div>
|
||||
</div>
|
||||
<div className="hidden sm:block w-24 h-[3px] rounded-full bg-faint overflow-hidden shrink-0">
|
||||
<motion.i
|
||||
className="block h-full rounded-full bg-accent"
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ ease: "linear", duration: 0.25 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden sm:block text-[11.5px] text-dim tabular-nums shrink-0">
|
||||
{mmss(left)}
|
||||
</span>
|
||||
<button
|
||||
onClick={stop}
|
||||
aria-label="Stop preview"
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
||||
text-ink cursor-pointer transition hover:border-accent-line
|
||||
hover:text-accent"
|
||||
>
|
||||
<Square className="w-3 h-3 fill-current" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PlayerCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { api, type Job } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobsSnapshot = {
|
||||
jobs: Job[];
|
||||
counts: Record<string, number>;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const PILL: Record<Job["state"], string> = {
|
||||
imported: "text-ok border-ok/40 bg-ok/10",
|
||||
failed: "text-err border-err/40 bg-err/10",
|
||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||
searching: "text-warn border-warn/40 bg-warn/10",
|
||||
queued: "text-warn border-warn/40 bg-warn/10",
|
||||
};
|
||||
|
||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||
const toast = useToast();
|
||||
if (!snap) return null;
|
||||
|
||||
const { jobs, counts, pending } = snap;
|
||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||
const done = counts.imported ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const total = active + done;
|
||||
|
||||
const stats: [string, number, string][] = [
|
||||
["In flight", active, "text-accent"],
|
||||
["Imported", done, "text-ok"],
|
||||
["Failed", failed, "text-err"],
|
||||
["Waiting", pending, ""],
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{stats.map(([label, n, colour]) => (
|
||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
||||
<motion.b
|
||||
key={n}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn("block text-2xl font-bold leading-tight tracking-[-0.7px]", colour)}
|
||||
>
|
||||
{n}
|
||||
</motion.b>
|
||||
<span className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active > 0 && total > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 4 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
||||
>
|
||||
<motion.i
|
||||
className="block h-1 rounded-full bg-accent"
|
||||
animate={{ width: `${(done / total) * 100}%` }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<AnimatePresence initial={false}>
|
||||
{jobs.map((j) => (
|
||||
<motion.div
|
||||
key={j.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
||||
>
|
||||
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
||||
{j.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={j.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14.5px] font-semibold clamp1">{j.title}</div>
|
||||
<div className="mt-px text-[12.5px] text-dim clamp1">
|
||||
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{j.state === "failed" && (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await api.retry(j.id);
|
||||
toast("Requeued");
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
|
||||
"text-[11px] font-semibold capitalize",
|
||||
PILL[j.state] ?? "text-dim border-edge",
|
||||
)}
|
||||
>
|
||||
{(j.state === "searching" || j.state === "downloading") && (
|
||||
<motion.span
|
||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
||||
animate={{ opacity: [1, 0.25, 1] }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{j.state}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, X, Loader2 } from "lucide-react";
|
||||
import { api, type Suggestions } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
||||
|
||||
export function SearchBar({
|
||||
busy,
|
||||
onSearch,
|
||||
onAlbum,
|
||||
onArtist,
|
||||
}: {
|
||||
busy?: boolean;
|
||||
onSearch: (q: string) => void;
|
||||
onAlbum: (id: number) => void;
|
||||
onArtist: (id: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = React.useState("");
|
||||
const [items, setItems] = React.useState<Item[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [sel, setSel] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Debounced so a fast typist makes one upstream call, not eight. The
|
||||
// AbortController matters more than the delay: without it a slow response
|
||||
// to "rad" can land after "radiohead" and repopulate the stale list.
|
||||
React.useEffect(() => {
|
||||
const v = q.trim();
|
||||
if (v.length < 2) {
|
||||
setItems([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const d: Suggestions = await api.suggest(v, ac.signal);
|
||||
const next: Item[] = [
|
||||
...d.tracks.map((t) => ({
|
||||
kind: "track" as const,
|
||||
label: t.title,
|
||||
sub: t.artist,
|
||||
cover: t.cover,
|
||||
run: () => {
|
||||
const nq = `${t.artist} ${t.title}`;
|
||||
setQ(nq);
|
||||
setOpen(false);
|
||||
onSearch(nq);
|
||||
},
|
||||
})),
|
||||
...d.albums.map((a) => ({
|
||||
kind: "album" as const,
|
||||
label: a.title,
|
||||
sub: a.artist,
|
||||
cover: a.cover,
|
||||
meta: a.nb_tracks ? `${a.nb_tracks} tracks` : "",
|
||||
run: () => {
|
||||
setOpen(false);
|
||||
onAlbum(a.album_id);
|
||||
},
|
||||
})),
|
||||
...d.artists.map((a) => ({
|
||||
kind: "artist" as const,
|
||||
label: a.name,
|
||||
sub: a.nb_album ? `${a.nb_album} releases` : "Artist",
|
||||
cover: a.cover,
|
||||
run: () => {
|
||||
setOpen(false);
|
||||
onArtist(a.artist_id);
|
||||
},
|
||||
})),
|
||||
];
|
||||
setItems(next);
|
||||
setSel(-1);
|
||||
setOpen(next.length > 0);
|
||||
} catch {
|
||||
/* aborted or offline — leave the previous list alone */
|
||||
}
|
||||
}, 180);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
ac.abort();
|
||||
};
|
||||
}, [q, onSearch, onAlbum, onArtist]);
|
||||
|
||||
// "/" focuses search, the way every music app does.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (document.activeElement as HTMLElement)?.tagName;
|
||||
if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(tag)) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const key = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSel((s) => (items.length ? (s + 1) % items.length : -1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSel((s) => (items.length ? (s - 1 + items.length) % items.length : -1));
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (sel >= 0 && items[sel]) items[sel].run();
|
||||
else {
|
||||
setOpen(false);
|
||||
onSearch(q.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const groups: { title: string; kind: Item["kind"] }[] = [
|
||||
{ title: "Songs", kind: "track" },
|
||||
{ title: "Albums", kind: "album" },
|
||||
{ title: "Artists", kind: "artist" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative z-50">
|
||||
<div
|
||||
className={cn(
|
||||
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||
"transition-all duration-250 ease-glass",
|
||||
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
||||
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||
)}
|
||||
>
|
||||
<Search className="w-[18px] h-[18px] shrink-0 text-dim" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={key}
|
||||
onFocus={() => items.length && setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||
placeholder="Search songs, albums, artists…"
|
||||
aria-label="Search"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-base
|
||||
font-medium tracking-[-0.1px] text-ink placeholder:text-dim
|
||||
placeholder:font-normal"
|
||||
/>
|
||||
{busy && <Loader2 className="w-4 h-4 shrink-0 text-accent animate-spin" />}
|
||||
{q && !busy && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
||||
glass-hi text-dim hover:text-ink cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && items.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, scale: 0.985 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.985 }}
|
||||
transition={{ duration: 0.18, ease: [0.22, 0.9, 0.28, 1] }}
|
||||
className="sheet absolute top-[calc(100%+8px)] left-0 right-0 z-60 p-1.5
|
||||
rounded-[20px] max-h-[min(64vh,460px)] overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{groups.map((g) => {
|
||||
const rows = items.filter((i) => i.kind === g.kind);
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<div key={g.kind}>
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10.5px] font-bold uppercase tracking-[0.9px] text-dim">
|
||||
{g.title}
|
||||
</div>
|
||||
{rows.map((it) => {
|
||||
const idx = items.indexOf(it);
|
||||
return (
|
||||
<div
|
||||
key={`${g.kind}-${idx}`}
|
||||
role="option"
|
||||
aria-selected={idx === sel}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
it.run();
|
||||
}}
|
||||
onMouseEnter={() => setSel(idx)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-2.5 py-2 rounded-xl cursor-pointer transition-colors",
|
||||
idx === sel && "bg-accent-soft",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={it.cover ?? ""}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
"w-[38px] h-[38px] shrink-0 object-cover bg-faint",
|
||||
it.kind === "artist" ? "rounded-full" : "rounded-lg",
|
||||
)}
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium clamp1">{it.label}</div>
|
||||
<div className="text-xs text-dim clamp1">{it.sub}</div>
|
||||
</div>
|
||||
{it.meta && <div className="shrink-0 text-[11px] text-dim">{it.meta}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
|
||||
export function Skeletons({ n = 6 }: { n?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="shimmer h-[68px] rounded-glass border border-edge"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Toast = { id: number; msg: string; err?: boolean };
|
||||
type Ctx = (msg: string, err?: boolean) => void;
|
||||
|
||||
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||
export const useToast = () => React.useContext(ToastCtx);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = React.useState<Toast[]>([]);
|
||||
|
||||
const push = React.useCallback<Ctx>((msg, err) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((x) => [...x, { id, msg, err }]);
|
||||
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={push}>
|
||||
{children}
|
||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
|
||||
<AnimatePresence>
|
||||
{items.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
||||
className={cn(
|
||||
"sheet rounded-full px-5 py-2.5 text-[13.5px] font-medium text-center",
|
||||
"max-w-[calc(100vw-32px)]",
|
||||
t.err && "border-err/50 text-err",
|
||||
)}
|
||||
>
|
||||
{t.msg}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { api, mmss, type Track } from "@/lib/api";
|
||||
import { usePlayer } from "@/components/player";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TrackRow({
|
||||
track,
|
||||
index,
|
||||
showNumber,
|
||||
showArtist = true,
|
||||
showAlbum = true,
|
||||
onAlbum,
|
||||
onArtist,
|
||||
onQueued,
|
||||
}: {
|
||||
track: Track;
|
||||
index: number;
|
||||
showNumber?: boolean;
|
||||
showArtist?: boolean;
|
||||
showAlbum?: boolean;
|
||||
onAlbum?: (id: number) => void;
|
||||
onArtist?: (id: number) => void;
|
||||
onQueued?: () => void;
|
||||
}) {
|
||||
const { playing, toggle } = usePlayer();
|
||||
const toast = useToast();
|
||||
const [state, setState] = React.useState<"idle" | "busy" | "done">("idle");
|
||||
|
||||
const isPlaying = playing === track.deezer_id;
|
||||
|
||||
const queue = async () => {
|
||||
setState("busy");
|
||||
try {
|
||||
await api.request(track);
|
||||
setState("done");
|
||||
toast(`Queued ${track.artist} — ${track.title}`);
|
||||
onQueued?.();
|
||||
} catch (e) {
|
||||
setState("idle");
|
||||
toast(`Could not queue: ${(e as Error).message}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
const facts: React.ReactNode[] = [];
|
||||
if (showAlbum && track.album)
|
||||
facts.push(
|
||||
track.album_id && onAlbum ? (
|
||||
<button
|
||||
key="al"
|
||||
onClick={() => onAlbum(track.album_id!)}
|
||||
className="hover:text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{track.album}
|
||||
</button>
|
||||
) : (
|
||||
<span key="al">{track.album}</span>
|
||||
),
|
||||
);
|
||||
if (track.year) facts.push(<span key="yr">{track.year}</span>);
|
||||
if (track.genre) facts.push(<span key="gn">{track.genre}</span>);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||
whileHover={{ y: -1 }}
|
||||
className={cn(
|
||||
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
||||
isPlaying && "border-accent-line bg-accent-soft",
|
||||
)}
|
||||
>
|
||||
{showNumber && (
|
||||
<div className="w-6 shrink-0 text-right text-[12.5px] text-dim tabular-nums">
|
||||
{track.track ?? ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => toggle(track)}
|
||||
disabled={!track.preview}
|
||||
aria-label={track.preview ? `Preview ${track.title}` : "No preview available"}
|
||||
className={cn(
|
||||
"relative w-[50px] h-[50px] shrink-0 rounded-[9px] overflow-hidden bg-faint group",
|
||||
track.preview ? "cursor-pointer" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
{track.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={track.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
{track.preview && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 grid place-items-center bg-black/50 text-white",
|
||||
"transition-opacity duration-200",
|
||||
isPlaying ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-[18px] h-[18px] fill-current" />
|
||||
) : (
|
||||
<Play className="w-[18px] h-[18px] fill-current" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14.5px] font-semibold tracking-[-0.15px] clamp1">
|
||||
{track.title}
|
||||
{track.explicit && (
|
||||
<span className="ml-1.5 align-[1px] inline-block rounded-[3px] border border-edge bg-faint px-1 text-[9px] font-bold text-dim">
|
||||
E
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-px text-[12.5px] text-dim clamp1">
|
||||
{showArtist && track.artist_id && onArtist ? (
|
||||
<button
|
||||
onClick={() => onArtist(track.artist_id!)}
|
||||
className="hover:text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{track.artist}
|
||||
</button>
|
||||
) : showArtist ? (
|
||||
<span>{track.artist}</span>
|
||||
) : null}
|
||||
{facts.map((f, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{(showArtist || i > 0) && <span className="mx-1.5 opacity-50">·</span>}
|
||||
{f}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block shrink-0 text-[12.5px] text-dim tabular-nums">
|
||||
{track.length ? mmss(track.length) : ""}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="pill"
|
||||
onClick={queue}
|
||||
disabled={state !== "idle"}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
state === "done" && "border-ok/50 text-ok bg-ok/10 hover:bg-ok/10 hover:text-ok hover:border-ok/50",
|
||||
)}
|
||||
>
|
||||
{state === "idle" ? "Get" : state === "busy" ? "…" : "Queued"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const button = cva(
|
||||
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
||||
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
||||
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Solid accent. The label is near-black because the accent is clamped
|
||||
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
||||
// to stay in step or this button becomes unreadable on some albums.
|
||||
primary: "bg-accent text-[#08090d] hover:brightness-110 active:scale-[0.97]",
|
||||
ghost:
|
||||
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
||||
outline:
|
||||
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
||||
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
||||
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
||||
},
|
||||
size: {
|
||||
sm: "text-xs px-3 py-1.5 rounded-full",
|
||||
md: "text-sm px-4.5 py-2.5 rounded-xl",
|
||||
pill: "text-[13px] px-4 py-2 rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof button> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
Reference in new issue
Block a user