From efb16a096d55dd11ae83080746be818e1360b3ed Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Fri, 10 Apr 2026 09:26:25 +0200 Subject: [PATCH] feat(band): two-step create flow with Nextcloud storage setup Band creation now starts with a Nextcloud credentials step when storage is not yet configured. Users can save NC credentials (or skip) before proceeding to band name/slug/folder entry. - StorageStep: NC URL, username, app password; PATCH /auth/me/settings - BandStep: name, slug (auto-generated), NC folder with warning when NC not set - StepDots: animated pill indicators for current step - Modal fetches /auth/me on open to determine starting step Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/TopBandBar.tsx | 425 +++++++++++++++++++----------- 1 file changed, 269 insertions(+), 156 deletions(-) diff --git a/web/src/components/TopBandBar.tsx b/web/src/components/TopBandBar.tsx index 7be8c28..9e83a2f 100644 --- a/web/src/components/TopBandBar.tsx +++ b/web/src/components/TopBandBar.tsx @@ -4,10 +4,157 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { listBands, createBand } from "../api/bands"; import { getInitials } from "../utils"; 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 ( +
+ {Array.from({ length: total }, (_, i) => ( +
+ ))} +
+ ); +} + +// ── Error banner ─────────────────────────────────────────────────────────────── + +function ErrorBanner({ msg }: { msg: string }) { + return ( +

+ {msg} +

+ ); +} + +// ── 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(null); + const urlRef = useRef(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 ( + <> +
+ + setNcUrl(e.target.value)} + style={inputStyle} + placeholder="https://cloud.example.com" + type="url" + /> +
+ +
+ + setNcUsername(e.target.value)} + style={inputStyle} + placeholder="your-nc-username" + autoComplete="username" + /> +
+ +
+ + setNcPassword(e.target.value)} + style={inputStyle} + type="password" + placeholder="Generate one in Nextcloud → Settings → Security" + autoComplete="current-password" + /> +
+

+ Use an app password, not your account password. +

+ + {error && } + +
+ + +
+ + ); +} + +// ── Step 2: Band details ─────────────────────────────────────────────────────── + +function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: () => void }) { const navigate = useNavigate(); const qc = useQueryClient(); const [name, setName] = useState(""); @@ -16,16 +163,7 @@ function CreateBandModal({ onClose }: { onClose: () => void }) { const [error, setError] = useState(null); const nameRef = useRef(null); - useEffect(() => { - 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]); + useEffect(() => { nameRef.current?.focus(); }, []); const mutation = useMutation({ mutationFn: () => @@ -39,165 +177,140 @@ function CreateBandModal({ onClose }: { onClose: () => void }) { onClose(); navigate(`/bands/${band.id}`); }, - onError: (err) => { - setError(err instanceof Error ? err.message : "Failed to create band"); - }, + 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, "") - ); - }; - - 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, + setSlug(v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); }; return ( - /* Backdrop */ + <> + {!ncConfigured && ( +
+ Storage not configured — recordings won't be scanned. You can set it up later in Settings → Storage. +
+ )} + + {error && } + +
+ + handleNameChange(e.target.value)} + style={inputStyle} + placeholder="e.g. The Midnight Trio" + onKeyDown={(e) => { if (e.key === "Enter" && name && slug) mutation.mutate(); }} + /> +
+ +
+ + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} + style={{ ...inputStyle, fontFamily: "monospace" }} + placeholder="the-midnight-trio" + /> +
+ +
+ + setNcFolder(e.target.value)} + style={{ ...inputStyle, fontFamily: "monospace" }} + placeholder={slug ? `bands/${slug}/` : "bands/my-band/"} + disabled={!ncConfigured} + /> +

+ {ncConfigured + ? <>Leave blank to auto-create bands/{slug || "slug"}/. + : "Connect storage first to set a folder."} +

+
+ +
+ + +
+ + ); +} + +// ── Create Band Modal (orchestrates steps) ───────────────────────────────────── + +function CreateBandModal({ onClose }: { onClose: () => void }) { + const { data: me, isLoading } = useQuery({ + 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 (
- {/* Dialog */}
e.stopPropagation()} - style={{ - 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)", - }} + 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)" }} > -

- New band -

-

- Create a workspace for your recordings. -

+ {/* Header */} +
+
+

+ {step === 0 ? "Connect storage" : "New band"} +

+

+ {step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."} +

+
+ {totalSteps > 1 && step !== null && ( + + )} +
- {error && ( -

- {error} -

+ {isLoading || step === null ? ( +

Loading…

+ ) : step === 0 ? ( + setStep(1)} /> + ) : ( + )} - -
- - handleNameChange(e.target.value)} - style={inputStyle} - placeholder="e.g. The Midnight Trio" - onKeyDown={(e) => { if (e.key === "Enter" && name && slug) mutation.mutate(); }} - /> -
- -
- - setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} - style={{ ...inputStyle, fontFamily: "monospace" }} - placeholder="the-midnight-trio" - /> -
- - {/* NC folder section */} -
- - setNcFolder(e.target.value)} - style={{ ...inputStyle, fontFamily: "monospace" }} - placeholder={slug ? `bands/${slug}/` : "bands/my-band/"} - /> -

- Path relative to your Nextcloud root. Leave blank to auto-create{" "} - - bands/{slug || "slug"}/ - - .{" "} - Nextcloud must be configured in{" "} - - Settings → Storage - - . -

-
- - {/* Actions */} -
- - -
);