development #1
@@ -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