From 1a29e6f492a8f76aadae0c38f29058d89eda54d5 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Fri, 10 Apr 2026 09:19:33 +0200 Subject: [PATCH] feat(band): add Nextcloud folder field to band creation modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/TopBandBar.tsx | 411 ++++++++++++++++++++++-------- 1 file changed, 308 insertions(+), 103 deletions(-) diff --git a/web/src/components/TopBandBar.tsx b/web/src/components/TopBandBar.tsx index 19dd60e..7be8c28 100644 --- a/web/src/components/TopBandBar.tsx +++ b/web/src/components/TopBandBar.tsx @@ -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(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]); + + 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 */ +
+ {/* 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)", + }} + > +

+ New band +

+

+ Create a workspace for your recordings. +

+ + {error && ( +

+ {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" + /> +
+ + {/* 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 */} +
+ + +
+
+
+ ); +} + +// ── TopBandBar ───────────────────────────────────────────────────────────────── + export function TopBandBar() { const navigate = useNavigate(); const location = useLocation(); const [open, setOpen] = useState(false); + const [showCreate, setShowCreate] = useState(false); const ref = useRef(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 ( -
- {/* Band switcher */} -
- + <> + {showCreate && setShowCreate(false)} />} - {open && ( -
- {bands?.map((band) => ( - - ))} + {activeBand.name} + + ) : ( + Select a band + )} + + + + -
- + {open && ( +
+ {bands?.map((band) => ( + + ))} + +
+ +
-
- )} + )} +
-
+ ); }