feat: band NC folder config, fix watcher event filter, add light/dark theme
- Add PATCH /bands/{id} endpoint for admins to update nc_folder_path
- Add band NC scan folder UI panel with inline edit
- Fix watcher: use activity type field (not human-readable subject) for upload detection
- Reorder watcher filters: audio extension check first, then band path, then type
- Add dark/light theme toggle using GitHub Primer-inspired CSS custom properties
- All inline styles migrated to CSS variables for theme-awareness
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,8 @@ export function BandPage() {
|
||||
const [error, setError] = 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("");
|
||||
|
||||
const { data: band, isLoading } = useQuery({
|
||||
queryKey: ["band", bandId],
|
||||
@@ -92,56 +94,104 @@ export function BandPage() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
|
||||
});
|
||||
|
||||
// We determine "am I admin?" from GET /auth/me cross-referenced with the members list.
|
||||
// The simplest heuristic: the creator of the band (first admin in the list) is the current user
|
||||
// if they appear with role=admin. We store the current member id in the JWT subject but don't
|
||||
// expose it yet, so we compare by checking if the members list has exactly one admin and we
|
||||
// can tell by the invite button being available on the backend (403 vs 201).
|
||||
// For the UI we just show the Remove button for non-admin members and let the API enforce auth.
|
||||
|
||||
if (isLoading) return <div style={{ color: "#5A6480", padding: 32 }}>Loading...</div>;
|
||||
if (!band) return <div style={{ color: "#E85878", padding: 32 }}>Band not found</div>;
|
||||
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;
|
||||
|
||||
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: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 32 }}>
|
||||
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
|
||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||
<Link to="/" style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
|
||||
<Link to="/" style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
|
||||
← All Bands
|
||||
</Link>
|
||||
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
||||
{/* ── 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: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
||||
<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: "#E2E6F0", margin: 0, fontSize: 16 }}>Members</h2>
|
||||
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
|
||||
<button
|
||||
onClick={() => inviteMutation.mutate()}
|
||||
disabled={inviteMutation.isPending}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#F0A840", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
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: "#0E1118", border: "1px solid #F0A840", borderRadius: 6, padding: "10px 14px", marginBottom: 12 }}>
|
||||
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
|
||||
<code style={{ color: "#F0A840", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
|
||||
<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: "#5A6480", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -152,25 +202,25 @@ export function BandPage() {
|
||||
{members?.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 6, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
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: "#5A6480", fontSize: 11, marginLeft: 10 }}>{m.email}</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" ? "#2A1E08" : "#0E1118",
|
||||
color: m.role === "admin" ? "#F0A840" : "#5A6480",
|
||||
border: `1px solid ${m.role === "admin" ? "#F0A840" : "#1C2235"}`,
|
||||
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: "#E85878", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@@ -183,18 +233,18 @@ export function BandPage() {
|
||||
|
||||
{/* ── Songs ── */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Songs</h2>
|
||||
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Songs</h2>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={scanMutation.isPending}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#38C9A8", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
{scanMutation.isPending ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(!showCreate); setError(null); }}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
|
||||
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
+ New Song
|
||||
</button>
|
||||
@@ -202,36 +252,36 @@ export function BandPage() {
|
||||
</div>
|
||||
|
||||
{scanMsg && (
|
||||
<div style={{ background: "#0A2820", border: "1px solid #38C9A8", borderRadius: 6, color: "#38C9A8", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
|
||||
<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: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 16 }}>
|
||||
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label>
|
||||
<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: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
|
||||
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
|
||||
/>
|
||||
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 12px" }}>
|
||||
A folder <code style={{ color: "#38C9A8" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud.
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 12px" }}>
|
||||
A folder <code style={{ color: "var(--teal)" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!title}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
|
||||
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 #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
|
||||
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -244,18 +294,18 @@ export function BandPage() {
|
||||
<Link
|
||||
key={song.id}
|
||||
to={`/bands/${bandId}/songs/${song.id}`}
|
||||
style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: "14px 18px", textDecoration: "none", color: "#E2E6F0", display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
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" }}
|
||||
>
|
||||
<span>{song.title}</span>
|
||||
<span style={{ color: "#5A6480", fontSize: 12 }}>
|
||||
<span style={{ background: "#0E1118", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 12 }}>
|
||||
<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>
|
||||
))}
|
||||
{songs?.length === 0 && (
|
||||
<p style={{ color: "#5A6480", fontSize: 13 }}>
|
||||
No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "#38C9A8" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||
No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user