feat: add sidebar component
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
export type SidebarPanel = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
component: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
panels: SidebarPanel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function useIsWide(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
window.addEventListener('resize', cb);
|
||||||
|
return () => window.removeEventListener('resize', cb);
|
||||||
|
},
|
||||||
|
() => window.innerWidth >= 768, // client snapshot
|
||||||
|
() => false, // server snapshot — must match initial SSR HTML
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED = '__closed__';
|
||||||
|
|
||||||
|
export default function Sidebar({ panels }: SidebarProps) {
|
||||||
|
const isWide = useIsWide();
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveId =
|
||||||
|
activeId === CLOSED ? null
|
||||||
|
: activeId ?? (isWide ? (panels[0]?.id ?? null) : null);
|
||||||
|
|
||||||
|
const activePanel = panels.find((p) => p.id === effectiveId);
|
||||||
|
const isOpen = !!activePanel;
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setActiveId((prev) => (prev === id || (effectiveId === id && prev === null) ? CLOSED : id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex border-r border-t border-muted">
|
||||||
|
{/* Expanded panel */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${isWide
|
||||||
|
? `flex transition-all duration-300 ease-in-out overflow-hidden ${
|
||||||
|
isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'
|
||||||
|
}`
|
||||||
|
: isOpen
|
||||||
|
? 'fixed left-14 top-(--var(header-height)) z-30 bg-primary'
|
||||||
|
: 'hidden'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{activePanel && (
|
||||||
|
<div
|
||||||
|
className={`w-64 flex flex-col border-muted ${
|
||||||
|
isWide ? 'border-r' : 'border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
|
||||||
|
<span className="text-sm font-semibold tracking-wide text-accent">
|
||||||
|
{activePanel.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveId(CLOSED)}
|
||||||
|
className="text-primary hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{activePanel.component}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back drop */}
|
||||||
|
{isOpen && !isWide && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 z-20"
|
||||||
|
onClick={() => setActiveId(CLOSED)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon rail */}
|
||||||
|
<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)}
|
||||||
|
title={panel.label}
|
||||||
|
className={`w-8 h-8 rounded-lg flex items-center justify-center text-primary transition-theme ${
|
||||||
|
effectiveId === panel.id
|
||||||
|
? 'bg-accent2'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{panel.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-1
@@ -29,6 +29,7 @@ body {
|
|||||||
--text-primary: #ffffff;
|
--text-primary: #ffffff;
|
||||||
|
|
||||||
--accent: #00d4ff;
|
--accent: #00d4ff;
|
||||||
|
--accent2: #7b2ff7;
|
||||||
|
|
||||||
--muted-color: #ffffff33;
|
--muted-color: #ffffff33;
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ body {
|
|||||||
|
|
||||||
--accent: #0077ff;
|
--accent: #0077ff;
|
||||||
|
|
||||||
--muted-color: #11111133;
|
--muted-color: #ffffff33;
|
||||||
|
|
||||||
--color-base: #ffffff;
|
--color-base: #ffffff;
|
||||||
--color-inverse: #000000;
|
--color-inverse: #000000;
|
||||||
@@ -70,6 +71,14 @@ body {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-accent {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-accent2 {
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user