'use client'; // Ops publish workbench hook — packages the current classroom into a frozen // bundle in the browser and uploads it to the courseware registry (L2). // // Collection mirrors the interactive-ZIP export (IndexedDB + asset pool); // packaging and the wire contract are the same `lib/bundle` code the server // uses, so what the ops end previews is exactly what the learner end plays. import { useCallback, useState } from 'react'; import { toast } from 'sonner'; import { useStageStore } from '@/lib/store/stage'; import { useI18n } from '@/lib/hooks/use-i18n'; import { collectAudioFiles, collectMediaFiles } from '@/lib/export/classroom-zip-utils'; import { preparePBLScenesForDocumentPersistence } from '@/lib/pbl/v2/runtime/document-persistence'; import { createProxiedFetch } from '@/lib/export/proxied-fetch'; import { createLogger } from '@/lib/logger'; import { packageCourseware, type PackageResult } from '@/lib/bundle/packager'; import { uploadCoursewareZip, type PublishedRecord } from '@/lib/bundle/client-upload'; import { getDefaultGeneratedAgentConfigs } from '@/lib/orchestration/registry/store'; const log = createLogger('PublishCourseware'); export type PublishStatus = 'idle' | 'packaging' | 'uploading' | 'success' | 'error'; export interface PublishState { status: PublishStatus; record?: PublishedRecord; /** Present on success — the local freeze report before registry version stamping. */ package?: PackageResult; /** Present on error — the failure message (incl. missing-resource detail). */ error?: string; } const IDLE: PublishState = { status: 'idle' }; export function usePublishCourseware() { const { t } = useI18n(); const [state, setState] = useState(IDLE); const publish = useCallback(async () => { const { stage, scenes } = useStageStore.getState(); if (!stage?.id || scenes.length === 0) return; setState({ status: 'packaging' }); const toastId = toast.loading(t('publish.packaging')); try { // 1. Collect bytes (IndexedDB + asset pool) — same sources as the ZIP export. const documentScenes = await preparePBLScenesForDocumentPersistence(stage.id, scenes); const audioFiles = await collectAudioFiles(documentScenes); const mediaFiles = await collectMediaFiles(stage.id); const audioById = new Map(audioFiles.map((a) => [a.record.id, a.record])); const mediaByElementId = new Map(mediaFiles.map((m) => [m.elementId, m.record])); // 2. Package (freeze gate: every speech/media resource must resolve). const agentConfigs = stage.generatedAgentConfigs?.length ? stage.generatedAgentConfigs : getDefaultGeneratedAgentConfigs(stage.agentIds); const packaged = await packageCourseware({ coursewareId: stage.id, stage, scenes: documentScenes, agentConfigs, mediaRecords: mediaFiles.map((m) => m.record), resolveAudioBytes: async (audioId) => audioById.get(audioId)?.blob ?? null, resolveMediaBytes: async (record) => { const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id; return mediaByElementId.get(elementId)?.blob ?? null; }, fetchImpl: createProxiedFetch(), requireAgentRoster: true, strictInteractiveAssets: true, requireComplete: true, }); // 3. Upload as an unversioned template. The server allocates the real // monotonic version under its publish lock and stamps it into bundle.json. setState({ status: 'uploading' }); toast.loading(t('publish.uploading'), { id: toastId }); const result = await uploadCoursewareZip({ zip: packaged.zip, coursewareId: stage.id, }); if (!result.ok) { log.error('Publish upload failed:', result); toast.error(t('publish.failed'), { id: toastId }); setState({ status: 'error', error: result.details ?? result.error }); return; } log.info(`Published courseware ${result.record?.coursewareId} v${result.record?.version}`); toast.success(t('publish.success'), { id: toastId }); setState({ status: 'success', record: result.record, package: packaged }); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Courseware publish failed:', error); toast.error(t('publish.failed'), { id: toastId }); setState({ status: 'error', error: message }); } }, [t]); const reset = useCallback(() => setState(IDLE), []); return { state, publish, reset }; }