feat(web): BandPage — By Date and Search tabs
Replaces flat song list with two tabs: - By Date: session list (newest first) with weekday, date, label, recording count — each row links to SessionPage - Search: title/key/BPM range/tag filters, results as flat song list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ interface SongSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
tags: string[];
|
||||||
|
global_key: string | null;
|
||||||
|
global_bpm: number | null;
|
||||||
version_count: number;
|
version_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +37,26 @@ interface NcScanResult {
|
|||||||
songs: SongSummary[];
|
songs: SongSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export function BandPage() {
|
||||||
const { bandId } = useParams<{ bandId: string }>();
|
const { bandId } = useParams<{ bandId: string }>();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const [tab, setTab] = useState<"dates" | "search">("dates");
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -45,16 +65,25 @@ export function BandPage() {
|
|||||||
const [editingFolder, setEditingFolder] = useState(false);
|
const [editingFolder, setEditingFolder] = useState(false);
|
||||||
const [folderInput, setFolderInput] = useState("");
|
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({
|
const { data: band, isLoading } = useQuery({
|
||||||
queryKey: ["band", bandId],
|
queryKey: ["band", bandId],
|
||||||
queryFn: () => getBand(bandId!),
|
queryFn: () => getBand(bandId!),
|
||||||
enabled: !!bandId,
|
enabled: !!bandId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: songs } = useQuery({
|
const { data: sessions } = useQuery({
|
||||||
queryKey: ["songs", bandId],
|
queryKey: ["sessions", bandId],
|
||||||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs`),
|
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
||||||
enabled: !!bandId,
|
enabled: !!bandId && tab === "dates",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: members } = useQuery({
|
const { data: members } = useQuery({
|
||||||
@@ -63,10 +92,24 @@ export function BandPage() {
|
|||||||
enabled: !!bandId,
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
|
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["songs", bandId] });
|
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -77,7 +120,7 @@ export function BandPage() {
|
|||||||
const scanMutation = useMutation({
|
const scanMutation = useMutation({
|
||||||
mutationFn: () => api.post<NcScanResult>(`/bands/${bandId}/nc-scan`, {}),
|
mutationFn: () => api.post<NcScanResult>(`/bands/${bandId}/nc-scan`, {}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ["songs", bandId] });
|
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||||
if (result.imported > 0) {
|
if (result.imported > 0) {
|
||||||
setScanMsg(`Imported ${result.imported} new song${result.imported !== 1 ? "s" : ""} from ${result.folder} (${result.skipped} already registered).`);
|
setScanMsg(`Imported ${result.imported} new song${result.imported !== 1 ? "s" : ""} from ${result.folder} (${result.skipped} already registered).`);
|
||||||
} else if (result.files_found === 0) {
|
} else if (result.files_found === 0) {
|
||||||
@@ -115,6 +158,16 @@ export function BandPage() {
|
|||||||
|
|
||||||
const amAdmin = members?.some((m) => m.role === "admin") ?? 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 (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>;
|
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||||||
|
|
||||||
@@ -125,7 +178,7 @@ export function BandPage() {
|
|||||||
← All Bands
|
← All Bands
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* ── Band header ── */}
|
{/* Band header */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
||||||
{band.genre_tags.length > 0 && (
|
{band.genre_tags.length > 0 && (
|
||||||
@@ -137,7 +190,7 @@ export function BandPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Nextcloud folder ── */}
|
{/* Nextcloud folder */}
|
||||||
<div style={{ marginBottom: 24, background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
|
<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 style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -182,7 +235,7 @@ export function BandPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Members ── */}
|
{/* Members */}
|
||||||
<div style={{ marginBottom: 32 }}>
|
<div style={{ marginBottom: 32 }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||||||
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
|
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
|
||||||
@@ -241,9 +294,9 @@ export function BandPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Songs ── */}
|
{/* Recordings header */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Songs</h2>
|
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Recordings</h2>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => scanMutation.mutate()}
|
onClick={() => scanMutation.mutate()}
|
||||||
@@ -278,9 +331,6 @@ export function BandPage() {
|
|||||||
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" }}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 12px" }}>
|
|
||||||
A folder <code style={{ color: "var(--teal)" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
@@ -299,26 +349,211 @@ export function BandPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: 8 }}>
|
{/* Tabs */}
|
||||||
{songs?.map((song) => (
|
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1px solid var(--border)" }}>
|
||||||
<Link
|
{(["dates", "search"] as const).map((t) => (
|
||||||
key={song.id}
|
<button
|
||||||
to={`/bands/${bandId}/songs/${song.id}`}
|
key={t}
|
||||||
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" }}
|
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,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>{song.title}</span>
|
{t === "dates" ? "By Date" : "Search"}
|
||||||
<span style={{ color: "var(--text-muted)", fontSize: 12 }}>
|
</button>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
{songs?.length === 0 && (
|
|
||||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
|
||||||
No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user