feat(band): add Nextcloud folder field to band creation modal

The "New band" button in TopBandBar previously navigated to the HomePage
which immediately redirected back if any bands already existed, making it
impossible to create additional bands.

Replaced the navigation with an inline modal that:
- Opens directly from the "New band" button in the band switcher dropdown
- Fields: band name (with auto-slug), slug, Nextcloud folder path
- NC folder input shows placeholder based on current slug, links to
  Settings → Storage so the user knows where to configure Nextcloud
- Validates: disabled submit until name + slug are filled
- On success: invalidates band list cache and navigates to the new band
- Closes on backdrop click or Escape key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-10 09:19:33 +02:00
parent 037881a821
commit 1a29e6f492

View File

@@ -1,14 +1,215 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands";
import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore";
// ── Create Band Modal ──────────────────────────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [ncFolder, setNcFolder] = useState("");
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(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]);
const mutation = useMutation({
mutationFn: () =>
createBand({
name,
slug,
...(ncFolder.trim() ? { nc_base_path: ncFolder.trim() } : {}),
}),
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, "")
);
};
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 (
/* Backdrop */
<div
onClick={onClose}
style={{
position: "fixed", inset: 0,
background: "rgba(0,0,0,0.55)",
zIndex: 200,
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
{/* Dialog */}
<div
onClick={(e) => 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)",
}}
>
<h3 style={{ margin: "0 0 4px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
New band
</h3>
<p style={{ margin: "0 0 22px", fontSize: 12, color: "rgba(232,233,240,0.4)", lineHeight: 1.5 }}>
Create a workspace for your recordings.
</p>
{error && (
<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" }}>
{error}
</p>
)}
<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>
);
}
// ── 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 });
@@ -27,7 +228,7 @@ export function TopBandBar() {
const currentBandId = urlBandId ?? activeBandId;
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
// Close on outside click
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
@@ -40,6 +241,9 @@ export function TopBandBar() {
const border = "rgba(255,255,255,0.06)";
return (
<>
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
<div style={{
height: 44,
flexShrink: 0,
@@ -132,7 +336,7 @@ export function TopBandBar() {
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
<button
onClick={() => { navigate("/"); setOpen(false); }}
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)")}
@@ -145,5 +349,6 @@ export function TopBandBar() {
)}
</div>
</div>
</>
);
}