Skip to content
Open
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
40 changes: 34 additions & 6 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ import {
Download,
FileDown,
Package,
BookOpen,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useTheme } from '@/lib/hooks/use-theme';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { SettingsDialog } from './settings';
import { ScormExportDialog } from './scorm-export-dialog';
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 { useExportScorm } from '@/lib/export/scorm';

interface HeaderProps {
readonly currentSceneTitle: string;
Expand All @@ -35,13 +38,17 @@ export function Header({ currentSceneTitle }: HeaderProps) {

// Export
const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
const { exporting: isExportingScorm, exportScorm } = useExportScorm();
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [scormDialogOpen, setScormDialogOpen] = useState(false);
const exportRef = useRef<HTMLDivElement>(null);
const scenes = useStageStore((s) => s.scenes);
const generatingOutlines = useStageStore((s) => s.generatingOutlines);
const failedOutlines = useStageStore((s) => s.failedOutlines);
const mediaTasks = useMediaGenerationStore((s) => s.tasks);

const anyExporting = isExporting || isExportingScorm;

const canExport =
scenes.length > 0 &&
generatingOutlines.length === 0 &&
Expand Down Expand Up @@ -222,31 +229,31 @@ export function Header({ currentSceneTitle }: HeaderProps) {
<div className="relative" ref={exportRef}>
<button
onClick={() => {
if (canExport && !isExporting) setExportMenuOpen(!exportMenuOpen);
if (canExport && !anyExporting) setExportMenuOpen(!exportMenuOpen);
}}
disabled={!canExport || isExporting}
disabled={!canExport || anyExporting}
title={
canExport
? isExporting
? anyExporting
? t('export.exporting')
: t('export.pptx')
: t('share.notReady')
}
className={cn(
'shrink-0 p-2 rounded-full transition-all',
canExport && !isExporting
canExport && !anyExporting
? '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 ? (
{anyExporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
{exportMenuOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[200px]">
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[220px]">
<button
onClick={() => {
setExportMenuOpen(false);
Expand All @@ -272,11 +279,32 @@ export function Header({ currentSceneTitle }: HeaderProps) {
</div>
</div>
</button>
<div className="border-t border-gray-100 dark:border-gray-700" />
<button
onClick={() => {
setExportMenuOpen(false);
setScormDialogOpen(true);
}}
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"
>
<BookOpen className="w-4 h-4 text-gray-400 shrink-0" />
<div>
<div>{t('export.scorm')}</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
{t('export.scormDesc')}
</div>
</div>
</button>
</div>
)}
</div>
</header>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<ScormExportDialog
open={scormDialogOpen}
onOpenChange={setScormDialogOpen}
onConfirm={(opts) => exportScorm(opts)}
/>
</>
);
}
104 changes: 104 additions & 0 deletions components/scorm-export-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use client';

import { useState } from 'react';
import { BookOpen } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { ScormExportOptions } from '@/lib/export/scorm';

interface ScormExportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (options: ScormExportOptions) => void;
}

export function ScormExportDialog({ open, onOpenChange, onConfirm }: ScormExportDialogProps) {
const { t } = useI18n();
const [includeVideos, setIncludeVideos] = useState(false);

function handleConfirm() {
onOpenChange(false);
onConfirm({ includeVideos });
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<BookOpen className="w-5 h-5 text-purple-500" />
<DialogTitle>{t('export.scormDialogTitle')}</DialogTitle>
</div>
<DialogDescription>{t('export.scormDialogDesc')}</DialogDescription>
</DialogHeader>

<div className="flex flex-col gap-3">
<label
className={`flex items-start gap-3 p-3.5 rounded-lg border-2 cursor-pointer transition-colors ${
!includeVideos
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="video-option"
className="mt-0.5 accent-purple-500"
checked={!includeVideos}
onChange={() => setIncludeVideos(false)}
/>
<div>
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">
{t('export.scormReplacePoster')}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
✓ Recommended
</div>
</div>
</label>

<label
className={`flex items-start gap-3 p-3.5 rounded-lg border-2 cursor-pointer transition-colors ${
includeVideos
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="video-option"
className="mt-0.5 accent-purple-500"
checked={includeVideos}
onChange={() => setIncludeVideos(true)}
/>
<div>
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">
{t('export.scormIncludeVideos')}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
⚠ May exceed LMS upload limits (100–300 MB)
</div>
</div>
</label>
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirm} className="bg-purple-600 hover:bg-purple-700 text-white">
{t('export.scormExport')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading