diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index d016d68..493c850 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -8,6 +8,9 @@ interface SongSummary { id: string; title: string; status: string; + tags: string[]; + global_key: string | null; + global_bpm: number | null; version_count: number; } @@ -34,9 +37,26 @@ interface NcScanResult { 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() { 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); @@ -45,16 +65,25 @@ export function BandPage() { 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: songs } = useQuery({ - queryKey: ["songs", bandId], - queryFn: () => api.get(`/bands/${bandId}/songs`), - enabled: !!bandId, + const { data: sessions } = useQuery({ + queryKey: ["sessions", bandId], + queryFn: () => api.get(`/bands/${bandId}/sessions`), + enabled: !!bandId && tab === "dates", }); const { data: members } = useQuery({ @@ -63,10 +92,24 @@ export function BandPage() { 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: ["songs", bandId] }); + qc.invalidateQueries({ queryKey: ["sessions", bandId] }); setShowCreate(false); setTitle(""); setError(null); @@ -77,7 +120,7 @@ export function BandPage() { const scanMutation = useMutation({ mutationFn: () => api.post(`/bands/${bandId}/nc-scan`, {}), onSuccess: (result) => { - qc.invalidateQueries({ queryKey: ["songs", bandId] }); + qc.invalidateQueries({ queryKey: ["sessions", bandId] }); if (result.imported > 0) { setScanMsg(`Imported ${result.imported} new song${result.imported !== 1 ? "s" : ""} from ${result.folder} (${result.skipped} already registered).`); } else if (result.files_found === 0) { @@ -115,6 +158,16 @@ export function BandPage() { 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
; @@ -125,7 +178,7 @@ export function BandPage() { ← All Bands - {/* ── Band header ── */} + {/* Band header */}

{band.name}

{band.genre_tags.length > 0 && ( @@ -137,7 +190,7 @@ export function BandPage() { )}
- {/* ── Nextcloud folder ── */} + {/* Nextcloud folder */}
@@ -182,7 +235,7 @@ export function BandPage() { )}
- {/* ── Members ── */} + {/* Members */}

Members

@@ -241,9 +294,9 @@ export function BandPage() {
- {/* ── Songs ── */} + {/* Recordings header */}
-

Songs

+

Recordings

)} -
- {songs?.map((song) => ( - + {(["dates", "search"] as const).map((t) => ( + ))} - {songs?.length === 0 && ( -

- No songs yet. Create one or scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}. -

- )}
+ + {/* 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 && ( +

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

+ )} +
+ )} + + {/* 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.

+ )} +
+ )}
);