3 Commits

Author SHA1 Message Date
Mistral Vibe
659598913b Remove play button from Library session rows
Play buttons don't make sense at the session level since sessions
group multiple recordings. Removed from both session rows and
unattributed song rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:44:02 +02:00
Mistral Vibe
013a2fc2d6 Fix Invalid Date for datetime strings from API
The API returns dates as "2024-12-11T00:00:00" (datetime, no timezone),
not bare "2024-12-11". Appending T12:00:00 directly produced an invalid
string. Use .slice(0, 10) to extract the date part first before parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:40:38 +02:00
Mistral Vibe
b09094658c Redesign Library view to match mockup spec
- Replace band-name header + tab structure (By Date / Search) with a
  unified Library view: title, inline search input, filter pills
  (All / instrument / Commented), and date-group headers
- Session rows now use the recording-row card style (play circle,
  mono filename, recording count)
- Move mini waveform bars from session list to individual recording
  rows in SessionPage, where they correspond to a single track
- Fix Invalid Date by appending T12:00:00 when parsing date-only
  ISO strings in both BandPage and SessionPage
- Update tests: drop tab assertions (TC-07), add Library heading
  (TC-08) and filter pill (TC-09) checks, update upload button label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:37:49 +02:00
3 changed files with 320 additions and 520 deletions

View File

@@ -43,14 +43,13 @@ const renderBandPage = () =>
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
describe("BandPage — cleanliness (TC-01 to TC-07)", () => { describe("BandPage — Library view (TC-01 to TC-09)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("TC-01: does not render a member list", async () => { it("TC-01: does not render a member list", async () => {
renderBandPage(); renderBandPage();
// Allow queries to settle
await new Promise((r) => setTimeout(r, 50)); await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/members/i)).toBeNull(); 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 () => { it("TC-04: renders sessions grouped by date", async () => {
renderBandPage(); renderBandPage();
// Sessions appear after the query resolves
const sessionEl = await screen.findByText("Late Night Jam"); const sessionEl = await screen.findByText("Late Night Jam");
expect(sessionEl).toBeTruthy(); expect(sessionEl).toBeTruthy();
}); });
@@ -81,17 +79,30 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
expect(btn).toBeTruthy(); expect(btn).toBeTruthy();
}); });
it("TC-06: renders the + New Song button", async () => { it("TC-06: renders the + Upload button", async () => {
renderBandPage(); renderBandPage();
const btn = await screen.findByText(/\+ new song/i); const btn = await screen.findByText(/\+ upload/i);
expect(btn).toBeTruthy(); 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(); renderBandPage();
const byDate = await screen.findByText(/by date/i); await new Promise((r) => setTimeout(r, 50));
const search = await screen.findByText(/^search$/i); expect(screen.queryByText(/by date/i)).toBeNull();
expect(byDate).toBeTruthy(); expect(screen.queryByText(/^search$/i)).toBeNull();
expect(search).toBeTruthy(); });
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();
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands"; import { getBand } from "../api/bands";
@@ -21,34 +21,37 @@ interface SessionSummary {
recording_count: number; 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 { function formatDate(iso: string): string {
const d = new Date(iso); const d = new Date(iso.slice(0, 10) + "T12:00:00");
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
} }
function weekday(iso: string): string { function formatDateLabel(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { weekday: "short" }); const d = new Date(iso.slice(0, 10) + "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() { 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 [newTitle, setNewTitle] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null); const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null); const [scanMsg, setScanMsg] = useState<string | null>(null);
const [librarySearch, setLibrarySearch] = useState("");
// Search state const [activePill, setActivePill] = useState<FilterPill>("all");
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],
@@ -59,35 +62,41 @@ export function BandPage() {
const { data: sessions } = useQuery({ const { data: sessions } = useQuery({
queryKey: ["sessions", bandId], queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`), queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId && tab === "dates", enabled: !!bandId,
}); });
const { data: unattributedSongs } = useQuery({ const { data: unattributedSongs } = useQuery({
queryKey: ["songs-unattributed", bandId], queryKey: ["songs-unattributed", bandId],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`), queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
enabled: !!bandId && tab === "dates", enabled: !!bandId,
}); });
// Search results — only fetch when user has triggered a search const filteredSessions = useMemo(() => {
const searchParams = new URLSearchParams(); return (sessions ?? []).filter((s) => {
if (searchQ) searchParams.set("q", searchQ); if (!librarySearch) return true;
if (searchKey) searchParams.set("key", searchKey); const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
if (searchBpmMin) searchParams.set("bpm_min", searchBpmMin); return haystack.includes(librarySearch.toLowerCase());
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,
}); });
}, [sessions, librarySearch]);
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({ const createMutation = useMutation({
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }), mutationFn: () => api.post(`/bands/${bandId}/songs`, { title: newTitle }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", bandId] }); qc.invalidateQueries({ queryKey: ["sessions", bandId] });
setShowCreate(false); setShowCreate(false);
setTitle(""); setNewTitle("");
setError(null); setError(null);
}, },
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"), onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
@@ -152,46 +161,53 @@ 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 <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>;
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
return ( return (
<div style={{ padding: "20px 32px", maxWidth: 760, margin: "0 auto" }}> <div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
{/* ── Page header ───────────────────────────────────────── */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}> {/* ── Header ─────────────────────────────────────────────── */}
<div> <div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h1 style={{ color: "#eeeef2", fontSize: 17, fontWeight: 500, margin: "0 0 4px" }}>{band.name}</h1> {/* Title row + search + actions */}
{band.genre_tags.length > 0 && ( <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
<div style={{ display: "flex", gap: 4, marginTop: 6 }}> <h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
{band.genre_tags.map((t: string) => ( Library
<span </h1>
key={t}
style={{ {/* Search input */}
background: "rgba(140,90,220,0.1)", <div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
color: "#a878e8", <svg
fontSize: 10, style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
padding: "1px 7px", width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
borderRadius: 12,
}}
> >
{t} <circle cx="5.5" cy="5.5" r="3.5" />
</span> <path d="M8.5 8.5l3 3" strokeLinecap="round" />
))} </svg>
</div> <input
)} value={librarySearch}
onChange={(e) => 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)")}
/>
</div> </div>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}> <div style={{ marginLeft: "auto", display: "flex", gap: 8, flexShrink: 0 }}>
<button <button
onClick={startScan} onClick={startScan}
disabled={scanning} disabled={scanning}
@@ -199,12 +215,11 @@ export function BandPage() {
background: "none", background: "none",
border: "1px solid rgba(255,255,255,0.09)", border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6, borderRadius: 6,
color: "#4dba85", color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
cursor: scanning ? "default" : "pointer", cursor: scanning ? "default" : "pointer",
padding: "6px 14px", padding: "5px 12px",
fontSize: 12, fontSize: 12,
fontFamily: "inherit", fontFamily: "inherit",
opacity: scanning ? 0.6 : 1,
}} }}
> >
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"} {scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
@@ -217,468 +232,220 @@ export function BandPage() {
borderRadius: 6, borderRadius: 6,
color: "#e8a22a", color: "#e8a22a",
cursor: "pointer", cursor: "pointer",
padding: "6px 14px", padding: "5px 12px",
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
fontFamily: "inherit", fontFamily: "inherit",
}} }}
> >
+ New Song + Upload
</button> </button>
</div> </div>
</div> </div>
{/* Filter pills */}
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", paddingBottom: 14 }}>
{PILLS.map((pill) => {
const active = activePill === pill;
return (
<button
key={pill}
onClick={() => setActivePill(pill)}
style={{
padding: "3px 10px",
borderRadius: 20,
cursor: "pointer",
border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
background: active ? "rgba(232,162,42,0.1)" : "transparent",
color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
fontSize: 11,
fontFamily: "inherit",
transition: "all 0.12s",
textTransform: "capitalize",
}}
>
{pill}
</button>
);
})}
</div>
</div>
{/* ── Scan feedback ─────────────────────────────────────── */} {/* ── Scan feedback ─────────────────────────────────────── */}
{scanning && scanProgress && ( {scanning && scanProgress && (
<div <div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
style={{ <div style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, color: "rgba(255,255,255,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8,
color: "rgba(255,255,255,0.42)",
fontSize: 12,
padding: "8px 14px",
marginBottom: 10,
fontFamily: "monospace",
}}
>
{scanProgress} {scanProgress}
</div> </div>
</div>
)} )}
{scanMsg && ( {scanMsg && (
<div <div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
style={{ <div style={{ background: "rgba(61,200,120,0.06)", border: "1px solid rgba(61,200,120,0.25)", borderRadius: 8, color: "#4dba85", fontSize: 12, padding: "8px 14px" }}>
background: "rgba(61,200,120,0.06)",
border: "1px solid rgba(61,200,120,0.25)",
borderRadius: 8,
color: "#4dba85",
fontSize: 12,
padding: "8px 14px",
marginBottom: 14,
}}
>
{scanMsg} {scanMsg}
</div> </div>
</div>
)} )}
{/* ── New song form ─────────────────────────────────────── */} {/* ── New song / upload form ─────────────────────────────── */}
{showCreate && ( {showCreate && (
<div <div style={{ padding: "14px 26px 0", flexShrink: 0 }}>
style={{ <div style={{ background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: 18 }}>
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8,
padding: 20,
marginBottom: 18,
}}
>
{error && <p style={{ color: "#e07070", fontSize: 13, marginBottom: 12 }}>{error}</p>} {error && <p style={{ color: "#e07070", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 6 }}> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 6 }}>
Song title Song title
</label> </label>
<input <input
value={title} value={newTitle}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} onKeyDown={(e) => e.key === "Enter" && newTitle && createMutation.mutate()}
style={{ 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" }}
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 autoFocus
/> />
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!title} disabled={!newTitle}
style={{ style={{ background: "rgba(232,162,42,0.14)", border: "1px solid rgba(232,162,42,0.28)", borderRadius: 6, color: "#e8a22a", cursor: newTitle ? "pointer" : "default", padding: "7px 18px", fontWeight: 600, fontSize: 13, fontFamily: "inherit", opacity: newTitle ? 1 : 0.4 }}
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: title ? "pointer" : "default",
padding: "7px 18px",
fontWeight: 600,
fontSize: 13,
fontFamily: "inherit",
opacity: title ? 1 : 0.4,
}}
> >
Create Create
</button> </button>
<button <button
onClick={() => { setShowCreate(false); setError(null); }} onClick={() => { setShowCreate(false); setError(null); }}
style={{ style={{ background: "none", border: "1px solid rgba(255,255,255,0.09)", borderRadius: 6, color: "rgba(255,255,255,0.42)", cursor: "pointer", padding: "7px 18px", fontSize: 13, fontFamily: "inherit" }}
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontFamily: "inherit",
}}
> >
Cancel Cancel
</button> </button>
</div> </div>
</div> </div>
</div>
)} )}
{/* ── Tabs ──────────────────────────────────────────────── */} {/* ── Scrollable content ────────────────────────────────── */}
<div style={{ display: "flex", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 18 }}> <div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
{(["dates", "search"] as const).map((t) => (
<button {/* Sessions — one date group per session */}
key={t} {filteredSessions.map((s) => (
onClick={() => setTab(t)} <div key={s.id} style={{ marginTop: 18 }}>
style={{ {/* Date group header */}
background: "none", <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
border: "none", <span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
borderBottom: `2px solid ${tab === t ? "#e8a22a" : "transparent"}`, {formatDateLabel(s.date)}{s.label ? `${s.label}` : ""}
color: tab === t ? "#e8a22a" : "rgba(255,255,255,0.35)", </span>
cursor: "pointer", <div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
padding: "8px 16px", <span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
fontSize: 13, {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
fontWeight: tab === t ? 600 : 400, </span>
marginBottom: -1,
fontFamily: "inherit",
transition: "color 0.12s",
}}
>
{t === "dates" ? "By Date" : "Search"}
</button>
))}
</div> </div>
{/* ── By Date tab ───────────────────────────────────────── */} {/* Session row */}
{tab === "dates" && (
<div style={{ display: "grid", gap: 4 }}>
{sessions?.map((s) => (
<Link <Link
key={s.id}
to={`/bands/${bandId}/sessions/${s.id}`} to={`/bands/${bandId}/sessions/${s.id}`}
style={{ style={{
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "13px 16px",
textDecoration: "none",
color: "#eeeef2",
display: "flex", display: "flex",
justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 12, gap: 11,
padding: "9px 13px",
borderRadius: 8,
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.04)",
textDecoration: "none",
cursor: "pointer",
transition: "background 0.12s, border-color 0.12s", transition: "background 0.12s, border-color 0.12s",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)"; (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)"; (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)"; (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)";
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}> {/* Session name */}
<span <span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
style={{ {s.label ?? formatDate(s.date)}
fontFamily: "monospace",
color: "rgba(255,255,255,0.28)",
fontSize: 10,
flexShrink: 0,
}}
>
{weekday(s.date)}
</span> </span>
<span style={{ fontWeight: 500, color: "#d8d8e4" }}>{formatDate(s.date)}</span>
{s.label && ( {/* Recording count */}
<span style={{ color: "#4dba85", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{s.label} {s.recording_count}
</span>
)}
</div>
<span
style={{
color: "rgba(255,255,255,0.28)",
fontSize: 12,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span> </span>
</Link> </Link>
</div>
))} ))}
{sessions?.length === 0 && !unattributedSongs?.length && ( {/* Unattributed recordings */}
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "8px 0" }}> {filteredUnattributed.length > 0 && (
No sessions yet. Scan Nextcloud or create a song to get started. <div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
</p> {/* Section header */}
)} <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
{/* Unattributed songs */} Unattributed
{!!unattributedSongs?.length && ( </span>
<div style={{ marginTop: sessions?.length ? 24 : 0 }}> <div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
<div <span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
style={{ {filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
color: "rgba(255,255,255,0.2)", </span>
fontSize: 10,
fontFamily: "monospace",
letterSpacing: 1,
textTransform: "uppercase",
marginBottom: 8,
paddingLeft: 2,
}}
>
Unattributed Recordings
</div> </div>
<div style={{ display: "grid", gap: 4 }}>
{unattributedSongs.map((song) => ( <div style={{ display: "grid", gap: 3 }}>
{filteredUnattributed.map((song) => (
<Link <Link
key={song.id} key={song.id}
to={`/bands/${bandId}/songs/${song.id}`} to={`/bands/${bandId}/songs/${song.id}`}
style={{ style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 13px", borderRadius: 8, background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.04)", textDecoration: "none", transition: "background 0.12s, border-color 0.12s" }}
background: "rgba(255,255,255,0.02)", onMouseEnter={(e) => {
border: "1px solid rgba(255,255,255,0.05)", (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
borderRadius: 8, (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
padding: "13px 16px", }}
textDecoration: "none", onMouseLeave={(e) => {
color: "#eeeef2", (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
display: "flex", (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}} }}
> >
<div style={{ minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div> <div style={{ fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginBottom: 3 }}>
{song.title}
</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => ( {song.tags.map((t) => (
<span <span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
key={t}
style={{
background: "rgba(61,200,120,0.08)",
color: "#4dba85",
fontSize: 9,
padding: "1px 6px",
borderRadius: 3,
fontFamily: "monospace",
}}
>
{t} {t}
</span> </span>
))} ))}
</div>
</div>
<div style={{ color: "rgba(255,255,255,0.28)", fontSize: 12, whiteSpace: "nowrap", flexShrink: 0 }}>
<span
style={{
background: "rgba(255,255,255,0.05)",
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>
))}
</div>
</div>
)}
</div>
)}
{/* ── Search tab ────────────────────────────────────────── */}
{tab === "search" && (
<div>
<div
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.06)",
borderRadius: 8,
padding: 16,
marginBottom: 16,
}}
>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", 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: "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" }}
/>
</div>
<div>
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
Key
</label>
<input
value={searchKey}
onChange={(e) => 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" }}
/>
</div>
<div>
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", 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: "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" }}
/>
</div>
<div>
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", 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: "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" }}
/>
</div>
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", 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: "rgba(61,200,120,0.08)",
color: "#4dba85",
fontSize: 11,
padding: "2px 8px",
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{t}
<button
onClick={() => removeTag(t)}
style={{ background: "none", border: "none", color: "#4dba85", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1, fontFamily: "inherit" }}
>
×
</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: "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" }}
/>
<button
onClick={addTag}
style={{ background: "none", border: "1px solid rgba(255,255,255,0.09)", borderRadius: 6, color: "rgba(255,255,255,0.42)", cursor: "pointer", padding: "6px 10px", fontSize: 12, fontFamily: "inherit" }}
>
+
</button>
</div>
</div>
<button
onClick={() => { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); }}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
}}
>
Search
</button>
</div>
{searchFetching && <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Searching</p>}
{!searchFetching && searchDirty && (
<div style={{ display: "grid", gap: 6 }}>
{searchResults?.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "13px 16px",
textDecoration: "none",
color: "#eeeef2",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
{song.global_key && ( {song.global_key && (
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span> <span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
{song.global_key}
</span>
)} )}
{song.global_bpm && ( {song.global_bpm && (
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span> <span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
{song.global_bpm.toFixed(0)} BPM
</span>
)} )}
</div> </div>
</div> </div>
<div style={{ color: "rgba(255,255,255,0.28)", fontSize: 12, whiteSpace: "nowrap", flexShrink: 0 }}>
<span style={{ background: "rgba(255,255,255,0.05)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span> <span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{song.version_count} version{song.version_count !== 1 ? "s" : ""} {song.version_count} ver{song.version_count !== 1 ? "s" : ""}
</div> </span>
</Link> </Link>
))} ))}
{searchResults?.length === 0 && ( </div>
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No songs match your filters.</p>
)}
</div> </div>
)} )}
{!searchDirty && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Enter filters above and hit Search.</p> {/* Empty state */}
{!hasResults && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "24px 0 8px" }}>
{librarySearch
? "No results match your search."
: "No sessions yet. Scan Nextcloud or create a song to get started."}
</p>
)} )}
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client"; import { api } from "../api/client";
@@ -24,10 +24,29 @@ interface SessionDetail {
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
const d = new Date(iso); const d = new Date(iso.slice(0, 10) + "T12:00:00");
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); 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 (
<div style={{ display: "flex", alignItems: "flex-end", gap: "1.5px", height: 18, width: 34, flexShrink: 0 }}>
{bars.map((h, i) => (
<div key={i} style={{ width: 2, background: "rgba(255,255,255,0.11)", borderRadius: 1, height: `${h}%` }} />
))}
</div>
);
}
export function SessionPage() { export function SessionPage() {
const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>(); const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -165,10 +184,13 @@ export function SessionPage() {
)} )}
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<MiniWaveBars seed={song.id} />
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}> <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> <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" : ""} {song.version_count} version{song.version_count !== 1 ? "s" : ""}
</div> </div>
</div>
</Link> </Link>
))} ))}
{session.songs.length === 0 && ( {session.songs.length === 0 && (