Compare commits
1 Commits
main
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a22348d282 |
311
web/src/components/CreateBandModal.tsx
Normal file
311
web/src/components/CreateBandModal.tsx
Normal file
@@ -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 (
|
||||
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: i === current ? 16 : 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: i === current ? "#14b8a6" : i < current ? "rgba(20,184,166,0.4)" : "rgba(255,255,255,0.12)",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error banner ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ErrorBanner({ msg }: { msg: string }) {
|
||||
return (
|
||||
<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" }}>
|
||||
{msg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string | null>(null);
|
||||
const urlRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>NEXTCLOUD URL</label>
|
||||
<input
|
||||
ref={urlRef}
|
||||
value={ncUrl}
|
||||
onChange={(e) => setNcUrl(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="https://cloud.example.com"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>USERNAME</label>
|
||||
<input
|
||||
value={ncUsername}
|
||||
onChange={(e) => setNcUsername(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="your-nc-username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<label style={labelStyle}>APP PASSWORD</label>
|
||||
<input
|
||||
value={ncPassword}
|
||||
onChange={(e) => setNcPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
type="password"
|
||||
placeholder="Generate one in Nextcloud → Settings → Security"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
|
||||
Use an app password, not your account password.
|
||||
</p>
|
||||
|
||||
{error && <ErrorBanner msg={error} />}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={onNext}
|
||||
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" }}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!canSave || saveMutation.isPending}
|
||||
style={{ padding: "8px 18px", background: canSave ? "#14b8a6" : "rgba(20,184,166,0.3)", border: "none", borderRadius: 7, color: canSave ? "#fff" : "rgba(255,255,255,0.4)", cursor: canSave ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving…" : "Save & Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(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 && (
|
||||
<div style={{ marginBottom: 18, padding: "9px 12px", background: "rgba(251,191,36,0.07)", border: "1px solid rgba(251,191,36,0.2)", borderRadius: 7, fontSize: 12, color: "rgba(251,191,36,0.8)", lineHeight: 1.5 }}>
|
||||
Storage not configured — recordings won't be scanned. You can set it up later in Settings → Storage.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <ErrorBanner msg={error} />}
|
||||
|
||||
<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>
|
||||
|
||||
<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/"}
|
||||
disabled={!ncConfigured}
|
||||
/>
|
||||
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
|
||||
{ncConfigured
|
||||
? <>Leave blank to auto-create <code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>bands/{slug || "slug"}/</code>.</>
|
||||
: "Connect storage first to set a folder."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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" }}
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Create Band"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create Band Modal (orchestrates steps) ─────────────────────────────────────
|
||||
|
||||
export function CreateBandModal({ onClose }: { onClose: () => void }) {
|
||||
const { data: me, isLoading } = useQuery<Me>({
|
||||
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 (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => 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 */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
|
||||
{step === 0 ? "Connect storage" : "New band"}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
|
||||
{step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."}
|
||||
</p>
|
||||
</div>
|
||||
{totalSteps > 1 && step !== null && (
|
||||
<StepDots current={currentDot} total={totalSteps} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading || step === null ? (
|
||||
<p style={{ color: "rgba(232,233,240,0.3)", fontSize: 13 }}>Loading…</p>
|
||||
) : step === 0 ? (
|
||||
<StorageStep me={me!} onNext={() => setStep(1)} />
|
||||
) : (
|
||||
<BandStep ncConfigured={me?.nc_configured ?? false} onClose={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: i === current ? 16 : 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: i === current ? "#14b8a6" : i < current ? "rgba(20,184,166,0.4)" : "rgba(255,255,255,0.12)",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error banner ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ErrorBanner({ msg }: { msg: string }) {
|
||||
return (
|
||||
<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" }}>
|
||||
{msg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string | null>(null);
|
||||
const urlRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>NEXTCLOUD URL</label>
|
||||
<input
|
||||
ref={urlRef}
|
||||
value={ncUrl}
|
||||
onChange={(e) => setNcUrl(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="https://cloud.example.com"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>USERNAME</label>
|
||||
<input
|
||||
value={ncUsername}
|
||||
onChange={(e) => setNcUsername(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="your-nc-username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<label style={labelStyle}>APP PASSWORD</label>
|
||||
<input
|
||||
value={ncPassword}
|
||||
onChange={(e) => setNcPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
type="password"
|
||||
placeholder="Generate one in Nextcloud → Settings → Security"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
|
||||
Use an app password, not your account password.
|
||||
</p>
|
||||
|
||||
{error && <ErrorBanner msg={error} />}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={onNext}
|
||||
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" }}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!canSave || saveMutation.isPending}
|
||||
style={{ padding: "8px 18px", background: canSave ? "#14b8a6" : "rgba(20,184,166,0.3)", border: "none", borderRadius: 7, color: canSave ? "#fff" : "rgba(255,255,255,0.4)", cursor: canSave ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving…" : "Save & Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(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 && (
|
||||
<div style={{ marginBottom: 18, padding: "9px 12px", background: "rgba(251,191,36,0.07)", border: "1px solid rgba(251,191,36,0.2)", borderRadius: 7, fontSize: 12, color: "rgba(251,191,36,0.8)", lineHeight: 1.5 }}>
|
||||
Storage not configured — recordings won't be scanned. You can set it up later in Settings → Storage.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <ErrorBanner msg={error} />}
|
||||
|
||||
<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>
|
||||
|
||||
<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/"}
|
||||
disabled={!ncConfigured}
|
||||
/>
|
||||
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
|
||||
{ncConfigured
|
||||
? <>Leave blank to auto-create <code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>bands/{slug || "slug"}/</code>.</>
|
||||
: "Connect storage first to set a folder."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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" }}
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Create Band"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create Band Modal (orchestrates steps) ─────────────────────────────────────
|
||||
|
||||
function CreateBandModal({ onClose }: { onClose: () => void }) {
|
||||
const { data: me, isLoading } = useQuery<Me>({
|
||||
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 (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => 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 */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
|
||||
{step === 0 ? "Connect storage" : "New band"}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
|
||||
{step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."}
|
||||
</p>
|
||||
</div>
|
||||
{totalSteps > 1 && step !== null && (
|
||||
<StepDots current={currentDot} total={totalSteps} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading || step === null ? (
|
||||
<p style={{ color: "rgba(232,233,240,0.3)", fontSize: 13 }}>Loading…</p>
|
||||
) : step === 0 ? (
|
||||
<StorageStep me={me!} onNext={() => setStep(1)} />
|
||||
) : (
|
||||
<BandStep ncConfigured={me?.nc_configured ?? false} onClose={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { CreateBandModal } from "./CreateBandModal";
|
||||
|
||||
// ── TopBandBar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
||||
@@ -30,127 +32,186 @@ export function TopBar() {
|
||||
}, [dropdownOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 50,
|
||||
background: "#0b0b0e",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
padding: "0 16px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div ref={dropdownRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 10px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<>
|
||||
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
|
||||
|
||||
<header
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 50,
|
||||
background: "#0b0b0e",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 16px",
|
||||
}}
|
||||
>
|
||||
{/* App name / logo */}
|
||||
<span style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 700, color: "#14b8a6", letterSpacing: "0.02em", userSelect: "none" }}>
|
||||
◈ RH
|
||||
</span>
|
||||
|
||||
{/* Band picker */}
|
||||
<div ref={dropdownRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
border: "1px solid rgba(232,162,42,0.3)",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
gap: 8,
|
||||
padding: "5px 10px",
|
||||
background: dropdownOpen ? "rgba(255,255,255,0.07)" : "rgba(255,255,255,0.04)",
|
||||
border: `1px solid ${dropdownOpen ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.07)"}`,
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 13,
|
||||
maxWidth: 180,
|
||||
}}
|
||||
>
|
||||
{activeBand ? getInitials(activeBand.name) : "?"}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
border: "1px solid rgba(232,162,42,0.3)",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activeBand ? getInitials(activeBand.name) : "?"}
|
||||
</div>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontSize: 13, fontWeight: 500 }}>
|
||||
{activeBand ? activeBand.name : "Select band"}
|
||||
</span>
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style={{ color: "rgba(232,233,240,0.3)", flexShrink: 0 }}>
|
||||
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && bands && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
right: 0,
|
||||
width: 200,
|
||||
background: "#18181e",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
padding: 6,
|
||||
zIndex: 1001,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{bands.map((band) => (
|
||||
<button
|
||||
key={band.id}
|
||||
onClick={() => {
|
||||
navigate(`/bands/${band.id}`);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
marginBottom: 2,
|
||||
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 6px)",
|
||||
right: 0,
|
||||
minWidth: 200,
|
||||
background: "#18181e",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
padding: 6,
|
||||
zIndex: 1001,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{bands?.map((band) => (
|
||||
<button
|
||||
key={band.id}
|
||||
onClick={() => {
|
||||
navigate(`/bands/${band.id}`);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
marginBottom: 2,
|
||||
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{getInitials(band.name)}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>
|
||||
{band.name}
|
||||
</span>
|
||||
{band.id === activeBandId && (
|
||||
<span style={{ fontSize: 12, color: "#e8a22a", flexShrink: 0 }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getInitials(band.name)}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{band.name}
|
||||
</span>
|
||||
{band.id === activeBandId && (
|
||||
<span style={{ fontSize: 12, color: "#e8a22a", flexShrink: 0 }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(20,184,166,0.1)",
|
||||
border: "1px dashed rgba(20,184,166,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 14,
|
||||
color: "#14b8a6",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
<span style={{ color: "rgba(232,233,240,0.5)", fontSize: 13 }}>New band</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user