diff --git a/web/src/App.tsx b/web/src/App.tsx index 7aabe5a..9f3198e 100755 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( @@ -51,18 +66,8 @@ export default function App() { } /> - } - /> - - - - } - /> + } /> + } /> - - - - - - ); -} - function IconSettings() { return ( @@ -56,17 +46,6 @@ function IconSettings() { ); } -function IconStorage() { - return ( - - - - - - - ); -} - function IconSignOut() { return ( @@ -76,14 +55,6 @@ function IconSignOut() { ); } -function IconChevron() { - return ( - - - - ); -} - // ── 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(null); - const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands }); const { data: me } = useQuery({ queryKey: ["me"], queryFn: () => api.get("/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 }) { )} - {/* Band switcher */} -
- - - {dropdownOpen && ( -
- {bands?.map((band) => ( - - ))} -
- -
-
- )} -
- {/* Navigation */} @@ -409,8 +269,11 @@ export function Sidebar({ children }: { children: React.ReactNode }) { {/* ── Main content ── */} -
- {children} +
+ +
+ {children} +
); diff --git a/web/src/components/TopBandBar.tsx b/web/src/components/TopBandBar.tsx new file mode 100644 index 0000000..5660a9f --- /dev/null +++ b/web/src/components/TopBandBar.tsx @@ -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(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 ( +
+ {/* Band switcher */} +
+ + + {open && ( +
+ {bands?.map((band) => ( + + ))} + +
+ +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/BandSettingsPage.test.tsx b/web/src/pages/BandSettingsPage.test.tsx deleted file mode 100755 index bc6aeba..0000000 --- a/web/src/pages/BandSettingsPage.test.tsx +++ /dev/null @@ -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(, { - 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(, { - 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); - }); -}); diff --git a/web/src/pages/BandSettingsPage.tsx b/web/src/pages/BandSettingsPage.tsx deleted file mode 100755 index 10ff8d6..0000000 --- a/web/src/pages/BandSettingsPage.tsx +++ /dev/null @@ -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 ( - - ); -} - -// ── Section title ───────────────────────────────────────────────────────────── - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function Divider() { - return
; -} - -// ── Members panel ───────────────────────────────────────────────────────────── - -function MembersPanel({ - bandId, - amAdmin, - members, - membersLoading, -}: { - bandId: string; - amAdmin: boolean; - members: BandMember[] | undefined; - membersLoading: boolean; -}) { - const qc = useQueryClient(); - const [inviteLink, setInviteLink] = useState(null); - - const { data: invitesData, isLoading: invitesLoading } = useQuery({ - queryKey: ["invites", bandId], - queryFn: () => listInvites(bandId), - enabled: amAdmin, - retry: false, - }); - - const inviteMutation = useMutation({ - mutationFn: () => api.post(`/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 ( -
- {/* Member list */} -
- Members - {amAdmin && ( - - )} -
- - {inviteLink && ( -
-

- Invite link (copied to clipboard · valid 72h): -

- {inviteLink} - -
- )} - - {membersLoading ? ( -

Loading…

- ) : ( -
- {members?.map((m) => ( -
-
- {m.display_name.slice(0, 2).toUpperCase()} -
-
-
{m.display_name}
-
{m.email}
-
- - {m.role} - - {amAdmin && m.role !== "admin" && ( - - )} -
- ))} -
- )} - - {/* Role info cards */} -
-
-
Admin
-
- Upload, delete, manage members and storage -
-
-
-
Member
-
- Listen, comment, annotate — no upload or management -
-
-
- - {/* Pending invites — admin only */} - {amAdmin && ( - <> - - Pending Invites - - {invitesLoading ? ( -

Loading invites…

- ) : activeInvites.length === 0 ? ( -

No pending invites.

- ) : ( -
- {activeInvites.map((invite) => ( -
-
- - {invite.token.slice(0, 8)}…{invite.token.slice(-4)} - -
- {formatExpiry(invite.expires_at)} · {invite.role} -
-
- - -
- ))} -
- )} -

- No account needed to accept an invite. -

- - )} -
- ); -} - -// ── 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(null); - const [scanMsg, setScanMsg] = useState(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; - 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 ( -
- Nextcloud Scan Folder -

- RehearsalHub reads recordings directly from your Nextcloud — files are never copied to our - servers. -

- -
-
-
-
- Scan path -
- - {currentPath} - -
- {amAdmin && !editing && ( - - )} -
- - {editing && ( -
- 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", - }} - /> -
- - -
-
- )} -
- - {/* Scan action */} -
- -
- - {scanning && scanProgress && ( -
- {scanProgress} -
- )} - {scanMsg && ( -
- {scanMsg} -
- )} -
- ); -} - -// ── 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(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 ( -
- Identity - -
- - 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, - }} - /> -
- -
- -
- {tags.map((t) => ( - - {t} - {amAdmin && ( - - )} - - ))} -
- {amAdmin && ( -
- 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", - }} - /> - -
- )} -
- - {amAdmin && ( - - )} - - {!amAdmin && ( -

Only admins can edit band settings.

- )} - - - - {/* Danger zone */} -
-
Delete this band
-
- Removes all members and deletes comments. Storage files are NOT deleted. -
- -
-
- ); -} - -// ── 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(`/bands/${bandId}/members`), - enabled: !!bandId, - }); - - const { data: me } = useQuery({ - queryKey: ["me"], - queryFn: () => api.get("/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
Loading…
; - } - if (!band) { - return
Band not found
; - } - - return ( -
- {/* ── Left panel nav ─────────────────────────────── */} -
-
- Band — {band.name} -
- go("members")} /> - go("storage")} /> - go("band")} /> -
- - {/* ── Panel content ──────────────────────────────── */} -
-
- {activePanel === "members" && ( - <> -

- Members -

-

- Manage who has access to {band.name}'s recordings. -

- - - )} - - {activePanel === "storage" && ( - <> -

- Storage -

-

- Configure where {band.name} stores recordings. -

- - - )} - - {activePanel === "band" && ( - <> -

- Band Settings -

-

- Only admins can edit these settings. -

- - - )} -
-
-
- ); -} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 65e16e0..5e2e546 100755 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,7 +1,13 @@ import { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; +import { listBands } from "../api/bands"; +import { listInvites, revokeInvite } from "../api/invites"; +import { useBandStore } from "../stores/bandStore"; +import { getInitials } from "../utils"; + +// ── Types ───────────────────────────────────────────────────────────────────── interface MemberRead { id: string; @@ -13,341 +19,831 @@ interface MemberRead { nc_configured: boolean; } -const getMe = () => api.get("/auth/me"); -const updateSettings = (data: { - display_name?: string; - nc_url?: string; - nc_username?: string; - nc_password?: string; - avatar_url?: string; -}) => api.patch("/auth/me/settings", data); +interface BandMember { + id: string; + display_name: string; + email: string; + role: string; + joined_at: string; +} -const inputStyle: React.CSSProperties = { - width: "100%", - padding: "8px 12px", - background: "var(--bg-inset)", - border: "1px solid var(--border)", - borderRadius: 7, - color: "var(--text)", - fontSize: 14, - boxSizing: "border-box", -}; +interface BandInvite { + id: string; + token: string; + role: string; + expires_at: string | null; + is_used: boolean; +} -function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) { +interface Band { + id: string; + name: string; + slug: string; + genre_tags: string[]; + nc_folder_path: string | null; +} + +type Section = "profile" | "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(); +} + +// ── Shared style helpers ────────────────────────────────────────────────────── + +const border = "rgba(255,255,255,0.06)"; +const borderBright = "rgba(255,255,255,0.12)"; + +function Label({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function SectionHeading({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +function Divider() { + return
; +} + +function Input(props: React.InputHTMLAttributes) { + const [focused, setFocused] = useState(false); + return ( + { setFocused(true); props.onFocus?.(e); }} + onBlur={(e) => { setFocused(false); props.onBlur?.(e); }} + style={{ + width: "100%", padding: "8px 12px", + background: "#151828", + border: `1px solid ${focused ? "rgba(139,92,246,0.4)" : border}`, + borderRadius: 8, color: "#e8e9f0", + fontSize: 13, fontFamily: "inherit", + outline: "none", boxSizing: "border-box", + transition: "border-color 0.15s", + ...props.style, + }} + /> + ); +} + +function SaveBtn({ pending, saved, onClick }: { pending: boolean; saved: boolean; onClick: () => void }) { + return ( + + ); +} + +// ── Profile section ─────────────────────────────────────────────────────────── + +function ProfileSection({ me }: { me: MemberRead }) { const qc = useQueryClient(); const [displayName, setDisplayName] = useState(me.display_name ?? ""); - const [ncUrl, setNcUrl] = useState(me.nc_url ?? ""); - const [ncUsername, setNcUsername] = useState(me.nc_username ?? ""); - const [ncPassword, setNcPassword] = useState(""); const [avatarUrl, setAvatarUrl] = useState(me.avatar_url ?? ""); const [uploading, setUploading] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); - // Keep local avatarUrl in sync when the server-side value changes (e.g. after - // a background refetch or a change made on another device). - useEffect(() => { - setAvatarUrl(me.avatar_url ?? ""); - }, [me.avatar_url]); + useEffect(() => { setAvatarUrl(me.avatar_url ?? ""); }, [me.avatar_url]); - // Image resizing function - const resizeImage = (file: File, maxWidth: number, maxHeight: number): Promise => { - return new Promise((resolve, reject) => { + const resizeImage = (file: File, max: number): Promise => + new Promise((resolve, reject) => { const img = new Image(); const reader = new FileReader(); - - reader.onload = (event) => { - if (typeof event.target?.result !== 'string') { - reject(new Error('Failed to read file')); - return; - } - + reader.onload = (ev) => { + if (typeof ev.target?.result !== "string") { reject(new Error("read failed")); return; } img.onload = () => { - const canvas = document.createElement('canvas'); - let width = img.width; - let height = img.height; - - // Calculate new dimensions - if (width > height) { - if (width > maxWidth) { - height *= maxWidth / width; - width = maxWidth; - } - } else { - if (height > maxHeight) { - width *= maxHeight / height; - height = maxHeight; - } - } - - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - reject(new Error('Failed to get canvas context')); - return; - } - - ctx.drawImage(img, 0, 0, width, height); - + const ratio = Math.min(max / img.width, max / img.height, 1); + const canvas = document.createElement("canvas"); + canvas.width = img.width * ratio; + canvas.height = img.height * ratio; + const ctx = canvas.getContext("2d"); + if (!ctx) { reject(new Error("no ctx")); return; } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { - if (!blob) { - reject(new Error('Failed to create blob')); - return; - } - - const resizedFile = new File([blob], file.name, { - type: 'image/jpeg', - lastModified: Date.now() - }); - - resolve(resizedFile); - }, 'image/jpeg', 0.8); // JPEG quality 80% + if (!blob) { reject(new Error("no blob")); return; } + resolve(new File([blob], file.name, { type: "image/jpeg", lastModified: Date.now() })); + }, "image/jpeg", 0.8); }; - img.onerror = reject; - img.src = event.target?.result; + img.src = ev.target!.result; }; - reader.onerror = reject; reader.readAsDataURL(file); }); - }; const saveMutation = useMutation({ - mutationFn: () => - updateSettings({ - display_name: displayName || undefined, - nc_url: ncUrl || undefined, - nc_username: ncUsername || undefined, - nc_password: ncPassword || undefined, - avatar_url: avatarUrl || undefined, - }), + mutationFn: () => api.patch("/auth/me/settings", { + display_name: displayName || undefined, + avatar_url: avatarUrl || undefined, + }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["me"] }); - setSaved(true); - setNcPassword(""); - setError(null); - setTimeout(() => setSaved(false), 3000); + setSaved(true); setError(null); + setTimeout(() => setSaved(false), 2500); }, onError: (err) => setError(err instanceof Error ? err.message : "Save failed"), }); - const labelStyle: React.CSSProperties = { - display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6, - }; - return ( - <> -
-

PROFILE

-
- {avatarUrl && ( - Profile +
+ + + {/* Avatar row */} +
+ {avatarUrl ? ( + avatar + ) : ( +
+ {getInitials(me.display_name)} +
)} -
- - setDisplayName(e.target.value)} style={inputStyle} /> -

{me.email}

-
-
-
- -
-

Nextcloud Connection

-

- Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials. -

- -
- - - {me.nc_configured ? "Connected" : "Not configured"} - -
- - - setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} /> - - - setNcUsername(e.target.value)} style={inputStyle} /> - - - setNcPassword(e.target.value)} - placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""} - style={inputStyle} - /> -

- Use an app password from Nextcloud Settings → Security for better security. -

-
- -
-

AVATAR

-
- + { const file = e.target.files?.[0]; - if (file) { - setUploading(true); - - try { - // Check file size and resize if needed - const maxSize = 4 * 1024 * 1024; // 4MB (more conservative to account for base64 overhead) - let processedFile = file; - - if (file.size > maxSize) { - processedFile = await resizeImage(file, 800, 800); // Max 800x800 - } - - const formData = new FormData(); - formData.append('file', processedFile, processedFile.name || file.name); - - const response = await api.upload('/auth/me/avatar', formData); - - setAvatarUrl(response.avatar_url || ''); - qc.invalidateQueries({ queryKey: ['me'] }); - qc.invalidateQueries({ queryKey: ['comments'] }); - } catch (err) { - let errorMessage = 'Failed to upload avatar. Please try again.'; - - if (err instanceof Error) { - errorMessage = err.message; - if (err.message.includes('413')) { - errorMessage = 'File too large. Maximum size is 5MB. Please choose a smaller image.'; - } else if (err.message.includes('422')) { - errorMessage = 'Invalid image file. Please upload a valid image (JPG, PNG, etc.).'; - } - } else if (typeof err === 'object' && err !== null) { - const errorObj = err as { status?: number; data?: { detail?: string } }; - if (errorObj.status === 422 && errorObj.data?.detail) { - errorMessage = errorObj.data.detail; - } - } - - setError(errorMessage); - } finally { - setUploading(false); - } - } - }} - style={{ display: "none" }} - id="avatar-upload" - /> - - -
- {avatarUrl && ( -
- Preview { - setAvatarUrl(`https://api.dicebear.com/9.x/identicon/svg?seed=${me.id}&backgroundType=gradientLinear&size=128`); - }} - /> + {avatarUrl && ( -
- )} -
- - {error &&

{error}

} - {saved &&

Settings saved.

} - -
- - + )} +
- + + {/* Display name */} +
+ + setDisplayName(e.target.value)} /> +
+ + {/* Email (read-only) */} +
+ +
+ {me.email} +
+
+ + {error &&

{error}

} + saveMutation.mutate()} /> +
); } -export function SettingsPage() { - const navigate = useNavigate(); - const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); +// ── Storage section (NC credentials + scan folder) ──────────────────────────── + +function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: Band; amAdmin: boolean; me: MemberRead }) { + const qc = useQueryClient(); + + // NC credentials state + const [ncUrl, setNcUrl] = useState(me.nc_url ?? ""); + const [ncUsername, setNcUsername] = useState(me.nc_username ?? ""); + const [ncPassword, setNcPassword] = useState(""); + const [ncSaved, setNcSaved] = useState(false); + const [ncError, setNcError] = useState(null); + + // Scan folder state + const [editingPath, setEditingPath] = useState(false); + const [folderInput, setFolderInput] = useState(""); + const [scanning, setScanning] = useState(false); + const [scanProgress, setScanProgress] = useState(null); + const [scanMsg, setScanMsg] = useState(null); + + const ncMutation = useMutation({ + mutationFn: () => api.patch("/auth/me/settings", { + nc_url: ncUrl || undefined, + nc_username: ncUsername || undefined, + nc_password: ncPassword || undefined, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["me"] }); + setNcSaved(true); setNcError(null); setNcPassword(""); + setTimeout(() => setNcSaved(false), 2500); + }, + onError: (err) => setNcError(err instanceof Error ? err.message : "Save failed"), + }); + + const pathMutation = useMutation({ + mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["band", bandId] }); setEditingPath(false); }, + }); + + async function startScan() { + if (scanning) return; + setScanning(true); setScanMsg(null); setScanProgress("Starting scan…"); + try { + const resp = await fetch(`/api/v1/bands/${bandId}/nc-scan/stream`, { credentials: "include" }); + if (!resp.ok || !resp.body) throw new Error(`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 ev: Record; + try { ev = JSON.parse(line); } catch { continue; } + if (ev.type === "progress") setScanProgress(ev.message as string); + else if (ev.type === "song" || ev.type === "session") { + qc.invalidateQueries({ queryKey: ["sessions", bandId] }); + } else if (ev.type === "done") { + const s = ev.stats as { found: number; imported: number; skipped: number }; + setScanMsg(s.imported > 0 + ? `Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).` + : s.found === 0 ? "No audio files found." + : `All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`); + setTimeout(() => setScanMsg(null), 6000); + } else if (ev.type === "error") setScanMsg(`Scan error: ${ev.message}`); + } + } + } catch (err) { setScanMsg(err instanceof Error ? err.message : "Scan failed"); } + finally { setScanning(false); setScanProgress(null); } + } + + const defaultPath = `bands/${band.slug}/`; + const currentPath = band.nc_folder_path ?? defaultPath; return ( -
-
-

- Settings -

+
+ - {isLoading &&

Loading...

} - {me && navigate(-1)} />} + {/* NC Connection */} +
+
+
+ + {me.nc_configured ? "Nextcloud connected" : "Nextcloud not configured"} + +
+ +
+
+
+
Nextcloud Connection
+
+ Your personal credentials — will move to per-band config in a future update. +
+
+
+ +
+
+ + setNcUrl(e.target.value)} placeholder="https://cloud.example.com" /> +
+
+ + setNcUsername(e.target.value)} /> +
+
+ + setNcPassword(e.target.value)} placeholder={me.nc_configured ? "•••••••• (leave blank to keep)" : ""} /> +
+ Use an app password from Nextcloud → Settings → Security. +
+
+
+ + {ncError &&

{ncError}

} +
+ ncMutation.mutate()} /> +
+
+
+ + {/* Scan folder — admin only */} + {amAdmin && ( + <> + +
Scan Folder
+
+ RehearsalHub reads recordings from your Nextcloud — files are never copied to our servers. +
+ +
+
+
+ + {currentPath} +
+ {!editingPath && ( + + )} +
+ + {editingPath && ( +
+ setFolderInput(e.target.value)} placeholder={defaultPath} style={{ fontFamily: "monospace" }} /> +
+ + +
+
+ )} +
+ + + + {scanning && scanProgress && ( +
+ {scanProgress} +
+ )} + {scanMsg && ( +
+ {scanMsg} +
+ )} + + )} +
+ ); +} + +// ── Members section ─────────────────────────────────────────────────────────── + +function MembersSection({ bandId, band, amAdmin, members, membersLoading }: { bandId: string; band: Band; amAdmin: boolean; members: BandMember[] | undefined; membersLoading: boolean }) { + const qc = useQueryClient(); + const [inviteLink, setInviteLink] = useState(null); + + const { data: invitesData, isLoading: invitesLoading } = useQuery({ + queryKey: ["invites", bandId], + queryFn: () => listInvites(bandId), + enabled: amAdmin, retry: false, + }); + + const inviteMutation = useMutation({ + mutationFn: () => api.post(`/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 ( +
+ + + {/* Invite button — admin only */} + {amAdmin && ( +
+ +
+ )} + + {inviteLink && ( +
+

Invite link (copied · valid 72h):

+ {inviteLink} + +
+ )} + + {/* Member list */} + {membersLoading ? ( +

Loading…

+ ) : ( +
+ {members?.map((m) => ( +
+
+ {getInitials(m.display_name)} +
+
+
{m.display_name}
+
{m.email}
+
+ + {m.role} + + {amAdmin && m.role !== "admin" && ( + + )} +
+ ))} +
+ )} + + {/* Role legend */} +
+
+
Admin
+
Upload, delete, manage members and storage
+
+
+
Member
+
Listen, comment, annotate recordings
+
+
+ + {/* Pending invites — admin only */} + {amAdmin && ( + <> + +
Pending Invites
+ {invitesLoading ? ( +

Loading invites…

+ ) : activeInvites.length === 0 ? ( +

No pending invites.

+ ) : ( +
+ {activeInvites.map((invite) => ( +
+
+ {invite.token.slice(0, 8)}…{invite.token.slice(-4)} +
{formatExpiry(invite.expires_at)} · {invite.role}
+
+ + +
+ ))} +
+ )} +

No account needed to accept an invite.

+ + )} +
+ ); +} + +// ── Band section ────────────────────────────────────────────────────────────── + +function BandSection({ bandId, band }: { bandId: string; band: Band }) { + const qc = useQueryClient(); + const [nameInput, setNameInput] = useState(band.name); + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState(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), 2500); + }, + }); + + function addTag() { + const t = tagInput.trim(); + if (t && !tags.includes(t)) setTags((p) => [...p, t]); + setTagInput(""); + } + + return ( +
+ + +
+ + setNameInput(e.target.value)} /> +
+ +
+ +
+ {tags.map((t) => ( + + {t} + + + ))} +
+
+ setTagInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addTag()} placeholder="Add tag…" style={{ flex: undefined, width: "auto" }} /> + +
+
+ + updateMutation.mutate({ name: nameInput.trim() || band.name, genre_tags: tags })} /> + + + + {/* Danger zone */} +
+
Delete this band
+
+ Removes all members and deletes comments. Storage files are NOT deleted. +
+ +
+
+ ); +} + +// ── Left nav ────────────────────────────────────────────────────────────────── + +function NavItem({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + const [hovered, setHovered] = useState(false); + return ( + + ); +} + +function NavLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ── SettingsPage ────────────────────────────────────────────────────────────── + +export function SettingsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const { activeBandId, setActiveBandId } = useBandStore(); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768); + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); + + const section = (searchParams.get("section") ?? "profile") as Section; + const go = (s: Section) => setSearchParams({ section: s }, { replace: true }); + + // Data + const { data: me } = useQuery({ queryKey: ["me"], queryFn: () => api.get("/auth/me") }); + const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands }); + const { data: band } = useQuery({ + queryKey: ["band", activeBandId], + queryFn: () => api.get(`/bands/${activeBandId}`), + enabled: !!activeBandId, + }); + const { data: members, isLoading: membersLoading } = useQuery({ + queryKey: ["members", activeBandId], + queryFn: () => api.get(`/bands/${activeBandId}/members`), + enabled: !!activeBandId, + }); + + const amAdmin = !!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false); + + if (!me) return
Loading…
; + + // ── Mobile: list → detail drill-down ───────────────────────────────────── + + if (isMobile) { + const sections: { key: Section; label: string; group: string }[] = [ + { key: "profile", label: "Profile", group: "Account" }, + ...(activeBandId && band ? [ + { key: "members" as Section, label: "Members", group: band.name }, + { key: "storage" as Section, label: "Storage", group: band.name }, + ...(amAdmin ? [{ key: "band" as Section, label: "Band", group: band.name }] : []), + ] : []), + ]; + + return ( +
+ {/* Back to list */} + {section !== "profile" || searchParams.has("section") ? ( +
+ +
+ {section === "profile" && } + {section === "members" && activeBandId && band && } + {section === "storage" && activeBandId && band && } + {section === "band" && activeBandId && band && amAdmin && } +
+
+ ) : ( +
+

Settings

+ {sections.map((s, i) => { + const showGroupLabel = i === 0 || sections[i - 1].group !== s.group; + return ( +
+ {showGroupLabel && {s.group}} + +
+ ); + })} +
+ )} +
+ ); + } + + // ── Desktop: two-column ─────────────────────────────────────────────────── + + return ( +
+ + {/* Left nav */} + + + {/* Content */} +
+
+ {section === "profile" && } + {section === "members" && activeBandId && band && ( + + )} + {section === "storage" && activeBandId && band && ( + + )} + {section === "band" && activeBandId && band && amAdmin && ( + + )} + {section === "band" && activeBandId && !amAdmin && ( +
Only admins can access band settings.
+ )} + {(section === "members" || section === "storage" || section === "band") && !activeBandId && ( +
No active band selected.
+ )} +
); diff --git a/web/src/stores/bandStore.ts b/web/src/stores/bandStore.ts new file mode 100644 index 0000000..ea015e3 --- /dev/null +++ b/web/src/stores/bandStore.ts @@ -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()((set) => ({ + activeBandId: load(), + setActiveBandId: (id) => { + try { if (id) localStorage.setItem("rh_active_band_id", id); } catch { /* ignore */ } + set({ activeBandId: id }); + }, +}));