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 ( -
-
-
- +
+ {activeBand ? getInitials(activeBand.name) : "?"} +
+ + {activeBand ? activeBand.name : "Select band"} + + + + + - {dropdownOpen && bands && ( -
- {bands.map((band) => ( -
- - {band.name} - - {band.id === activeBandId && ( - - )} - - ))} -
- )} -
- +
+ {getInitials(band.name)} +
+ + {band.name} + + {band.id === activeBandId && ( + + )} + + ))} + +
+ +
+ + )} + + + ); -} \ No newline at end of file +}