development #1

Merged
sschuhmann merged 11 commits from development into main 2026-04-10 07:57:43 +00:00
Showing only changes of commit 1a29e6f492 - Show all commits

View File

@@ -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>
</>
);
}