Files
rehearshalhub/web/src/pages/SettingsPage.tsx
Mistral Vibe 312f3dd161 feat(theme): replace purple accent with teal/turquoise color scheme
Swaps violet (#8b5cf6) for teal (#14b8a6/#0d9488) across all components
and updates dark backgrounds to have a green-tinted hue instead of blue-navy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:20:25 +02:00

851 lines
41 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div style={{ fontSize: 10, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "rgba(232,233,240,0.35)", marginBottom: 6 }}>
{children}
</div>
);
}
function SectionHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div style={{ marginBottom: 24 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, letterSpacing: -0.4, color: "#e8e9f0" }}>{title}</h2>
{subtitle && <p style={{ margin: "4px 0 0", fontSize: 12, color: "rgba(232,233,240,0.4)", lineHeight: 1.5 }}>{subtitle}</p>}
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: border, margin: "24px 0" }} />;
}
function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
const [focused, setFocused] = useState(false);
return (
<input
{...props}
onFocus={(e) => { 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 (
<button
onClick={onClick}
disabled={pending}
style={{
padding: "8px 20px", borderRadius: 8,
background: saved ? "rgba(52,211,153,0.12)" : "linear-gradient(135deg, #0d9488, #06b6d4)",
border: saved ? "1px solid rgba(52,211,153,0.3)" : "none",
color: saved ? "#34d399" : "white",
cursor: pending ? "default" : "pointer",
fontSize: 13, fontWeight: 600, fontFamily: "inherit",
transition: "all 0.2s",
boxShadow: saved ? "none" : "0 2px 12px rgba(20,184,166,0.3)",
}}
>
{pending ? "Saving…" : saved ? "Saved ✓" : "Save"}
</button>
);
}
// ── 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<string | null>(null);
useEffect(() => { setAvatarUrl(me.avatar_url ?? ""); }, [me.avatar_url]);
const resizeImage = (file: File, max: number): Promise<File> =>
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<MemberRead>("/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 (
<div>
<SectionHeading title="Profile" />
{/* Avatar row */}
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 24 }}>
{avatarUrl ? (
<img src={avatarUrl} alt="avatar" style={{ width: 64, height: 64, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: "50%", background: "rgba(52,211,153,0.12)", border: "2px solid rgba(52,211,153,0.25)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
{getInitials(me.display_name)}
</div>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<input type="file" accept="image/*" id="avatar-upload" style={{ display: "none" }}
onChange={async (e) => {
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<MemberRead>("/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); }
}}
/>
<label htmlFor="avatar-upload" style={{ padding: "7px 14px", borderRadius: 8, background: "rgba(20,184,166,0.12)", border: "1px solid rgba(20,184,166,0.3)", color: "#2dd4bf", cursor: "pointer", fontSize: 12, fontWeight: 600 }}>
{uploading ? "Uploading…" : "Upload"}
</label>
<button
onClick={async () => {
const seed = Math.random().toString(36).slice(2, 15);
const url = `https://api.dicebear.com/9.x/identicon/svg?seed=${seed}&backgroundType=gradientLinear&size=128`;
try {
await api.patch<MemberRead>("/auth/me/settings", { avatar_url: url });
setAvatarUrl(url);
qc.invalidateQueries({ queryKey: ["me"] });
qc.invalidateQueries({ queryKey: ["comments"] });
} catch (err) { setError(err instanceof Error ? err.message : "Failed"); }
}}
style={{ padding: "7px 14px", borderRadius: 8, background: "transparent", border: `1px solid ${border}`, color: "rgba(232,233,240,0.5)", cursor: "pointer", fontSize: 12, fontFamily: "inherit" }}
>
Generate
</button>
{avatarUrl && (
<button
onClick={async () => {
try {
await api.patch<MemberRead>("/auth/me/settings", { avatar_url: "" });
setAvatarUrl("");
qc.invalidateQueries({ queryKey: ["me"] });
qc.invalidateQueries({ queryKey: ["comments"] });
} catch (err) { setError(err instanceof Error ? err.message : "Failed"); }
}}
style={{ padding: "7px 14px", borderRadius: 8, background: "transparent", border: `1px solid rgba(244,63,94,0.2)`, color: "#f87171", cursor: "pointer", fontSize: 12, fontFamily: "inherit" }}
>
Remove
</button>
)}
</div>
</div>
{/* Display name */}
<div style={{ marginBottom: 16 }}>
<Label>Display name</Label>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
{/* Email (read-only) */}
<div style={{ marginBottom: 24 }}>
<Label>Email</Label>
<div style={{ fontSize: 13, color: "rgba(232,233,240,0.45)", padding: "8px 12px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 8 }}>
{me.email}
</div>
</div>
{error && <p style={{ color: "#f87171", fontSize: 12, marginBottom: 12 }}>{error}</p>}
<SaveBtn pending={saveMutation.isPending} saved={saved} onClick={() => saveMutation.mutate()} />
</div>
);
}
// ── 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<string | null>(null);
// Scan folder state
const [editingPath, setEditingPath] = 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);
const ncMutation = useMutation({
mutationFn: () => api.patch<MemberRead>("/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<string, unknown>;
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 (
<div>
<SectionHeading title="Storage" subtitle="Configure Nextcloud credentials and your band's recording folder." />
{/* NC Connection */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: me.nc_configured ? "#34d399" : "rgba(232,233,240,0.25)", flexShrink: 0, boxShadow: me.nc_configured ? "0 0 6px rgba(52,211,153,0.5)" : "none" }} />
<span style={{ fontSize: 12, color: me.nc_configured ? "#34d399" : "rgba(232,233,240,0.4)" }}>
{me.nc_configured ? "Nextcloud connected" : "Nextcloud not configured"}
</span>
</div>
<div style={{ padding: "14px 16px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 2 }}>Nextcloud Connection</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.35)" }}>
Your personal credentials will move to per-band config in a future update.
</div>
</div>
</div>
<div style={{ display: "grid", gap: 12 }}>
<div>
<Label>Nextcloud URL</Label>
<Input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" />
</div>
<div>
<Label>Username</Label>
<Input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} />
</div>
<div>
<Label>Password / App Password</Label>
<Input type="password" value={ncPassword} onChange={(e) => setNcPassword(e.target.value)} placeholder={me.nc_configured ? "•••••••• (leave blank to keep)" : ""} />
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", marginTop: 4 }}>
Use an app password from Nextcloud Settings Security.
</div>
</div>
</div>
{ncError && <p style={{ color: "#f87171", fontSize: 12, margin: "12px 0 0" }}>{ncError}</p>}
<div style={{ marginTop: 14 }}>
<SaveBtn pending={ncMutation.isPending} saved={ncSaved} onClick={() => ncMutation.mutate()} />
</div>
</div>
</div>
{/* Scan folder — admin only */}
{amAdmin && (
<>
<Divider />
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 4 }}>Scan Folder</div>
<div style={{ fontSize: 12, color: "rgba(232,233,240,0.35)", marginBottom: 16, lineHeight: 1.55 }}>
RehearsalHub reads recordings from your Nextcloud files are never copied to our servers.
</div>
<div style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "12px 16px", marginBottom: 14 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<Label>Scan path</Label>
<code style={{ fontSize: 13, color: "#34d399", fontFamily: "monospace" }}>{currentPath}</code>
</div>
{!editingPath && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingPath(true); }}
style={{ padding: "4px 10px", background: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}
>
Edit
</button>
)}
</div>
{editingPath && (
<div style={{ marginTop: 12 }}>
<Input value={folderInput} onChange={(e) => setFolderInput(e.target.value)} placeholder={defaultPath} style={{ fontFamily: "monospace" }} />
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button onClick={() => pathMutation.mutate(folderInput)} disabled={pathMutation.isPending}
style={{ padding: "6px 14px", background: "rgba(20,184,166,0.12)", border: "1px solid rgba(20,184,166,0.3)", borderRadius: 6, color: "#2dd4bf", cursor: "pointer", fontSize: 12, fontWeight: 600, fontFamily: "inherit" }}>
{pathMutation.isPending ? "Saving…" : "Save"}
</button>
<button onClick={() => setEditingPath(false)}
style={{ padding: "6px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 12, fontFamily: "inherit" }}>
Cancel
</button>
</div>
</div>
)}
</div>
<button
onClick={startScan} disabled={scanning}
style={{ padding: "7px 16px", background: scanning ? "transparent" : "rgba(52,211,153,0.08)", border: `1px solid ${scanning ? border : "rgba(52,211,153,0.25)"}`, borderRadius: 8, color: scanning ? "rgba(232,233,240,0.28)" : "#34d399", cursor: scanning ? "default" : "pointer", fontSize: 12, fontFamily: "inherit", transition: "all 0.12s" }}>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
{scanning && scanProgress && (
<div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
{scanProgress}
</div>
)}
{scanMsg && (
<div style={{ marginTop: 10, background: "rgba(52,211,153,0.06)", border: "1px solid rgba(52,211,153,0.25)", borderRadius: 8, color: "#34d399", fontSize: 12, padding: "8px 14px" }}>
{scanMsg}
</div>
)}
</>
)}
</div>
);
}
// ── 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<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>
<SectionHeading title="Members" subtitle={`Who has access to ${band.name}'s recordings.`} />
{/* Invite button — admin only */}
{amAdmin && (
<div style={{ marginBottom: 16 }}>
<button
onClick={() => inviteMutation.mutate()} disabled={inviteMutation.isPending}
style={{ padding: "8px 16px", background: "rgba(20,184,166,0.12)", border: "1px solid rgba(20,184,166,0.3)", borderRadius: 8, color: "#2dd4bf", cursor: "pointer", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}>
{inviteMutation.isPending ? "Generating…" : "+ Generate invite link"}
</button>
</div>
)}
{inviteLink && (
<div style={{ background: "rgba(20,184,166,0.06)", border: "1px solid rgba(20,184,166,0.22)", borderRadius: 10, padding: "12px 16px", marginBottom: 16 }}>
<p style={{ color: "rgba(232,233,240,0.35)", fontSize: 11, margin: "0 0 5px" }}>Invite link (copied · valid 72h):</p>
<code style={{ color: "#2dd4bf", fontSize: 12, wordBreak: "break-all", fontFamily: "monospace" }}>{inviteLink}</code>
<button onClick={() => setInviteLink(null)} style={{ display: "block", marginTop: 6, background: "none", border: "none", color: "rgba(232,233,240,0.28)", cursor: "pointer", fontSize: 11, padding: 0, fontFamily: "inherit" }}>
Dismiss
</button>
</div>
)}
{/* Member list */}
{membersLoading ? (
<p style={{ color: "rgba(232,233,240,0.28)", fontSize: 13 }}>Loading</p>
) : (
<div style={{ display: "grid", gap: 6, marginBottom: 20 }}>
{members?.map((m) => (
<div key={m.id} style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: "50%", background: "rgba(52,211,153,0.12)", border: "1px solid rgba(52,211,153,0.25)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
{getInitials(m.display_name)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "rgba(232,233,240,0.75)" }}>{m.display_name}</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", marginTop: 1 }}>{m.email}</div>
</div>
<span style={{ fontSize: 10, fontFamily: "monospace", padding: "2px 7px", borderRadius: 4, background: m.role === "admin" ? "rgba(20,184,166,0.1)" : "rgba(255,255,255,0.05)", color: m.role === "admin" ? "#2dd4bf" : "rgba(232,233,240,0.38)", border: `1px solid ${m.role === "admin" ? "rgba(20,184,166,0.28)" : border}`, whiteSpace: "nowrap" }}>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button onClick={() => removeMutation.mutate(m.id)} disabled={removeMutation.isPending}
style={{ background: "rgba(244,63,94,0.08)", border: "1px solid rgba(244,63,94,0.2)", borderRadius: 6, color: "#f87171", cursor: "pointer", fontSize: 11, padding: "3px 8px", fontFamily: "inherit" }}>
Remove
</button>
)}
</div>
))}
</div>
)}
{/* Role legend */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: amAdmin ? 0 : 0 }}>
<div style={{ padding: "10px 12px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 8 }}>
<div style={{ fontSize: 12, color: "#2dd4bf", marginBottom: 3 }}>Admin</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", lineHeight: 1.55 }}>Upload, delete, manage members and storage</div>
</div>
<div style={{ padding: "10px 12px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 8 }}>
<div style={{ fontSize: 12, color: "rgba(232,233,240,0.45)", marginBottom: 3 }}>Member</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", lineHeight: 1.55 }}>Listen, comment, annotate recordings</div>
</div>
</div>
{/* Pending invites — admin only */}
{amAdmin && (
<>
<Divider />
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 12 }}>Pending Invites</div>
{invitesLoading ? (
<p style={{ color: "rgba(232,233,240,0.28)", fontSize: 13 }}>Loading invites</p>
) : activeInvites.length === 0 ? (
<p style={{ color: "rgba(232,233,240,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.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<code style={{ fontSize: 11, color: "rgba(232,233,240,0.35)", fontFamily: "monospace" }}>{invite.token.slice(0, 8)}{invite.token.slice(-4)}</code>
<div style={{ fontSize: 11, color: "rgba(232,233,240,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: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,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(244,63,94,0.08)", border: "1px solid rgba(244,63,94,0.2)", borderRadius: 6, color: "#f87171", cursor: "pointer", fontSize: 11, padding: "3px 8px", fontFamily: "inherit" }}>
Revoke
</button>
</div>
))}
</div>
)}
<p style={{ fontSize: 11, color: "rgba(232,233,240,0.2)", marginTop: 8 }}>No account needed to accept an invite.</p>
</>
)}
</div>
);
}
// ── 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<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), 2500);
},
});
function addTag() {
const t = tagInput.trim();
if (t && !tags.includes(t)) setTags((p) => [...p, t]);
setTagInput("");
}
return (
<div>
<SectionHeading title="Band" subtitle="Identity settings and danger zone." />
<div style={{ marginBottom: 14 }}>
<Label>Band name</Label>
<Input value={nameInput} onChange={(e) => setNameInput(e.target.value)} />
</div>
<div style={{ marginBottom: 20 }}>
<Label>Genre tags</Label>
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 8 }}>
{tags.map((t) => (
<span key={t} style={{ background: "rgba(20,184,166,0.1)", color: "#2dd4bf", fontSize: 11, padding: "3px 10px", borderRadius: 20, display: "flex", alignItems: "center", gap: 5 }}>
{t}
<button onClick={() => setTags((p) => p.filter((x) => x !== t))} style={{ background: "none", border: "none", color: "#2dd4bf", cursor: "pointer", fontSize: 13, padding: 0, lineHeight: 1, fontFamily: "inherit" }}>×</button>
</span>
))}
</div>
<div style={{ display: "flex", gap: 6 }}>
<Input value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addTag()} placeholder="Add tag…" style={{ flex: undefined, width: "auto" }} />
<button onClick={addTag} style={{ padding: "7px 12px", background: "transparent", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 12, fontFamily: "inherit" }}>+</button>
</div>
</div>
<SaveBtn pending={updateMutation.isPending} saved={saved} onClick={() => updateMutation.mutate({ name: nameInput.trim() || band.name, genre_tags: tags })} />
<Divider />
{/* Danger zone */}
<div style={{ border: "1px solid rgba(244,63,94,0.18)", borderRadius: 10, padding: "16px 18px", background: "rgba(244,63,94,0.03)" }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#f87171", marginBottom: 4 }}>Delete this band</div>
<div style={{ fontSize: 12, color: "rgba(244,63,94,0.45)", marginBottom: 12, lineHeight: 1.5 }}>
Removes all members and deletes comments. Storage files are NOT deleted.
</div>
<button style={{ background: "rgba(244,63,94,0.08)", border: "1px solid rgba(244,63,94,0.2)", borderRadius: 7, color: "#f87171", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontFamily: "inherit" }}>
Delete band
</button>
</div>
</div>
);
}
// ── Left nav ──────────────────────────────────────────────────────────────────
function NavItem({ 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: "8px 10px", borderRadius: 8,
border: "none", cursor: "pointer",
fontSize: 13, fontFamily: "inherit", fontWeight: active ? 600 : 400,
background: active ? "rgba(20,184,166,0.1)" : hovered ? "rgba(255,255,255,0.04)" : "transparent",
color: active ? "#2dd4bf" : hovered ? "rgba(232,233,240,0.75)" : "rgba(232,233,240,0.45)",
transition: "all 0.12s",
position: "relative",
}}
>
{active && <div style={{ position: "absolute", left: 0, top: "20%", bottom: "20%", width: 2, borderRadius: "0 2px 2px 0", background: "linear-gradient(to bottom, #0d9488, #22d3ee)" }} />}
{label}
</button>
);
}
function NavLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontSize: 10, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "rgba(232,233,240,0.25)", padding: "12px 10px 5px" }}>
{children}
</div>
);
}
// ── 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<MemberRead>("/auth/me") });
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { data: band } = useQuery({
queryKey: ["band", activeBandId],
queryFn: () => api.get<Band>(`/bands/${activeBandId}`),
enabled: !!activeBandId,
});
const { data: members, isLoading: membersLoading } = useQuery({
queryKey: ["members", activeBandId],
queryFn: () => api.get<BandMember[]>(`/bands/${activeBandId}/members`),
enabled: !!activeBandId,
});
const amAdmin = !!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false);
if (!me) return <div style={{ padding: 32, color: "rgba(232,233,240,0.35)" }}>Loading</div>;
// ── 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 (
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflowY: "auto" }}>
{/* Back to list */}
{section !== "profile" || searchParams.has("section") ? (
<div>
<button onClick={() => setSearchParams({})}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "12px 16px", background: "none", border: "none", cursor: "pointer", color: "#2dd4bf", fontSize: 13, fontFamily: "inherit" }}>
Settings
</button>
<div style={{ padding: "0 16px 24px" }}>
{section === "profile" && <ProfileSection me={me} />}
{section === "members" && activeBandId && band && <MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />}
{section === "storage" && activeBandId && band && <StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} me={me} />}
{section === "band" && activeBandId && band && amAdmin && <BandSection bandId={activeBandId} band={band} />}
</div>
</div>
) : (
<div style={{ padding: "20px 16px" }}>
<h1 style={{ fontSize: 22, fontWeight: 800, color: "#e8e9f0", margin: "0 0 20px", letterSpacing: -0.5 }}>Settings</h1>
{sections.map((s, i) => {
const showGroupLabel = i === 0 || sections[i - 1].group !== s.group;
return (
<div key={s.key}>
{showGroupLabel && <NavLabel>{s.group}</NavLabel>}
<button onClick={() => go(s.key)}
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "13px 12px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, cursor: "pointer", color: "#e8e9f0", fontFamily: "inherit", fontSize: 13, marginBottom: 6 }}>
{s.label}
<span style={{ color: "rgba(232,233,240,0.3)", fontSize: 16 }}></span>
</button>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Desktop: two-column ───────────────────────────────────────────────────
return (
<div style={{ display: "flex", height: "100%", overflow: "hidden" }}>
{/* Left nav */}
<nav style={{ width: 220, minWidth: 220, background: "#0c1612", borderRight: `1px solid ${border}`, padding: "20px 12px", display: "flex", flexDirection: "column", overflow: "hidden", flexShrink: 0 }}>
<h1 style={{ fontSize: 16, fontWeight: 800, color: "#e8e9f0", margin: "0 0 4px 8px", letterSpacing: -0.3 }}>Settings</h1>
<div style={{ height: 1, background: border, margin: "12px 0" }} />
<NavLabel>Account</NavLabel>
<NavItem label="Profile" active={section === "profile"} onClick={() => go("profile")} />
{activeBandId && band && (
<>
<div style={{ height: 1, background: border, margin: "10px 0" }} />
{/* Band switcher in nav */}
<div style={{ padding: "0 10px 8px" }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "rgba(232,233,240,0.25)", marginBottom: 6 }}>Band</div>
<select
value={activeBandId}
onChange={(e) => {
setActiveBandId(e.target.value);
// Stay on same section if possible
setSearchParams({ section }, { replace: true });
}}
style={{ width: "100%", padding: "6px 8px", background: "#101c18", border: `1px solid ${borderBright}`, borderRadius: 7, color: "#e8e9f0", fontSize: 12, fontFamily: "inherit", cursor: "pointer", outline: "none" }}
>
{bands?.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
<NavItem label="Members" active={section === "members"} onClick={() => go("members")} />
<NavItem label="Storage" active={section === "storage"} onClick={() => go("storage")} />
{amAdmin && <NavItem label="Band" active={section === "band"} onClick={() => go("band")} />}
</>
)}
{!activeBandId && (
<div style={{ padding: "12px 10px", fontSize: 12, color: "rgba(232,233,240,0.25)", lineHeight: 1.55 }}>
No band active. Navigate to a band to see band settings.
</div>
)}
</nav>
{/* Content */}
<div style={{ flex: 1, overflowY: "auto", padding: "32px 40px" }}>
<div style={{ maxWidth: 560 }}>
{section === "profile" && <ProfileSection me={me} />}
{section === "members" && activeBandId && band && (
<MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />
)}
{section === "storage" && activeBandId && band && (
<StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} me={me} />
)}
{section === "band" && activeBandId && band && amAdmin && (
<BandSection bandId={activeBandId} band={band} />
)}
{section === "band" && activeBandId && !amAdmin && (
<div style={{ color: "rgba(232,233,240,0.35)", fontSize: 13 }}>Only admins can access band settings.</div>
)}
{(section === "members" || section === "storage" || section === "band") && !activeBandId && (
<div style={{ color: "rgba(232,233,240,0.35)", fontSize: 13 }}>No active band selected.</div>
)}
</div>
</div>
</div>
);
}