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(null); const [scanning, setScanning] = useState(false); const [scanProgress, setScanProgress] = useState(null); const [scanMsg, setScanMsg] = useState(null); const [inviteLink, setInviteLink] = useState(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([]); 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(`/bands/${bandId}/sessions`), enabled: !!bandId && tab === "dates", }); const { data: unattributedSongs } = useQuery({ queryKey: ["songs-unattributed", bandId], queryFn: () => api.get(`/bands/${bandId}/songs/search?unattributed=true`), enabled: !!bandId && tab === "dates", }); const { data: members } = useQuery({ queryKey: ["members", bandId], queryFn: () => api.get(`/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(`/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; 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(`/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
Loading...
; if (!band) return
Band not found
; return (
← All Bands {/* Band header */}

{band.name}

{band.genre_tags.length > 0 && (
{band.genre_tags.map((t: string) => ( {t} ))}
)}
{/* Nextcloud folder */}
NEXTCLOUD SCAN FOLDER
{band.nc_folder_path ?? `bands/${band.slug}/`}
{amAdmin && !editingFolder && ( )}
{editingFolder && (
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" }} />
)}
{/* Members */}

Members

{inviteLink && (

Invite link (copied to clipboard, valid 72h):

{inviteLink}
)}
{members?.map((m) => (
{m.display_name} {m.email}
{m.role} {amAdmin && m.role !== "admin" && ( )}
))}
{/* Recordings header */}

Recordings

{scanning && scanProgress && (
{scanProgress}
)} {scanMsg && (
{scanMsg}
)} {showCreate && (
{error &&

{error}

} 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 />
)} {/* Tabs */}
{(["dates", "search"] as const).map((t) => ( ))}
{/* By Date tab */} {tab === "dates" && (
{sessions?.map((s) => (
{weekday(s.date)} {formatDate(s.date)} {s.label && ( {s.label} )}
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} ))} {sessions?.length === 0 && !unattributedSongs?.length && (

No sessions yet. Scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}.

)} {/* Songs not linked to any dated session */} {!!unattributedSongs?.length && (
UNATTRIBUTED RECORDINGS
{unattributedSongs.map((song) => (
{song.title}
{song.tags.map((t) => ( {t} ))}
{song.status} {song.version_count} version{song.version_count !== 1 ? "s" : ""} ))}
)}
)} {/* Search tab */} {tab === "search" && (
{/* Filters */}
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" }} />
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" }} />
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" }} />
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" }} />
{/* Tag filter */}
{searchTags.map((t) => ( {t} ))}
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 }} />
{/* Results */} {searchFetching &&

Searching…

} {!searchFetching && searchDirty && (
{searchResults?.map((song) => (
{song.title}
{song.tags.map((t) => ( {t} ))} {song.global_key && ( {song.global_key} )} {song.global_bpm && ( {song.global_bpm.toFixed(0)} BPM )}
{song.status} {song.version_count} version{song.version_count !== 1 ? "s" : ""}
))} {searchResults?.length === 0 && (

No songs match your filters.

)}
)} {!searchDirty && (

Enter filters above and hit Search.

)}
)}
); }