652 lines
30 KiB
TypeScript
652 lines
30 KiB
TypeScript
import { useState } 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 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;
|
||
label: string | null;
|
||
recording_count: number;
|
||
}
|
||
|
||
function formatDate(iso: string): string {
|
||
const d = new Date(iso);
|
||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||
}
|
||
|
||
function weekday(iso: string): string {
|
||
return new Date(iso).toLocaleDateString(undefined, { weekday: "short" });
|
||
}
|
||
|
||
export function BandPage() {
|
||
const { bandId } = useParams<{ bandId: string }>();
|
||
const qc = useQueryClient();
|
||
const [tab, setTab] = useState<"dates" | "search">("dates");
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [title, setTitle] = 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 [inviteLink, setInviteLink] = useState<string | null>(null);
|
||
const [editingFolder, setEditingFolder] = useState(false);
|
||
const [folderInput, setFolderInput] = useState("");
|
||
|
||
// Search state
|
||
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({
|
||
queryKey: ["band", bandId],
|
||
queryFn: () => getBand(bandId!),
|
||
enabled: !!bandId,
|
||
});
|
||
|
||
const { data: sessions } = useQuery({
|
||
queryKey: ["sessions", bandId],
|
||
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
||
enabled: !!bandId && tab === "dates",
|
||
});
|
||
|
||
const { data: unattributedSongs } = useQuery({
|
||
queryKey: ["songs-unattributed", bandId],
|
||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
|
||
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);
|
||
if (searchKey) searchParams.set("key", searchKey);
|
||
if (searchBpmMin) searchParams.set("bpm_min", searchBpmMin);
|
||
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,
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||
setShowCreate(false);
|
||
setTitle("");
|
||
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 token = localStorage.getItem("rh_token");
|
||
const url = `/api/v1/bands/${bandId}/nc-scan/stream${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||
|
||
try {
|
||
const resp = await fetch(url);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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]);
|
||
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 (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||
|
||
return (
|
||
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
|
||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||
<Link to="/" style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
|
||
← All Bands
|
||
</Link>
|
||
|
||
{/* Band header */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
||
{band.genre_tags.length > 0 && (
|
||
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
|
||
{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>
|
||
))}
|
||
</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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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>
|
||
|
||
{/* 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>
|
||
<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>
|
||
</div>
|
||
|
||
{inviteLink && (
|
||
<div style={{ background: "var(--accent-bg)", border: "1px solid var(--accent)", borderRadius: 6, 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)", borderRadius: 6, 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)" : "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>
|
||
</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>
|
||
<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: 6, 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: 6, 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: 6, 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)}
|
||
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",
|
||
fontSize: 13,
|
||
fontWeight: tab === t ? 600 : 400,
|
||
marginBottom: -1,
|
||
}}
|
||
>
|
||
{t === "dates" ? "By Date" : "Search"}
|
||
</button>
|
||
))}
|
||
</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}`}
|
||
style={{
|
||
background: "var(--bg-inset)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 8,
|
||
padding: "14px 18px",
|
||
textDecoration: "none",
|
||
color: "var(--text)",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<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>
|
||
)}
|
||
|
||
{/* 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-inset)",
|
||
border: "1px solid var(--border)",
|
||
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>
|
||
)}
|
||
|
||
{/* 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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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: 6, 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 }}
|
||
>
|
||
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) => (
|
||
<Link
|
||
key={song.id}
|
||
to={`/bands/${bandId}/songs/${song.id}`}
|
||
style={{
|
||
background: "var(--bg-inset)",
|
||
border: "1px solid var(--border)",
|
||
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>
|
||
))}
|
||
{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>
|
||
{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>
|
||
</div>
|
||
);
|
||
}
|