diff --git a/.gitignore b/.gitignore index 1170717..006062e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* +.claude/ +CLAUDE.md + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..702233a --- /dev/null +++ b/App.tsx @@ -0,0 +1,294 @@ + +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import Clock from './components/Clock'; +import SearchBox from './components/SearchBox'; +import SettingsModal from './components/SettingsModal'; +import ErrorBoundary from './components/ErrorBoundary'; +import GlobalContextMenu from './components/GlobalContextMenu'; +import { SettingsIcon } from './components/Icons'; +import { UserSettings, WallpaperFit } from './types'; +import { PRESET_WALLPAPERS, SEARCH_ENGINES, THEMES } from './constants'; +import { loadSettings, saveSettings } from './utils/storage'; +import { I18nProvider } from './i18n'; + +// Default settings - moved outside component to avoid recreation on each render +const DEFAULT_SETTINGS: UserSettings = { + use24HourFormat: true, + showSeconds: true, + backgroundBlur: 8, + searchEngines: [...SEARCH_ENGINES], + selectedEngine: SEARCH_ENGINES[0].name, + themeColor: THEMES[0].hex, + searchOpacity: 0.8, + enableMaskBlur: false, + backgroundUrl: PRESET_WALLPAPERS[0].url, + backgroundType: PRESET_WALLPAPERS[0].type, + wallpaperFit: 'cover', + customWallpapers: [], + enableSearchHistory: true, + searchHistory: [], + language: 'en' +}; + +type ViewMode = 'search' | 'dashboard'; + +const App: React.FC = () => { + // State for settings visibility + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + + // State for view mode (Search Panel vs Dashboard Panel) + const [viewMode, setViewMode] = useState('search'); + + // State for wallpaper loaded (prevent flash) + const [bgLoaded, setBgLoaded] = useState(false); + + // State for search box interaction (controls background blur) + const [isSearchActive, setIsSearchActive] = useState(false); + + // Application Settings - loaded from Local Storage + const [settings, setSettings] = useState(() => loadSettings(DEFAULT_SETTINGS)); + + // Flag to track if this is the initial mount + const isInitialMount = useRef(true); + + // Save settings to Local Storage (skip initial mount) + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + saveSettings(settings); + }, [settings]); + + // Preload background when URL changes + useEffect(() => { + setBgLoaded(false); + let isMounted = true; + + if (settings.backgroundType === 'image') { + const img = new Image(); + img.src = settings.backgroundUrl; + img.onload = () => { + if (isMounted) { + setBgLoaded(true); + } + }; + // Handle error case to avoid stuck loading state + img.onerror = () => { + if (isMounted) { + setBgLoaded(true); + } + }; + + // Cleanup function + return () => { + isMounted = false; + // Clean up Image object event handlers + img.onload = null; + img.onerror = null; + // Cancel image loading + img.src = ''; + }; + } else { + // For video, we can consider it "loaded" once it starts playing or immediately + // depending on desired UX. Here we'll set it true immediately to show the video element + // which handles its own buffering. + setBgLoaded(true); + } + }, [settings.backgroundUrl, settings.backgroundType]); + + const handleSelectEngine = (name: string) => { + setSettings(prev => ({ ...prev, selectedEngine: name })); + }; + + const handleUpdateHistory = (newHistory: string[]) => { + setSettings(prev => ({ ...prev, searchHistory: newHistory })); + }; + + const getBackgroundStyle = (fit: WallpaperFit): React.CSSProperties => { + const baseStyle = { backgroundImage: `url(${settings.backgroundUrl})` }; + switch (fit) { + case 'contain': + return { ...baseStyle, backgroundSize: 'contain', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }; + case 'fill': + return { ...baseStyle, backgroundSize: '100% 100%', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }; + case 'repeat': + return { ...baseStyle, backgroundSize: 'auto', backgroundPosition: 'top left', backgroundRepeat: 'repeat' }; + case 'center': + return { ...baseStyle, backgroundSize: 'auto', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }; + case 'cover': + default: + return { ...baseStyle, backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }; + } + }; + + const getVideoClass = (fit: WallpaperFit) => { + switch (fit) { + case 'contain': return 'object-contain'; + case 'fill': return 'object-fill'; + case 'center': return 'object-none'; + case 'repeat': return 'object-cover'; // Video tile not supported natively in same way, fallback to cover + case 'cover': + default: return 'object-cover'; + } + }; + + // Handle right-click on the background to switch to Dashboard + const handleBackgroundContextMenu = (e: React.MouseEvent) => { + // If settings modal is open, let standard behavior apply (or it's covered by modal backdrop) + if (isSettingsOpen) return; + + // We only want to capture clicks on the "background" or general containers. + // Specific interactive elements (like SearchBox) should stop propagation. + e.preventDefault(); + if (viewMode === 'search') { + setViewMode('dashboard'); + } + }; + + // Handle left-click on the dashboard to return to Search + const handleDashboardClick = (e: React.MouseEvent) => { + if (viewMode === 'dashboard' && !isSettingsOpen) { + setViewMode('search'); + } + }; + + const handleLanguageChange = (lang: 'en' | 'zh') => { + setSettings(prev => ({ ...prev, language: lang })); + }; + + return ( + + +
+ {/* Context Menu Global Listener */} + + + {/* Background Layer */} +
+ {settings.backgroundType === 'video' ? ( +