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>
452 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|