Spaces:
No application file
No application file
| 'use client'; | |
| import { | |
| Settings, | |
| Sun, | |
| Moon, | |
| Monitor, | |
| ArrowLeft, | |
| Loader2, | |
| Download, | |
| FileDown, | |
| Package, | |
| } 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 { cn } from '@/lib/utils'; | |
| import { useSettingsStore } from '@/lib/store/settings'; | |
| import { useStageStore } from '@/lib/store/stage'; | |
| import { useMediaGenerationStore } from '@/lib/store/media-generation'; | |
| import { useExportPPTX } from '@/lib/export/use-export-pptx'; | |
| interface HeaderProps { | |
| readonly currentSceneTitle: string; | |
| } | |
| export function Header({ currentSceneTitle }: HeaderProps) { | |
| const { t, locale, setLocale } = useI18n(); | |
| const { theme, setTheme } = useTheme(); | |
| const router = useRouter(); | |
| const [settingsOpen, setSettingsOpen] = useState(false); | |
| const [languageOpen, setLanguageOpen] = useState(false); | |
| const [themeOpen, setThemeOpen] = useState(false); | |
| // Model setup state | |
| const currentModelId = useSettingsStore((s) => s.modelId); | |
| const needsSetup = !currentModelId; | |
| // Export | |
| const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); | |
| 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 languageRef = useRef<HTMLDivElement>(null); | |
| const themeRef = useRef<HTMLDivElement>(null); | |
| // Close dropdown when clicking outside | |
| const handleClickOutside = useCallback( | |
| (e: MouseEvent) => { | |
| if (languageOpen && languageRef.current && !languageRef.current.contains(e.target as Node)) { | |
| setLanguageOpen(false); | |
| } | |
| 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); | |
| } | |
| }, | |
| [languageOpen, themeOpen, exportMenuOpen], | |
| ); | |
| useEffect(() => { | |
| if (languageOpen || themeOpen || exportMenuOpen) { | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => document.removeEventListener('mousedown', handleClickOutside); | |
| } | |
| }, [languageOpen, 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.push('/')} | |
| 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 */} | |
| <div className="relative" ref={languageRef}> | |
| <button | |
| onClick={() => { | |
| setLanguageOpen(!languageOpen); | |
| setThemeOpen(false); | |
| }} | |
| className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" | |
| > | |
| {locale === 'zh-CN' ? 'CN' : 'EN'} | |
| </button> | |
| {languageOpen && ( | |
| <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-[120px]"> | |
| <button | |
| onClick={() => { | |
| setLocale('zh-CN'); | |
| setLanguageOpen(false); | |
| }} | |
| className={cn( | |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors', | |
| locale === 'zh-CN' && | |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', | |
| )} | |
| > | |
| 简体中文 | |
| </button> | |
| <button | |
| onClick={() => { | |
| setLocale('en-US'); | |
| setLanguageOpen(false); | |
| }} | |
| className={cn( | |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors', | |
| locale === 'en-US' && | |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', | |
| )} | |
| > | |
| English | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" /> | |
| {/* Theme Selector */} | |
| <div className="relative" ref={themeRef}> | |
| <button | |
| onClick={() => { | |
| setThemeOpen(!themeOpen); | |
| setLanguageOpen(false); | |
| }} | |
| 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={cn( | |
| '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', | |
| needsSetup && 'animate-setup-glow', | |
| )} | |
| > | |
| <Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" /> | |
| </button> | |
| {needsSetup && ( | |
| <> | |
| <span className="absolute -top-0.5 -right-0.5 flex h-3 w-3"> | |
| <span className="animate-setup-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" /> | |
| <span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" /> | |
| </span> | |
| <span className="animate-setup-float absolute top-full mt-2 right-0 whitespace-nowrap text-[11px] font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/50 px-2 py-0.5 rounded-full shadow-sm pointer-events-none"> | |
| {t('settings.setupNeeded')} | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Export Dropdown */} | |
| <div className="relative" ref={exportRef}> | |
| <button | |
| onClick={() => { | |
| if (canExport && !isExporting) setExportMenuOpen(!exportMenuOpen); | |
| }} | |
| disabled={!canExport || isExporting} | |
| title={ | |
| canExport | |
| ? isExporting | |
| ? t('export.exporting') | |
| : t('export.pptx') | |
| : t('share.notReady') | |
| } | |
| className={cn( | |
| 'shrink-0 p-2 rounded-full transition-all', | |
| canExport && !isExporting | |
| ? '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 ? ( | |
| <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> | |
| </div> | |
| )} | |
| </div> | |
| </header> | |
| <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} /> | |
| </> | |
| ); | |
| } | |