Files
rehearshalhub/web/src/pages/BandPage.tsx
2026-03-29 15:14:18 +02:00

652 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands";
import { api } from "../api/client";
interface SongSummary {
id: string;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface BandMember {
id: string;
display_name: string;
email: string;
role: string;
joined_at: string;
}
interface BandInvite {
id: string;
token: string;
role: string;
expires_at: string;
}
interface SessionSummary {
id: string;
date: string;
label: string | null;
recording_count: number;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function weekday(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { weekday: "short" });
}
export function BandPage() {
const { bandId } = useParams<{ bandId: string }>();
const qc = useQueryClient();
const [tab, setTab] = useState<"dates" | "search">("dates");
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [editingFolder, setEditingFolder] = useState(false);
const [folderInput, setFolderInput] = useState("");
// Search state
const [searchQ, setSearchQ] = useState("");
const [searchKey, setSearchKey] = useState("");
const [searchBpmMin, setSearchBpmMin] = useState("");
const [searchBpmMax, setSearchBpmMax] = useState("");
const [searchTagInput, setSearchTagInput] = useState("");
const [searchTags, setSearchTags] = useState<string[]>([]);
const [searchDirty, setSearchDirty] = useState(false);
const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId],
queryFn: () => getBand(bandId!),
enabled: !!bandId,
});
const { data: sessions } = useQuery({
queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId && tab === "dates",
});
const { data: unattributedSongs } = useQuery({
queryKey: ["songs-unattributed", bandId],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
enabled: !!bandId && tab === "dates",
});
const { data: members } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
// Search results — only fetch when user has triggered a search
const searchParams = new URLSearchParams();
if (searchQ) searchParams.set("q", searchQ);
if (searchKey) searchParams.set("key", searchKey);
if (searchBpmMin) searchParams.set("bpm_min", searchBpmMin);
if (searchBpmMax) searchParams.set("bpm_max", searchBpmMax);
searchTags.forEach((t) => searchParams.append("tags", t));
const { data: searchResults, isFetching: searchFetching } = useQuery({
queryKey: ["songs-search", bandId, searchParams.toString()],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?${searchParams}`),
enabled: !!bandId && tab === "search" && searchDirty,
});
const createMutation = useMutation({
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
setShowCreate(false);
setTitle("");
setError(null);
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
});
async function startScan() {
if (scanning || !bandId) return;
setScanning(true);
setScanMsg(null);
setScanProgress("Starting scan…");
const token = localStorage.getItem("rh_token");
const url = `/api/v1/bands/${bandId}/nc-scan/stream${token ? `?token=${encodeURIComponent(token)}` : ""}`;
try {
const resp = await fetch(url);
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => resp.statusText);
throw new Error(text || `HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: Record<string, unknown>;
try { event = JSON.parse(line); } catch { continue; }
if (event.type === "progress") {
setScanProgress(event.message as string);
} else if (event.type === "song" || event.type === "session") {
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
} else if (event.type === "done") {
const s = event.stats as { found: number; imported: number; skipped: number };
if (s.imported > 0) {
setScanMsg(`Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).`);
} else if (s.found === 0) {
setScanMsg("No audio files found.");
} else {
setScanMsg(`All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`);
}
setTimeout(() => setScanMsg(null), 6000);
} else if (event.type === "error") {
setScanMsg(`Scan error: ${event.message}`);
}
}
}
} catch (err) {
setScanMsg(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false);
setScanProgress(null);
}
}
const inviteMutation = useMutation({
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
onSuccess: (invite) => {
const url = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(url);
navigator.clipboard.writeText(url).catch(() => {});
},
});
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
const updateFolderMutation = useMutation({
mutationFn: (nc_folder_path: string) =>
api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
setEditingFolder(false);
},
});
const amAdmin = members?.some((m) => m.role === "admin") ?? false;
function addTag() {
const t = searchTagInput.trim();
if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]);
setSearchTagInput("");
}
function removeTag(t: string) {
setSearchTags((prev) => prev.filter((x) => x !== t));
}
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
return (
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<Link to="/" style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
All Bands
</Link>
{/* Band header */}
<div style={{ marginBottom: 24 }}>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
{band.genre_tags.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{band.genre_tags.map((t: string) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
</div>
)}
</div>
{/* Nextcloud folder */}
<div style={{ marginBottom: 24, background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>NEXTCLOUD SCAN FOLDER</span>
<div style={{ fontFamily: "monospace", color: "var(--teal)", fontSize: 13, marginTop: 4 }}>
{band.nc_folder_path ?? `bands/${band.slug}/`}
</div>
</div>
{amAdmin && !editingFolder && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingFolder(true); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "4px 10px", fontSize: 11 }}
>
Edit
</button>
)}
</div>
{editingFolder && (
<div style={{ marginTop: 10 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={`bands/${band.slug}/`}
style={{ width: "100%", padding: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateFolderMutation.mutate(folderInput)}
disabled={updateFolderMutation.isPending}
style={{ background: "var(--teal)", border: "none", borderRadius: 6, color: "var(--bg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
Save
</button>
<button
onClick={() => setEditingFolder(false)}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Members */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--accent)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
+ Invite
</button>
</div>
{inviteLink && (
<div style={{ background: "var(--accent-bg)", border: "1px solid var(--accent)", borderRadius: 6, padding: "10px 14px", marginBottom: 12 }}>
<p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
<code style={{ color: "var(--accent)", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Dismiss
</button>
</div>
)}
<div style={{ display: "grid", gap: 6 }}>
{members?.map((m) => (
<div
key={m.id}
style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 6, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<div>
<span style={{ fontWeight: 500 }}>{m.display_name}</span>
<span style={{ color: "var(--text-muted)", fontSize: 11, marginLeft: 10 }}>{m.email}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{
fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3,
background: m.role === "admin" ? "var(--accent-bg)" : "var(--bg-inset)",
color: m.role === "admin" ? "var(--accent)" : "var(--text-muted)",
border: `1px solid ${m.role === "admin" ? "var(--accent)" : "var(--border)"}`,
}}>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMemberMutation.mutate(m.id)}
style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Remove
</button>
)}
</div>
</div>
))}
</div>
</div>
{/* Recordings header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Recordings</h2>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={startScan}
disabled={scanning}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
<button
onClick={() => { setShowCreate(!showCreate); setError(null); }}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
+ New Song
</button>
</div>
</div>
{scanning && scanProgress && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", fontSize: 12, padding: "8px 14px", marginBottom: 8, fontFamily: "monospace" }}>
{scanProgress}
</div>
)}
{scanMsg && (
<div style={{ background: "var(--teal-bg)", border: "1px solid var(--teal)", borderRadius: 6, color: "var(--teal)", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
{scanMsg}
</div>
)}
{showCreate && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 20, marginBottom: 16 }}>
{error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
autoFocus
/>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => createMutation.mutate()}
disabled={!title}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
>
Create
</button>
<button
onClick={() => { setShowCreate(false); setError(null); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
>
Cancel
</button>
</div>
</div>
)}
{/* Tabs */}
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1px solid var(--border)" }}>
{(["dates", "search"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
background: "none",
border: "none",
borderBottom: `2px solid ${tab === t ? "var(--accent)" : "transparent"}`,
color: tab === t ? "var(--accent)" : "var(--text-muted)",
cursor: "pointer",
padding: "8px 16px",
fontSize: 13,
fontWeight: tab === t ? 600 : 400,
marginBottom: -1,
}}
>
{t === "dates" ? "By Date" : "Search"}
</button>
))}
</div>
{/* By Date tab */}
{tab === "dates" && (
<div style={{ display: "grid", gap: 6 }}>
{sessions?.map((s) => (
<Link
key={s.id}
to={`/bands/${bandId}/sessions/${s.id}`}
style={{
background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div>
<span style={{ fontFamily: "monospace", color: "var(--text-muted)", fontSize: 10, marginRight: 8 }}>{weekday(s.date)}</span>
<span style={{ fontWeight: 500 }}>{formatDate(s.date)}</span>
{s.label && (
<span style={{ color: "var(--teal)", fontSize: 12, marginLeft: 10 }}>{s.label}</span>
)}
</div>
<span style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span>
</Link>
))}
{sessions?.length === 0 && !unattributedSongs?.length && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
No sessions yet. Scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
</p>
)}
{/* Songs not linked to any dated session */}
{!!unattributedSongs?.length && (
<div style={{ marginTop: sessions?.length ? 24 : 0 }}>
<div style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "monospace", letterSpacing: 1, marginBottom: 8 }}>
UNATTRIBUTED RECORDINGS
</div>
<div style={{ display: "grid", gap: 6 }}>
{unattributedSongs.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
</div>
</div>
<span style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
</div>
)}
</div>
)}
{/* Search tab */}
{tab === "search" && (
<div>
{/* Filters */}
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>TITLE</label>
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }}
placeholder="Search by name…"
style={{ width: "100%", padding: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>KEY</label>
<input
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder="e.g. Am, C, F#"
style={{ width: "100%", padding: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>BPM MIN</label>
<input
value={searchBpmMin}
onChange={(e) => setSearchBpmMin(e.target.value)}
type="number"
min={0}
placeholder="e.g. 80"
style={{ width: "100%", padding: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>BPM MAX</label>
<input
value={searchBpmMax}
onChange={(e) => setSearchBpmMax(e.target.value)}
type="number"
min={0}
placeholder="e.g. 140"
style={{ width: "100%", padding: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
</div>
{/* Tag filter */}
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>TAGS (must have all)</label>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 6 }}>
{searchTags.map((t) => (
<span
key={t}
style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 11, padding: "2px 8px", borderRadius: 12, fontFamily: "monospace", display: "flex", alignItems: "center", gap: 4 }}
>
{t}
<button
onClick={() => removeTag(t)}
style={{ background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1 }}
>×</button>
</span>
))}
</div>
<div style={{ display: "flex", gap: 6 }}>
<input
value={searchTagInput}
onChange={(e) => setSearchTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder="Add tag…"
style={{ flex: 1, padding: "6px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 12 }}
/>
<button
onClick={addTag}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 10px", fontSize: 12 }}
>
+
</button>
</div>
</div>
<button
onClick={() => { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); }}
style={{ marginTop: 12, background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "7px 18px", fontSize: 13, fontWeight: 600 }}
>
Search
</button>
</div>
{/* Results */}
{searchFetching && <p style={{ color: "var(--text-muted)", fontSize: 13 }}>Searching</p>}
{!searchFetching && searchDirty && (
<div style={{ display: "grid", gap: 8 }}>
{searchResults?.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
{song.global_key && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span>
)}
{song.global_bpm && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span>
)}
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</div>
</Link>
))}
{searchResults?.length === 0 && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No songs match your filters.</p>
)}
</div>
)}
{!searchDirty && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>Enter filters above and hit Search.</p>
)}
</div>
)}
</div>
</div>
);
}