262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
'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<string | null>(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 (
|
|
<ThemeProvider>
|
|
<MediaStageProvider value={classroomId}>
|
|
<div className="h-screen flex flex-col overflow-hidden">
|
|
{loading ? (
|
|
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center text-muted-foreground">
|
|
<p>Loading classroom...</p>
|
|
</div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center">
|
|
<p className="text-destructive mb-4">Error: {error}</p>
|
|
<button
|
|
onClick={() => {
|
|
setError(null);
|
|
setLoading(true);
|
|
loadClassroom();
|
|
}}
|
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : resuming && !resumeDone ? (
|
|
<GenerationResume
|
|
onEnterClassroom={() => setResumeDone(true)}
|
|
onRetryOutline={retrySingleOutline}
|
|
/>
|
|
) : (
|
|
<Stage 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 && (
|
|
<button
|
|
onClick={() =>
|
|
router.push(
|
|
`/learn/course/${encodeURIComponent(learnerCourseId)}${
|
|
learnerCourseVersion
|
|
? `?version=${encodeURIComponent(learnerCourseVersion)}`
|
|
: ''
|
|
}`,
|
|
)
|
|
}
|
|
className="fixed top-4 left-4 z-50 inline-flex items-center gap-1.5 rounded-full bg-white/85 dark:bg-zinc-900/85 backdrop-blur-md border border-border/60 px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-white dark:hover:bg-zinc-900 transition-colors"
|
|
>
|
|
<ArrowLeft className="size-3.5" />
|
|
{t('course.learner.backToCourse')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</MediaStageProvider>
|
|
</ThemeProvider>
|
|
);
|
|
}
|