Files
lms-web-fe/src/lms-renderer/components/Sidebar.tsx
T
2026-08-03 14:06:06 +09:00

233 lines
10 KiB
TypeScript

import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
const navItems = [
{ id: 'curriculum', label: '커리큘럼', icon: BookOpen },
{ id: 'studio', label: '개발 스튜디오', icon: LayoutDashboard },
{ id: 'guide', label: 'AI 가이드', icon: Bot }
] as const
type NavItemId = (typeof navItems)[number]['id']
export default function Sidebar({
collapsed,
onToggle,
activeItem = 'curriculum',
studioEnabled = false,
onNavigate,
sessionStatus = 'idle',
runningSessions = [],
onOpenSession,
onDisconnectSession
}: {
collapsed: boolean
onToggle: () => void
activeItem?: NavItemId
studioEnabled?: boolean
onNavigate?: (item: NavItemId) => void
sessionStatus?: 'idle' | 'running' | 'ended'
sessionPlatform?: string
sessionStartedAt?: string | null
sessionEndedAt?: string | null
sessionEndsAt?: string | null
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
canReleaseControl?: boolean
onOpenSession?: (sessionId?: string) => void
onDisconnectSession?: (sessionId?: string) => void
onReleaseControl?: () => void
}): React.JSX.Element {
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
const sessionPopoverRef = useRef<HTMLDivElement>(null)
const visibleRunningSessions = sessionStatus === 'running' && runningSessions.length ? runningSessions : []
useEffect(() => {
if (!sessionPopoverOpen) return
const closeOnOutsideClick = (event: MouseEvent): void => {
if (!sessionPopoverRef.current?.contains(event.target as Node)) {
setSessionPopoverOpen(false)
}
}
document.addEventListener('mousedown', closeOnOutsideClick)
return () => document.removeEventListener('mousedown', closeOnOutsideClick)
}, [sessionPopoverOpen])
return (
<aside
className={[
'flex shrink-0 flex-col rounded-bl-[18px] bg-[#111520] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out',
collapsed ? 'w-[68px]' : 'w-[240px]'
].join(' ')}
>
<div className={['flex items-center pb-6', collapsed ? 'justify-center gap-0 px-0' : 'gap-3 px-1.5'].join(' ')}>
<button
type="button"
aria-label="Menu"
aria-pressed={collapsed}
onClick={onToggle}
className="grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded border-0 bg-transparent text-white/55 transition hover:bg-white/[0.06] hover:text-white"
>
<Menu size={15} />
</button>
<div
className={[
'min-w-0 overflow-hidden whitespace-nowrap transition-opacity duration-150',
collapsed ? 'pointer-events-none w-0 opacity-0' : 'w-[160px] opacity-100 delay-100'
].join(' ')}
>
<strong className="block text-[14px] font-bold leading-tight text-white/90">
Autonomy <span className="text-[#67e8f9]">Studio</span>
</strong>
<span className="block text-[10px] leading-tight text-white/58">Student Portal</span>
</div>
</div>
<nav className="grid gap-2">
{navItems.map((item) => {
const Icon = item.icon
const active = item.id === activeItem
const disabled = item.id === 'studio' && !studioEnabled
return (
<button
key={item.label}
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) onNavigate?.(item.id)
}}
className={[
'group relative flex h-[34px] items-center rounded text-left text-[13px] font-bold transition',
collapsed ? 'justify-center gap-0 px-0' : 'gap-3 px-2.5',
disabled
? 'cursor-not-allowed text-[#5d6572]'
: active
? activeItem === 'studio'
? 'bg-[#2563eb]/24 text-[#9dbdff] ring-1 ring-[#2563eb]/35'
: 'bg-[#714ef6]/20 text-[#c7b8ff] ring-1 ring-[#714ef6]/35'
: 'cursor-pointer text-[#9aa4b4] hover:bg-white/[0.06] hover:text-[#d7dde8]'
].join(' ')}
>
<Icon size={18} />
{collapsed && (
<span className="pointer-events-none absolute left-[46px] top-1/2 z-50 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-[#303030] px-2 py-1 text-[11px] font-semibold text-white/78 shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block">
{disabled ? '강좌 선택 후 이동 가능' : item.label}
</span>
)}
<span
className={[
'overflow-hidden whitespace-nowrap transition-opacity duration-150',
collapsed ? 'pointer-events-none w-0 opacity-0' : 'w-[132px] opacity-100 delay-100'
].join(' ')}
>
{item.label}
</span>
</button>
)
})}
</nav>
{visibleRunningSessions.length > 0 && (
<div
ref={sessionPopoverRef}
className="relative mt-auto overflow-visible text-left transition-all duration-200"
>
{!collapsed && (
<div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1">
{visibleRunningSessions.map((session) => (
<div key={session.id} className="relative">
<button
type="button"
onClick={() => onOpenSession?.(session.id)}
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]"
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className={['h-2.5 w-2.5 shrink-0 rounded-full', session.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
</span>
{(session.startedAt || session.endsAt) && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">
{[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')}
</span>
)}
</span>
</button>
{sessionStatus === 'running' && (
<button
type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => {
event.stopPropagation()
onDisconnectSession?.(session.id)
}}
aria-label="세션 연결종료"
title="연결종료"
>
<Power size={15} />
</button>
)}
</div>
))}
</div>
)}
{collapsed && (
<button
type="button"
onClick={() => setSessionPopoverOpen((value) => !value)}
className="relative grid h-[34px] w-full cursor-pointer place-items-center rounded bg-transparent text-white/75 transition hover:bg-white/[0.06]"
>
<Activity size={17} />
<span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#111520]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
</button>
)}
{collapsed && sessionPopoverOpen && (
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
{visibleRunningSessions.map((session) => (
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
<button
type="button"
onClick={() => {
setSessionPopoverOpen(false)
onOpenSession?.(session.id)
}}
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]"
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className={['h-2.5 w-2.5 shrink-0 rounded-full', session.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
</span>
{(session.startedAt || session.endsAt) && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">
{[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')}
</span>
)}
</span>
</button>
{sessionStatus === 'running' && (
<button
type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => {
event.stopPropagation()
setSessionPopoverOpen(false)
onDisconnectSession?.(session.id)
}}
aria-label="세션 연결종료"
title="연결종료"
>
<Power size={15} />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</aside>
)
}