Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 62 additions & 23 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Monitor,
BotOff,
ChevronUp,
Upload,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { LanguageSwitcher } from '@/components/language-switcher';
Expand Down Expand Up @@ -48,6 +49,7 @@ import { toast } from 'sonner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
import { useImportClassroom } from '@/lib/import/use-import-classroom';

const log = createLogger('Home');

Expand Down Expand Up @@ -149,6 +151,12 @@ function HomePage() {
}
};

const { importing, fileInputRef, triggerFileSelect, handleFileChange } = useImportClassroom(
() => {
loadClassrooms();
},
);

useEffect(() => {
// Clear stale media store to prevent cross-course thumbnail contamination.
// The store may hold tasks from a previously visited classroom whose elementIds
Expand Down Expand Up @@ -319,6 +327,13 @@ function HomePage() {

return (
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center p-4 pt-16 md:p-8 md:pt-16 overflow-x-hidden">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleFileChange}
className="hidden"
/>
{/* ═══ Top-right pill (unchanged) ═══ */}
<div
ref={toolbarRef}
Expand Down Expand Up @@ -543,6 +558,18 @@ function HomePage() {
</motion.div>
)}
</AnimatePresence>

{/* ── Import button (empty state) ── */}
{classrooms.length === 0 && (
<button
onClick={triggerFileSelect}
disabled={importing}
className="relative z-10 mt-4 flex items-center gap-1.5 text-[12px] text-muted-foreground/40 hover:text-foreground/60 transition-colors"
>
<Upload className="size-3.5" />
<span>{t('import.classroom')}</span>
</button>
)}
</motion.div>

{/* ═══ Recent classrooms — collapsible ═══ */}
Expand All @@ -554,32 +581,44 @@ function HomePage() {
className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center"
>
{/* Trigger — divider-line with centered text */}
<button
onClick={() => {
const next = !recentOpen;
setRecentOpen(next);
try {
localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next));
} catch {
/* ignore */
}
}}
className="group w-full flex items-center gap-4 py-2 cursor-pointer"
>
<div className="group w-full flex items-center gap-4 py-2">
<div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
<span className="shrink-0 flex items-center gap-2 text-[13px] text-muted-foreground/60 group-hover:text-foreground/70 transition-colors select-none">
<Clock className="size-3.5" />
{t('classroom.recentClassrooms')}
<span className="text-[11px] tabular-nums opacity-60">{classrooms.length}</span>
<motion.div
animate={{ rotate: recentOpen ? 180 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
<div className="shrink-0 flex items-center gap-3 text-[13px] text-muted-foreground/60 select-none">
<button
onClick={() => {
const next = !recentOpen;
setRecentOpen(next);
try {
localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next));
} catch {
/* ignore */
}
}}
className="flex items-center gap-2 hover:text-foreground/70 transition-colors cursor-pointer"
>
<ChevronDown className="size-3.5" />
</motion.div>
</span>
<Clock className="size-3.5" />
{t('classroom.recentClassrooms')}
<span className="text-[11px] tabular-nums opacity-60">{classrooms.length}</span>
<motion.div
animate={{ rotate: recentOpen ? 180 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<ChevronDown className="size-3.5" />
</motion.div>
</button>
<button
onClick={triggerFileSelect}
disabled={importing}
className="group/import grid grid-cols-[auto_0fr] hover:grid-cols-[auto_1fr] items-center gap-1 rounded-full px-1.5 py-0.5 text-[12px] text-muted-foreground/35 hover:text-muted-foreground/70 hover:bg-muted/50 transition-all duration-200 cursor-pointer"
>
<Upload className="size-3" />
<span className="overflow-hidden opacity-0 group-hover/import:opacity-100 transition-opacity duration-200 whitespace-nowrap">
{t('import.classroom')}
</span>
</button>
</div>
<div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
</button>
</div>

{/* Expandable content */}
<AnimatePresence>
Expand Down
29 changes: 24 additions & 5 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Download,
FileDown,
Package,
Archive,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useTheme } from '@/lib/hooks/use-theme';
Expand All @@ -21,6 +22,7 @@ import { cn } from '@/lib/utils';
import { useStageStore } from '@/lib/store/stage';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useExportPPTX } from '@/lib/export/use-export-pptx';
import { useExportClassroom } from '@/lib/export/use-export-classroom';

interface HeaderProps {
readonly currentSceneTitle: string;
Expand All @@ -35,6 +37,7 @@ export function Header({ currentSceneTitle }: HeaderProps) {

// Export
const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
const { exporting: isExportingZip, exportClassroomZip } = useExportClassroom();
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportRef = useRef<HTMLDivElement>(null);
const scenes = useStageStore((s) => s.scenes);
Expand Down Expand Up @@ -177,24 +180,24 @@ export function Header({ currentSceneTitle }: HeaderProps) {
<div className="relative" ref={exportRef}>
<button
onClick={() => {
if (canExport && !isExporting) setExportMenuOpen(!exportMenuOpen);
if (canExport && !isExporting && !isExportingZip) setExportMenuOpen(!exportMenuOpen);
}}
disabled={!canExport || isExporting}
disabled={!canExport || isExporting || isExportingZip}
title={
canExport
? isExporting
? isExporting || isExportingZip
? t('export.exporting')
: t('export.pptx')
: t('share.notReady')
}
className={cn(
'shrink-0 p-2 rounded-full transition-all',
canExport && !isExporting
canExport && !isExporting && !isExportingZip
? 'text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm'
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed opacity-50',
)}
>
{isExporting ? (
{isExporting || isExportingZip ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
Expand Down Expand Up @@ -227,6 +230,22 @@ export function Header({ currentSceneTitle }: HeaderProps) {
</div>
</div>
</button>
<button
onClick={() => {
setExportMenuOpen(false);
exportClassroomZip();
}}
disabled={isExportingZip}
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
>
<Archive className="w-4 h-4 text-gray-400 shrink-0" />
<div>
<div>{t('export.classroomZip')}</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
{t('export.classroomZipDesc')}
</div>
</div>
</button>
</div>
)}
</div>
Expand Down
66 changes: 66 additions & 0 deletions lib/export/classroom-zip-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// lib/export/classroom-zip-types.ts
import type { SceneType, SceneContent } from '@/lib/types/stage';
import type { Action } from '@/lib/types/action';
import type { Slide } from '@/lib/types/slides';

export const CLASSROOM_ZIP_FORMAT_VERSION = 1;
export const CLASSROOM_ZIP_EXTENSION = '.maic.zip';

export interface ClassroomManifest {
formatVersion: number;
exportedAt: string;
appVersion: string;
stage: ManifestStage;
agents: ManifestAgent[];
scenes: ManifestScene[];
mediaIndex: Record<string, MediaIndexEntry>;
}

export interface ManifestStage {
name: string;
description?: string;
language?: string;
style?: string;
createdAt: number;
updatedAt: number;
}

export interface ManifestAgent {
name: string;
role: string;
persona: string;
avatar: string;
color: string;
priority: number;
/** Reserved for forward-compat. Not currently persisted in GeneratedAgentRecord DB schema. */
voiceConfig?: { providerId: string; voiceId: string };
}

export interface ManifestScene {
type: SceneType;
title: string;
order: number;
content: SceneContent;
actions?: ManifestAction[];
whiteboards?: Slide[];
multiAgent?: {
enabled: boolean;
agentIndices: number[];
directorPrompt?: string;
};
}

export type ManifestAction = Omit<Action, 'audioId'> & {
audioRef?: string;
};

export interface MediaIndexEntry {
type: 'audio' | 'image' | 'generated';
mimeType?: string;
format?: string;
duration?: number;
voice?: string;
size?: number;
prompt?: string;
missing?: boolean;
}
89 changes: 89 additions & 0 deletions lib/export/classroom-zip-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Action, SpeechAction } from '@/lib/types/action';
import type { ManifestAction } from './classroom-zip-types';
import { db } from '@/lib/utils/database';
import type { AudioFileRecord, MediaFileRecord } from '@/lib/utils/database';
import type { Scene } from '@/lib/types/stage';

// ─── Export: Collect Media ─────────────────────────────────────

export interface CollectedAudio {
zipPath: string;
record: AudioFileRecord;
}

export interface CollectedMedia {
zipPath: string;
record: MediaFileRecord;
elementId: string;
}

export async function collectAudioFiles(scenes: Scene[]): Promise<CollectedAudio[]> {
const audioIds = new Set<string>();
for (const scene of scenes) {
for (const action of scene.actions ?? []) {
if (action.type === 'speech' && (action as SpeechAction).audioId) {
audioIds.add((action as SpeechAction).audioId!);
}
}
}
const collected: CollectedAudio[] = [];
for (const audioId of audioIds) {
const record = await db.audioFiles.get(audioId);
if (record) {
const ext = record.format || 'mp3';
collected.push({ zipPath: `audio/${audioId}.${ext}`, record });
}
}
return collected;
}

export async function collectMediaFiles(stageId: string): Promise<CollectedMedia[]> {
const records = await db.mediaFiles.where('stageId').equals(stageId).toArray();
const collected: CollectedMedia[] = [];
for (const record of records) {
const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id;
const ext = record.mimeType?.split('/')[1] || 'jpg';
collected.push({ zipPath: `media/${elementId}.${ext}`, record, elementId });
}
return collected;
}

// ─── Export: Action Serialization ──────────────────────────────

export function actionsToManifest(
actions: Action[],
audioIdToPath: Map<string, string>,
): ManifestAction[] {
return actions.map((action) => {
if (action.type === 'speech') {
const speech = action as SpeechAction;
const { audioId, ...rest } = speech;
const audioRef = audioId ? audioIdToPath.get(audioId) : undefined;
return {
...rest,
...(audioRef ? { audioRef } : {}),
...(speech.audioUrl ? { audioUrl: speech.audioUrl } : {}),
} as ManifestAction;
}
return action as ManifestAction;
});
}

// ─── Import: Reference Rewriting ───────────────────────────────

export function rewriteAudioRefsToIds(
actions: ManifestAction[],
audioRefMap: Record<string, string>,
): Action[] {
return actions.map((action) => {
if (action.type === 'speech' && 'audioRef' in action) {
const { audioRef, ...rest } = action;
const audioId = audioRef ? audioRefMap[audioRef] : undefined;
return {
...rest,
...(audioId ? { audioId } : {}),
} as Action;
}
return action as Action;
});
}
Loading
Loading