mirror of
https://github.com/handsomezhuzhu/AeroStart.git
synced 2026-02-20 12:00:15 +00:00
Implement a modern, customizable browser start page with comprehensive features: - Multi-theme support with 8 preset color schemes - Custom wallpaper system supporting images and videos with multiple fit modes - Integrated search functionality with 5 major search engines (Google, Baidu, Bing, DuckDuckGo, Bilibili) - Real-time clock component with 12/24 hour format options - Dynamic background blur effect during search for enhanced focus - Complete i18n system with English and Chinese language support - Responsive design with smooth animations and transitions - Local storage integration for persistent user preferences - Context menu system for quick settings access - Toast notification system for user feedback - Error boundary for robust error handling Tech Stack: - React 19 with TypeScript - Vite 6 for build tooling - Tailwind CSS for styling - Local storage for data persistence Project Structure: - Core components: Clock, SearchBox, SettingsModal, ThemeSettings, WallpaperManager - Utility modules: storage management, search suggestions - Context providers: Toast notifications, i18n - Type definitions and constants configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
|
|
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<ViewMode>('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<UserSettings>(() => 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 (
|
|
<ErrorBoundary>
|
|
<I18nProvider language={settings.language} onLanguageChange={handleLanguageChange}>
|
|
<div
|
|
className="relative w-screen h-screen overflow-hidden bg-black text-white"
|
|
onContextMenu={handleBackgroundContextMenu}
|
|
onClick={handleDashboardClick}
|
|
>
|
|
{/* Context Menu Global Listener */}
|
|
<GlobalContextMenu />
|
|
|
|
{/* Background Layer */}
|
|
<div
|
|
className={`absolute inset-0 transition-all duration-700 ease-[cubic-bezier(0.25,0.4,0.25,1)] overflow-hidden ${bgLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
style={{
|
|
filter: `blur(${isSearchActive ? settings.backgroundBlur : 0}px)`,
|
|
transform: isSearchActive ? 'scale(1.05)' : 'scale(1)',
|
|
}}
|
|
>
|
|
{settings.backgroundType === 'video' ? (
|
|
<video
|
|
key={settings.backgroundUrl}
|
|
className={`absolute inset-0 w-full h-full ${getVideoClass(settings.wallpaperFit)}`}
|
|
src={settings.backgroundUrl}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
/>
|
|
) : (
|
|
<div
|
|
className="absolute inset-0 w-full h-full"
|
|
style={getBackgroundStyle(settings.wallpaperFit)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overlay to ensure text readability */}
|
|
<div className={`
|
|
absolute inset-0 bg-black/40
|
|
${settings.enableMaskBlur ? 'backdrop-blur-sm' : ''}
|
|
`} />
|
|
|
|
{/*
|
|
Main Content Area (Search View)
|
|
Pointer events are disabled when not visible to prevent interaction with hidden elements
|
|
*/}
|
|
<div
|
|
className={`
|
|
absolute inset-0 z-10 flex flex-col items-center pt-[18vh] w-full px-4 space-y-8
|
|
transition-all duration-500 ease-in-out
|
|
${viewMode === 'search' ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none'}
|
|
`}
|
|
>
|
|
{/* Clock Component */}
|
|
<ErrorBoundary>
|
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-150">
|
|
<Clock
|
|
showSeconds={settings.showSeconds}
|
|
use24HourFormat={settings.use24HourFormat}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
|
|
{/* Search Input Component */}
|
|
<ErrorBoundary>
|
|
<div className="w-full max-w-xl animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-300">
|
|
<SearchBox
|
|
engines={settings.searchEngines}
|
|
selectedEngineName={settings.selectedEngine}
|
|
onSelectEngine={handleSelectEngine}
|
|
themeColor={settings.themeColor}
|
|
opacity={settings.searchOpacity}
|
|
onInteractionChange={setIsSearchActive}
|
|
enableHistory={settings.enableSearchHistory}
|
|
history={settings.searchHistory}
|
|
onUpdateHistory={handleUpdateHistory}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</div>
|
|
|
|
{/*
|
|
Dashboard Panel (Under Development)
|
|
Visible only in dashboard mode
|
|
*/}
|
|
<div
|
|
className={`
|
|
absolute inset-0 z-10 flex flex-col items-center justify-center
|
|
transition-all duration-500 ease-in-out
|
|
${viewMode === 'dashboard' ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-105 pointer-events-none'}
|
|
`}
|
|
>
|
|
<div className="relative group cursor-default">
|
|
<h1 className="text-4xl md:text-5xl font-extralight tracking-[0.2em] text-white/30 group-hover:text-white/50 transition-colors duration-500 select-none">
|
|
DASHBOARD
|
|
</h1>
|
|
<div className="absolute -bottom-4 left-0 w-full flex justify-center">
|
|
<span className="text-xs font-mono text-white/20 tracking-widest uppercase bg-white/5 px-2 py-0.5 rounded">
|
|
Under Construction
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Right Settings Button - Only visible in Dashboard */}
|
|
<div className="absolute top-6 right-6">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation(); // Prevent clicking dashboard background
|
|
setIsSettingsOpen(true);
|
|
}}
|
|
className="group p-3 rounded-full bg-black/20 hover:bg-white/20 backdrop-blur-md border border-white/5 hover:border-white/30 transition-all duration-300 shadow-lg"
|
|
aria-label="Settings"
|
|
>
|
|
<SettingsIcon className="w-6 h-6 text-white/70 group-hover:text-white group-hover:rotate-90 transition-all duration-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Settings Modal */}
|
|
<ErrorBoundary>
|
|
<SettingsModal
|
|
isOpen={isSettingsOpen}
|
|
onClose={() => setIsSettingsOpen(false)}
|
|
settings={settings}
|
|
onUpdateSettings={setSettings}
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
</I18nProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
};
|
|
|
|
export default App;
|