188 lines
4.7 KiB
TypeScript
188 lines
4.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useSyncExternalStore } from 'react';
|
|
import { CloseIcon } from './icons/CloseIcon';
|
|
|
|
export type SidebarPanel = {
|
|
id: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
component: React.ReactNode;
|
|
};
|
|
|
|
function useIsWide(): boolean {
|
|
return useSyncExternalStore(
|
|
(cb) => {
|
|
window.addEventListener('resize', cb);
|
|
return () => window.removeEventListener('resize', cb);
|
|
},
|
|
() => window.innerWidth >= 768,
|
|
() => false,
|
|
);
|
|
}
|
|
|
|
/* -----------------------------
|
|
Shared sidebar logic
|
|
------------------------------*/
|
|
function useSidebarState(panels: SidebarPanel[], isWide: boolean) {
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
// open first panel by default ONLY on first mount (desktop only)
|
|
const [initialized, setInitialized] = useState(false);
|
|
|
|
if (!initialized && isWide) {
|
|
setActiveId(panels[0]?.id ?? null);
|
|
setInitialized(true);
|
|
}
|
|
|
|
const setClosed = () => setActiveId(null);
|
|
|
|
const toggle = (id: string) => {
|
|
setActiveId((prev) => (prev === id ? null : id));
|
|
};
|
|
|
|
return {
|
|
activeId,
|
|
setActiveId,
|
|
setClosed,
|
|
toggle,
|
|
};
|
|
}
|
|
|
|
/* -----------------------------
|
|
Desktop UI
|
|
------------------------------*/
|
|
function DesktopSidebar({
|
|
activePanel,
|
|
isOpen,
|
|
setClosed,
|
|
}: {
|
|
activePanel?: SidebarPanel;
|
|
isOpen: boolean;
|
|
setClosed: () => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`
|
|
flex transition-all duration-300 ease overflow-hidden
|
|
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
|
|
`}
|
|
>
|
|
{activePanel && (
|
|
<div className="w-64 flex flex-col border-r border-muted transition-theme">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0 transition-theme">
|
|
<span className="text-sm font-semibold text-accent">
|
|
{activePanel.label}
|
|
</span>
|
|
<button onClick={setClosed}>
|
|
<CloseIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-3 min-h-0">
|
|
{activePanel.component}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -----------------------------
|
|
Mobile UI (drawer)
|
|
------------------------------*/
|
|
function MobileSidebar({
|
|
activePanel,
|
|
setClosed,
|
|
}: {
|
|
activePanel?: SidebarPanel;
|
|
setClosed: () => void;
|
|
}) {
|
|
if (!activePanel) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 bg-black/40 z-20" onClick={setClosed} />
|
|
|
|
{/* Drawer */}
|
|
<div className="fixed left-14 top-[var(--header-height)] bottom-0 z-30 bg-primary overflow-y-auto border border-muted w-64">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
|
|
<span className="text-sm font-semibold text-accent">
|
|
{activePanel.label}
|
|
</span>
|
|
|
|
<button onClick={setClosed}>
|
|
<CloseIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-3">{activePanel.component}</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* -----------------------------
|
|
Icon rail (shared)
|
|
------------------------------*/
|
|
function IconRail({
|
|
panels,
|
|
effectiveId,
|
|
toggle,
|
|
}: {
|
|
panels: SidebarPanel[];
|
|
effectiveId: string | null;
|
|
toggle: (id: string, effectiveId: string | null) => void;
|
|
}) {
|
|
return (
|
|
<div className="w-12 flex flex-col items-center py-3 gap-1 z-30">
|
|
{panels.map((panel) => (
|
|
<button
|
|
key={panel.id}
|
|
onClick={() => toggle(panel.id, effectiveId)}
|
|
title={panel.label}
|
|
className={`w-8 h-8 rounded-lg flex items-center justify-center text-secondary ${
|
|
effectiveId === panel.id ? 'border-2 border-[var(--accent2)]' : ''
|
|
}`}
|
|
>
|
|
{panel.icon}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -----------------------------
|
|
Main Sidebar (controller)
|
|
------------------------------*/
|
|
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
|
|
const isWide = useIsWide();
|
|
|
|
const { activeId, setClosed, toggle } = useSidebarState(panels, isWide);
|
|
|
|
const effectiveId = activeId;
|
|
const activePanel = panels.find((p) => p.id === effectiveId);
|
|
const isOpen = !!activePanel;
|
|
|
|
return (
|
|
<div className="flex border-r border-t border-muted transition-theme">
|
|
{/* Desktop */}
|
|
{isWide && (
|
|
<DesktopSidebar
|
|
activePanel={activePanel}
|
|
isOpen={isOpen}
|
|
setClosed={setClosed}
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile */}
|
|
{!isWide && (
|
|
<MobileSidebar activePanel={activePanel} setClosed={setClosed} />
|
|
)}
|
|
|
|
{/* Icon rail */}
|
|
<IconRail panels={panels} effectiveId={effectiveId} toggle={toggle} />
|
|
</div>
|
|
);
|
|
}
|