WIP: Mobile optimizations - responsive layout with bottom nav
This commit is contained in:
9
web/package-lock.json
generated
9
web/package-lock.json
generated
@@ -28,7 +28,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^5.4.1",
|
||||
"vitest": "^2.1.1"
|
||||
@@ -1667,14 +1667,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2488,7 +2488,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -4301,7 +4301,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^5.4.1",
|
||||
"vitest": "^2.1.1"
|
||||
|
||||
@@ -1,629 +1,5 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate, 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 type { MemberRead } from "../api/auth";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AppShell ──────────────────────────────────────────────────────────────────
|
||||
import { ResponsiveLayout } from "./ResponsiveLayout";
|
||||
|
||||
export function AppShell({ 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;
|
||||
|
||||
// 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={isPlayer}
|
||||
onClick={() => {}}
|
||||
disabled={!isPlayer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
return <ResponsiveLayout>{children}</ResponsiveLayout>;
|
||||
}
|
||||
|
||||
130
web/src/components/BottomNavBar.tsx
Normal file
130
web/src/components/BottomNavBar.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
||||
|
||||
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
||||
function IconLibrary() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPlay() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M3 2l9 5-9 5V2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings() {
|
||||
return (
|
||||
<svg width="20" height="20" 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="20" height="20" 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NavItem ─────────────────────────────────────────────────────────────────
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
|
||||
const color = active ? "#e8a22a" : "rgba(255,255,255,0.5)";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "8px 4px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
color,
|
||||
fontSize: 10,
|
||||
transition: "color 0.12s",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span style={{ fontSize: 10 }}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BottomNavBar ────────────────────────────────────────────────────────────
|
||||
export function BottomNavBar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
// Derive active states
|
||||
const isLibrary = !!matchPath("/bands/:bandId", location.pathname);
|
||||
const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
|
||||
const isSettings = location.pathname.startsWith("/settings");
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
background: "#0b0b0e",
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
zIndex: 1000,
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
>
|
||||
<NavItem
|
||||
icon={<IconLibrary />}
|
||||
label="Library"
|
||||
active={isLibrary}
|
||||
onClick={() => navigate("/bands")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconPlay />}
|
||||
label="Player"
|
||||
active={isPlayer}
|
||||
onClick={() => {}}
|
||||
disabled={!isPlayer}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconMembers />}
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() => navigate("/settings")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconSettings />}
|
||||
label="Settings"
|
||||
active={isSettings}
|
||||
onClick={() => navigate("/settings")}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
39
web/src/components/ResponsiveLayout.tsx
Normal file
39
web/src/components/ResponsiveLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { BottomNavBar } from "./BottomNavBar";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
export function ResponsiveLayout({ children }: { children: React.ReactNode }) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Check screen size on mount and resize
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkScreenSize();
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
return isMobile ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: "calc(100vh - 60px)", // Account for bottom nav height
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<BottomNavBar />
|
||||
</>
|
||||
) : (
|
||||
<Sidebar>{children}</Sidebar>
|
||||
);
|
||||
}
|
||||
625
web/src/components/Sidebar.tsx
Normal file
625
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
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 type { MemberRead } from "../api/auth";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
// 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={isPlayer}
|
||||
onClick={() => {}}
|
||||
disabled={!isPlayer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -34,3 +34,39 @@ input, textarea, button, select {
|
||||
--danger: #e07070;
|
||||
--danger-bg: rgba(220,80,80,0.1);
|
||||
}
|
||||
|
||||
/* ── Responsive Layout ──────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
/* Ensure main content doesn't overlap bottom nav */
|
||||
body {
|
||||
padding-bottom: 60px; /* Height of bottom nav */
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom Navigation Bar */
|
||||
nav[style*="position: fixed"] {
|
||||
display: flex;
|
||||
background: #0b0b0e;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* Bottom Nav Items */
|
||||
button[style*="flex-direction: column"] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 10px;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
button[style*="flex-direction: column"][style*="color: rgb(232, 162, 42)"] {
|
||||
color: #e8a22a;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user