Merge feature/ui-refinement into main

- Library view redesigned to match mockup: unified view with search
  input, filter pills, date-group headers, and recording-row style
- Mini waveform bars moved to SessionPage individual recording rows
- Play buttons removed from Library session rows
- Fixed Invalid Date for API datetime strings (slice to date part)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-06 18:45:38 +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 && (