233 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|