diff --git a/web/src/components/CreateBandModal.tsx b/web/src/components/CreateBandModal.tsx
new file mode 100644
index 0000000..58e9831
--- /dev/null
+++ b/web/src/components/CreateBandModal.tsx
@@ -0,0 +1,311 @@
+import { useRef, useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { createBand } from "../api/bands";
+import { api } from "../api/client";
+
+// ── Shared primitives ──────────────────────────────────────────────────────────
+
+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("");
+ const [slug, setSlug] = useState("");
+ const [ncFolder, setNcFolder] = useState("");
+ const [error, setError] = useState(null);
+ const nameRef = useRef(null);
+
+ useEffect(() => { nameRef.current?.focus(); }, []);
+
+ 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, ""));
+ };
+
+ return (
+ <>
+ {!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) ─────────────────────────────────────
+
+export 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>(me ? (me.nc_configured ? 1 : 0) : null);
+
+ // 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 (
+
+
e.stopPropagation()}
+ style={{ background: "#112018", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 14, padding: 28, width: 420, maxWidth: "calc(100vw - 32px)", boxShadow: "0 24px 64px rgba(0,0,0,0.6)" }}
+ >
+ {/* 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 && (
+
+ )}
+
+
+ {isLoading || step === null ? (
+
Loading…
+ ) : step === 0 ? (
+
setStep(1)} />
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/TopBandBar.tsx b/web/src/components/TopBandBar.tsx
index b64edd3..beca4af 100644
--- a/web/src/components/TopBandBar.tsx
+++ b/web/src/components/TopBandBar.tsx
@@ -1,316 +1,10 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { listBands, createBand } from "../api/bands";
+import { useQuery } from "@tanstack/react-query";
+import { listBands } from "../api/bands";
import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore";
-import { api } from "../api/client";
-
-// ── Shared primitives ──────────────────────────────────────────────────────────
-
-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("");
- const [slug, setSlug] = useState("");
- const [ncFolder, setNcFolder] = useState("");
- const [error, setError] = useState(null);
- const nameRef = useRef(null);
-
- useEffect(() => { nameRef.current?.focus(); }, []);
-
- 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, ""));
- };
-
- return (
- <>
- {!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>(me ? (me.nc_configured ? 1 : 0) : null);
-
- // 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 (
-
-
e.stopPropagation()}
- 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)" }}
- >
- {/* 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 && (
-
- )}
-
-
- {isLoading || step === null ? (
-
Loading…
- ) : step === 0 ? (
-
setStep(1)} />
- ) : (
-
- )}
-
-
- );
-}
+import { CreateBandModal } from "./CreateBandModal";
// ── TopBandBar ─────────────────────────────────────────────────────────────────
diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx
index 1086ba8..b2c77f8 100755
--- a/web/src/components/TopBar.tsx
+++ b/web/src/components/TopBar.tsx
@@ -3,11 +3,13 @@ import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { getInitials } from "../utils";
+import { CreateBandModal } from "./CreateBandModal";
export function TopBar() {
const navigate = useNavigate();
const location = useLocation();
const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [showCreate, setShowCreate] = useState(false);
const dropdownRef = useRef(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
@@ -30,127 +32,186 @@ export function TopBar() {
}, [dropdownOpen]);
return (
-
-
-
+ ))}
+
+
+
{
+ setDropdownOpen(false);
+ setShowCreate(true);
+ }}
+ style={{
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "8px 10px",
+ background: "transparent",
+ border: "none",
+ borderRadius: 6,
+ cursor: "pointer",
+ color: "rgba(232,233,240,0.4)",
+ textAlign: "left",
+ fontFamily: "inherit",
+ fontSize: 13,
+ }}
+ >
+
+ +
+
+ New band
+
+
+
+ )}
+
+
+ >
);
-}
\ No newline at end of file
+}