281 lines
11 KiB
TypeScript
Executable File
281 lines
11 KiB
TypeScript
Executable File
import { useState } from "react";
|
|
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
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";
|
|
import { useBandStore } from "../stores/bandStore";
|
|
import { TopBandBar } from "./TopBandBar";
|
|
|
|
// ── Icons ────────────────────────────────────────────────────────────────────
|
|
|
|
function IconMenu() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<path d="M3 5h12M3 9h12M3 13h8" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconLibrary() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<rect x="2" y="3.5" width="14" height="2" rx="1" fill="currentColor" />
|
|
<rect x="2" y="8" width="14" height="2" rx="1" fill="currentColor" />
|
|
<rect x="2" y="12.5" width="14" height="2" rx="1" fill="currentColor" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconPlay() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
|
<path d="M5 3l11 6-11 6V3z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSettings() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.4" />
|
|
<path d="M9 2v1.5M9 14.5V16M2 9h1.5M14.5 9H16M3.7 3.7l1.06 1.06M13.24 13.24l1.06 1.06M14.3 3.7l-1.06 1.06M4.76 13.24l-1.06 1.06" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSignOut() {
|
|
return (
|
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M5.5 2.5H3A1.5 1.5 0 0 0 1.5 4v7A1.5 1.5 0 0 0 3 12.5H5.5" />
|
|
<path d="M10 10.5l3-3-3-3M13 7.5H6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ── NavItem ──────────────────────────────────────────────────────────────────
|
|
|
|
interface NavItemProps {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
badge?: number;
|
|
collapsed: boolean;
|
|
}
|
|
|
|
function NavItem({ icon, label, active, onClick, disabled, badge, collapsed }: NavItemProps) {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
const fg = active
|
|
? "#a78bfa"
|
|
: disabled
|
|
? "rgba(255,255,255,0.16)"
|
|
: hovered
|
|
? "rgba(232,233,240,0.7)"
|
|
: "rgba(232,233,240,0.35)";
|
|
|
|
const bg = active
|
|
? "rgba(139,92,246,0.12)"
|
|
: hovered && !disabled
|
|
? "rgba(255,255,255,0.04)"
|
|
: "transparent";
|
|
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
title={collapsed ? label : undefined}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
width: "100%",
|
|
padding: "9px 10px",
|
|
borderRadius: 8,
|
|
border: "none",
|
|
cursor: disabled ? "default" : "pointer",
|
|
color: fg,
|
|
background: bg,
|
|
textAlign: "left",
|
|
transition: "background 0.15s, color 0.15s",
|
|
fontFamily: "inherit",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{/* Active indicator */}
|
|
{active && (
|
|
<div style={{
|
|
position: "absolute", left: 0, top: "20%", bottom: "20%",
|
|
width: 2, borderRadius: "0 2px 2px 0",
|
|
background: "linear-gradient(to bottom, #7b5cf6, #22d3ee)",
|
|
}} />
|
|
)}
|
|
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
|
{icon}
|
|
</span>
|
|
{!collapsed && (
|
|
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{label}
|
|
</span>
|
|
)}
|
|
{!collapsed && badge != null && badge > 0 && (
|
|
<span style={{
|
|
fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 20,
|
|
background: "linear-gradient(135deg, #7b5cf6, #22d3ee)", color: "white",
|
|
flexShrink: 0,
|
|
}}>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
|
|
|
export function Sidebar({ children }: { children: React.ReactNode }) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [collapsed, setCollapsed] = useState(true);
|
|
|
|
const { data: me } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: () => api.get<MemberRead>("/auth/me"),
|
|
});
|
|
|
|
const { activeBandId } = useBandStore();
|
|
|
|
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 { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
|
|
const hasActiveSong = !!currentSongId && !!playerBandId;
|
|
|
|
const sidebarWidth = collapsed ? 68 : 230;
|
|
const border = "rgba(255,255,255,0.06)";
|
|
|
|
return (
|
|
<div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#0c0e1a", color: "#e8e9f0", fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif", fontSize: 13 }}>
|
|
|
|
{/* ── Sidebar ── */}
|
|
<aside style={{
|
|
width: sidebarWidth,
|
|
minWidth: sidebarWidth,
|
|
background: "#10131f",
|
|
borderRight: `1px solid ${border}`,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)",
|
|
flexShrink: 0,
|
|
zIndex: 20,
|
|
}}>
|
|
|
|
{/* Logo / toggle */}
|
|
<div style={{ padding: "18px 14px 14px", display: "flex", alignItems: "center", gap: 10, borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
|
|
<button
|
|
onClick={() => setCollapsed((c) => !c)}
|
|
title={collapsed ? "Expand menu" : "Collapse menu"}
|
|
style={{
|
|
width: 40, height: 40, borderRadius: 12, flexShrink: 0,
|
|
background: "linear-gradient(135deg, #7b5cf6, #06b6d4)",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
border: "none", cursor: "pointer",
|
|
boxShadow: "0 0 20px rgba(139,92,246,0.3)",
|
|
transition: "box-shadow 0.2s",
|
|
}}
|
|
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 0 30px rgba(139,92,246,0.5)")}
|
|
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 0 20px rgba(139,92,246,0.3)")}
|
|
>
|
|
<IconMenu />
|
|
</button>
|
|
{!collapsed && (
|
|
<div style={{ overflow: "hidden" }}>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#e8e9f0", letterSpacing: -0.3, whiteSpace: "nowrap" }}>
|
|
RehearsalHub
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
|
|
{activeBandId && (
|
|
<>
|
|
<NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBandId}`)} collapsed={collapsed} />
|
|
<NavItem
|
|
icon={<IconPlay />}
|
|
label="Now Playing"
|
|
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
|
|
onClick={() => { if (hasActiveSong) navigate(`/bands/${playerBandId}/songs/${currentSongId}`); }}
|
|
disabled={!hasActiveSong}
|
|
collapsed={collapsed}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
|
|
<NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
|
|
</nav>
|
|
|
|
{/* User row */}
|
|
<div style={{ padding: "10px 12px", borderTop: `1px solid ${border}`, flexShrink: 0 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<button
|
|
onClick={() => navigate("/settings")}
|
|
title={collapsed ? (me?.display_name ?? "Account") : undefined}
|
|
style={{ flex: 1, display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", background: "transparent", border: "none", borderRadius: 8, cursor: "pointer", color: "#e8e9f0", textAlign: "left", minWidth: 0, fontFamily: "inherit", overflow: "hidden" }}
|
|
>
|
|
{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(52,211,153,0.15)", border: "1.5px solid rgba(52,211,153,0.3)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
|
|
{getInitials(me?.display_name ?? "?")}
|
|
</div>
|
|
)}
|
|
{!collapsed && (
|
|
<span style={{ flex: 1, fontSize: 12, color: "rgba(232,233,240,0.55)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{me?.display_name ?? "…"}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{!collapsed && (
|
|
<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: "hidden", display: "grid", gridTemplateRows: "44px 1fr", background: "#0c0e1a", minWidth: 0 }}>
|
|
<TopBandBar />
|
|
<div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|