Move band management into dedicated settings pages

- Add BandSettingsPage (/bands/:id/settings/:panel) with Members,
  Storage, and Band Settings panels matching the mockup design
- Strip members list, invite controls, and NC folder config from
  BandPage — library view now focuses purely on recordings workflow
- Add band-scoped nav section to AppShell sidebar (Members, Storage,
  Band Settings) with correct per-panel active states
- Fix amAdmin bug: was checking if any member is admin; now correctly
  checks if the current user holds the admin role
- Add 31 vitest tests covering BandPage cleanliness, routing, access
  control (admin vs member), and per-panel mutation behaviour
- Add test:web, test:api:unit, test:feature (post-feature pipeline),
  and ci tasks to Taskfile; frontend tests run via podman node:20-alpine
- Add README with architecture overview, setup guide, and test docs
- Add @testing-library/dom and @testing-library/jest-dom to package.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-01 14:55:10 +02:00
parent 69c614cf62
commit 16bfdd2e90
12 changed files with 2428 additions and 465 deletions

View File

@@ -3,7 +3,6 @@ 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";
import { InviteManagement } from "../components/InviteManagement";
interface SongSummary {
id: string;
@@ -15,21 +14,6 @@ interface SongSummary {
version_count: number;
}
interface BandMember {
id: string;
display_name: string;
email: string;
role: string;
joined_at: string;
}
interface BandInvite {
id: string;
token: string;
role: string;
expires_at: string;
}
interface SessionSummary {
id: string;
date: string;
@@ -56,9 +40,6 @@ export function BandPage() {
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [editingFolder, setEditingFolder] = useState(false);
const [folderInput, setFolderInput] = useState("");
// Search state
const [searchQ, setSearchQ] = useState("");
@@ -87,12 +68,6 @@ export function BandPage() {
enabled: !!bandId && tab === "dates",
});
const { data: members } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
// Search results — only fetch when user has triggered a search
const searchParams = new URLSearchParams();
if (searchQ) searchParams.set("q", searchQ);
@@ -127,7 +102,6 @@ export function BandPage() {
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
try {
// credentials: "include" sends the rh_token httpOnly cookie automatically
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => resp.statusText);
@@ -178,31 +152,6 @@ export function BandPage() {
}
}
const inviteMutation = useMutation({
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
onSuccess: (invite) => {
const url = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(url);
navigator.clipboard.writeText(url).catch(() => {});
},
});
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
const updateFolderMutation = useMutation({
mutationFn: (nc_folder_path: string) =>
api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
setEditingFolder(false);
},
});
const amAdmin = members?.some((m) => m.role === "admin") ?? false;
function addTag() {
const t = searchTagInput.trim();
if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]);
@@ -217,405 +166,292 @@ export function BandPage() {
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
return (
<div style={{ padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
{/* Band header */}
<div style={{ marginBottom: 24 }}>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
<div style={{ padding: "20px 32px", maxWidth: 760, margin: "0 auto" }}>
{/* ── Page header ───────────────────────────────────────── */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div>
<h1 style={{ color: "#eeeef2", fontSize: 17, fontWeight: 500, margin: "0 0 4px" }}>{band.name}</h1>
{band.genre_tags.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
<div style={{ display: "flex", gap: 4, marginTop: 6 }}>
{band.genre_tags.map((t: string) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
<span
key={t}
style={{
background: "rgba(140,90,220,0.1)",
color: "#a878e8",
fontSize: 10,
padding: "1px 7px",
borderRadius: 12,
}}
>
{t}
</span>
))}
</div>
)}
</div>
{/* Nextcloud folder */}
<div style={{ marginBottom: 24, background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>NEXTCLOUD SCAN FOLDER</span>
<div style={{ fontFamily: "monospace", color: "var(--teal)", fontSize: 13, marginTop: 4 }}>
{band.nc_folder_path ?? `bands/${band.slug}/`}
</div>
</div>
{amAdmin && !editingFolder && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingFolder(true); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "4px 10px", fontSize: 11 }}
>
Edit
</button>
)}
</div>
{editingFolder && (
<div style={{ marginTop: 10 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={`bands/${band.slug}/`}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateFolderMutation.mutate(folderInput)}
disabled={updateFolderMutation.isPending}
style={{ background: "var(--teal)", border: "none", borderRadius: 6, color: "var(--bg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
Save
</button>
<button
onClick={() => setEditingFolder(false)}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Cancel
</button>
</div>
</div>
)}
<div style={{ 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: "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
opacity: scanning ? 0.6 : 1,
}}
>
{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: "6px 14px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
}}
>
+ New Song
</button>
</div>
</div>
{/* Members */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
{amAdmin && (
<>
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--accent)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
+ Invite
</button>
{/* Search for users to invite (new feature) */}
{/* Temporarily hide user search until backend supports it */}
</>
)}
</div>
{inviteLink && (
<div style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", borderRadius: 8, padding: "10px 14px", marginBottom: 12 }}>
<p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
<code style={{ color: "var(--accent)", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Dismiss
</button>
</div>
)}
<div style={{ display: "grid", gap: 6 }}>
{members?.map((m) => (
<div
key={m.id}
style={{ background: "var(--bg-subtle)", border: "1px solid var(--border-subtle)", borderRadius: 8, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<div>
<span style={{ fontWeight: 500 }}>{m.display_name}</span>
<span style={{ color: "var(--text-muted)", fontSize: 11, marginLeft: 10 }}>{m.email}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{
fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3,
background: m.role === "admin" ? "var(--accent-bg)" : "var(--bg-inset)",
color: m.role === "admin" ? "var(--accent)" : "var(--text-muted)",
border: `1px solid ${m.role === "admin" ? "var(--accent-border)" : "var(--border)"}`,
}}>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMemberMutation.mutate(m.id)}
style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Remove
</button>
)}
</div>
</div>
))}
</div>
{/* Admin: Invite Management Section (new feature) */}
{amAdmin && <InviteManagement bandId={bandId!} />}
{/* ── Scan feedback ─────────────────────────────────────── */}
{scanning && scanProgress && (
<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",
marginBottom: 10,
fontFamily: "monospace",
}}
>
{scanProgress}
</div>
)}
{scanMsg && (
<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",
marginBottom: 14,
}}
>
{scanMsg}
</div>
)}
{/* Recordings header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Recordings</h2>
{/* ── New song form ─────────────────────────────────────── */}
{showCreate && (
<div
style={{
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>}
<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={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && 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={startScan}
disabled={scanning}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
<button
onClick={() => { setShowCreate(!showCreate); setError(null); }}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
+ New Song
</button>
</div>
</div>
{scanning && scanProgress && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, color: "var(--text-muted)", fontSize: 12, padding: "8px 14px", marginBottom: 8, fontFamily: "monospace" }}>
{scanProgress}
</div>
)}
{scanMsg && (
<div style={{ background: "var(--teal-bg)", border: "1px solid var(--teal)", borderRadius: 8, color: "var(--teal)", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
{scanMsg}
</div>
)}
{showCreate && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 20, marginBottom: 16 }}>
{error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
autoFocus
/>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => createMutation.mutate()}
disabled={!title}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
>
Create
</button>
<button
onClick={() => { setShowCreate(false); setError(null); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
>
Cancel
</button>
</div>
</div>
)}
{/* Tabs */}
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1px solid var(--border)" }}>
{(["dates", "search"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
onClick={() => createMutation.mutate()}
disabled={!title}
style={{
background: "none",
border: "none",
borderBottom: `2px solid ${tab === t ? "var(--accent)" : "transparent"}`,
color: tab === t ? "var(--accent)" : "var(--text-muted)",
cursor: "pointer",
padding: "8px 16px",
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,
fontWeight: tab === t ? 600 : 400,
marginBottom: -1,
fontFamily: "inherit",
opacity: title ? 1 : 0.4,
}}
>
{t === "dates" ? "By Date" : "Search"}
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>
)}
{/* By Date tab */}
{tab === "dates" && (
<div style={{ display: "grid", gap: 6 }}>
{sessions?.map((s) => (
<Link
key={s.id}
to={`/bands/${bandId}/sessions/${s.id}`}
{/* ── Tabs ──────────────────────────────────────────────── */}
<div style={{ display: "flex", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 18 }}>
{(["dates", "search"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
background: "none",
border: "none",
borderBottom: `2px solid ${tab === t ? "#e8a22a" : "transparent"}`,
color: tab === t ? "#e8a22a" : "rgba(255,255,255,0.35)",
cursor: "pointer",
padding: "8px 16px",
fontSize: 13,
fontWeight: tab === t ? 600 : 400,
marginBottom: -1,
fontFamily: "inherit",
transition: "color 0.12s",
}}
>
{t === "dates" ? "By Date" : "Search"}
</button>
))}
</div>
{/* ── By Date tab ───────────────────────────────────────── */}
{tab === "dates" && (
<div style={{ display: "grid", gap: 4 }}>
{sessions?.map((s) => (
<Link
key={s.id}
to={`/bands/${bandId}/sessions/${s.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,
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)";
}}
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.05)";
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<span
style={{
fontFamily: "monospace",
color: "rgba(255,255,255,0.28)",
fontSize: 10,
flexShrink: 0,
}}
>
{weekday(s.date)}
</span>
<span style={{ fontWeight: 500, color: "#d8d8e4" }}>{formatDate(s.date)}</span>
{s.label && (
<span style={{ color: "#4dba85", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.label}
</span>
)}
</div>
<span
style={{
background: "var(--bg-subtle)",
border: "1px solid var(--border-subtle)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
color: "rgba(255,255,255,0.28)",
fontSize: 12,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
<div>
<span style={{ fontFamily: "monospace", color: "var(--text-muted)", fontSize: 10, marginRight: 8 }}>{weekday(s.date)}</span>
<span style={{ fontWeight: 500 }}>{formatDate(s.date)}</span>
{s.label && (
<span style={{ color: "var(--teal)", fontSize: 12, marginLeft: 10 }}>{s.label}</span>
)}
</div>
<span style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span>
</Link>
))}
{sessions?.length === 0 && !unattributedSongs?.length && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
No sessions yet. Scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
</p>
)}
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span>
</Link>
))}
{/* Songs not linked to any dated session */}
{!!unattributedSongs?.length && (
<div style={{ marginTop: sessions?.length ? 24 : 0 }}>
<div style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "monospace", letterSpacing: 1, marginBottom: 8 }}>
UNATTRIBUTED RECORDINGS
</div>
<div style={{ display: "grid", gap: 6 }}>
{unattributedSongs.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "var(--bg-subtle)",
border: "1px solid var(--border-subtle)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
</div>
</div>
<span 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>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
</div>
)}
</div>
)}
{sessions?.length === 0 && !unattributedSongs?.length && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "8px 0" }}>
No sessions yet. Scan Nextcloud or create a song to get started.
</p>
)}
{/* Search tab */}
{tab === "search" && (
<div>
{/* Filters */}
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, 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: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, 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: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, 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: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, 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: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
/>
</div>
</div>
{/* Tag filter */}
<div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, 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: "var(--teal-bg)", color: "var(--teal)", fontSize: 11, padding: "2px 8px", borderRadius: 12, fontFamily: "monospace", display: "flex", alignItems: "center", gap: 4 }}
>
{t}
<button
onClick={() => removeTag(t)}
style={{ background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1 }}
>×</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: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 12 }}
/>
<button
onClick={addTag}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 10px", fontSize: 12 }}
>
+
</button>
</div>
</div>
<button
onClick={() => { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); }}
style={{ marginTop: 12, background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "7px 18px", fontSize: 13, fontWeight: 600 }}
{/* Unattributed songs */}
{!!unattributedSongs?.length && (
<div style={{ marginTop: sessions?.length ? 24 : 0 }}>
<div
style={{
color: "rgba(255,255,255,0.2)",
fontSize: 10,
fontFamily: "monospace",
letterSpacing: 1,
textTransform: "uppercase",
marginBottom: 8,
paddingLeft: 2,
}}
>
Search
</button>
</div>
{/* Results */}
{searchFetching && <p style={{ color: "var(--text-muted)", fontSize: 13 }}>Searching</p>}
{!searchFetching && searchDirty && (
<div style={{ display: "grid", gap: 8 }}>
{searchResults?.map((song) => (
Unattributed Recordings
</div>
<div style={{ display: "grid", gap: 4 }}>
{unattributedSongs.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "var(--bg-subtle)",
border: "1px solid var(--border-subtle)",
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "14px 18px",
padding: "13px 16px",
textDecoration: "none",
color: "var(--text)",
color: "#eeeef2",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
@@ -623,36 +459,226 @@ export function BandPage() {
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div>
<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: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
<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: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span>
)}
{song.global_bpm && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span>
)}
</div>
</div>
<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>
<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>
))}
{searchResults?.length === 0 && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No songs match your filters.</p>
)}
</div>
)}
{!searchDirty && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>Enter filters above and hit Search.</p>
)}
</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>
)}
</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 && (
<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>
<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>
))}
{searchResults?.length === 0 && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No songs match your filters.</p>
)}
</div>
)}
{!searchDirty && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Enter filters above and hit Search.</p>
)}
</div>
)}
</div>
);
}