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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user