/
nayvok
/
livecoding
Обзор
Документация
Войти
/
nayvok
/
livecoding
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
frontend/src/components/SessionScreen.tsx
471 строка
15 KB
nayvok
fix: harden sessions and WebRTC recovery
17 июн 2026, 22:02
17 июн 2026, 22:02
37b9a92
Код
Авторство
О чём код?
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react' import { buildSessionLink, clearTeacherToken, deleteSession, fetchSession, getStoredTeacherToken, HttpError, kickStudent, runCode, storeTeacherToken, } from '@/lib/api' import { SUPPORTED_LANGUAGES, type Role, type SessionEndReason, type SessionMetadata, type SupportedLanguageId, } from '@/types/session' import { SessionProvider } from '@/context/SessionContext' import { useSession } from '@/context/useSession' import { TaskPanel } from './TaskPanel' import { CodeEditor } from './CodeEditor' import { VideoPanel, type VideoPanelHandle } from './VideoPanel' import { OutputPanel, type OutputState } from './OutputPanel' import { RunButton } from './RunButton' import { SessionEndedModal } from './SessionEndedModal' import { SessionOccupiedScreen } from './SessionOccupiedScreen' import { ThemeToggle } from './ThemeToggle' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { GripHorizontal } from 'lucide-react' import { toast } from 'sonner' const OUTPUT_MIN_PX = 80 const OUTPUT_DEFAULT_PX = 160 const EDITOR_MIN_PX = 160 interface SessionScreenProps { sessionId: string onLeave: () => void } export function SessionScreen({ sessionId, onLeave }: SessionScreenProps) { const [metadata, setMetadata] = useState<SessionMetadata | null>(null) const [role, setRole] = useState<Role | null>(null) const [teacherToken, setTeacherToken] = useState<string | null>(null) const [error, setError] = useState<string | null>(null) useEffect(() => { let cancelled = false const load = async () => { try { const meta = await fetchSession(sessionId) if (cancelled) return const token = getStoredTeacherToken(sessionId) setMetadata(meta) setRole(token ? 'teacher' : 'student') setTeacherToken(token) } catch (err) { if (cancelled) return if (err instanceof HttpError && err.status === 404) { setError('Сессия не найдена или уже завершена.') } else { setError(err instanceof Error ? err.message : 'Ошибка загрузки сессии') } } } load() return () => { cancelled = true } }, [sessionId]) if (error) { return ( <div className="min-h-screen flex flex-col items-center justify-center gap-4 px-4 text-center bg-background text-foreground"> <h2 className="text-xl font-semibold">{error}</h2> <Button onClick={onLeave} variant="outline"> Вернуться на главную </Button> </div> ) } if (!metadata || !role) { return ( <div className="min-h-screen flex items-center justify-center bg-background text-muted-foreground"> Загрузка сессии... </div> ) } return ( <SessionProvider sessionId={sessionId} metadata={metadata} role={role} teacherToken={teacherToken} > <SessionLayout onLeave={onLeave} /> </SessionProvider> ) } function SessionLayout({ onLeave }: { onLeave: () => void }) { const { sessionId, metadata, role, teacherToken, status, ydoc, awareness, subscribeSignaling, } = useSession() const [language, setLanguage] = useState<string>(metadata.language) const [endReason, setEndReason] = useState<SessionEndReason | null>(null) const [output, setOutput] = useState<OutputState>({ kind: 'idle' }) const [isRunning, setIsRunning] = useState(false) const [outputHeightPx, setOutputHeightPx] = useState<number>(OUTPUT_DEFAULT_PX) const videoPanelRef = useRef<VideoPanelHandle | null>(null) const editorColumnRef = useRef<HTMLDivElement | null>(null) // Общий язык в Y.Map. const yMeta = useMemo(() => ydoc.getMap<string>('meta'), [ydoc]) useEffect(() => { if (role !== 'teacher' || !teacherToken) return storeTeacherToken(sessionId, teacherToken) const handle = window.setInterval(() => { storeTeacherToken(sessionId, teacherToken) }, 60_000) return () => window.clearInterval(handle) }, [role, teacherToken, sessionId]) useEffect(() => { const applyFromMap = () => { const value = yMeta.get('language') if (typeof value === 'string') setLanguage(value) } applyFromMap() const observer = () => applyFromMap() yMeta.observe(observer) return () => yMeta.unobserve(observer) }, [yMeta]) // Дефолтный язык. useEffect(() => { if (role !== 'teacher') return if (status !== 'online') return if (yMeta.get('language') == null) { yMeta.set('language', metadata.language) } }, [role, status, yMeta, metadata.language]) const subscribeAwareness = useCallback( (onStoreChange: () => void) => { if (!awareness) return () => undefined awareness.on('change', onStoreChange) return () => { awareness.off('change', onStoreChange) } }, [awareness], ) const getHasStudentSnapshot = useCallback(() => { if (!awareness) return false const states = awareness.getStates() const others = Array.from(states.keys()).filter( (clientId) => clientId !== awareness.clientID, ) return others.length > 0 }, [awareness]) // Присутствие собеседника. const hasStudent = useSyncExternalStore( subscribeAwareness, getHasStudentSnapshot, () => false, ) // Завершение сессии сервером. useEffect(() => { const unsubscribe = subscribeSignaling('session-ended', (msg) => { if (msg.type !== 'session-ended') return setEndReason(msg.reason) }) return unsubscribe }, [subscribeSignaling]) // Результат запуска. useEffect(() => { const unsubscribe = subscribeSignaling('run-output', (msg) => { if (msg.type !== 'run-output') return setOutput({ kind: 'done', payload: msg.payload, runBy: msg.runBy }) setIsRunning(false) }) return unsubscribe }, [subscribeSignaling]) const handleCopyLink = useCallback(() => { const link = buildSessionLink(sessionId) navigator.clipboard .writeText(link) .then(() => toast.success('Ссылка скопирована')) .catch(() => toast.error('Не удалось скопировать')) }, [sessionId]) const handleEnd = useCallback(async () => { if (!teacherToken) return if (!window.confirm('Завершить сессию для всех участников?')) return try { await deleteSession(sessionId, teacherToken) clearTeacherToken(sessionId) toast.success('Сессия завершена') onLeave() } catch (err) { // сессия уже закрыта if (err instanceof HttpError && err.status === 404) { clearTeacherToken(sessionId) onLeave() return } const message = err instanceof Error ? err.message : 'Ошибка' toast.error(`Не удалось завершить, ${message}`) } }, [sessionId, teacherToken, onLeave]) const handleKickStudent = useCallback(async () => { if (!teacherToken) return if (!window.confirm('Отключить студента от сессии?')) return try { await kickStudent(sessionId, teacherToken) videoPanelRef.current?.closeRemotePeer() toast.success('Студент отключен') } catch (err) { const message = err instanceof Error ? err.message : 'Ошибка' toast.error(`Не удалось отключить, ${message}`) } }, [sessionId, teacherToken]) const handleLanguageChange = useCallback( (value: string) => { if (role !== 'teacher') return yMeta.set('language', value) }, [role, yMeta], ) const handleResizerPointerDown = useCallback( (event: React.PointerEvent<HTMLDivElement>) => { event.preventDefault() const column = editorColumnRef.current if (!column) return const startY = event.clientY const startHeight = outputHeightPx const target = event.currentTarget try { target.setPointerCapture(event.pointerId) } catch { // window поймает pointer } const onMove = (ev: PointerEvent) => { const columnHeight = column.getBoundingClientRect().height const maxOutput = Math.max(OUTPUT_MIN_PX, columnHeight - EDITOR_MIN_PX) const delta = startY - ev.clientY const next = Math.min(Math.max(startHeight + delta, OUTPUT_MIN_PX), maxOutput) setOutputHeightPx(next) } const onUp = () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onUp) try { target.releasePointerCapture(event.pointerId) } catch { // ignore } } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onUp) }, [outputHeightPx], ) const handleRun = useCallback(async () => { if (isRunning) return if (status !== 'online') { toast.error('Дождитесь подключения к сессии') return } const code = ydoc.getText('content').toString() if (!code.trim()) { toast.error('Редактор пуст') return } if (language !== 'javascript' && language !== 'python') { toast.error('Запуск доступен только для JavaScript и Python') return } setIsRunning(true) setOutput({ kind: 'running' }) try { const result = await runCode( sessionId, code, language as SupportedLanguageId, teacherToken, ) // HTTP-ответ нужен как fallback. setOutput({ kind: 'done', payload: result, runBy: result.runBy }) setIsRunning(false) } catch (err) { const message = err instanceof Error ? err.message : 'Ошибка' setOutput({ kind: 'error', message }) setIsRunning(false) } }, [isRunning, status, ydoc, language, sessionId, teacherToken]) if (status === 'occupied') { return <SessionOccupiedScreen onLeave={onLeave} /> } const isLanguageLocked = role !== 'teacher' return ( <div className="min-h-dvh lg:h-dvh lg:min-h-0 flex flex-col bg-background text-foreground"> <header className="border-b px-3 py-2 sm:px-4 flex flex-col items-stretch gap-2 shrink-0 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0"> <h1 className="text-base font-semibold truncate">{metadata.title}</h1> <ConnectionBadge status={status} /> <span className="text-xs text-muted-foreground"> роль, {role === 'teacher' ? 'преподаватель' : 'студент'} </span> </div> <div className="flex flex-wrap items-center gap-2"> <select value={language} onChange={(e) => handleLanguageChange(e.target.value)} disabled={isLanguageLocked} className="text-sm border rounded px-2 py-1 bg-background text-foreground disabled:opacity-60 disabled:cursor-not-allowed" aria-label="Язык программирования" title={ isLanguageLocked ? 'Язык выбирает преподаватель' : 'Выберите язык программирования' } > {SUPPORTED_LANGUAGES.map((lang) => ( <option key={lang.id} value={lang.id}> {lang.label} </option> ))} </select> <RunButton onRun={handleRun} running={isRunning} /> {role === 'teacher' && ( <> <Button variant="outline" size="sm" onClick={handleCopyLink}> Копировать ссылку </Button> <Button variant="outline" size="sm" onClick={handleKickStudent} disabled={!hasStudent} title={ hasStudent ? 'Отключить студента' : 'Студент ещё не подключён' } > Отключить студента </Button> <Separator orientation="vertical" className="mx-1 hidden h-6! sm:block" /> <Button variant="destructive" size="sm" onClick={handleEnd}> Завершить </Button> </> )} {role === 'student' && ( <Button variant="outline" size="sm" onClick={onLeave}> Выйти </Button> )} <Separator orientation="vertical" className="mx-1 hidden h-6! sm:block" /> <ThemeToggle /> </div> </header> <main className="flex-1 grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-2 p-2 overflow-visible lg:overflow-hidden min-h-0"> <div className="min-h-64 lg:min-h-0"> <TaskPanel /> </div> <div ref={editorColumnRef} className="flex flex-col min-h-[32rem] lg:min-h-0" > <div className="bg-card rounded-md border overflow-hidden min-h-0 flex-1"> <CodeEditor language={language} /> </div> <div role="separator" aria-orientation="horizontal" aria-label="Изменить высоту окна вывода" onPointerDown={handleResizerPointerDown} className="my-1 h-2 rounded cursor-row-resize bg-border hover:bg-primary/40 transition-colors flex items-center justify-center select-none" > <GripHorizontal className="w-4 h-4 text-muted-foreground pointer-events-none" /> </div> <div className="bg-card rounded-md border overflow-hidden shrink-0" style={{ height: outputHeightPx }} > <OutputPanel state={output} /> </div> </div> <div className="bg-card rounded-md border overflow-hidden min-h-[28rem] lg:min-h-0"> <VideoPanel ref={videoPanelRef} /> </div> </main> {endReason && ( <SessionEndedModal reason={endReason} onLeave={() => { setEndReason(null) if (role === 'teacher') clearTeacherToken(sessionId) onLeave() }} /> )} </div> ) } function ConnectionBadge({ status }: { status: string }) { const label = status === 'online' ? 'онлайн' : status === 'connecting' ? 'подключение' : status === 'offline' ? 'оффлайн' : status === 'occupied' ? 'занято' : 'ошибка' const color = status === 'online' ? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400' : status === 'connecting' ? 'bg-amber-500/15 text-amber-700 dark:text-amber-400' : 'bg-red-500/15 text-red-700 dark:text-red-400' return ( <span className={`text-xs px-2 py-0.5 rounded-full ${color}`}>{label}</span> ) }