Files
rehearshalhub/web/src/components/Sidebar.tsx
2026-04-08 15:10:52 +02:00

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