development #1

Merged
sschuhmann merged 11 commits from development into main 2026-04-10 07:57:43 +00:00
Showing only changes of commit efb16a096d - Show all commits

View File

@@ -4,10 +4,157 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands"; import { listBands, createBand } from "../api/bands";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore"; import { useBandStore } from "../stores/bandStore";
import { api } from "../api/client";
// ── Create Band Modal ────────────────────────────────────────────────────────── // ── Shared primitives ──────────────────────────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) { 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,
};
// ── Step indicator ─────────────────────────────────────────────────────────────
function StepDots({ current, total }: { current: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
style={{
width: i === current ? 16 : 6,
height: 6,
borderRadius: 3,
background: i === current ? "#14b8a6" : i < current ? "rgba(20,184,166,0.4)" : "rgba(255,255,255,0.12)",
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
// ── 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>
);
}
// ── Step 1: Storage setup ──────────────────────────────────────────────────────
interface Me { nc_configured: boolean; nc_url: string | null; nc_username: string | null; }
function StorageStep({ me, onNext }: { me: Me; onNext: () => void }) {
const qc = useQueryClient();
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => { urlRef.current?.focus(); }, []);
const saveMutation = useMutation({
mutationFn: () =>
api.patch("/auth/me/settings", {
nc_url: ncUrl.trim() || null,
nc_username: ncUsername.trim() || null,
nc_password: ncPassword || null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
onNext();
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});
const canSave = ncUrl.trim() && ncUsername.trim() && ncPassword;
return (
<>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>NEXTCLOUD URL</label>
<input
ref={urlRef}
value={ncUrl}
onChange={(e) => setNcUrl(e.target.value)}
style={inputStyle}
placeholder="https://cloud.example.com"
type="url"
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>USERNAME</label>
<input
value={ncUsername}
onChange={(e) => setNcUsername(e.target.value)}
style={inputStyle}
placeholder="your-nc-username"
autoComplete="username"
/>
</div>
<div style={{ marginBottom: 4 }}>
<label style={labelStyle}>APP PASSWORD</label>
<input
value={ncPassword}
onChange={(e) => setNcPassword(e.target.value)}
style={inputStyle}
type="password"
placeholder="Generate one in Nextcloud → Settings → Security"
autoComplete="current-password"
/>
</div>
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Use an app password, not your account password.
</p>
{error && <ErrorBanner msg={error} />}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={onNext}
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" }}
>
Skip for now
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={!canSave || saveMutation.isPending}
style={{ padding: "8px 18px", background: canSave ? "#14b8a6" : "rgba(20,184,166,0.3)", border: "none", borderRadius: 7, color: canSave ? "#fff" : "rgba(255,255,255,0.4)", cursor: canSave ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{saveMutation.isPending ? "Saving…" : "Save & Continue"}
</button>
</div>
</>
);
}
// ── Step 2: Band details ───────────────────────────────────────────────────────
function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -16,16 +163,7 @@ function CreateBandModal({ onClose }: { onClose: () => void }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => { nameRef.current?.focus(); }, []);
nameRef.current?.focus();
}, []);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -39,165 +177,140 @@ function CreateBandModal({ onClose }: { onClose: () => void }) {
onClose(); onClose();
navigate(`/bands/${band.id}`); navigate(`/bands/${band.id}`);
}, },
onError: (err) => { onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
setError(err instanceof Error ? err.message : "Failed to create band");
},
}); });
const handleNameChange = (v: string) => { const handleNameChange = (v: string) => {
setName(v); setName(v);
setSlug( setSlug(v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
);
};
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,
}; };
return ( return (
/* Backdrop */ <>
{!ncConfigured && (
<div style={{ marginBottom: 18, padding: "9px 12px", background: "rgba(251,191,36,0.07)", border: "1px solid rgba(251,191,36,0.2)", borderRadius: 7, fontSize: 12, color: "rgba(251,191,36,0.8)", lineHeight: 1.5 }}>
Storage not configured recordings won't be scanned. You can set it up later in Settings Storage.
</div>
)}
{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: 20 }}>
<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>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 18, marginBottom: 22 }}>
<label style={labelStyle}>
NEXTCLOUD FOLDER{" "}
<span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400, letterSpacing: 0 }}>(optional)</span>
</label>
<input
value={ncFolder}
onChange={(e) => setNcFolder(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder={slug ? `bands/${slug}/` : "bands/my-band/"}
disabled={!ncConfigured}
/>
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
{ncConfigured
? <>Leave blank to auto-create <code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>bands/{slug || "slug"}/</code>.</>
: "Connect storage first to set a folder."}
</p>
</div>
<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 (orchestrates steps) ─────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) {
const { data: me, isLoading } = useQuery<Me>({
queryKey: ["me"],
queryFn: () => api.get("/auth/me"),
});
// Start on step 0 (storage) if NC not configured, otherwise jump straight to step 1 (band)
const [step, setStep] = useState<0 | 1 | null>(null);
useEffect(() => {
if (me && step === null) setStep(me.nc_configured ? 1 : 0);
}, [me, step]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const totalSteps = me?.nc_configured === false ? 2 : 1;
const currentDot = step === 0 ? 0 : totalSteps - 1;
return (
<div <div
onClick={onClose} onClick={onClose}
style={{ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
position: "fixed", inset: 0,
background: "rgba(0,0,0,0.55)",
zIndex: 200,
display: "flex", alignItems: "center", justifyContent: "center",
}}
> >
{/* Dialog */}
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ 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)" }}
background: "#112018",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 14,
padding: 28,
width: 400,
boxShadow: "0 24px 64px rgba(0,0,0,0.6)",
}}
> >
<h3 style={{ margin: "0 0 4px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}> {/* Header */}
New band <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}>
</h3> <div>
<p style={{ margin: "0 0 22px", fontSize: 12, color: "rgba(232,233,240,0.4)", lineHeight: 1.5 }}> <h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
Create a workspace for your recordings. {step === 0 ? "Connect storage" : "New band"}
</p> </h3>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
{step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."}
</p>
</div>
{totalSteps > 1 && step !== null && (
<StepDots current={currentDot} total={totalSteps} />
)}
</div>
{error && ( {isLoading || step === null ? (
<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" }}> <p style={{ color: "rgba(232,233,240,0.3)", fontSize: 13 }}>Loading</p>
{error} ) : step === 0 ? (
</p> <StorageStep me={me!} onNext={() => setStep(1)} />
) : (
<BandStep ncConfigured={me?.nc_configured ?? false} onClose={onClose} />
)} )}
<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: 20 }}>
<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>
{/* NC folder section */}
<div style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
paddingTop: 18,
marginBottom: 22,
}}>
<label style={labelStyle}>NEXTCLOUD FOLDER <span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400, letterSpacing: 0 }}>(optional)</span></label>
<input
value={ncFolder}
onChange={(e) => setNcFolder(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder={slug ? `bands/${slug}/` : "bands/my-band/"}
/>
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Path relative to your Nextcloud root. Leave blank to auto-create{" "}
<code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>
bands/{slug || "slug"}/
</code>
.{" "}
Nextcloud must be configured in{" "}
<a
href="/settings?section=storage"
onClick={onClose}
style={{ color: "#2dd4bf", textDecoration: "none" }}
>
Settings Storage
</a>
.
</p>
</div>
{/* Actions */}
<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",
transition: "background 0.12s",
}}
>
{mutation.isPending ? "Creating…" : "Create Band"}
</button>
</div>
</div> </div>
</div> </div>
); );