|
|
|
|
| import { |
| Settings, |
| Sun, |
| Moon, |
| Monitor, |
| ArrowLeft, |
| Loader2, |
| Download, |
| FileDown, |
| Package, |
| Archive, |
| } from 'lucide-react'; |
| import { useI18n } from '@/lib/hooks/use-i18n'; |
| import { useTheme } from '@/lib/hooks/use-theme'; |
| import { LanguageSwitcher } from './language-switcher'; |
| import { useState, useEffect, useRef, useCallback } from 'react'; |
| import { useNavigate } from 'react-router-dom'; |
| import { SettingsDialog } from './settings'; |
| 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; |
| } |
|
|
| export function Header({ currentSceneTitle }: HeaderProps) { |
| const { t } = useI18n(); |
| const { theme, setTheme } = useTheme(); |
| const router = useNavigate(); |
| const [settingsOpen, setSettingsOpen] = useState(false); |
| const [themeOpen, setThemeOpen] = useState(false); |
|
|
| |
| 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); |
| const generatingOutlines = useStageStore((s) => s.generatingOutlines); |
| const failedOutlines = useStageStore((s) => s.failedOutlines); |
| const mediaTasks = useMediaGenerationStore((s) => s.tasks); |
|
|
| const canExport = |
| scenes.length > 0 && |
| generatingOutlines.length === 0 && |
| failedOutlines.length === 0 && |
| Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed'); |
|
|
| const themeRef = useRef<HTMLDivElement>(null); |
|
|
| |
| const handleClickOutside = useCallback( |
| (e: MouseEvent) => { |
| if (themeOpen && themeRef.current && !themeRef.current.contains(e.target as Node)) { |
| setThemeOpen(false); |
| } |
| if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) { |
| setExportMenuOpen(false); |
| } |
| }, |
| [themeOpen, exportMenuOpen], |
| ); |
|
|
| useEffect(() => { |
| if (themeOpen || exportMenuOpen) { |
| document.addEventListener('mousedown', handleClickOutside); |
| return () => document.removeEventListener('mousedown', handleClickOutside); |
| } |
| }, [themeOpen, exportMenuOpen, handleClickOutside]); |
|
|
| return ( |
| <> |
| <header className="h-20 px-8 flex items-center justify-between z-10 bg-transparent gap-4"> |
| <div className="flex items-center gap-3 min-w-0 flex-1"> |
| <button |
| onClick={() => router('/')} |
| className="shrink-0 p-2 rounded-lg text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition-colors" |
| title={t('generation.backToHome')} |
| > |
| <ArrowLeft className="w-5 h-5" /> |
| </button> |
| <div className="flex flex-col min-w-0"> |
| <span className="text-[10px] uppercase tracking-widest font-bold text-gray-400 dark:text-gray-500 mb-0.5"> |
| {t('stage.currentScene')} |
| </span> |
| <h1 |
| className="text-xl font-bold text-gray-800 dark:text-gray-200 tracking-tight truncate" |
| suppressHydrationWarning |
| > |
| {currentSceneTitle || t('common.loading')} |
| </h1> |
| </div> |
| </div> |
| |
| <div className="flex items-center gap-4 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm shrink-0"> |
| {/* Language Selector */} |
| <LanguageSwitcher onOpen={() => setThemeOpen(false)} /> |
| |
| <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" /> |
| |
| {/* Theme Selector */} |
| <div className="relative" ref={themeRef}> |
| <button |
| onClick={() => { |
| setThemeOpen(!themeOpen); |
| }} |
| className="p-2 rounded-full 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 transition-all group" |
| > |
| {theme === 'light' && <Sun className="w-4 h-4" />} |
| {theme === 'dark' && <Moon className="w-4 h-4" />} |
| {theme === 'system' && <Monitor className="w-4 h-4" />} |
| </button> |
| {themeOpen && ( |
| <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-[140px]"> |
| <button |
| onClick={() => { |
| setTheme('light'); |
| setThemeOpen(false); |
| }} |
| className={cn( |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2', |
| theme === 'light' && |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', |
| )} |
| > |
| <Sun className="w-4 h-4" /> |
| {t('settings.themeOptions.light')} |
| </button> |
| <button |
| onClick={() => { |
| setTheme('dark'); |
| setThemeOpen(false); |
| }} |
| className={cn( |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2', |
| theme === 'dark' && |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', |
| )} |
| > |
| <Moon className="w-4 h-4" /> |
| {t('settings.themeOptions.dark')} |
| </button> |
| <button |
| onClick={() => { |
| setTheme('system'); |
| setThemeOpen(false); |
| }} |
| className={cn( |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2', |
| theme === 'system' && |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', |
| )} |
| > |
| <Monitor className="w-4 h-4" /> |
| {t('settings.themeOptions.system')} |
| </button> |
| </div> |
| )} |
| </div> |
| |
| <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" /> |
| |
| {/* Settings Button */} |
| <div className="relative"> |
| <button |
| onClick={() => setSettingsOpen(true)} |
| className="p-2 rounded-full 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 transition-all group" |
| > |
| <Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" /> |
| </button> |
| </div> |
| </div> |
| |
| {/* Export Dropdown */} |
| <div className="relative" ref={exportRef}> |
| <button |
| onClick={() => { |
| if (canExport && !isExporting && !isExportingZip) setExportMenuOpen(!exportMenuOpen); |
| }} |
| disabled={!canExport || isExporting || isExportingZip} |
| title={ |
| canExport |
| ? isExporting || isExportingZip |
| ? t('export.exporting') |
| : t('export.pptx') |
| : t('share.notReady') |
| } |
| className={cn( |
| 'shrink-0 p-2 rounded-full transition-all', |
| 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 || isExportingZip ? ( |
| <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]"> |
| <button |
| onClick={() => { |
| setExportMenuOpen(false); |
| exportPPTX(); |
| }} |
| 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" |
| > |
| <FileDown className="w-4 h-4 text-gray-400 shrink-0" /> |
| <span>{t('export.pptx')}</span> |
| </button> |
| <button |
| onClick={() => { |
| setExportMenuOpen(false); |
| exportResourcePack(); |
| }} |
| 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" |
| > |
| <Package className="w-4 h-4 text-gray-400 shrink-0" /> |
| <div> |
| <div>{t('export.resourcePack')}</div> |
| <div className="text-[11px] text-gray-400 dark:text-gray-500"> |
| {t('export.resourcePackDesc')} |
| </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> |
| </header> |
| <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} /> |
| </> |
| ); |
| } |
|
|