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:
@@ -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,110 +241,114 @@ export function TopBandBar() {
|
||||
const border = "rgba(255,255,255,0.06)";
|
||||
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
|
||||
|
||||
{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 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={{ 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>
|
||||
))}
|
||||
<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>
|
||||
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
|
||||
<button
|
||||
onClick={() => { navigate("/"); setOpen(false); }}
|
||||
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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user