Replaces per-member Nextcloud credentials with a BandStorage model that supports multiple providers. Credentials are Fernet-encrypted at rest; worker receives audio via an internal streaming endpoint instead of direct storage access. - Add BandStorage DB model with partial unique index (one active per band) - Add migrations 0007 (create band_storage) and 0008 (drop old nc columns) - Add StorageFactory that builds the correct StorageClient from BandStorage - Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect - Add Fernet encryption helpers in security/encryption.py - Rewrite watcher for per-band polling via internal API config endpoint - Update worker to stream audio from API instead of accessing storage directly - Update frontend: new storage API in bands.ts, rewritten StorageSection, simplified band creation modal (no storage step) - Add STORAGE_ENCRYPTION_KEY to all docker-compose files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import { useRef, useState, useEffect } from "react";
|
|
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { listBands, createBand } from "../api/bands";
|
|
import { getInitials } from "../utils";
|
|
import { useBandStore } from "../stores/bandStore";
|
|
|
|
// ── Shared primitives ──────────────────────────────────────────────────────────
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
padding: "8px 11px",
|
|
background: "rgba(255,255,255,0.04)",
|
|
border: "1px solid rgba(255,255,255,0.1)",
|
|
borderRadius: 7,
|
|
color: "#e8e9f0",
|
|
fontSize: 13,
|
|
fontFamily: "inherit",
|
|
outline: "none",
|
|
boxSizing: "border-box",
|
|
};
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
display: "block",
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
letterSpacing: "0.06em",
|
|
color: "rgba(232,233,240,0.4)",
|
|
marginBottom: 5,
|
|
};
|
|
|
|
// ── Error banner ───────────────────────────────────────────────────────────────
|
|
|
|
function ErrorBanner({ msg }: { msg: string }) {
|
|
return (
|
|
<p style={{ margin: "0 0 14px", fontSize: 12, color: "#f87171", background: "rgba(248,113,113,0.08)", border: "1px solid rgba(248,113,113,0.2)", borderRadius: 6, padding: "8px 10px" }}>
|
|
{msg}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// ── Band creation form ─────────────────────────────────────────────────────────
|
|
|
|
function BandStep({ onClose }: { onClose: () => void }) {
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const [name, setName] = useState("");
|
|
const [slug, setSlug] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const nameRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => { nameRef.current?.focus(); }, []);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: () => createBand({ name, slug }),
|
|
onSuccess: (band) => {
|
|
qc.invalidateQueries({ queryKey: ["bands"] });
|
|
onClose();
|
|
navigate(`/bands/${band.id}`);
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
|
|
});
|
|
|
|
const handleNameChange = (v: string) => {
|
|
setName(v);
|
|
setSlug(v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{error && <ErrorBanner msg={error} />}
|
|
|
|
<div style={{ marginBottom: 14 }}>
|
|
<label style={labelStyle}>BAND NAME</label>
|
|
<input
|
|
ref={nameRef}
|
|
value={name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
style={inputStyle}
|
|
placeholder="e.g. The Midnight Trio"
|
|
onKeyDown={(e) => { if (e.key === "Enter" && name && slug) mutation.mutate(); }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 24 }}>
|
|
<label style={labelStyle}>SLUG</label>
|
|
<input
|
|
value={slug}
|
|
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
|
style={{ ...inputStyle, fontFamily: "monospace" }}
|
|
placeholder="the-midnight-trio"
|
|
/>
|
|
</div>
|
|
|
|
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
|
|
Connect storage after creating the band via Settings → Storage.
|
|
</p>
|
|
|
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
|
<button
|
|
onClick={onClose}
|
|
style={{ padding: "8px 16px", background: "transparent", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, color: "rgba(232,233,240,0.5)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => mutation.mutate()}
|
|
disabled={!name || !slug || mutation.isPending}
|
|
style={{ padding: "8px 18px", background: !name || !slug ? "rgba(20,184,166,0.3)" : "#14b8a6", border: "none", borderRadius: 7, color: !name || !slug ? "rgba(255,255,255,0.4)" : "#fff", cursor: !name || !slug ? "default" : "pointer", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
|
|
>
|
|
{mutation.isPending ? "Creating…" : "Create Band"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Create Band Modal ──────────────────────────────────────────────────────────
|
|
|
|
function CreateBandModal({ onClose }: { onClose: () => void }) {
|
|
// Close on Escape
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
document.addEventListener("keydown", handler);
|
|
return () => document.removeEventListener("keydown", handler);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div
|
|
onClick={onClose}
|
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{ background: "#112018", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 14, padding: 28, width: 420, boxShadow: "0 24px 64px rgba(0,0,0,0.6)" }}
|
|
>
|
|
<div style={{ marginBottom: 18 }}>
|
|
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>New band</h3>
|
|
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
|
|
Create a workspace for your recordings.
|
|
</p>
|
|
</div>
|
|
<BandStep onClose={onClose} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── TopBandBar ─────────────────────────────────────────────────────────────────
|
|
|
|
export function TopBandBar() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [open, setOpen] = useState(false);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
|
const { activeBandId, setActiveBandId } = useBandStore();
|
|
|
|
// Sync store from URL when on a band page
|
|
const urlMatch =
|
|
matchPath("/bands/:bandId/*", location.pathname) ??
|
|
matchPath("/bands/:bandId", location.pathname);
|
|
const urlBandId = urlMatch?.params?.bandId ?? null;
|
|
|
|
useEffect(() => {
|
|
if (urlBandId) setActiveBandId(urlBandId);
|
|
}, [urlBandId, setActiveBandId]);
|
|
|
|
const currentBandId = urlBandId ?? activeBandId;
|
|
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
|
|
|
|
// Close dropdown 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 (
|
|
<>
|
|
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
|
|
|
|
<div style={{
|
|
height: 44,
|
|
flexShrink: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "0 20px",
|
|
borderBottom: `1px solid ${border}`,
|
|
background: "#0c1612",
|
|
zIndex: 10,
|
|
}}>
|
|
{/* Band switcher */}
|
|
<div ref={ref} style={{ position: "relative" }}>
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
padding: "5px 10px",
|
|
background: open ? "rgba(255,255,255,0.06)" : "transparent",
|
|
border: `1px solid ${open ? "rgba(255,255,255,0.12)" : "transparent"}`,
|
|
borderRadius: 8,
|
|
cursor: "pointer", color: "#e8e9f0",
|
|
fontFamily: "inherit", fontSize: 13,
|
|
transition: "background 0.12s, border-color 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => { if (!open) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
|
|
onMouseLeave={(e) => { if (!open) e.currentTarget.style.background = "transparent"; }}
|
|
>
|
|
{activeBand ? (
|
|
<>
|
|
<div style={{
|
|
width: 22, height: 22, borderRadius: 6, flexShrink: 0,
|
|
background: "rgba(20,184,166,0.15)",
|
|
border: "1px solid rgba(20,184,166,0.3)",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: 9, fontWeight: 800, color: "#2dd4bf",
|
|
}}>
|
|
{getInitials(activeBand.name)}
|
|
</div>
|
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{activeBand.name}</span>
|
|
</>
|
|
) : (
|
|
<span style={{ color: "rgba(232,233,240,0.35)", fontSize: 13 }}>Select a band</span>
|
|
)}
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ color: "rgba(232,233,240,0.3)", marginLeft: 2 }}>
|
|
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
|
|
{open && (
|
|
<div style={{
|
|
position: "absolute", top: "calc(100% + 6px)", left: 0,
|
|
minWidth: 220,
|
|
background: "#142420",
|
|
border: "1px solid rgba(255,255,255,0.1)",
|
|
borderRadius: 10, padding: 6, zIndex: 100,
|
|
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
|
}}>
|
|
{bands?.map((band) => (
|
|
<button
|
|
key={band.id}
|
|
onClick={() => {
|
|
setActiveBandId(band.id);
|
|
navigate(`/bands/${band.id}`);
|
|
setOpen(false);
|
|
}}
|
|
style={{
|
|
width: "100%", display: "flex", alignItems: "center", gap: 8,
|
|
padding: "7px 9px", marginBottom: 1,
|
|
background: band.id === currentBandId ? "rgba(20,184,166,0.1)" : "transparent",
|
|
border: "none", borderRadius: 6,
|
|
cursor: "pointer", color: "#e8e9f0",
|
|
textAlign: "left", fontFamily: "inherit",
|
|
transition: "background 0.12s",
|
|
}}
|
|
onMouseEnter={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
|
|
onMouseLeave={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "transparent"; }}
|
|
>
|
|
<div style={{ width: 22, height: 22, borderRadius: 6, background: "rgba(20,184,166,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#2dd4bf", flexShrink: 0 }}>
|
|
{getInitials(band.name)}
|
|
</div>
|
|
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{band.name}
|
|
</span>
|
|
{band.id === currentBandId && (
|
|
<span style={{ fontSize: 10, color: "#2dd4bf", flexShrink: 0 }}>✓</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
|
|
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
|
|
<button
|
|
onClick={() => { setOpen(false); setShowCreate(true); }}
|
|
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", background: "transparent", border: "none", borderRadius: 6, cursor: "pointer", color: "rgba(232,233,240,0.35)", fontSize: 12, textAlign: "left", fontFamily: "inherit" }}
|
|
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.7)")}
|
|
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.35)")}
|
|
>
|
|
<span style={{ fontSize: 14, opacity: 0.6 }}>+</span>
|
|
New band
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|