626 lines
20 KiB
TypeScript
Executable File
626 lines
20 KiB
TypeScript
Executable File
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 { api } from "../api/client";
|
|
import { logout } from "../api/auth";
|
|
import { getInitials } from "../utils";
|
|
import type { MemberRead } from "../api/auth";
|
|
import { usePlayerStore } from "../stores/playerStore";
|
|
|
|
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
|
function IconWaveform() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" />
|
|
<rect x="1" y="5.5" width="9" height="2" rx="1" fill="white" opacity=".7" />
|
|
<rect x="1" y="9.5" width="11" height="2" rx="1" fill="white" opacity=".8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconLibrary() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconPlay() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
<path d="M3 2l9 5-9 5V2z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSettings() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
<circle cx="7" cy="7" r="2" />
|
|
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconMembers() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
<circle cx="5" cy="4.5" r="2" />
|
|
<path d="M1 12c0-2.2 1.8-3.5 4-3.5s4 1.3 4 3.5H1z" />
|
|
<circle cx="10.5" cy="4.5" r="1.5" opacity=".6" />
|
|
<path d="M10.5 8.5c1.4 0 2.5 1 2.5 2.5H9.5" opacity=".6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconStorage() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
<rect x="1" y="3" width="12" height="3" rx="1.5" />
|
|
<rect x="1" y="8" width="12" height="3" rx="1.5" />
|
|
<circle cx="11" cy="4.5" r=".75" fill="#0b0b0e" />
|
|
<circle cx="11" cy="9.5" r=".75" fill="#0b0b0e" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconChevron() {
|
|
return (
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M3 5l3 3 3-3" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSignOut() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M5 2H2.5A1.5 1.5 0 0 0 1 3.5v7A1.5 1.5 0 0 0 2.5 12H5" />
|
|
<path d="M9 10l3-3-3-3M12 7H5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ── NavItem ─────────────────────────────────────────────────────────────────
|
|
interface NavItemProps {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
const color = active
|
|
? "#e8a22a"
|
|
: disabled
|
|
? "rgba(255,255,255,0.18)"
|
|
: hovered
|
|
? "rgba(255,255,255,0.7)"
|
|
: "rgba(255,255,255,0.35)";
|
|
|
|
const bg = active
|
|
? "rgba(232,162,42,0.12)"
|
|
: hovered && !disabled
|
|
? "rgba(255,255,255,0.045)"
|
|
: "transparent";
|
|
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 9,
|
|
width: "100%",
|
|
padding: "7px 10px",
|
|
borderRadius: 7,
|
|
border: "none",
|
|
cursor: disabled ? "default" : "pointer",
|
|
color,
|
|
background: bg,
|
|
fontSize: 12,
|
|
textAlign: "left",
|
|
marginBottom: 1,
|
|
transition: "background 0.12s, color 0.12s",
|
|
fontFamily: "inherit",
|
|
}}
|
|
>
|
|
{icon}
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Sidebar ────────────────────────────────────────────────────────────────
|
|
export function Sidebar({ children }: { children: React.ReactNode }) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
|
const { data: me } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: () => api.get<MemberRead>("/auth/me"),
|
|
});
|
|
|
|
// Derive active band from the current URL
|
|
const bandMatch =
|
|
matchPath("/bands/:bandId/*", location.pathname) ??
|
|
matchPath("/bands/:bandId", location.pathname);
|
|
const activeBandId = bandMatch?.params?.bandId ?? null;
|
|
const activeBand = bands?.find((b) => b.id === activeBandId) ?? null;
|
|
|
|
// Nav active states
|
|
const isLibrary = !!(
|
|
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
|
|
matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) ||
|
|
matchPath("/bands/:bandId/sessions/:sessionId/*", location.pathname)
|
|
);
|
|
const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
|
|
const isSettings = location.pathname.startsWith("/settings");
|
|
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
|
|
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
|
|
|
|
// Player state
|
|
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
|
|
const hasActiveSong = !!currentSongId && !!playerBandId;
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
if (!dropdownOpen) return;
|
|
function handleClick(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setDropdownOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [dropdownOpen]);
|
|
|
|
const border = "rgba(255,255,255,0.06)";
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
height: "100vh",
|
|
overflow: "hidden",
|
|
background: "#0f0f12",
|
|
color: "#eeeef2",
|
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
{/* ── Sidebar ──────────────────────────────────────────────────── */}
|
|
<aside
|
|
style={{
|
|
width: 210,
|
|
minWidth: 210,
|
|
background: "#0b0b0e",
|
|
borderRight: `1px solid ${border}`,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Logo */}
|
|
<div
|
|
style={{
|
|
padding: "17px 14px 14px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
borderBottom: `1px solid ${border}`,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
background: "#e8a22a",
|
|
borderRadius: 7,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<IconWaveform />
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}>
|
|
RehearsalHub
|
|
</div>
|
|
{activeBand && (
|
|
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.25)", marginTop: 1 }}>
|
|
{activeBand.name}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Band switcher */}
|
|
<div
|
|
ref={dropdownRef}
|
|
style={{
|
|
padding: "10px 8px",
|
|
borderBottom: `1px solid ${border}`,
|
|
position: "relative",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => setDropdownOpen((o) => !o)}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "7px 9px",
|
|
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",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 26,
|
|
height: 26,
|
|
background: "rgba(232,162,42,0.15)",
|
|
border: "1px solid rgba(232,162,42,0.3)",
|
|
borderRadius: 7,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
color: "#e8a22a",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{activeBand ? getInitials(activeBand.name) : "?"}
|
|
</div>
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{activeBand?.name ?? "Select a band"}
|
|
</span>
|
|
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
|
|
<IconChevron />
|
|
</span>
|
|
</button>
|
|
|
|
{dropdownOpen && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "calc(100% - 2px)",
|
|
left: 8,
|
|
right: 8,
|
|
background: "#18181e",
|
|
border: "1px solid rgba(255,255,255,0.1)",
|
|
borderRadius: 10,
|
|
padding: 6,
|
|
zIndex: 100,
|
|
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: "7px 9px",
|
|
marginBottom: 1,
|
|
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
cursor: "pointer",
|
|
color: "#eeeef2",
|
|
textAlign: "left",
|
|
fontFamily: "inherit",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 5,
|
|
background: "rgba(232,162,42,0.15)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 9,
|
|
fontWeight: 700,
|
|
color: "#e8a22a",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{getInitials(band.name)}
|
|
</div>
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 12,
|
|
color: "rgba(255,255,255,0.62)",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{band.name}
|
|
</span>
|
|
{band.id === activeBandId && (
|
|
<span style={{ fontSize: 10, color: "#e8a22a", flexShrink: 0 }}>✓</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
|
|
<div
|
|
style={{
|
|
borderTop: "1px solid rgba(255,255,255,0.06)",
|
|
marginTop: 4,
|
|
paddingTop: 4,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
navigate("/");
|
|
setDropdownOpen(false);
|
|
}}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "7px 9px",
|
|
background: "transparent",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
cursor: "pointer",
|
|
color: "rgba(255,255,255,0.35)",
|
|
fontSize: 12,
|
|
textAlign: "left",
|
|
fontFamily: "inherit",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
|
|
Create new band
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}>
|
|
{activeBand && (
|
|
<>
|
|
<SectionLabel>{activeBand.name}</SectionLabel>
|
|
<NavItem
|
|
icon={<IconLibrary />}
|
|
label="Library"
|
|
active={isLibrary}
|
|
onClick={() => navigate(`/bands/${activeBand.id}`)}
|
|
/>
|
|
<NavItem
|
|
icon={<IconPlay />}
|
|
label="Player"
|
|
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
|
|
onClick={() => {
|
|
if (hasActiveSong) {
|
|
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
|
|
}
|
|
}}
|
|
disabled={!hasActiveSong}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeBand && (
|
|
<>
|
|
<SectionLabel style={{ paddingTop: 14 }}>Band Settings</SectionLabel>
|
|
<NavItem
|
|
icon={<IconMembers />}
|
|
label="Members"
|
|
active={isBandSettings && bandSettingsPanel === "members"}
|
|
onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)}
|
|
/>
|
|
<NavItem
|
|
icon={<IconStorage />}
|
|
label="Storage"
|
|
active={isBandSettings && bandSettingsPanel === "storage"}
|
|
onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)}
|
|
/>
|
|
<NavItem
|
|
icon={<IconSettings />}
|
|
label="Band Settings"
|
|
active={isBandSettings && bandSettingsPanel === "band"}
|
|
onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<SectionLabel style={{ paddingTop: 14 }}>Account</SectionLabel>
|
|
<NavItem
|
|
icon={<IconSettings />}
|
|
label="Settings"
|
|
active={isSettings}
|
|
onClick={() => navigate("/settings")}
|
|
/>
|
|
</nav>
|
|
|
|
{/* User row */}
|
|
<div
|
|
style={{
|
|
padding: "10px",
|
|
borderTop: `1px solid ${border}`,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<button
|
|
onClick={() => navigate("/settings")}
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "6px 8px",
|
|
background: "transparent",
|
|
border: "none",
|
|
borderRadius: 8,
|
|
cursor: "pointer",
|
|
color: "#eeeef2",
|
|
textAlign: "left",
|
|
minWidth: 0,
|
|
fontFamily: "inherit",
|
|
}}
|
|
>
|
|
{me?.avatar_url ? (
|
|
<img
|
|
src={me.avatar_url}
|
|
alt=""
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: "50%",
|
|
objectFit: "cover",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: "50%",
|
|
background: "rgba(232,162,42,0.18)",
|
|
border: "1.5px solid rgba(232,162,42,0.35)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
color: "#e8a22a",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{getInitials(me?.display_name ?? "?")}
|
|
</div>
|
|
)}
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 12,
|
|
color: "rgba(255,255,255,0.55)",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{me?.display_name ?? "…"}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => logout()}
|
|
title="Sign out"
|
|
style={{
|
|
flexShrink: 0,
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 7,
|
|
background: "transparent",
|
|
border: "1px solid transparent",
|
|
cursor: "pointer",
|
|
color: "rgba(255,255,255,0.2)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
transition: "border-color 0.12s, color 0.12s",
|
|
padding: 0,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)";
|
|
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "transparent";
|
|
e.currentTarget.style.color = "rgba(255,255,255,0.2)";
|
|
}}
|
|
>
|
|
<IconSignOut />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ── Main content ──────────────────────────────────────────────── */}
|
|
<main
|
|
style={{
|
|
flex: 1,
|
|
overflow: "auto",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
background: "#0f0f12",
|
|
}}
|
|
>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({
|
|
children,
|
|
style,
|
|
}: {
|
|
children: React.ReactNode;
|
|
style?: React.CSSProperties;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
fontSize: 10,
|
|
fontWeight: 500,
|
|
color: "rgba(255,255,255,0.2)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.7px",
|
|
padding: "0 6px 5px",
|
|
...style,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|