diff --git a/web/src/pages/BandPage.test.tsx b/web/src/pages/BandPage.test.tsx index 0417c35..9905540 100644 --- a/web/src/pages/BandPage.test.tsx +++ b/web/src/pages/BandPage.test.tsx @@ -43,14 +43,13 @@ const renderBandPage = () => // ── Tests ───────────────────────────────────────────────────────────────────── -describe("BandPage — cleanliness (TC-01 to TC-07)", () => { +describe("BandPage — Library view (TC-01 to TC-09)", () => { beforeEach(() => { vi.clearAllMocks(); }); it("TC-01: does not render a member list", async () => { renderBandPage(); - // Allow queries to settle await new Promise((r) => setTimeout(r, 50)); expect(screen.queryByText(/members/i)).toBeNull(); }); @@ -70,7 +69,6 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => { it("TC-04: renders sessions grouped by date", async () => { renderBandPage(); - // Sessions appear after the query resolves const sessionEl = await screen.findByText("Late Night Jam"); expect(sessionEl).toBeTruthy(); }); @@ -81,17 +79,30 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => { expect(btn).toBeTruthy(); }); - it("TC-06: renders the + New Song button", async () => { + it("TC-06: renders the + Upload button", async () => { renderBandPage(); - const btn = await screen.findByText(/\+ new song/i); + const btn = await screen.findByText(/\+ upload/i); expect(btn).toBeTruthy(); }); - it("TC-07: renders both By Date and Search tabs", async () => { + it("TC-07: does not render By Date / Search tabs", async () => { renderBandPage(); - const byDate = await screen.findByText(/by date/i); - const search = await screen.findByText(/^search$/i); - expect(byDate).toBeTruthy(); - expect(search).toBeTruthy(); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(/by date/i)).toBeNull(); + expect(screen.queryByText(/^search$/i)).toBeNull(); + }); + + it("TC-08: renders the Library heading", async () => { + renderBandPage(); + const heading = await screen.findByText("Library"); + expect(heading).toBeTruthy(); + }); + + it("TC-09: renders filter pills including All and Guitar", async () => { + renderBandPage(); + const allPill = await screen.findByText("all"); + const guitarPill = await screen.findByText("guitar"); + expect(allPill).toBeTruthy(); + expect(guitarPill).toBeTruthy(); }); }); diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index 2a6de0b..6dfb29c 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getBand } from "../api/bands"; @@ -21,34 +21,37 @@ interface SessionSummary { recording_count: number; } +type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented"; + +const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"]; + function formatDate(iso: string): string { - const d = new Date(iso); + const d = new Date(iso + "T12:00:00"); return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } -function weekday(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { weekday: "short" }); +function formatDateLabel(iso: string): string { + const d = new Date(iso + "T12:00:00"); + const today = new Date(); + today.setHours(12, 0, 0, 0); + const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 0) { + return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } 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 [newTitle, setNewTitle] = useState(""); const [error, setError] = useState(null); const [scanning, setScanning] = useState(false); const [scanProgress, setScanProgress] = useState(null); const [scanMsg, setScanMsg] = useState(null); - - // 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 [librarySearch, setLibrarySearch] = useState(""); + const [activePill, setActivePill] = useState("all"); const { data: band, isLoading } = useQuery({ queryKey: ["band", bandId], @@ -59,35 +62,41 @@ export function BandPage() { const { data: sessions } = useQuery({ queryKey: ["sessions", bandId], queryFn: () => api.get(`/bands/${bandId}/sessions`), - enabled: !!bandId && tab === "dates", + enabled: !!bandId, }); const { data: unattributedSongs } = useQuery({ queryKey: ["songs-unattributed", bandId], queryFn: () => api.get(`/bands/${bandId}/songs/search?unattributed=true`), - enabled: !!bandId && tab === "dates", + 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 filteredSessions = useMemo(() => { + return (sessions ?? []).filter((s) => { + if (!librarySearch) return true; + const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase(); + return haystack.includes(librarySearch.toLowerCase()); + }); + }, [sessions, librarySearch]); - 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 filteredUnattributed = useMemo(() => { + return (unattributedSongs ?? []).filter((song) => { + const matchesSearch = + !librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase()); + const matchesPill = + activePill === "all" || + activePill === "commented" || + song.tags.some((t) => t.toLowerCase() === activePill); + return matchesSearch && matchesPill; + }); + }, [unattributedSongs, librarySearch, activePill]); const createMutation = useMutation({ - mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }), + mutationFn: () => api.post(`/bands/${bandId}/songs`, { title: newTitle }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["sessions", bandId] }); setShowCreate(false); - setTitle(""); + setNewTitle(""); setError(null); }, onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"), @@ -152,533 +161,304 @@ export function BandPage() { } } - 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
; + const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0; + return ( -
- {/* ── Page header ───────────────────────────────────────── */} -
-
-

{band.name}

- {band.genre_tags.length > 0 && ( -
- {band.genre_tags.map((t: string) => ( - - {t} - - ))} -
- )} +
+ + {/* ── Header ─────────────────────────────────────────────── */} +
+ {/* Title row + search + actions */} +
+

+ Library +

+ + {/* Search input */} +
+ + + + + setLibrarySearch(e.target.value)} + placeholder="Search recordings, comments…" + style={{ + width: "100%", + padding: "7px 12px 7px 30px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 7, + color: "#e2e2e8", + fontSize: 13, + fontFamily: "inherit", + outline: "none", + boxSizing: "border-box", + }} + onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")} + onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")} + /> +
+ +
+ + +
-
- - + {/* Filter pills */} +
+ {PILLS.map((pill) => { + const active = activePill === pill; + return ( + + ); + })}
{/* ── Scan feedback ─────────────────────────────────────── */} {scanning && scanProgress && ( -
- {scanProgress} +
+
+ {scanProgress} +
)} {scanMsg && ( -
- {scanMsg} -
- )} - - {/* ── New song form ─────────────────────────────────────── */} - {showCreate && ( -
- {error &&

{error}

} - - setTitle(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} - style={{ - width: "100%", - padding: "8px 12px", - background: "rgba(255,255,255,0.05)", - border: "1px solid rgba(255,255,255,0.08)", - borderRadius: 7, - color: "#eeeef2", - marginBottom: 12, - fontSize: 14, - fontFamily: "inherit", - boxSizing: "border-box", - outline: "none", - }} - autoFocus - /> -
- - +
+
+ {scanMsg}
)} - {/* ── Tabs ──────────────────────────────────────────────── */} -
- {(["dates", "search"] as const).map((t) => ( - - ))} -
+ {/* ── New song / upload form ─────────────────────────────── */} + {showCreate && ( +
+
+ {error &&

{error}

} + + setNewTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && newTitle && createMutation.mutate()} + style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", marginBottom: 12, fontSize: 14, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} + autoFocus + /> +
+ + +
+
+
+ )} - {/* ── By Date tab ───────────────────────────────────────── */} - {tab === "dates" && ( -
- {sessions?.map((s) => ( + {/* ── Scrollable content ────────────────────────────────── */} +
+ + {/* Sessions — one date group per session */} + {filteredSessions.map((s) => ( +
+ {/* Date group header */} +
+ + {formatDateLabel(s.date)}{s.label ? ` — ${s.label}` : ""} + +
+ + {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} + +
+ + {/* Session row */} { - (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)"; - (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)"; + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)"; - (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)"; }} > -
- - {weekday(s.date)} - - {formatDate(s.date)} - {s.label && ( - - {s.label} - - )} + {/* Play button circle */} +
+ + +
- - {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} + + {/* Session name */} + + {s.label ?? formatDate(s.date)} + + + {/* Recording count */} + + {s.recording_count} - ))} - - {sessions?.length === 0 && !unattributedSongs?.length && ( -

- No sessions yet. Scan Nextcloud or create a song to get started. -

- )} - - {/* Unattributed songs */} - {!!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" && ( -
-
-
-
- - 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: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- - setSearchKey(e.target.value)} - placeholder="e.g. Am, C, F#" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- - setSearchBpmMin(e.target.value)} - type="number" - min={0} - placeholder="e.g. 80" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- - setSearchBpmMax(e.target.value)} - type="number" - min={0} - placeholder="e.g. 140" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- -
- -
- {searchTags.map((t) => ( - - {t} - - - ))} -
-
- setSearchTagInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && addTag()} - placeholder="Add tag…" - style={{ flex: 1, padding: "6px 10px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 12, fontFamily: "inherit", outline: "none" }} - /> - -
-
- -
+ ))} - {searchFetching &&

Searching…

} - {!searchFetching && searchDirty && ( -
- {searchResults?.map((song) => ( + {/* Unattributed recordings */} + {filteredUnattributed.length > 0 && ( +
0 ? 28 : 18 }}> + {/* Section header */} +
+ + Unattributed + +
+ + {filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""} + +
+ +
+ {filteredUnattributed.map((song) => ( { + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)"; }} > -
-
{song.title}
+
+ + + +
+ +
+
+ {song.title} +
{song.tags.map((t) => ( - {t} + + {t} + ))} {song.global_key && ( - {song.global_key} + + {song.global_key} + )} {song.global_bpm && ( - {song.global_bpm.toFixed(0)} BPM + + {song.global_bpm.toFixed(0)} BPM + )}
-
- {song.status} - {song.version_count} version{song.version_count !== 1 ? "s" : ""} -
+ + + {song.version_count} ver{song.version_count !== 1 ? "s" : ""} + ))} - {searchResults?.length === 0 && ( -

No songs match your filters.

- )}
- )} - {!searchDirty && ( -

Enter filters above and hit Search.

- )} -
- )} +
+ )} + + {/* Empty state */} + {!hasResults && ( +

+ {librarySearch + ? "No results match your search." + : "No sessions yet. Scan Nextcloud or create a song to get started."} +

+ )} +
); } + diff --git a/web/src/pages/SessionPage.tsx b/web/src/pages/SessionPage.tsx index 52e65ea..e2e8173 100644 --- a/web/src/pages/SessionPage.tsx +++ b/web/src/pages/SessionPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; @@ -24,10 +24,29 @@ interface SessionDetail { } function formatDate(iso: string): string { - const d = new Date(iso); + const d = new Date(iso + "T12:00:00"); return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); } +function computeWaveBars(seed: string): number[] { + let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337); + return Array.from({ length: 14 }, () => { + s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0; + return Math.max(15, Math.floor((s / 0xffffffff) * 100)); + }); +} + +function MiniWaveBars({ seed }: { seed: string }) { + const bars = useMemo(() => computeWaveBars(seed), [seed]); + return ( +
+ {bars.map((h, i) => ( +
+ ))} +
+ ); +} + export function SessionPage() { const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>(); const qc = useQueryClient(); @@ -165,9 +184,12 @@ export function SessionPage() { )}
-
- {song.status} - {song.version_count} version{song.version_count !== 1 ? "s" : ""} +
+ +
+ {song.status} + {song.version_count} version{song.version_count !== 1 ? "s" : ""} +
))}