'use client'; import { Stage } from '@/components/stage'; import { ThemeProvider } from '@/lib/hooks/use-theme'; import { useStageStore } from '@/lib/store'; import { useSettingsStore } from '@/lib/store/settings'; import { claimStageSceneLoadToken, isCurrentStageSceneLoadToken } from '@/lib/store/stage'; import { loadImageMapping } from '@/lib/utils/image-storage'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useParams, useSearchParams, useRouter } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSceneGenerator } from '@/lib/hooks/use-scene-generator'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { createLogger } from '@/lib/logger'; import { MediaStageProvider } from '@/lib/contexts/media-stage-context'; import { generateMediaForOutlines } from '@/lib/media/media-orchestrator'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; import { applyClassroomStageAndScenes, defaultClassroomLoadDeps, runClassroomLoad, } from '@/lib/classroom/load-classroom'; import { GenerationResume } from '@/components/stage/generation-resume'; import { classroomGenerationResumeMode } from '@/lib/classroom/generation-resume-policy'; const log = createLogger('Classroom'); export default function ClassroomDetailPage() { const params = useParams(); const router = useRouter(); const { t } = useI18n(); const classroomId = params?.id as string; // Learner mode keeps the original Stage chrome, including its contextual // ChatArea/Roundtable. The query is only used for course navigation here. const searchParams = useSearchParams(); const learnerMode = searchParams.get('learner') === '1'; // Large-course mode: entered through a course framework page — offer a way // back so the learner can continue with the next module. const learnerCourseId = searchParams.get('course') ?? ''; const learnerCourseVersion = searchParams.get('courseVersion') ?? ''; const { loadFromStorage } = useStageStore(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Generation-resume overlay: reopened mid-generation → show progress instead // of dumping the user into a half-generated deck. const [resuming, setResuming] = useState(false); const [resumeDone, setResumeDone] = useState(false); const generationStartedRef = useRef(false); const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({ onComplete: () => { log.info('[Classroom] All scenes generated'); setResumeDone(true); }, }); const loadClassroom = useCallback( async (isEffectCurrent: () => boolean = () => true) => { const loadToken = claimStageSceneLoadToken(); const isCurrent = () => isEffectCurrent() && isCurrentStageSceneLoadToken(loadToken); await runClassroomLoad({ classroomId, loadToken, isCurrent, loadFromStorage, getCurrentStage: () => useStageStore.getState().stage, fetchClassroom: defaultClassroomLoadDeps.fetchClassroom, applyFallbackScenes: (args) => defaultClassroomLoadDeps.applyFallbackScenes({ ...args, isCurrent, applyStageAndScenes: applyClassroomStageAndScenes, }), loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks, applyRestoredMediaTasks: defaultClassroomLoadDeps.applyRestoredMediaTasks, discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks, loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks, commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs, applyGeneratedAgents: defaultClassroomLoadDeps.applyGeneratedAgents, getSettings: () => useSettingsStore.getState(), getAgent: (id) => useAgentRegistry.getState().getAgent(id), restoreAgentSelection: defaultClassroomLoadDeps.restoreAgentSelection, setError, setLoading, log, }); }, [classroomId, loadFromStorage], ); useEffect(() => { // Reset loading state on course switch to unmount Stage during transition, // preventing stale data from syncing back to the new course /* eslint-disable react-hooks/set-state-in-effect -- Course switch must hide stale Stage before async load */ setLoading(true); setError(null); setResuming(false); setResumeDone(false); /* eslint-enable react-hooks/set-state-in-effect */ generationStartedRef.current = false; // Clear previous classroom's media tasks to prevent cross-classroom contamination. // Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages, // so stale tasks from a previous classroom would shadow the new one's. const mediaStore = useMediaGenerationStore.getState(); mediaStore.revokeObjectUrls(); useMediaGenerationStore.setState({ tasks: {} }); // Clear whiteboard history to prevent snapshots from a previous course leaking in. useWhiteboardHistoryStore.getState().clearHistory(); let cancelled = false; loadClassroom(() => !cancelled); // Cancel ongoing generation when classroomId changes or component unmounts return () => { cancelled = true; stop(); }; }, [classroomId, loadClassroom, stop]); // Auto-resume generation for pending outlines useEffect(() => { if (loading || error || generationStartedRef.current) return; const state = useStageStore.getState(); const { outlines, scenes, stage, generationComplete } = state; const resumeMode = classroomGenerationResumeMode({ learnerMode, generationComplete, outlineOrders: outlines.map((outline) => outline.order), sceneOrders: scenes.map((scene) => scene.order), }); if (resumeMode === 'scenes' && stage) { generationStartedRef.current = true; /* eslint-disable react-hooks/set-state-in-effect -- resume overlay must mount synchronously to avoid flashing the half-generated deck */ setResuming(true); /* eslint-enable react-hooks/set-state-in-effect */ // Load generation params from sessionStorage (stored by generation-preview before navigating) const genParamsStr = sessionStorage.getItem('generationParams'); const params = genParamsStr ? JSON.parse(genParamsStr) : {}; // Reconstruct imageMapping from IndexedDB using pdfImages storageIds const storageIds = (params.pdfImages || []) .map((img: { storageId?: string }) => img.storageId) .filter(Boolean); loadImageMapping(storageIds).then((imageMapping) => { generateRemaining({ pdfImages: params.pdfImages, imageMapping, stageInfo: { name: stage.name || '', description: stage.description, style: stage.style, }, agents: params.agents, userProfile: params.userProfile, languageDirective: params.languageDirective || stage.languageDirective, }); }); } else if (resumeMode === 'media' && stage) { // All scenes are generated, but some media may not have finished. // Resume media generation for any tasks not yet in IndexedDB. // generateMediaForOutlines skips already-completed tasks automatically. generationStartedRef.current = true; setResuming(true); // The deck reached the classroom already fully materialized (e.g. a // single-slide course, or a deck whose last slide finished in // generation-preview), so generateRemaining's completion path never // ran. Record completion now so a later edit/delete is not treated as // an interrupted generation. No-op if already complete or not all // outlines have scenes. useStageStore.getState().markGenerationCompleteIfDone(); // Resume media only for outlines that still have a scene. On a finished // deck the user may have deleted a slide, leaving an orphaned outline; // generating its media would waste API calls on a slide that is gone. const materializedOrders = new Set(scenes.map((s) => s.order)); const materializedOutlines = outlines.filter((o) => materializedOrders.has(o.order)); generateMediaForOutlines(materializedOutlines, stage.id) .catch((err) => { log.warn('[Classroom] Media generation resume error:', err); }) .finally(() => { setResumeDone(true); }); } else { // Nothing to resume — straight into the deck. setResumeDone(true); } }, [loading, error, generateRemaining, learnerMode]); return (
{loading ? (

Loading classroom...

) : error ? (

Error: {error}

) : resuming && !resumeDone ? ( setResumeDone(true)} onRetryOutline={retrySingleOutline} /> ) : ( )} {/* Large-course mode: back to the course framework page so the learner can continue with the next module (only when entered via a course). */} {learnerMode && learnerCourseId && ( )}
); }