Files
rehearshalhub/web/src/pages/BandPage.tsx
2026-04-08 15:10:52 +02:00

291 lines
13 KiB
TypeScript
Executable File

import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery } 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 [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]);
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>
{/* 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>
{/* ── 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. Go to Storage settings to scan your Nextcloud folder."}
</p>
)}
</div>
</div>
);
}