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 { useRef, useState, useEffect } from "react";
|
||||||
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { listBands } 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";
|
||||||
|
|
||||||
|
// ── 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() {
|
export function TopBandBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
||||||
@@ -27,7 +228,7 @@ export function TopBandBar() {
|
|||||||
const currentBandId = urlBandId ?? activeBandId;
|
const currentBandId = urlBandId ?? activeBandId;
|
||||||
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
|
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
|
||||||
|
|
||||||
// Close on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -40,6 +241,9 @@ export function TopBandBar() {
|
|||||||
const border = "rgba(255,255,255,0.06)";
|
const border = "rgba(255,255,255,0.06)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 44,
|
height: 44,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -132,7 +336,7 @@ export function TopBandBar() {
|
|||||||
|
|
||||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
|
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
|
||||||
<button
|
<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" }}
|
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)")}
|
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.7)")}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.35)")}
|
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.35)")}
|
||||||
@@ -145,5 +349,6 @@ export function TopBandBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user