development #1
@@ -1,16 +1,17 @@
|
||||
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 { isLoggedIn } from "./api/client";
|
||||
import { AppShell } from "./components/AppShell";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { BandPage } from "./pages/BandPage";
|
||||
import { BandSettingsPage } from "./pages/BandSettingsPage";
|
||||
import { SessionPage } from "./pages/SessionPage";
|
||||
import { SongPage } from "./pages/SongPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { InvitePage } from "./pages/InvitePage";
|
||||
import { useBandStore } from "./stores/bandStore";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
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() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -51,18 +66,8 @@ export default function App() {
|
||||
</ShellRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bands/:bandId/settings"
|
||||
element={<Navigate to="members" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/bands/:bandId/settings/:panel"
|
||||
element={
|
||||
<ShellRoute>
|
||||
<BandSettingsPage />
|
||||
</ShellRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/bands/:bandId/settings" element={<BandSettingsRedirect />} />
|
||||
<Route path="/bands/:bandId/settings/:panel" element={<BandSettingsRedirect />} />
|
||||
<Route
|
||||
path="/bands/:bandId/sessions/:sessionId"
|
||||
element={
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listBands } from "../api/bands";
|
||||
import { api } from "../api/client";
|
||||
import { logout } from "../api/auth";
|
||||
import { getInitials } from "../utils";
|
||||
import type { MemberRead } from "../api/auth";
|
||||
import { usePlayerStore } from "../stores/playerStore";
|
||||
import { useBandStore } from "../stores/bandStore";
|
||||
import { TopBandBar } from "./TopBandBar";
|
||||
|
||||
// ── 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() {
|
||||
return (
|
||||
<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() {
|
||||
return (
|
||||
<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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NavItemProps {
|
||||
@@ -174,20 +145,13 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get<MemberRead>("/auth/me"),
|
||||
});
|
||||
|
||||
const bandMatch =
|
||||
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 { activeBandId } = useBandStore();
|
||||
|
||||
const isLibrary = !!(
|
||||
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 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 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 border = "rgba(255,255,255,0.06)";
|
||||
|
||||
@@ -260,93 +211,11 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</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 */}
|
||||
<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
|
||||
icon={<IconPlay />}
|
||||
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 }} />
|
||||
<NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
|
||||
</nav>
|
||||
@@ -409,8 +269,11 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
</aside>
|
||||
|
||||
{/* ── 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 }}>
|
||||
<TopBandBar />
|
||||
<div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
149
web/src/components/TopBandBar.tsx
Normal file
149
web/src/components/TopBandBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
18
web/src/stores/bandStore.ts
Normal file
18
web/src/stores/bandStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user