111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
'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<PublishState>(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 };
|
|
}
|