Files
rehearshalhub/web/src/pages/BandPage.tsx
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

452 lines
20 KiB
TypeScript

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";
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 SessionSummary {
id: string;
date: string;
label: string | null;
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.slice(0, 10) + "T12:00:00");
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function formatDateLabel(iso: string): string {
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() {
const { bandId } = useParams<{ bandId: string }>();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const [librarySearch, setLibrarySearch] = useState("");
const [activePill, setActivePill] = useState<FilterPill>("all");
const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId],
queryFn: () => getBand(bandId!),
enabled: !!bandId,
});
const { data: sessions } = useQuery({
queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId,
});
const { data: unattributedSongs } = useQuery({
queryKey: ["songs-unattributed", bandId],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
enabled: !!bandId,
});
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 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: newTitle }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
setShowCreate(false);
setNewTitle("");
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 url = `/api/v1/bands/${bandId}/nc-scan/stream`;
try {
const resp = await fetch(url, { credentials: "include" });
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<string, unknown>;
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);
}
}
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>;
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
{/* ── Header ─────────────────────────────────────────────── */}
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{/* Title row + search + actions */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
Library
</h1>
{/* Search input */}
<div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
<svg
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
>
<circle cx="5.5" cy="5.5" r="3.5" />
<path d="M8.5 8.5l3 3" strokeLinecap="round" />
</svg>
<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 style={{ marginLeft: "auto", display: "flex", gap: 8, flexShrink: 0 }}>
<button
onClick={startScan}
disabled={scanning}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "5px 12px",
fontSize: 12,
fontFamily: "inherit",
}}
>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
<button
onClick={() => { setShowCreate(!showCreate); setError(null); }}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "5px 12px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
}}
>
+ Upload
</button>
</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 ─────────────────────────────────────── */}
{scanning && scanProgress && (
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
<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" }}>
{scanProgress}
</div>
</div>
)}
{scanMsg && (
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
<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" }}>
{scanMsg}
</div>
</div>
)}
{/* ── New song / upload form ─────────────────────────────── */}
{showCreate && (
<div style={{ padding: "14px 26px 0", flexShrink: 0 }}>
<div style={{ background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: 18 }}>
{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 }}>
Song title
</label>
<input
value={newTitle}
onChange={(e) => 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
/>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => createMutation.mutate()}
disabled={!newTitle}
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 }}
>
Create
</button>
<button
onClick={() => { setShowCreate(false); setError(null); }}
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" }}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* ── Scrollable content ────────────────────────────────── */}
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
{/* Sessions — one date group per session */}
{filteredSessions.map((s) => (
<div key={s.id} style={{ marginTop: 18 }}>
{/* Date group 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" }}>
{formatDateLabel(s.date)}{s.label ? `${s.label}` : ""}
</span>
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span>
</div>
{/* Session row */}
<Link
to={`/bands/${bandId}/sessions/${s.id}`}
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",
cursor: "pointer",
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
(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)";
}}
>
{/* Session name */}
<span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.label ?? formatDate(s.date)}
</span>
{/* Recording count */}
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{s.recording_count}
</span>
</Link>
</div>
))}
{/* Unattributed recordings */}
{filteredUnattributed.length > 0 && (
<div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
{/* 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
</span>
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
{filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
</span>
</div>
<div style={{ display: "grid", gap: 3 }}>
{filteredUnattributed.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
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" }}
onMouseEnter={(e) => {
(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)";
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<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" }}>
{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 && (
<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 && (
<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>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{song.version_count} ver{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
</div>
)}
{/* 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>
);
}