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>
851 lines
41 KiB
TypeScript
Executable File
851 lines
41 KiB
TypeScript
Executable File
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>
|
||
);
|
||
}
|