view v2 update

This commit is contained in:
Mistral Vibe
2026-04-10 00:34:09 +02:00
parent d73377ec2f
commit 8ea114755a
7 changed files with 972 additions and 1722 deletions

View File

@@ -1,16 +1,17 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { BrowserRouter, Route, Routes, Navigate, useParams, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import "./index.css"; import "./index.css";
import { isLoggedIn } from "./api/client"; import { isLoggedIn } from "./api/client";
import { AppShell } from "./components/AppShell"; import { AppShell } from "./components/AppShell";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage"; import { BandPage } from "./pages/BandPage";
import { BandSettingsPage } from "./pages/BandSettingsPage";
import { SessionPage } from "./pages/SessionPage"; import { SessionPage } from "./pages/SessionPage";
import { SongPage } from "./pages/SongPage"; import { SongPage } from "./pages/SongPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { InvitePage } from "./pages/InvitePage"; import { InvitePage } from "./pages/InvitePage";
import { useBandStore } from "./stores/bandStore";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
@@ -28,6 +29,20 @@ function ShellRoute({ children }: { children: React.ReactNode }) {
); );
} }
// Redirect /bands/:bandId/settings/:panel → /settings?section=:panel, setting bandStore
function BandSettingsRedirect() {
const { bandId, panel } = useParams<{ bandId: string; panel?: string }>();
const navigate = useNavigate();
const { setActiveBandId } = useBandStore();
useEffect(() => {
if (bandId) setActiveBandId(bandId);
navigate(`/settings${panel ? `?section=${panel}` : ""}`, { replace: true });
}, [bandId, panel, navigate, setActiveBandId]);
return null;
}
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -51,18 +66,8 @@ export default function App() {
</ShellRoute> </ShellRoute>
} }
/> />
<Route <Route path="/bands/:bandId/settings" element={<BandSettingsRedirect />} />
path="/bands/:bandId/settings" <Route path="/bands/:bandId/settings/:panel" element={<BandSettingsRedirect />} />
element={<Navigate to="members" replace />}
/>
<Route
path="/bands/:bandId/settings/:panel"
element={
<ShellRoute>
<BandSettingsPage />
</ShellRoute>
}
/>
<Route <Route
path="/bands/:bandId/sessions/:sessionId" path="/bands/:bandId/sessions/:sessionId"
element={ element={

View File

@@ -1,12 +1,13 @@
import { useRef, useState, useEffect } from "react"; import { useState } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom"; import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { api } from "../api/client"; import { api } from "../api/client";
import { logout } from "../api/auth"; import { logout } from "../api/auth";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
import type { MemberRead } from "../api/auth"; import type { MemberRead } from "../api/auth";
import { usePlayerStore } from "../stores/playerStore"; import { usePlayerStore } from "../stores/playerStore";
import { useBandStore } from "../stores/bandStore";
import { TopBandBar } from "./TopBandBar";
// ── Icons ──────────────────────────────────────────────────────────────────── // ── Icons ────────────────────────────────────────────────────────────────────
@@ -36,17 +37,6 @@ function IconPlay() {
); );
} }
function IconMembers() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="7" cy="6.5" r="2.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M1.5 14.5c0-2.761 2.462-4.5 5.5-4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<circle cx="13" cy="11" r="2" stroke="currentColor" strokeWidth="1.4" />
<path d="M16 16c0-1.657-1.343-2.5-3-2.5S10 14.343 10 16" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}
function IconSettings() { function IconSettings() {
return ( return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -56,17 +46,6 @@ function IconSettings() {
); );
} }
function IconStorage() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<rect x="1" y="4" width="16" height="3.5" rx="1.5" />
<rect x="1" y="10.5" width="16" height="3.5" rx="1.5" />
<circle cx="14" cy="5.75" r="0.9" fill="#0c0e1a" />
<circle cx="14" cy="12.25" r="0.9" fill="#0c0e1a" />
</svg>
);
}
function IconSignOut() { function IconSignOut() {
return ( return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
@@ -76,14 +55,6 @@ function IconSignOut() {
); );
} }
function IconChevron() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5l3 3 3-3" />
</svg>
);
}
// ── NavItem ────────────────────────────────────────────────────────────────── // ── NavItem ──────────────────────────────────────────────────────────────────
interface NavItemProps { interface NavItemProps {
@@ -174,20 +145,13 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"), queryFn: () => api.get<MemberRead>("/auth/me"),
}); });
const bandMatch = const { activeBandId } = useBandStore();
matchPath("/bands/:bandId/*", location.pathname) ??
matchPath("/bands/:bandId", location.pathname);
const activeBandId = bandMatch?.params?.bandId ?? null;
const activeBand = bands?.find((b) => b.id === activeBandId) ?? null;
const isLibrary = !!( const isLibrary = !!(
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) || matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
@@ -196,23 +160,10 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
); );
const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname); const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
const isSettings = location.pathname.startsWith("/settings"); const isSettings = location.pathname.startsWith("/settings");
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore(); const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
const hasActiveSong = !!currentSongId && !!playerBandId; const hasActiveSong = !!currentSongId && !!playerBandId;
useEffect(() => {
if (!dropdownOpen) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [dropdownOpen]);
const sidebarWidth = collapsed ? 68 : 230; const sidebarWidth = collapsed ? 68 : 230;
const border = "rgba(255,255,255,0.06)"; const border = "rgba(255,255,255,0.06)";
@@ -260,93 +211,11 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
)} )}
</div> </div>
{/* Band switcher */}
<div ref={dropdownRef} style={{ padding: "10px 12px", borderBottom: `1px solid ${border}`, position: "relative", flexShrink: 0 }}>
<button
onClick={() => setDropdownOpen((o) => !o)}
title={collapsed ? (activeBand?.name ?? "Select band") : undefined}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 8,
padding: "7px 8px",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8, cursor: "pointer", color: "#e8e9f0",
textAlign: "left", fontFamily: "inherit",
transition: "border-color 0.15s",
overflow: "hidden",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.12)")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.07)")}
>
<div style={{
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
background: "rgba(139,92,246,0.15)",
border: "1px solid rgba(139,92,246,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 10, fontWeight: 800, color: "#a78bfa",
}}>
{activeBand ? getInitials(activeBand.name) : "?"}
</div>
{!collapsed && (
<>
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{activeBand?.name ?? "Select a band"}
</span>
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
<IconChevron />
</span>
</>
)}
</button>
{dropdownOpen && (
<div style={{
position: "absolute", top: "calc(100% - 2px)",
left: 12, right: 12,
background: "#1a1e30",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10, padding: 6, zIndex: 100,
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
}}>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => { navigate(`/bands/${band.id}`); setDropdownOpen(false); }}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 8,
padding: "7px 9px", marginBottom: 1,
background: band.id === activeBandId ? "rgba(139,92,246,0.1)" : "transparent",
border: "none", borderRadius: 6, cursor: "pointer",
color: "#e8e9f0", textAlign: "left", fontFamily: "inherit",
}}
>
<div style={{ width: 22, height: 22, borderRadius: 6, background: "rgba(139,92,246,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#a78bfa", flexShrink: 0 }}>
{getInitials(band.name)}
</div>
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "rgba(232,233,240,0.7)" }}>
{band.name}
</span>
{band.id === activeBandId && <span style={{ fontSize: 10, color: "#a78bfa", flexShrink: 0 }}></span>}
</button>
))}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
<button
onClick={() => { navigate("/"); setDropdownOpen(false); }}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", background: "transparent", border: "none", borderRadius: 6, cursor: "pointer", color: "rgba(232,233,240,0.35)", fontSize: 12, textAlign: "left", fontFamily: "inherit" }}
>
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
Create new band
</button>
</div>
</div>
)}
</div>
{/* Navigation */} {/* Navigation */}
<nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}> <nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
{activeBand && ( {activeBandId && (
<> <>
<NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBand.id}`)} collapsed={collapsed} /> <NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBandId}`)} collapsed={collapsed} />
<NavItem <NavItem
icon={<IconPlay />} icon={<IconPlay />}
label="Now Playing" label="Now Playing"
@@ -358,15 +227,6 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
</> </>
)} )}
{activeBand && (
<>
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
<NavItem icon={<IconMembers />} label="Members" active={isBandSettings && bandSettingsPanel === "members"} onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)} collapsed={collapsed} />
<NavItem icon={<IconStorage />} label="Storage" active={isBandSettings && bandSettingsPanel === "storage"} onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)} collapsed={collapsed} />
<NavItem icon={<IconSettings />} label="Band Settings" active={isBandSettings && bandSettingsPanel === "band"} onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)} collapsed={collapsed} />
</>
)}
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} /> <div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
<NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} /> <NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
</nav> </nav>
@@ -409,8 +269,11 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
</aside> </aside>
{/* ── Main content ── */} {/* ── Main content ── */}
<main style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", background: "#0c0e1a", minWidth: 0 }}> <main style={{ flex: 1, overflow: "hidden", display: "grid", gridTemplateRows: "44px 1fr", background: "#0c0e1a", minWidth: 0 }}>
{children} <TopBandBar />
<div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
{children}
</div>
</main> </main>
</div> </div>
); );

View File

@@ -0,0 +1,149 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore";
export function TopBandBar() {
const navigate = useNavigate();
const location = useLocation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { activeBandId, setActiveBandId } = useBandStore();
// Sync store from URL when on a band page
const urlMatch =
matchPath("/bands/:bandId/*", location.pathname) ??
matchPath("/bands/:bandId", location.pathname);
const urlBandId = urlMatch?.params?.bandId ?? null;
useEffect(() => {
if (urlBandId) setActiveBandId(urlBandId);
}, [urlBandId, setActiveBandId]);
const currentBandId = urlBandId ?? activeBandId;
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const border = "rgba(255,255,255,0.06)";
return (
<div style={{
height: 44,
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 8,
padding: "0 20px",
borderBottom: `1px solid ${border}`,
background: "#10131f",
zIndex: 10,
}}>
{/* Band switcher */}
<div ref={ref} style={{ position: "relative" }}>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: "5px 10px",
background: open ? "rgba(255,255,255,0.06)" : "transparent",
border: `1px solid ${open ? "rgba(255,255,255,0.12)" : "transparent"}`,
borderRadius: 8,
cursor: "pointer", color: "#e8e9f0",
fontFamily: "inherit", fontSize: 13,
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => { if (!open) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={(e) => { if (!open) e.currentTarget.style.background = "transparent"; }}
>
{activeBand ? (
<>
<div style={{
width: 22, height: 22, borderRadius: 6, flexShrink: 0,
background: "rgba(139,92,246,0.15)",
border: "1px solid rgba(139,92,246,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 9, fontWeight: 800, color: "#a78bfa",
}}>
{getInitials(activeBand.name)}
</div>
<span style={{ fontWeight: 600, fontSize: 13 }}>{activeBand.name}</span>
</>
) : (
<span style={{ color: "rgba(232,233,240,0.35)", fontSize: 13 }}>Select a band</span>
)}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ color: "rgba(232,233,240,0.3)", marginLeft: 2 }}>
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 6px)", left: 0,
minWidth: 220,
background: "#1a1e30",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10, padding: 6, zIndex: 100,
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => {
setActiveBandId(band.id);
navigate(`/bands/${band.id}`);
setOpen(false);
}}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 8,
padding: "7px 9px", marginBottom: 1,
background: band.id === currentBandId ? "rgba(139,92,246,0.1)" : "transparent",
border: "none", borderRadius: 6,
cursor: "pointer", color: "#e8e9f0",
textAlign: "left", fontFamily: "inherit",
transition: "background 0.12s",
}}
onMouseEnter={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "transparent"; }}
>
<div style={{ width: 22, height: 22, borderRadius: 6, background: "rgba(139,92,246,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#a78bfa", flexShrink: 0 }}>
{getInitials(band.name)}
</div>
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{band.name}
</span>
{band.id === currentBandId && (
<span style={{ fontSize: 10, color: "#a78bfa", flexShrink: 0 }}></span>
)}
</button>
))}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
<button
onClick={() => { navigate("/"); setOpen(false); }}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", background: "transparent", border: "none", borderRadius: 6, cursor: "pointer", color: "rgba(232,233,240,0.35)", fontSize: 12, textAlign: "left", fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.7)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.35)")}
>
<span style={{ fontSize: 14, opacity: 0.6 }}>+</span>
New band
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,320 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../test/helpers";
import { BandSettingsPage } from "./BandSettingsPage";
// ── Shared fixtures ───────────────────────────────────────────────────────────
const ME = { id: "m-me", email: "s@example.com", display_name: "Steffen", avatar_url: null, created_at: "" };
const BAND = {
id: "band-1",
name: "Loud Hands",
slug: "loud-hands",
genre_tags: ["post-rock", "math-rock"],
nc_folder_path: "bands/loud-hands/",
};
const MEMBERS_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "admin", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "member", joined_at: "" },
];
const MEMBERS_NON_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "member", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "admin", joined_at: "" },
];
const INVITES_RESPONSE = {
invites: [
{
id: "inv-1",
token: "abcdef1234567890abcd",
role: "member",
expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
is_used: false,
band_id: "band-1",
created_at: new Date().toISOString(),
used_at: null,
},
],
total: 1,
pending: 1,
};
// ── Mocks ─────────────────────────────────────────────────────────────────────
const {
mockGetBand,
mockApiGet,
mockApiPost,
mockApiPatch,
mockApiDelete,
mockListInvites,
mockRevokeInvite,
} = vi.hoisted(() => ({
mockGetBand: vi.fn(),
mockApiGet: vi.fn(),
mockApiPost: vi.fn(),
mockApiPatch: vi.fn(),
mockApiDelete: vi.fn(),
mockListInvites: vi.fn(),
mockRevokeInvite: vi.fn(),
}));
vi.mock("../api/bands", () => ({ getBand: mockGetBand }));
vi.mock("../api/invites", () => ({
listInvites: mockListInvites,
revokeInvite: mockRevokeInvite,
}));
vi.mock("../api/client", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
patch: mockApiPatch,
delete: mockApiDelete,
},
isLoggedIn: vi.fn().mockReturnValue(true),
}));
// ── Default mock implementations ─────────────────────────────────────────────
afterEach(() => vi.clearAllMocks());
function setupApiGet(members: typeof MEMBERS_ADMIN) {
mockApiGet.mockImplementation((url: string) => {
if (url === "/auth/me") return Promise.resolve(ME);
if (url.includes("/members")) return Promise.resolve(members);
return Promise.resolve([]);
});
}
beforeEach(() => {
mockGetBand.mockResolvedValue(BAND);
setupApiGet(MEMBERS_ADMIN);
mockApiPost.mockResolvedValue({ id: "inv-new", token: "newtoken123", role: "member", expires_at: "" });
mockApiPatch.mockResolvedValue(BAND);
mockApiDelete.mockResolvedValue({});
mockListInvites.mockResolvedValue(INVITES_RESPONSE);
mockRevokeInvite.mockResolvedValue({});
});
// ── Render helpers ────────────────────────────────────────────────────────────
function renderPanel(panel: "members" | "storage" | "band", members = MEMBERS_ADMIN) {
setupApiGet(members);
return renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: `/bands/band-1/settings/${panel}`,
});
}
// ── Routing ───────────────────────────────────────────────────────────────────
describe("BandSettingsPage — routing (TC-15 to TC-17)", () => {
it("TC-15: renders Storage panel for /settings/storage", async () => {
renderPanel("storage");
const heading = await screen.findByRole("heading", { name: /storage/i });
expect(heading).toBeTruthy();
});
it("TC-16: renders Band Settings panel for /settings/band", async () => {
renderPanel("band");
const heading = await screen.findByRole("heading", { name: /band settings/i });
expect(heading).toBeTruthy();
});
it("TC-17: unknown panel falls back to Members", async () => {
mockApiGet.mockResolvedValue(MEMBERS_ADMIN);
renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: "/bands/band-1/settings/unknown-panel",
});
const heading = await screen.findByRole("heading", { name: /members/i });
expect(heading).toBeTruthy();
});
});
// ── Members panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Members panel access control (TC-18 to TC-23)", () => {
it("TC-18: admin sees + Invite button", async () => {
renderPanel("members", MEMBERS_ADMIN);
const btn = await screen.findByText(/\+ invite/i);
expect(btn).toBeTruthy();
});
it("TC-19: non-admin does not see + Invite button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex"); // wait for members to load
expect(screen.queryByText(/\+ invite/i)).toBeNull();
});
it("TC-20: admin sees Remove button on non-admin members", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
expect(removeBtn).toBeTruthy();
});
it("TC-21: non-admin does not see any Remove button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText("Remove")).toBeNull();
});
it("TC-22: admin does not see Remove on admin-role members", async () => {
renderPanel("members", MEMBERS_ADMIN);
await screen.findByText("Steffen");
// Only one Remove button — for Alex (member), not Steffen (admin)
const removeBtns = screen.queryAllByText("Remove");
expect(removeBtns).toHaveLength(1);
});
it("TC-23: Pending Invites section hidden from non-admins", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText(/pending invites/i)).toBeNull();
});
});
// ── Members panel — functionality ─────────────────────────────────────────────
describe("BandSettingsPage — Members panel functionality (TC-24 to TC-28)", () => {
it("TC-24: generate invite shows link in UI", async () => {
const token = "tok123abc456def789gh";
mockApiPost.mockResolvedValue({ id: "inv-new", token, role: "member", expires_at: "" });
renderPanel("members", MEMBERS_ADMIN);
const inviteBtn = await screen.findByText(/\+ invite/i);
fireEvent.click(inviteBtn);
const linkEl = await screen.findByText(new RegExp(token));
expect(linkEl).toBeTruthy();
});
it("TC-26: remove member calls DELETE endpoint", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
fireEvent.click(removeBtn);
await waitFor(() => {
expect(mockApiDelete).toHaveBeenCalledWith("/bands/band-1/members/m-2");
});
});
it("TC-27: revoke invite calls revokeInvite and refetches", async () => {
renderPanel("members", MEMBERS_ADMIN);
const revokeBtn = await screen.findByText("Revoke");
fireEvent.click(revokeBtn);
await waitFor(() => {
expect(mockRevokeInvite).toHaveBeenCalledWith("inv-1");
});
});
});
// ── Storage panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Storage panel access control (TC-29 to TC-33)", () => {
it("TC-29: admin sees Edit button", async () => {
renderPanel("storage", MEMBERS_ADMIN);
const edit = await screen.findByText("Edit");
expect(edit).toBeTruthy();
});
it("TC-30: non-admin does not see Edit button", async () => {
renderPanel("storage", MEMBERS_NON_ADMIN);
await screen.findByText(/scan path/i);
expect(screen.queryByText("Edit")).toBeNull();
});
it("TC-31: saving NC folder path calls PATCH and closes form", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
const input = screen.getByPlaceholderText(/bands\/loud-hands\//i);
fireEvent.change(input, { target: { value: "bands/custom-path/" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith(
"/bands/band-1",
{ nc_folder_path: "bands/custom-path/" }
);
});
});
it("TC-32: cancel edit closes form without calling PATCH", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockApiPatch).not.toHaveBeenCalled();
});
expect(screen.queryByText("Save")).toBeNull();
});
it("TC-33: shows default path when nc_folder_path is null", async () => {
mockGetBand.mockResolvedValueOnce({ ...BAND, nc_folder_path: null });
renderPanel("storage", MEMBERS_ADMIN);
const path = await screen.findByText("bands/loud-hands/");
expect(path).toBeTruthy();
});
});
// ── Band settings panel — access control ──────────────────────────────────────
describe("BandSettingsPage — Band Settings panel access control (TC-34 to TC-40)", () => {
it("TC-34: admin sees Save changes button", async () => {
renderPanel("band", MEMBERS_ADMIN);
const btn = await screen.findByText(/save changes/i);
expect(btn).toBeTruthy();
});
it("TC-35: non-admin sees info text instead of Save button", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
// Wait for the band panel heading so we know the page has fully loaded
await screen.findByRole("heading", { name: /band settings/i });
// Once queries settle, the BandPanel-level info text should appear and Save should be absent
await waitFor(() => {
expect(screen.getByText(/only admins can edit band settings/i)).toBeTruthy();
});
expect(screen.queryByText(/save changes/i)).toBeNull();
});
it("TC-36: name field is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const input = await screen.findByDisplayValue("Loud Hands");
expect((input as HTMLInputElement).disabled).toBe(true);
});
it("TC-37: saving calls PATCH with name and genre_tags", async () => {
renderPanel("band", MEMBERS_ADMIN);
await screen.findByText(/save changes/i);
fireEvent.click(screen.getByText(/save changes/i));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith("/bands/band-1", {
name: "Loud Hands",
genre_tags: ["post-rock", "math-rock"],
});
});
});
it("TC-38: adding a genre tag shows the new pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
const tagInput = await screen.findByPlaceholderText(/add genre tag/i);
fireEvent.change(tagInput, { target: { value: "punk" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
expect(screen.getByText("punk")).toBeTruthy();
});
it("TC-39: removing a genre tag removes its pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
// Find the × button next to "post-rock"
await screen.findByText("post-rock");
// There are two tags; find the × buttons
const removeButtons = screen.getAllByText("×");
fireEvent.click(removeButtons[0]);
expect(screen.queryByText("post-rock")).toBeNull();
});
it("TC-40: Delete band button is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const deleteBtn = await screen.findByText(/delete band/i);
expect((deleteBtn as HTMLButtonElement).disabled).toBe(true);
});
});

View File

@@ -1,961 +0,0 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands";
import { api } from "../api/client";
import { listInvites, revokeInvite } from "../api/invites";
import type { MemberRead } from "../api/auth";
// ── Types ─────────────────────────────────────────────────────────────────────
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 | null;
is_used: boolean;
}
type Panel = "members" | "storage" | "band";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatExpiry(expiresAt: string | null | undefined): string {
if (!expiresAt) return "No expiry";
const date = new Date(expiresAt);
const diffHours = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60));
if (diffHours <= 0) return "Expired";
if (diffHours < 24) return `Expires in ${diffHours}h`;
return `Expires in ${Math.floor(diffHours / 24)}d`;
}
function isActive(invite: BandInvite): boolean {
return !invite.is_used && !!invite.expires_at && new Date(invite.expires_at) > new Date();
}
// ── Panel nav item ────────────────────────────────────────────────────────────
function PanelNavItem({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
width: "100%",
textAlign: "left",
padding: "7px 10px",
borderRadius: 7,
border: "none",
cursor: "pointer",
fontSize: 12,
fontFamily: "inherit",
marginBottom: 1,
background: active
? "rgba(232,162,42,0.1)"
: hovered
? "rgba(255,255,255,0.04)"
: "transparent",
color: active
? "#e8a22a"
: hovered
? "rgba(255,255,255,0.65)"
: "rgba(255,255,255,0.35)",
transition: "background 0.12s, color 0.12s",
}}
>
{label}
</button>
);
}
// ── Section title ─────────────────────────────────────────────────────────────
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div
style={{
fontSize: 11,
fontWeight: 500,
color: "rgba(255,255,255,0.28)",
textTransform: "uppercase",
letterSpacing: "0.7px",
marginBottom: 12,
}}
>
{children}
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "rgba(255,255,255,0.05)", margin: "20px 0" }} />;
}
// ── Members panel ─────────────────────────────────────────────────────────────
function MembersPanel({
bandId,
amAdmin,
members,
membersLoading,
}: {
bandId: string;
amAdmin: boolean;
members: BandMember[] | undefined;
membersLoading: boolean;
}) {
const qc = useQueryClient();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const { data: invitesData, isLoading: invitesLoading } = useQuery({
queryKey: ["invites", bandId],
queryFn: () => listInvites(bandId),
enabled: amAdmin,
retry: false,
});
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(() => {});
qc.invalidateQueries({ queryKey: ["invites", bandId] });
},
});
const removeMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
const revokeMutation = useMutation({
mutationFn: (inviteId: string) => revokeInvite(inviteId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["invites", bandId] }),
});
const activeInvites = invitesData?.invites.filter(isActive) ?? [];
return (
<div>
{/* Member list */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<SectionTitle>Members</SectionTitle>
{amAdmin && (
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "4px 12px",
fontSize: 12,
fontFamily: "inherit",
}}
>
{inviteMutation.isPending ? "Generating…" : "+ Invite"}
</button>
)}
</div>
{inviteLink && (
<div
style={{
background: "rgba(232,162,42,0.06)",
border: "1px solid rgba(232,162,42,0.22)",
borderRadius: 8,
padding: "10px 14px",
marginBottom: 14,
}}
>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 11, margin: "0 0 5px" }}>
Invite link (copied to clipboard · valid 72h):
</p>
<code style={{ color: "#e8a22a", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{
display: "block",
marginTop: 6,
background: "none",
border: "none",
color: "rgba(255,255,255,0.28)",
cursor: "pointer",
fontSize: 11,
padding: 0,
fontFamily: "inherit",
}}
>
Dismiss
</button>
</div>
)}
{membersLoading ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Loading</p>
) : (
<div style={{ display: "grid", gap: 6, marginBottom: 0 }}>
{members?.map((m) => (
<div
key={m.id}
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: "50%",
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{m.display_name.slice(0, 2).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.72)" }}>{m.display_name}</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", marginTop: 1 }}>{m.email}</div>
</div>
<span
style={{
fontSize: 10,
fontFamily: "monospace",
padding: "2px 7px",
borderRadius: 3,
background: m.role === "admin" ? "rgba(232,162,42,0.1)" : "rgba(255,255,255,0.06)",
color: m.role === "admin" ? "#e8a22a" : "rgba(255,255,255,0.38)",
border: `1px solid ${m.role === "admin" ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
whiteSpace: "nowrap",
}}
>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMutation.mutate(m.id)}
disabled={removeMutation.isPending}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 5,
color: "#e07070",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Remove
</button>
)}
</div>
))}
</div>
)}
{/* Role info cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 16 }}>
<div
style={{
padding: "10px 12px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
}}
>
<div style={{ fontSize: 12, color: "#e8a22a", marginBottom: 4 }}>Admin</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", lineHeight: 1.55 }}>
Upload, delete, manage members and storage
</div>
</div>
<div
style={{
padding: "10px 12px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
}}
>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.55)", marginBottom: 4 }}>Member</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", lineHeight: 1.55 }}>
Listen, comment, annotate no upload or management
</div>
</div>
</div>
{/* Pending invites — admin only */}
{amAdmin && (
<>
<Divider />
<SectionTitle>Pending Invites</SectionTitle>
{invitesLoading ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Loading invites</p>
) : activeInvites.length === 0 ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No pending invites.</p>
) : (
<div style={{ display: "grid", gap: 6 }}>
{activeInvites.map((invite) => (
<div
key={invite.id}
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<code
style={{
fontSize: 11,
color: "rgba(255,255,255,0.35)",
fontFamily: "monospace",
}}
>
{invite.token.slice(0, 8)}{invite.token.slice(-4)}
</code>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.25)", marginTop: 2 }}>
{formatExpiry(invite.expires_at)} · {invite.role}
</div>
</div>
<button
onClick={() =>
navigator.clipboard
.writeText(`${window.location.origin}/invite/${invite.token}`)
.catch(() => {})
}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 5,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Copy
</button>
<button
onClick={() => revokeMutation.mutate(invite.id)}
disabled={revokeMutation.isPending}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 5,
color: "#e07070",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Revoke
</button>
</div>
))}
</div>
)}
<p style={{ fontSize: 11, color: "rgba(255,255,255,0.2)", marginTop: 8 }}>
No account needed to accept an invite.
</p>
</>
)}
</div>
);
}
// ── Storage panel ─────────────────────────────────────────────────────────────
function StoragePanel({
bandId,
band,
amAdmin,
}: {
bandId: string;
band: { slug: string; nc_folder_path: string | null };
amAdmin: boolean;
}) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const [folderInput, setFolderInput] = useState("");
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
async function startScan() {
if (scanning) return;
setScanning(true);
setScanMsg(null);
setScanProgress("Starting scan…");
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
try {
const resp = await fetch(url, { credentials: "include" });
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 updateMutation = useMutation({
mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
setEditing(false);
},
});
const defaultPath = `bands/${band.slug}/`;
const currentPath = band.nc_folder_path ?? defaultPath;
return (
<div>
<SectionTitle>Nextcloud Scan Folder</SectionTitle>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 16, lineHeight: 1.55 }}>
RehearsalHub reads recordings directly from your Nextcloud files are never copied to our
servers.
</p>
<div
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 9,
padding: "12px 16px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.22)", textTransform: "uppercase", letterSpacing: "0.6px", marginBottom: 4 }}>
Scan path
</div>
<code style={{ fontSize: 13, color: "#4dba85", fontFamily: "monospace" }}>
{currentPath}
</code>
</div>
{amAdmin && !editing && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditing(true); }}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "4px 10px",
fontSize: 11,
fontFamily: "inherit",
}}
>
Edit
</button>
)}
</div>
{editing && (
<div style={{ marginTop: 12 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={defaultPath}
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: "monospace",
boxSizing: "border-box",
outline: "none",
}}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateMutation.mutate(folderInput)}
disabled={updateMutation.isPending}
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",
}}
>
{updateMutation.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={() => setEditing(false)}
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 14px",
fontSize: 12,
fontFamily: "inherit",
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Scan action */}
<div style={{ marginTop: 16 }}>
<button
onClick={startScan}
disabled={scanning}
style={{
background: scanning ? "transparent" : "rgba(61,200,120,0.08)",
border: `1px solid ${scanning ? "rgba(255,255,255,0.07)" : "rgba(61,200,120,0.25)"}`,
borderRadius: 6,
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
transition: "all 0.12s",
}}
>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
</div>
{scanning && scanProgress && (
<div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, color: "rgba(255,255,255,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
{scanProgress}
</div>
)}
{scanMsg && (
<div style={{ marginTop: 10, 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" }}>
{scanMsg}
</div>
)}
</div>
);
}
// ── Band settings panel ───────────────────────────────────────────────────────
function BandPanel({
bandId,
band,
amAdmin,
}: {
bandId: string;
band: { name: string; slug: string; genre_tags: string[] };
amAdmin: boolean;
}) {
const qc = useQueryClient();
const [nameInput, setNameInput] = useState(band.name);
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>(band.genre_tags);
const [saved, setSaved] = useState(false);
const updateMutation = useMutation({
mutationFn: (payload: { name?: string; genre_tags?: string[] }) =>
api.patch(`/bands/${bandId}`, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
qc.invalidateQueries({ queryKey: ["bands"] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
function addTag() {
const t = tagInput.trim();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput("");
}
function removeTag(t: string) {
setTags((prev) => prev.filter((x) => x !== t));
}
return (
<div>
<SectionTitle>Identity</SectionTitle>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 12, color: "rgba(255,255,255,0.42)", marginBottom: 5 }}>
Band name
</label>
<input
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
disabled={!amAdmin}
style={{
width: "100%",
padding: "8px 11px",
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",
opacity: amAdmin ? 1 : 0.5,
}}
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 12, color: "rgba(255,255,255,0.42)", marginBottom: 5 }}>
Genre tags
</label>
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 6 }}>
{tags.map((t) => (
<span
key={t}
style={{
background: "rgba(140,90,220,0.1)",
color: "#a878e8",
fontSize: 11,
padding: "2px 8px",
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{t}
{amAdmin && (
<button
onClick={() => removeTag(t)}
style={{
background: "none",
border: "none",
color: "#a878e8",
cursor: "pointer",
fontSize: 13,
padding: 0,
lineHeight: 1,
fontFamily: "inherit",
}}
>
×
</button>
)}
</span>
))}
</div>
{amAdmin && (
<div style={{ display: "flex", gap: 6 }}>
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder="Add genre 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>
{amAdmin && (
<button
onClick={() => updateMutation.mutate({ name: nameInput.trim() || band.name, genre_tags: tags })}
disabled={updateMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: saved ? "#4dba85" : "#e8a22a",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
transition: "color 0.2s",
}}
>
{updateMutation.isPending ? "Saving…" : saved ? "Saved ✓" : "Save changes"}
</button>
)}
{!amAdmin && (
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.28)" }}>Only admins can edit band settings.</p>
)}
<Divider />
{/* Danger zone */}
<div
style={{
border: "1px solid rgba(220,80,80,0.18)",
borderRadius: 9,
padding: "14px 16px",
background: "rgba(220,80,80,0.04)",
}}
>
<div style={{ fontSize: 13, color: "#e07070", marginBottom: 3 }}>Delete this band</div>
<div style={{ fontSize: 11, color: "rgba(220,80,80,0.45)", marginBottom: 10 }}>
Removes all members and deletes comments. Storage files are NOT deleted.
</div>
<button
disabled={!amAdmin}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 6,
color: "#e07070",
cursor: amAdmin ? "pointer" : "default",
padding: "5px 12px",
fontSize: 11,
fontFamily: "inherit",
opacity: amAdmin ? 1 : 0.4,
}}
>
Delete band
</button>
</div>
</div>
);
}
// ── BandSettingsPage ──────────────────────────────────────────────────────────
export function BandSettingsPage() {
const { bandId, panel } = useParams<{ bandId: string; panel: string }>();
const navigate = useNavigate();
const activePanel: Panel =
panel === "storage" ? "storage" : panel === "band" ? "band" : "members";
const { data: band, isLoading: bandLoading } = useQuery({
queryKey: ["band", bandId],
queryFn: () => getBand(bandId!),
enabled: !!bandId,
});
const { data: members, isLoading: membersLoading } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"),
});
const amAdmin =
!!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false);
const go = (p: Panel) => navigate(`/bands/${bandId}/settings/${p}`);
if (bandLoading) {
return <div style={{ color: "rgba(255,255,255,0.28)", padding: 32 }}>Loading</div>;
}
if (!band) {
return <div style={{ color: "#e07070", padding: 32 }}>Band not found</div>;
}
return (
<div style={{ display: "flex", height: "100%", overflow: "hidden" }}>
{/* ── Left panel nav ─────────────────────────────── */}
<div
style={{
width: 180,
minWidth: 180,
background: "#0b0b0e",
borderRight: "1px solid rgba(255,255,255,0.05)",
padding: "20px 10px",
display: "flex",
flexDirection: "column",
gap: 0,
}}
>
<div
style={{
fontSize: 10,
fontWeight: 500,
color: "rgba(255,255,255,0.2)",
textTransform: "uppercase",
letterSpacing: "0.7px",
padding: "0 6px 8px",
}}
>
Band {band.name}
</div>
<PanelNavItem label="Members" active={activePanel === "members"} onClick={() => go("members")} />
<PanelNavItem label="Storage" active={activePanel === "storage"} onClick={() => go("storage")} />
<PanelNavItem label="Band Settings" active={activePanel === "band"} onClick={() => go("band")} />
</div>
{/* ── Panel content ──────────────────────────────── */}
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 580 }}>
{activePanel === "members" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Members
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Manage who has access to {band.name}'s recordings.
</p>
<MembersPanel
bandId={bandId!}
amAdmin={amAdmin}
members={members}
membersLoading={membersLoading}
/>
</>
)}
{activePanel === "storage" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Storage
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Configure where {band.name} stores recordings.
</p>
<StoragePanel bandId={bandId!} band={band} amAdmin={amAdmin} />
</>
)}
{activePanel === "band" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Band Settings
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Only admins can edit these settings.
</p>
<BandPanel bandId={bandId!} band={band} amAdmin={amAdmin} />
</>
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import { create } from "zustand";
interface BandState {
activeBandId: string | null;
setActiveBandId: (id: string | null) => void;
}
function load(): string | null {
try { return localStorage.getItem("rh_active_band_id"); } catch { return null; }
}
export const useBandStore = create<BandState>()((set) => ({
activeBandId: load(),
setActiveBandId: (id) => {
try { if (id) localStorage.setItem("rh_active_band_id", id); } catch { /* ignore */ }
set({ activeBandId: id });
},
}));