import { useState, useEffect } from "react"; 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; display_name: string; email: string; avatar_url: string | null; nc_username: string | null; nc_url: string | null; nc_configured: boolean; } 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; } 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: "#101c18", border: `1px solid ${focused ? "rgba(20,184,166,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 [avatarUrl, setAvatarUrl] = useState(me.avatar_url ?? ""); const [uploading, setUploading] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); useEffect(() => { setAvatarUrl(me.avatar_url ?? ""); }, [me.avatar_url]); const resizeImage = (file: File, max: number): Promise => new Promise((resolve, reject) => { const img = new Image(); const reader = new FileReader(); reader.onload = (ev) => { if (typeof ev.target?.result !== "string") { reject(new Error("read failed")); return; } img.onload = () => { 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("no blob")); return; } resolve(new File([blob], file.name, { type: "image/jpeg", lastModified: Date.now() })); }, "image/jpeg", 0.8); }; img.onerror = reject; img.src = ev.target!.result; }; reader.onerror = reject; reader.readAsDataURL(file); }); const saveMutation = useMutation({ mutationFn: () => api.patch("/auth/me/settings", { display_name: displayName || undefined, avatar_url: avatarUrl || undefined, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["me"] }); setSaved(true); setError(null); setTimeout(() => setSaved(false), 2500); }, onError: (err) => setError(err instanceof Error ? err.message : "Save failed"), }); return (
{/* Avatar row */}
{avatarUrl ? ( avatar ) : (
{getInitials(me.display_name)}
)}
{ const file = e.target.files?.[0]; if (!file) return; setUploading(true); try { const processed = file.size > 4 * 1024 * 1024 ? await resizeImage(file, 800) : file; const form = new FormData(); form.append("file", processed, processed.name || file.name); const resp = await api.upload("/auth/me/avatar", form); setAvatarUrl(resp.avatar_url || ""); qc.invalidateQueries({ queryKey: ["me"] }); qc.invalidateQueries({ queryKey: ["comments"] }); } catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); } finally { setUploading(false); } }} /> {avatarUrl && ( )}
{/* Display name */}
setDisplayName(e.target.value)} />
{/* Email (read-only) */}
{me.email}
{error &&

{error}

} saveMutation.mutate()} />
); } // ── 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 (
{/* 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.
)}
); }