feat: add LMS web client
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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?: () => 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-[#181b20] 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, index) => (
|
||||
<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' && index === 0 && (
|
||||
<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?.()
|
||||
}}
|
||||
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-[#181b20]', 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-[#181b20] 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, index) => (
|
||||
<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' && index === 0 && (
|
||||
<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?.()
|
||||
}}
|
||||
aria-label="세션 연결종료"
|
||||
title="연결종료"
|
||||
>
|
||||
<Power size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user