mirror of
https://github.com/handsomezhuzhu/AeroStart.git
synced 2026-02-20 12:00:15 +00:00
✨ feat: initialize AeroStart browser start page project
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>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
294
App.tsx
Normal file
294
App.tsx
Normal file
@@ -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<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;
|
||||
120
README.md
Normal file
120
README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# AeroStart
|
||||
|
||||
A modern, customizable browser start page with elegant search experience and personalized settings.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Multi-Theme Support** - 8 preset theme colors to choose from
|
||||
- 🖼️ **Custom Wallpapers** - Support for image and video backgrounds with multiple fit modes
|
||||
- 🔍 **Multiple Search Engines** - Built-in Google, Baidu, Bing, DuckDuckGo, Bilibili
|
||||
- ⏰ **Real-time Clock** - Support for 12/24 hour format with optional seconds display
|
||||
- 🎭 **Dynamic Blur** - Background automatically blurs during search for enhanced focus
|
||||
- 💾 **Local Storage** - All settings automatically saved to browser local storage
|
||||
- 📱 **Responsive Design** - Perfect adaptation to all screen sizes
|
||||
- 🎬 **Smooth Animations** - Carefully designed transitions and interactive animations
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
**Prerequisites:** Node.js 16+
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
# or
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run Development Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000` to view the application.
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# or
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🎯 Usage Guide
|
||||
|
||||
### Search Functionality
|
||||
- Enter keywords in the search box and press Enter to search
|
||||
- Click the icon on the left side of the search box to switch search engines
|
||||
- Background automatically blurs during search to enhance focus
|
||||
|
||||
### Settings Panel
|
||||
- Right-click the background to enter Dashboard mode
|
||||
- Click the settings icon in the top right corner to open the settings panel
|
||||
- Customizable options:
|
||||
- Clock format (12/24 hour)
|
||||
- Background wallpaper (preset or custom URL)
|
||||
- Theme color
|
||||
- Search box opacity
|
||||
- Background blur intensity
|
||||
|
||||
### Wallpaper Settings
|
||||
- Support for image and video backgrounds
|
||||
- 5 fit modes: Cover, Contain, Fill, Center, Repeat
|
||||
- Add custom wallpaper URLs
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Framework:** React 19
|
||||
- **Build Tool:** Vite 6
|
||||
- **Language:** TypeScript
|
||||
- **Styling:** Tailwind CSS
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
AeroStart/
|
||||
├── components/ # React components
|
||||
│ ├── Clock.tsx # Clock component
|
||||
│ ├── SearchBox.tsx # Search box component
|
||||
│ ├── SettingsModal.tsx # Settings panel
|
||||
│ └── ...
|
||||
├── utils/ # Utility functions
|
||||
├── context/ # React Context
|
||||
├── constants.ts # Constants configuration
|
||||
├── types.ts # TypeScript type definitions
|
||||
├── App.tsx # Main application component
|
||||
└── index.tsx # Application entry point
|
||||
```
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Add Search Engine
|
||||
|
||||
Edit the `SEARCH_ENGINES` array in `constants.ts`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'Engine Name',
|
||||
urlPattern: 'https://example.com/search?q=',
|
||||
icon: 'SVG icon string'
|
||||
}
|
||||
```
|
||||
|
||||
### Add Theme Color
|
||||
|
||||
Edit the `THEMES` array in `constants.ts`:
|
||||
|
||||
```typescript
|
||||
{ name: 'Theme Name', hex: '#colorcode' }
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
78
components/Clock.tsx
Normal file
78
components/Clock.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface ClockProps {
|
||||
showSeconds?: boolean;
|
||||
use24HourFormat?: boolean;
|
||||
}
|
||||
|
||||
const Clock: React.FC<ClockProps> = ({ showSeconds = true, use24HourFormat = true }) => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
const { language } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTime(new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const rawHours = time.getHours();
|
||||
const rawMinutes = time.getMinutes();
|
||||
const rawSeconds = time.getSeconds();
|
||||
|
||||
let displayHours = rawHours;
|
||||
let ampm = '';
|
||||
|
||||
if (!use24HourFormat) {
|
||||
displayHours = rawHours % 12 || 12; // Convert 0 to 12
|
||||
ampm = rawHours >= 12 ? 'PM' : 'AM';
|
||||
}
|
||||
|
||||
const hoursStr = displayHours.toString().padStart(2, '0');
|
||||
const minutesStr = rawMinutes.toString().padStart(2, '0');
|
||||
const secondsStr = rawSeconds.toString().padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center select-none text-white drop-shadow-2xl">
|
||||
<div className="flex items-baseline font-light tracking-tight">
|
||||
{/* Hours */}
|
||||
<span className="text-5xl md:text-6xl lg:text-7xl font-sans font-extralight tracking-tighter tabular-nums text-white/95">
|
||||
{hoursStr}
|
||||
</span>
|
||||
|
||||
{/* Separator */}
|
||||
<span className="text-4xl md:text-5xl lg:text-6xl px-2 md:px-4 text-white/60 animate-pulse -translate-y-1 md:-translate-y-2">
|
||||
:
|
||||
</span>
|
||||
|
||||
{/* Minutes */}
|
||||
<span className="text-5xl md:text-6xl lg:text-7xl font-sans font-extralight tracking-tighter tabular-nums text-white/95">
|
||||
{minutesStr}
|
||||
</span>
|
||||
|
||||
{/* Seconds (Optional small display) */}
|
||||
{showSeconds && (
|
||||
<span className="ml-2 md:ml-3 text-xl md:text-2xl lg:text-3xl font-mono text-white/60 font-light w-12 tabular-nums">
|
||||
{secondsStr}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AM/PM Indicator */}
|
||||
{!use24HourFormat && (
|
||||
<span className="ml-2 md:ml-3 text-lg md:text-xl font-light text-white/60 self-end mb-2 md:mb-3">
|
||||
{ampm}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date Display */}
|
||||
<div className="mt-4 text-lg md:text-xl font-light text-white/70 tracking-widest uppercase">
|
||||
{time.toLocaleDateString(language === 'zh' ? 'zh-CN' : 'en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clock;
|
||||
96
components/ErrorBoundary.tsx
Normal file
96
components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
declare state: State;
|
||||
declare props: Props;
|
||||
declare setState: (state: Partial<State> | ((prevState: State) => Partial<State>)) => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null
|
||||
});
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-gray-900 to-black text-white p-8">
|
||||
<div className="max-w-md w-full bg-white/10 backdrop-blur-md rounded-2xl p-8 border border-white/20 shadow-2xl">
|
||||
<div className="flex items-center justify-center w-16 h-16 mx-auto mb-6 bg-red-500/20 rounded-full">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-center mb-4">Something went wrong</h1>
|
||||
|
||||
<p className="text-white/70 text-center mb-6">
|
||||
The application encountered an unexpected error. Please try refreshing the page or resetting the app.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="mb-6 p-4 bg-black/30 rounded-lg border border-white/10">
|
||||
<p className="text-sm text-red-300 font-mono break-all">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex-1 px-4 py-3 bg-white/10 hover:bg-white/20 rounded-lg border border-white/20 hover:border-white/30 transition-all duration-200 font-medium"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 px-4 py-3 bg-blue-500/80 hover:bg-blue-500 rounded-lg border border-blue-400/30 hover:border-blue-400/50 transition-all duration-200 font-medium"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
243
components/GlobalContextMenu.tsx
Normal file
243
components/GlobalContextMenu.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { CopyIcon, ScissorsIcon, ClipboardIcon } from './Icons';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const GlobalContextMenu: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState<MenuPosition>({ x: 0, y: 0 });
|
||||
const targetRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleContextMenu = useCallback((event: MouseEvent) => {
|
||||
// Safety check for target
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if target is input or textarea
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
const inputTarget = target as HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
// Don't show custom menu for non-text inputs (like range, color, checkbox) unless it's search/text/url/etc
|
||||
const type = inputTarget.getAttribute('type');
|
||||
const validTypes = ['text', 'search', 'url', 'email', 'password', 'tel', 'number', null, ''];
|
||||
|
||||
if (inputTarget.tagName === 'INPUT' && !validTypes.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent default browser menu
|
||||
// NOTE: We do NOT call event.stopPropagation() here anymore.
|
||||
// This allows the event to bubble to React handlers (like SearchBox)
|
||||
// which will handle stopping propagation to the App background.
|
||||
event.preventDefault();
|
||||
|
||||
targetRef.current = inputTarget;
|
||||
|
||||
// Calculate position to prevent overflow
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 130;
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth - 10;
|
||||
}
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
setPosition({ x, y });
|
||||
setVisible(true);
|
||||
} else {
|
||||
// If clicked elsewhere, hide menu
|
||||
setVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (visible) setVisible(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase (true) to catch the event early
|
||||
document.addEventListener('contextmenu', handleContextMenu, true);
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
}, [handleContextMenu, handleClick, handleScroll]);
|
||||
|
||||
const updateReactState = (element: HTMLInputElement | HTMLTextAreaElement, newValue: string) => {
|
||||
// This helper is crucial for React controlled components
|
||||
// We must trigger a proper 'input' event so React updates its state
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
|
||||
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
|
||||
if (element.tagName === 'INPUT' && nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(element, newValue);
|
||||
} else if (element.tagName === 'TEXTAREA' && nativeTextAreaValueSetter) {
|
||||
nativeTextAreaValueSetter.call(element, newValue);
|
||||
} else {
|
||||
element.value = newValue;
|
||||
}
|
||||
|
||||
const event = new Event('input', { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!targetRef.current) return;
|
||||
const element = targetRef.current;
|
||||
const selection = element.value.substring(
|
||||
element.selectionStart || 0,
|
||||
element.selectionEnd || 0
|
||||
);
|
||||
|
||||
if (selection) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
// showToast(t.copy, 'success', 1000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast(t.copyFailed, 'error');
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
element.focus();
|
||||
};
|
||||
|
||||
const handleCut = async () => {
|
||||
if (!targetRef.current) return;
|
||||
const element = targetRef.current;
|
||||
const start = element.selectionStart || 0;
|
||||
const end = element.selectionEnd || 0;
|
||||
const selection = element.value.substring(start, end);
|
||||
|
||||
if (selection) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
const newValue = element.value.slice(0, start) + element.value.slice(end);
|
||||
updateReactState(element, newValue);
|
||||
// Restore cursor position
|
||||
element.setSelectionRange(start, start);
|
||||
// showToast(t.cut, 'success', 1000);
|
||||
} catch (err) {
|
||||
console.error('Failed to cut: ', err);
|
||||
showToast(t.cutFailed, 'error');
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
element.focus();
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
if (!targetRef.current) return;
|
||||
const element = targetRef.current;
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
const start = element.selectionStart || 0;
|
||||
const end = element.selectionEnd || 0;
|
||||
|
||||
const newValue = element.value.slice(0, start) + text + element.value.slice(end);
|
||||
updateReactState(element, newValue);
|
||||
|
||||
// Move cursor to end of pasted text
|
||||
const newCursorPos = start + text.length;
|
||||
element.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to paste: ', err);
|
||||
showToast(t.cannotReadClipboard, 'error');
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
element.focus();
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="
|
||||
fixed z-[9999] min-w-[140px] py-1.5
|
||||
rounded-xl bg-[#1a1a1a]/95 backdrop-blur-xl
|
||||
border border-white/10 shadow-[0_10px_40px_rgba(0,0,0,0.6)]
|
||||
animate-in fade-in zoom-in-95 duration-200 origin-top-left
|
||||
"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 px-1.5">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="
|
||||
flex items-center gap-3 px-3 py-2 text-sm text-white/90 rounded-lg
|
||||
hover:bg-white/10 transition-colors w-full text-left group
|
||||
"
|
||||
>
|
||||
<CopyIcon className="w-4 h-4 text-white/60 group-hover:text-white" />
|
||||
<span>{t.copy}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCut}
|
||||
className="
|
||||
flex items-center gap-3 px-3 py-2 text-sm text-white/90 rounded-lg
|
||||
hover:bg-white/10 transition-colors w-full text-left group
|
||||
"
|
||||
>
|
||||
<ScissorsIcon className="w-4 h-4 text-white/60 group-hover:text-white" />
|
||||
<span>{t.cut}</span>
|
||||
</button>
|
||||
|
||||
<div className="h-[1px] bg-white/10 my-1 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
className="
|
||||
flex items-center gap-3 px-3 py-2 text-sm text-white/90 rounded-lg
|
||||
hover:bg-white/10 transition-colors w-full text-left group
|
||||
"
|
||||
>
|
||||
<ClipboardIcon className="w-4 h-4 text-white/60 group-hover:text-white" />
|
||||
<span>{t.paste}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalContextMenu;
|
||||
361
components/Icons.tsx
Normal file
361
components/Icons.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SearchIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SettingsIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.47a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.39a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const XIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CheckIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AlertCircleIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfoIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EditIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const GlobeIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1 4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrendingIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
|
||||
<polyline points="17 6 23 6 23 12"></polyline>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const GripVerticalIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="5" r="1"></circle>
|
||||
<circle cx="9" cy="19" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="5" r="1"></circle>
|
||||
<circle cx="15" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const UploadIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ImageIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CopyIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ScissorsIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<circle cx="6" cy="6" r="3"></circle>
|
||||
<circle cx="6" cy="18" r="3"></circle>
|
||||
<line x1="20" y1="4" x2="8.12" y2="15.88"></line>
|
||||
<line x1="14.47" y1="14.48" x2="20" y2="20"></line>
|
||||
<line x1="8.12" y1="8.12" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ClipboardIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HistoryIcon = ({ className, style }: IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SearchEngineIconProps extends IconProps {
|
||||
path: string;
|
||||
fillColor?: string;
|
||||
}
|
||||
|
||||
export const SearchEngineIcon = ({
|
||||
className,
|
||||
style,
|
||||
path,
|
||||
fillColor = 'currentColor'
|
||||
}: SearchEngineIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill={fillColor}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
472
components/SearchBox.tsx
Normal file
472
components/SearchBox.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
|
||||
import React, { useState, KeyboardEvent, useRef, useEffect, useCallback, FocusEvent } from 'react';
|
||||
import { SearchIcon, ChevronDownIcon, GlobeIcon, TrendingIcon, HistoryIcon, TrashIcon } from './Icons';
|
||||
import { SearchEngine } from '../types';
|
||||
import { fetchSuggestions } from '../utils/suggestions';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface SearchBoxProps {
|
||||
engines: SearchEngine[];
|
||||
selectedEngineName: string;
|
||||
onSelectEngine: (name: string) => void;
|
||||
themeColor: string;
|
||||
opacity: number;
|
||||
onInteractionChange?: (isActive: boolean) => void;
|
||||
enableHistory: boolean;
|
||||
history: string[];
|
||||
onUpdateHistory: (history: string[]) => void;
|
||||
}
|
||||
|
||||
const SearchBox: React.FC<SearchBoxProps> = ({
|
||||
engines,
|
||||
selectedEngineName,
|
||||
onSelectEngine,
|
||||
themeColor,
|
||||
opacity,
|
||||
onInteractionChange,
|
||||
enableHistory,
|
||||
history,
|
||||
onUpdateHistory
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const selectedEngine = React.useMemo(() => {
|
||||
return engines.find(e => e.name === selectedEngineName) || engines[0];
|
||||
}, [engines, selectedEngineName]);
|
||||
|
||||
// Determine if the search box should be in "Active/Expanded" mode
|
||||
const isActive = isFocused || query.length > 0 || isDropdownOpen;
|
||||
|
||||
// Sync active state with parent
|
||||
useEffect(() => {
|
||||
onInteractionChange?.(isActive);
|
||||
}, [isActive, onInteractionChange]);
|
||||
|
||||
// Handle outside clicks to close dropdowns and clear query
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
const isClickInButton = dropdownRef.current && dropdownRef.current.contains(target);
|
||||
const isClickInMenu = menuRef.current && menuRef.current.contains(target);
|
||||
|
||||
if (!isClickInButton && !isClickInMenu) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
|
||||
if (containerRef.current && !containerRef.current.contains(target)) {
|
||||
setShowSuggestions(false);
|
||||
setQuery(''); // Clear query when clicking outside to restore unfocused state
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Fetch suggestions with debounce
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.trim()) {
|
||||
const results = await fetchSuggestions(selectedEngine.name, query);
|
||||
setSuggestions(results.slice(0, 8));
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
setSelectedIndex(-1);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, selectedEngine.name]);
|
||||
|
||||
const performSearch = useCallback((text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
// Record History
|
||||
if (enableHistory) {
|
||||
// Remove duplicate if exists, then add to front, limit to 20
|
||||
const newHistory = [text, ...history.filter(h => h !== text)].slice(0, 20);
|
||||
onUpdateHistory(newHistory);
|
||||
}
|
||||
|
||||
let url = selectedEngine.urlPattern;
|
||||
if (url.includes('%s')) {
|
||||
url = url.replace('%s', encodeURIComponent(text));
|
||||
} else {
|
||||
url = `${url}${encodeURIComponent(text)}`;
|
||||
}
|
||||
|
||||
// Security check: only allow http and https protocols
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
||||
console.error('Unsafe URL protocol:', urlObj.protocol);
|
||||
showToast(t.unsupportedProtocol, 'error');
|
||||
return;
|
||||
}
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Invalid URL:', url, error);
|
||||
showToast(t.invalidSearchUrl, 'error');
|
||||
}
|
||||
}, [selectedEngine.urlPattern, showToast, enableHistory, history, onUpdateHistory, t]);
|
||||
|
||||
const handleSearch = useCallback(() => performSearch(query), [performSearch, query]);
|
||||
|
||||
const handleSuggestionClick = useCallback((suggestion: string) => {
|
||||
setQuery(suggestion);
|
||||
performSearch(suggestion);
|
||||
setShowSuggestions(false);
|
||||
}, [performSearch]);
|
||||
|
||||
const handleClearHistory = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onUpdateHistory([]);
|
||||
// Keep focus
|
||||
inputRef.current?.focus();
|
||||
}, [onUpdateHistory]);
|
||||
|
||||
const showHistoryDropdown = isFocused && !query && enableHistory && history.length > 0;
|
||||
// Calculate total items for keyboard navigation
|
||||
const visibleItems = showSuggestions && suggestions.length > 0 ? suggestions : (showHistoryDropdown ? history : []);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (selectedIndex >= 0 && visibleItems[selectedIndex]) {
|
||||
handleSuggestionClick(visibleItems[selectedIndex]);
|
||||
} else {
|
||||
handleSearch();
|
||||
}
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < visibleItems.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev > -1 ? prev - 1 : visibleItems.length - 1
|
||||
);
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}, [selectedIndex, visibleItems, handleSuggestionClick, handleSearch]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
if (suggestions.length > 0) setShowSuggestions(true);
|
||||
}, [suggestions.length]);
|
||||
|
||||
const handleBlur = useCallback((e: FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false);
|
||||
// If focus moves outside the component (e.g. clicking background or tabbing out), clear the query
|
||||
if (containerRef.current && !containerRef.current.contains(e.relatedTarget as Node)) {
|
||||
setQuery('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// IMPORTANT: This handler stops the context menu event from bubbling up to the App component.
|
||||
// This prevents the right-click from triggering the dashboard switch when interacting with the search box.
|
||||
const handleElementContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full max-w-xl z-30 group"
|
||||
style={{ opacity: opacity }}
|
||||
onContextMenu={handleElementContextMenu}
|
||||
>
|
||||
{/*
|
||||
Enhanced Aperture/Glow Effect Layers
|
||||
This provides the requested "increased Gaussian blur effect"
|
||||
*/}
|
||||
|
||||
{/* 1. Large ambient blur (The outer aura) */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full pointer-events-none transition-all duration-700 ease-[cubic-bezier(0.25,0.4,0.25,1)]"
|
||||
style={{
|
||||
backgroundColor: isActive ? themeColor : 'transparent',
|
||||
filter: isActive ? 'blur(45px)' : 'blur(0px)', // High blur for aperture effect
|
||||
opacity: isActive ? 0.35 : 0,
|
||||
transform: isActive ? 'scale(1.15)' : 'scale(0.8)',
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 2. Intense core glow (The ring) */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full pointer-events-none transition-all duration-500 ease-out"
|
||||
style={{
|
||||
boxShadow: isActive ? `0 0 25px 5px ${themeColor}60` : 'none',
|
||||
opacity: isActive ? 0.8 : 0,
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search Input Container */}
|
||||
<div
|
||||
className={`
|
||||
relative flex items-center w-full px-2 py-1 rounded-full
|
||||
backdrop-blur-xl transition-all duration-500 ease-out
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'rgba(10, 10, 10, 0.75)' : 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: isActive ? `rgba(255,255,255,0.2)` : 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
// Internal lighting
|
||||
boxShadow: isActive ? `inset 0 0 20px -5px ${themeColor}30` : 'none',
|
||||
zIndex: 50
|
||||
}}
|
||||
>
|
||||
{/* Engine Selector - Collapsible Area */}
|
||||
<div
|
||||
className={`
|
||||
overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]
|
||||
flex items-center flex-shrink-0
|
||||
`}
|
||||
style={{
|
||||
maxWidth: isActive ? '200px' : '0px',
|
||||
opacity: isActive ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-1 pr-2" ref={dropdownRef}>
|
||||
<button
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
onContextMenu={handleElementContextMenu}
|
||||
className={`
|
||||
flex items-center gap-2 pl-3 pr-2 py-1.5 rounded-full transition-all duration-300
|
||||
${isDropdownOpen ? 'bg-white/10 shadow-inner' : 'hover:bg-white/10'}
|
||||
`}
|
||||
style={{ color: isDropdownOpen ? themeColor : 'rgba(255,255,255,0.8)' }}
|
||||
>
|
||||
{selectedEngine.icon ? (
|
||||
<div
|
||||
className="w-5 h-5 flex-shrink-0 [&_svg]:w-full [&_svg]:h-full"
|
||||
dangerouslySetInnerHTML={{ __html: selectedEngine.icon }}
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="w-5 h-5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium hidden sm:block tracking-wide whitespace-nowrap">{selectedEngine.name}</span>
|
||||
<ChevronDownIcon
|
||||
className={`w-3 h-3 opacity-60 transition-transform duration-300 flex-shrink-0 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Vertical Separator */}
|
||||
<div className="h-5 w-[1px] bg-white/10 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Engine Dropdown Menu */}
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="
|
||||
absolute top-full left-4 mt-3 w-48 p-1.5
|
||||
rounded-2xl bg-[#121212]/90 backdrop-blur-3xl
|
||||
border border-white/10 shadow-[0_20px_40px_-10px_rgba(0,0,0,0.6)]
|
||||
animate-in fade-in slide-in-from-top-2 duration-200
|
||||
z-[60]
|
||||
"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{engines.map((engine) => (
|
||||
<button
|
||||
key={engine.name}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onSelectEngine(engine.name);
|
||||
setIsDropdownOpen(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}}
|
||||
onContextMenu={handleElementContextMenu}
|
||||
className={`
|
||||
w-full px-3 py-2.5 text-left text-sm rounded-xl transition-all flex items-center justify-between group
|
||||
${selectedEngineName === engine.name
|
||||
? 'bg-white/15 shadow-sm'
|
||||
: 'text-white/60 hover:bg-white/10 hover:text-white'}
|
||||
`}
|
||||
style={{
|
||||
color: selectedEngineName === engine.name ? themeColor : undefined
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{engine.icon ? (
|
||||
<div
|
||||
className="w-4 h-4 flex-shrink-0 [&_svg]:w-full [&_svg]:h-full"
|
||||
dangerouslySetInnerHTML={{ __html: engine.icon }}
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium tracking-wide">{engine.name}</span>
|
||||
</div>
|
||||
{selectedEngineName === engine.name && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full shadow-[0_0_8px_rgba(255,255,255,0.5)]"
|
||||
style={{ backgroundColor: themeColor, boxShadow: `0 0 8px ${themeColor}` }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={isActive ? `${t.searchOn} ${selectedEngine.name}...` : t.search}
|
||||
className={`
|
||||
flex-1 w-full min-w-0 bg-transparent border-none outline-none text-base placeholder-white/40 font-light px-4
|
||||
transition-all duration-500 ease-out
|
||||
${isActive ? 'text-left' : 'text-center placeholder-white/70'}
|
||||
`}
|
||||
style={{
|
||||
caretColor: themeColor
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search Button / Icon */}
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] overflow-hidden flex items-center flex-shrink-0
|
||||
`}
|
||||
style={{
|
||||
maxWidth: isActive ? '60px' : '0px',
|
||||
opacity: isActive ? 1 : 0,
|
||||
transform: isActive ? 'translateX(0)' : 'translateX(10px)'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
onContextMenu={handleElementContextMenu}
|
||||
className="p-2 mr-1 rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<SearchIcon
|
||||
className="w-6 h-6 transition-colors duration-300"
|
||||
style={{ color: isActive ? themeColor : 'white' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions / History Dropdown */}
|
||||
{((showSuggestions && suggestions.length > 0) || (showHistoryDropdown && history.length > 0)) && (
|
||||
<div className="
|
||||
absolute top-full left-0 right-0 mt-2
|
||||
bg-black/80 backdrop-blur-3xl
|
||||
border border-white/10
|
||||
rounded-2xl shadow-[0_30px_60px_-12px_rgba(0,0,0,0.8)]
|
||||
animate-in fade-in slide-in-from-top-2 duration-300
|
||||
overflow-hidden z-40
|
||||
">
|
||||
{/* Suggestions List */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
// Prevent input blur when clicking
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
onContextMenu={handleElementContextMenu}
|
||||
className={`
|
||||
group relative w-full px-3 py-1 rounded-md text-left flex items-center gap-3 transition-all duration-200 ease-out
|
||||
${index === selectedIndex
|
||||
? 'bg-white/10 text-white shadow-sm translate-x-1'
|
||||
: 'text-white/70 hover:bg-white/5 hover:text-white'}
|
||||
`}
|
||||
>
|
||||
<SearchIcon
|
||||
className={`w-4 h-4 transition-all duration-300`}
|
||||
style={{
|
||||
color: index === selectedIndex ? themeColor : 'currentColor'
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History List */}
|
||||
{!showSuggestions && showHistoryDropdown && (
|
||||
<div className="flex flex-col">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold text-white/30 uppercase tracking-widest">
|
||||
{t.recentSearches}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-2">
|
||||
{history.map((item, index) => (
|
||||
<button
|
||||
key={item + index}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSuggestionClick(item)}
|
||||
onContextMenu={handleElementContextMenu}
|
||||
className={`
|
||||
group relative w-full px-3 py-1.5 rounded-md text-left flex items-center gap-3 transition-all duration-200 ease-out
|
||||
${index === selectedIndex
|
||||
? 'bg-white/10 text-white shadow-sm translate-x-1'
|
||||
: 'text-white/60 hover:bg-white/5 hover:text-white'}
|
||||
`}
|
||||
>
|
||||
<HistoryIcon
|
||||
className={`w-4 h-4 transition-all duration-300`}
|
||||
style={{
|
||||
color: index === selectedIndex ? themeColor : 'currentColor',
|
||||
opacity: index === selectedIndex ? 1 : 0.6
|
||||
}}
|
||||
/>
|
||||
<span className="truncate font-light">{item}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-[1px] bg-white/5 my-2 mx-2" />
|
||||
<div className="px-2 pb-2">
|
||||
<button
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleClearHistory}
|
||||
className="w-full flex items-center justify-center gap-2 py-1.5 rounded-md text-xs text-white/30 hover:text-red-400 hover:bg-white/5 transition-all duration-200"
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
<span>{t.clearHistory}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBox;
|
||||
303
components/SearchEngineManager.tsx
Normal file
303
components/SearchEngineManager.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { TrashIcon, PlusIcon, GripVerticalIcon, EditIcon } from './Icons';
|
||||
import { UserSettings, SearchEngine } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface SearchEngineManagerProps {
|
||||
settings: UserSettings;
|
||||
onUpdateSettings: (newSettings: UserSettings) => void;
|
||||
}
|
||||
|
||||
const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onUpdateSettings }) => {
|
||||
const [newEngineName, setNewEngineName] = useState('');
|
||||
const [newEngineUrl, setNewEngineUrl] = useState('');
|
||||
const [newEngineIcon, setNewEngineIcon] = useState('');
|
||||
const [isAddingEngine, setIsAddingEngine] = useState(false);
|
||||
const [editingOriginalName, setEditingOriginalName] = useState<string | null>(null);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDeleteEngine = useCallback((nameToDelete: string) => {
|
||||
if (settings.searchEngines.length <= 1) return;
|
||||
|
||||
const newEngines = settings.searchEngines.filter(e => e.name !== nameToDelete);
|
||||
let newSelected = settings.selectedEngine;
|
||||
|
||||
if (settings.selectedEngine === nameToDelete) {
|
||||
newSelected = newEngines[0].name;
|
||||
}
|
||||
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
searchEngines: newEngines,
|
||||
selectedEngine: newSelected
|
||||
});
|
||||
showToast(t.searchEngineDeleted, 'success');
|
||||
}, [settings, onUpdateSettings, showToast, t]);
|
||||
|
||||
const handleSetDefault = useCallback((name: string) => {
|
||||
onUpdateSettings({ ...settings, selectedEngine: name });
|
||||
// Optional: showToast(`已设置 ${name} 为默认搜索引擎`, 'success');
|
||||
}, [settings, onUpdateSettings]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", index.toString());
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const newEngines = [...settings.searchEngines];
|
||||
const draggedItem = newEngines[draggedIndex];
|
||||
newEngines.splice(draggedIndex, 1);
|
||||
newEngines.splice(index, 0, draggedItem);
|
||||
|
||||
onUpdateSettings({ ...settings, searchEngines: newEngines });
|
||||
setDraggedIndex(index);
|
||||
}, [draggedIndex, settings, onUpdateSettings]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleEditEngine = useCallback((engine: SearchEngine) => {
|
||||
setNewEngineName(engine.name);
|
||||
setNewEngineUrl(engine.urlPattern);
|
||||
setNewEngineIcon(engine.icon || '');
|
||||
setEditingOriginalName(engine.name);
|
||||
setIsAddingEngine(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveEngine = useCallback(() => {
|
||||
if (!newEngineName.trim() || !newEngineUrl.trim()) return;
|
||||
|
||||
const name = newEngineName.trim();
|
||||
|
||||
// Check for duplicate name if we are adding new or renaming
|
||||
const isRenaming = editingOriginalName && editingOriginalName !== name;
|
||||
const isAdding = !editingOriginalName;
|
||||
|
||||
if (isAdding || isRenaming) {
|
||||
const exists = settings.searchEngines.some(e => e.name === name);
|
||||
if (exists) {
|
||||
showToast(t.duplicateEngineName, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newEngine: SearchEngine = {
|
||||
name: name,
|
||||
urlPattern: newEngineUrl.trim(),
|
||||
icon: newEngineIcon.trim() || undefined
|
||||
};
|
||||
|
||||
let updatedEngines = [...settings.searchEngines];
|
||||
let updatedSelected = settings.selectedEngine;
|
||||
|
||||
if (editingOriginalName) {
|
||||
// Update existing
|
||||
const index = updatedEngines.findIndex(e => e.name === editingOriginalName);
|
||||
if (index !== -1) {
|
||||
updatedEngines[index] = newEngine;
|
||||
}
|
||||
|
||||
// If the edited engine was the selected one, update the selection reference
|
||||
if (settings.selectedEngine === editingOriginalName) {
|
||||
updatedSelected = newEngine.name;
|
||||
}
|
||||
showToast(t.searchEngineUpdated, 'success');
|
||||
} else {
|
||||
// Add new
|
||||
updatedEngines.push(newEngine);
|
||||
showToast(t.newSearchEngineAdded, 'success');
|
||||
}
|
||||
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
searchEngines: updatedEngines,
|
||||
selectedEngine: updatedSelected
|
||||
});
|
||||
|
||||
setNewEngineName('');
|
||||
setNewEngineUrl('');
|
||||
setNewEngineIcon('');
|
||||
setEditingOriginalName(null);
|
||||
setIsAddingEngine(false);
|
||||
}, [newEngineName, newEngineUrl, newEngineIcon, editingOriginalName, settings, onUpdateSettings, showToast, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsAddingEngine(false);
|
||||
setNewEngineName('');
|
||||
setNewEngineUrl('');
|
||||
setNewEngineIcon('');
|
||||
setEditingOriginalName(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/40 uppercase tracking-wider">{t.searchEngines}</h3>
|
||||
{!isAddingEngine && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingOriginalName(null);
|
||||
setNewEngineName('');
|
||||
setNewEngineUrl('');
|
||||
setNewEngineIcon('');
|
||||
setIsAddingEngine(true);
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-white/10 transition-colors"
|
||||
style={{ color: settings.themeColor }}
|
||||
title={t.addCustomEngine}
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search engine list */}
|
||||
<div className="space-y-2">
|
||||
{settings.searchEngines.map((engine, index) => (
|
||||
<div
|
||||
key={engine.name}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5 group transition-all duration-200
|
||||
${draggedIndex === index ? 'opacity-40 border-dashed border-white/30' : ''}
|
||||
${editingOriginalName === engine.name ? 'ring-1 ring-white/30 bg-white/10' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
{/* Drag handle */}
|
||||
<div className="cursor-grab active:cursor-grabbing text-white/20 hover:text-white/60 p-1 flex-shrink-0">
|
||||
<GripVerticalIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-sm font-medium text-white/90 truncate">{engine.name}</span>
|
||||
<span className="text-xs text-white/40 truncate font-mono">{engine.urlPattern}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2 flex-shrink-0">
|
||||
{/* Default/Set as default button */}
|
||||
{settings.selectedEngine === engine.name ? (
|
||||
<span className="text-[10px] font-medium bg-white/20 text-white/90 px-2 py-1 rounded-md whitespace-nowrap cursor-default">
|
||||
{t.current}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSetDefault(engine.name)}
|
||||
className="text-[10px] font-medium bg-transparent hover:bg-white/10 text-white/40 hover:text-white px-2 py-1 rounded-md border border-white/10 transition-colors"
|
||||
>
|
||||
{t.setDefault}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit button */}
|
||||
<button
|
||||
onClick={() => handleEditEngine(engine)}
|
||||
className="p-1.5 rounded-md transition-colors text-white/20 hover:bg-white/10 hover:text-white"
|
||||
title={t.edit}
|
||||
>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => handleDeleteEngine(engine.name)}
|
||||
disabled={settings.searchEngines.length <= 1}
|
||||
className={`
|
||||
p-1.5 rounded-md transition-colors
|
||||
${settings.searchEngines.length <= 1
|
||||
? 'opacity-0 cursor-default'
|
||||
: 'text-white/20 hover:bg-red-500/20 hover:text-red-400'}
|
||||
`}
|
||||
title={t.delete}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit engine form */}
|
||||
{isAddingEngine && (
|
||||
<div className="mt-4 p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-white/80">
|
||||
{editingOriginalName ? t.editSearchEngine : t.addCustomEngine}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-white/40 ml-1">{t.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEngineName}
|
||||
onChange={(e) => setNewEngineName(e.target.value)}
|
||||
placeholder="e.g.: Google"
|
||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-white/40 ml-1">{t.searchUrl}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEngineUrl}
|
||||
onChange={(e) => setNewEngineUrl(e.target.value)}
|
||||
placeholder="https://example.com/search?q="
|
||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-white/40 ml-1">{t.svgIconCode} ({t.optional})</label>
|
||||
<textarea
|
||||
value={newEngineIcon}
|
||||
onChange={(e) => setNewEngineIcon(e.target.value)}
|
||||
placeholder='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">...</svg>'
|
||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:border-white/30 focus:outline-none transition-colors font-mono resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
{newEngineIcon.trim() && (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 bg-black/20 rounded-lg">
|
||||
<span className="text-xs text-white/40">{t.preview}:</span>
|
||||
<div
|
||||
className="w-5 h-5 text-white/80 [&_svg]:w-full [&_svg]:h-full"
|
||||
dangerouslySetInnerHTML={{ __html: newEngineIcon }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEngine}
|
||||
disabled={!newEngineName.trim() || !newEngineUrl.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-white text-black font-medium rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{editingOriginalName ? t.save : t.add}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchEngineManager;
|
||||
85
components/SettingsModal.tsx
Normal file
85
components/SettingsModal.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import React from 'react';
|
||||
import { XIcon } from './Icons';
|
||||
import { UserSettings } from '../types';
|
||||
import ThemeSettings from './ThemeSettings';
|
||||
import WallpaperManager from './WallpaperManager';
|
||||
import SearchEngineManager from './SearchEngineManager';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: UserSettings;
|
||||
onUpdateSettings: (newSettings: UserSettings) => void;
|
||||
}
|
||||
|
||||
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings, onUpdateSettings }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Handler to block right-click context menu within the settings modal
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Background overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className="
|
||||
relative w-full max-w-md rounded-3xl
|
||||
bg-[#1a1a1a]/90 backdrop-blur-xl
|
||||
border border-white/10
|
||||
shadow-[0_20px_50px_rgba(0,0,0,0.5)]
|
||||
text-white animate-in fade-in zoom-in-95 duration-200
|
||||
max-h-[85vh] flex flex-col overflow-hidden
|
||||
">
|
||||
{/* Header - fixed */}
|
||||
<div className="flex items-center justify-between px-6 pt-6 pb-4 shrink-0 z-10">
|
||||
<h2 className="text-2xl font-light tracking-wide">{t.settings}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors text-white/70 hover:text-white"
|
||||
>
|
||||
<XIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable area */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 custom-scrollbar">
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Appearance settings section */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-semibold text-white/40 uppercase tracking-wider">{t.appearance}</h3>
|
||||
|
||||
{/* Theme settings component */}
|
||||
<ThemeSettings settings={settings} onUpdateSettings={onUpdateSettings} />
|
||||
|
||||
{/* Wallpaper manager component */}
|
||||
<WallpaperManager settings={settings} onUpdateSettings={onUpdateSettings} />
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-white/10 w-full" />
|
||||
|
||||
{/* Search engine manager component */}
|
||||
<SearchEngineManager settings={settings} onUpdateSettings={onUpdateSettings} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
186
components/ThemeSettings.tsx
Normal file
186
components/ThemeSettings.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
|
||||
import React from 'react';
|
||||
import { CheckIcon } from './Icons';
|
||||
import { UserSettings, Language } from '../types';
|
||||
import { THEMES } from '../constants';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface ThemeSettingsProps {
|
||||
settings: UserSettings;
|
||||
onUpdateSettings: (newSettings: UserSettings) => void;
|
||||
}
|
||||
|
||||
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSettings }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleSeconds = () => {
|
||||
onUpdateSettings({ ...settings, showSeconds: !settings.showSeconds });
|
||||
};
|
||||
|
||||
const toggleMaskBlur = () => {
|
||||
onUpdateSettings({ ...settings, enableMaskBlur: !settings.enableMaskBlur });
|
||||
};
|
||||
|
||||
const toggle24Hour = () => {
|
||||
onUpdateSettings({ ...settings, use24HourFormat: !settings.use24HourFormat });
|
||||
};
|
||||
|
||||
const toggleSearchHistory = () => {
|
||||
onUpdateSettings({ ...settings, enableSearchHistory: !settings.enableSearchHistory });
|
||||
};
|
||||
|
||||
const handleBlurChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onUpdateSettings({ ...settings, backgroundBlur: parseInt(e.target.value) });
|
||||
};
|
||||
|
||||
const handleOpacityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onUpdateSettings({ ...settings, searchOpacity: parseFloat(e.target.value) });
|
||||
};
|
||||
|
||||
const handleThemeChange = (colorHex: string) => {
|
||||
onUpdateSettings({ ...settings, themeColor: colorHex });
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: Language) => {
|
||||
onUpdateSettings({ ...settings, language: lang });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Language Selection */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-white/80 font-light block">{t.language}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
className={`
|
||||
flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${settings.language === 'en'
|
||||
? 'bg-white text-black'
|
||||
: 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'}
|
||||
`}
|
||||
>
|
||||
{t.english}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
className={`
|
||||
flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${settings.language === 'zh'
|
||||
? 'bg-white text-black'
|
||||
: 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'}
|
||||
`}
|
||||
>
|
||||
{t.chinese}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme color */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-white/80 font-light block">{t.themeColor}</span>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.hex}
|
||||
onClick={() => handleThemeChange(theme.hex)}
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300
|
||||
${settings.themeColor === theme.hex ? 'ring-2 ring-white scale-110' : 'hover:scale-110 opacity-80 hover:opacity-100'}
|
||||
`}
|
||||
style={{ backgroundColor: theme.hex }}
|
||||
title={theme.name}
|
||||
>
|
||||
{settings.themeColor === theme.hex && (
|
||||
<CheckIcon className="w-4 h-4 text-white drop-shadow-md" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/80 font-light">{t.showSeconds}</span>
|
||||
<button
|
||||
onClick={toggleSeconds}
|
||||
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10"
|
||||
style={{ backgroundColor: settings.showSeconds ? settings.themeColor : undefined }}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.showSeconds ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/80 font-light">{t.use24HourFormat}</span>
|
||||
<button
|
||||
onClick={toggle24Hour}
|
||||
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10"
|
||||
style={{ backgroundColor: settings.use24HourFormat ? settings.themeColor : undefined }}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.use24HourFormat ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/80 font-light">{t.maskBlurEffect}</span>
|
||||
<button
|
||||
onClick={toggleMaskBlur}
|
||||
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10"
|
||||
style={{ backgroundColor: settings.enableMaskBlur ? settings.themeColor : undefined }}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.enableMaskBlur ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/80 font-light">{t.searchHistory}</span>
|
||||
<button
|
||||
onClick={toggleSearchHistory}
|
||||
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10"
|
||||
style={{ backgroundColor: settings.enableSearchHistory ? settings.themeColor : undefined }}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.enableSearchHistory ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background blur slider */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm text-white/60 font-light">
|
||||
<span>{t.backgroundBlur}</span>
|
||||
<span>{settings.backgroundBlur}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
value={settings.backgroundBlur}
|
||||
onChange={handleBlurChange}
|
||||
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search box opacity slider */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm text-white/60 font-light">
|
||||
<span>{t.searchBoxOpacity}</span>
|
||||
<span>{Math.round(settings.searchOpacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={settings.searchOpacity}
|
||||
onChange={handleOpacityChange}
|
||||
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSettings;
|
||||
296
components/WallpaperManager.tsx
Normal file
296
components/WallpaperManager.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { useRef, useMemo, useCallback } from 'react';
|
||||
import { TrashIcon, UploadIcon, ImageIcon, CheckIcon } from './Icons';
|
||||
import { UserSettings, PresetWallpaper, BackgroundType, WallpaperFit } from '../types';
|
||||
import { PRESET_WALLPAPERS } from '../constants';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface WallpaperManagerProps {
|
||||
settings: UserSettings;
|
||||
onUpdateSettings: (newSettings: UserSettings) => void;
|
||||
}
|
||||
|
||||
const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateSettings }) => {
|
||||
const [customUrl, setCustomUrl] = React.useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const WALLPAPER_FITS: { value: WallpaperFit; label: string }[] = [
|
||||
{ value: 'cover', label: t.cover },
|
||||
{ value: 'contain', label: t.contain },
|
||||
{ value: 'fill', label: t.fill },
|
||||
{ value: 'repeat', label: t.repeat },
|
||||
{ value: 'center', label: t.center },
|
||||
];
|
||||
|
||||
const handlePresetWallpaper = useCallback((preset: PresetWallpaper) => {
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
backgroundUrl: preset.url,
|
||||
backgroundType: preset.type
|
||||
});
|
||||
// Optional: showToast(`已应用壁纸: ${preset.name}`, 'success');
|
||||
}, [settings, onUpdateSettings]);
|
||||
|
||||
const handleCustomUrlApply = useCallback(() => {
|
||||
if (!customUrl.trim()) return;
|
||||
|
||||
// Validate URL protocol, only allow https and http
|
||||
const trimmedUrl = customUrl.trim();
|
||||
try {
|
||||
const url = new URL(trimmedUrl);
|
||||
if (!['https:', 'http:'].includes(url.protocol)) {
|
||||
showToast(t.unsupportedProtocol, 'error');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
showToast(t.invalidUrlFormat, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const isVideo = trimmedUrl.match(/\.(mp4|webm|ogg)$/i);
|
||||
const type: BackgroundType = isVideo ? 'video' : 'image';
|
||||
|
||||
const newWallpaper: PresetWallpaper = {
|
||||
id: Date.now().toString(),
|
||||
name: 'Custom URL',
|
||||
type: type,
|
||||
url: trimmedUrl,
|
||||
isCustom: true
|
||||
};
|
||||
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
backgroundUrl: newWallpaper.url,
|
||||
backgroundType: newWallpaper.type,
|
||||
customWallpapers: [...settings.customWallpapers, newWallpaper]
|
||||
});
|
||||
|
||||
setCustomUrl('');
|
||||
showToast(t.customWallpaperApplied, 'success');
|
||||
}, [customUrl, settings, onUpdateSettings, showToast, t]);
|
||||
|
||||
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// File size limit: 3.5MB (approximately 4.7MB after Base64 encoding, leaving space for other settings data)
|
||||
const MAX_FILE_SIZE = 3.5 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast(t.fileSizeExceeded, 'error', 5000);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Strict MIME type validation
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg'];
|
||||
const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES];
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
showToast(t.unsupportedFileType, 'error', 4000);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const type: BackgroundType = ALLOWED_VIDEO_TYPES.includes(file.type) ? 'video' : 'image';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const base64Url = event.target?.result as string;
|
||||
|
||||
// Validate that file content matches the declared MIME type
|
||||
if (type === 'image' && !base64Url.startsWith('data:image/')) {
|
||||
showToast(t.fileContentMismatch, 'error');
|
||||
return;
|
||||
}
|
||||
if (type === 'video' && !base64Url.startsWith('data:video/')) {
|
||||
showToast(t.fileContentMismatch, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Base64 encoded size
|
||||
const base64Size = new Blob([base64Url]).size;
|
||||
const estimatedTotalSize = base64Size + JSON.stringify(settings).length;
|
||||
|
||||
// localStorage limit is 5MB, we set a strict 5MB limit
|
||||
if (estimatedTotalSize > 5 * 1024 * 1024) {
|
||||
showToast(t.storageFull, 'error', 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const newWallpaper: PresetWallpaper = {
|
||||
id: Date.now().toString(),
|
||||
name: file.name,
|
||||
type: type,
|
||||
url: base64Url,
|
||||
isCustom: true
|
||||
};
|
||||
|
||||
try {
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
backgroundUrl: base64Url,
|
||||
backgroundType: type,
|
||||
customWallpapers: [...settings.customWallpapers, newWallpaper]
|
||||
});
|
||||
showToast(t.wallpaperUploaded, 'success');
|
||||
} catch (error) {
|
||||
showToast(t.storageFull, 'error', 5000);
|
||||
console.error('Failed to save wallpaper:', error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
showToast(t.fileContentMismatch, 'error');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
}, [settings, onUpdateSettings, showToast, t]);
|
||||
|
||||
const handleDeleteWallpaper = useCallback((e: React.MouseEvent, wallpaperToDelete: PresetWallpaper) => {
|
||||
e.stopPropagation();
|
||||
if (!wallpaperToDelete.isCustom) return;
|
||||
|
||||
const newCustomWallpapers = settings.customWallpapers.filter(
|
||||
w => w.id !== wallpaperToDelete.id && w.url !== wallpaperToDelete.url
|
||||
);
|
||||
|
||||
let newBgUrl = settings.backgroundUrl;
|
||||
let newBgType = settings.backgroundType;
|
||||
|
||||
if (settings.backgroundUrl === wallpaperToDelete.url) {
|
||||
newBgUrl = PRESET_WALLPAPERS[0].url;
|
||||
newBgType = PRESET_WALLPAPERS[0].type;
|
||||
}
|
||||
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
customWallpapers: newCustomWallpapers,
|
||||
backgroundUrl: newBgUrl,
|
||||
backgroundType: newBgType
|
||||
});
|
||||
showToast(t.wallpaperDeleted, 'info');
|
||||
}, [settings, onUpdateSettings, showToast, t]);
|
||||
|
||||
const allWallpapers = useMemo(() => {
|
||||
return [...PRESET_WALLPAPERS, ...settings.customWallpapers];
|
||||
}, [settings.customWallpapers]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<span className="text-white/80 font-light block">{t.wallpaperSettings}</span>
|
||||
|
||||
{/* Wallpaper grid */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{allWallpapers.map((preset) => (
|
||||
<button
|
||||
key={preset.id || preset.url}
|
||||
onClick={() => handlePresetWallpaper(preset)}
|
||||
className={`
|
||||
relative aspect-video rounded-lg overflow-hidden border transition-all duration-200 group
|
||||
${settings.backgroundUrl === preset.url ? 'border-white ring-1 ring-white' : 'border-white/10 hover:border-white/40'}
|
||||
`}
|
||||
>
|
||||
<img
|
||||
src={preset.thumbnail || preset.url}
|
||||
alt={preset.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{preset.type === 'video' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="w-6 h-6 rounded-full border border-white/50 flex items-center justify-center bg-black/30">
|
||||
<div className="w-0 h-0 border-t-[4px] border-t-transparent border-l-[6px] border-l-white border-b-[4px] border-b-transparent ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.backgroundUrl === preset.url && (
|
||||
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
|
||||
<CheckIcon className="w-6 h-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preset.isCustom && (
|
||||
<div
|
||||
className="absolute top-1 right-1 p-1 rounded-full bg-black/50 text-white/60 hover:text-white hover:bg-red-500/80 transition-colors opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => handleDeleteWallpaper(e, preset)}
|
||||
title={t.deleteWallpaper}
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom wallpaper controls */}
|
||||
<div className="space-y-2 pt-1">
|
||||
{/* Local upload */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-colors text-sm text-white/80 hover:text-white"
|
||||
>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
<span>{t.uploadImageVideo}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL input */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
placeholder={t.enterImageVideoUrl}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-xl pl-9 pr-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none transition-colors"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomUrlApply()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCustomUrlApply}
|
||||
disabled={!customUrl.trim()}
|
||||
className="px-3 py-2 bg-white text-black text-xs font-bold rounded-xl hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{t.apply}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallpaper fit mode */}
|
||||
<div className="space-y-1 pt-1">
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-xl overflow-x-auto custom-scrollbar">
|
||||
{WALLPAPER_FITS.map((fit) => (
|
||||
<button
|
||||
key={fit.value}
|
||||
onClick={() => onUpdateSettings({ ...settings, wallpaperFit: fit.value })}
|
||||
className={`
|
||||
flex-1 px-3 py-1.5 text-xs rounded-lg whitespace-nowrap transition-colors border
|
||||
${settings.wallpaperFit === fit.value || (!settings.wallpaperFit && fit.value === 'cover')
|
||||
? 'bg-white text-black font-medium border-white'
|
||||
: 'bg-transparent text-white/60 border-transparent hover:text-white hover:bg-white/10'}
|
||||
`}
|
||||
>
|
||||
{fit.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WallpaperManager;
|
||||
88
constants.ts
Normal file
88
constants.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
|
||||
import { SearchEngine, PresetWallpaper } from './types';
|
||||
|
||||
// Inlined SVG icons to avoid module resolution issues
|
||||
const googleIcon = `<svg t="1764829226293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2463" width="200" height="200"><path d="M214.101333 512c0-32.512 5.546667-63.701333 15.36-92.928L57.173333 290.218667A491.861333 491.861333 0 0 0 4.693333 512c0 79.701333 18.858667 154.88 52.394667 221.610667l172.202667-129.066667A290.56 290.56 0 0 1 214.101333 512" fill="#FBBC05" p-id="2464"></path><path d="M516.693333 216.192c72.106667 0 137.258667 25.002667 188.458667 65.962667L854.101333 136.533333C763.349333 59.178667 646.997333 11.392 516.693333 11.392c-202.325333 0-376.234667 113.28-459.52 278.826667l172.373334 128.853333c39.68-118.016 152.832-202.88 287.146666-202.88" fill="#EA4335" p-id="2465"></path><path d="M516.693333 807.808c-134.357333 0-247.509333-84.864-287.232-202.88l-172.288 128.853333c83.242667 165.546667 257.152 278.826667 459.52 278.826667 124.842667 0 244.053333-43.392 333.568-124.757333l-163.584-123.818667c-46.122667 28.458667-104.234667 43.776-170.026666 43.776" fill="#34A853" p-id="2466"></path><path d="M1005.397333 512c0-29.568-4.693333-61.44-11.648-91.008H516.650667V614.4h274.602666c-13.696 65.962667-51.072 116.650667-104.533333 149.632l163.541333 123.818667c93.994667-85.418667 155.136-212.650667 155.136-375.850667" fill="#4285F4" p-id="2467"></path></svg>`;
|
||||
|
||||
const baiduIcon = `<svg t="1764829489600" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13632" width="200" height="200"><path d="M184.191325 539.587c111.321-23.96 95.966-157.129 92.8-186.207-5.47-44.784-58.252-123.189-129.843-116.855C57.164325 244.458 44.017325 374.717 44.017325 374.717c-12.252 60.139 29.077 188.766 140.142 164.838z m206.646-223.538c61.418 0 111.065-70.791 111.065-158.249C501.904325 70.791 452.417325 0.001 390.999325 0.001S279.646325 70.407 279.646325 157.865s49.9 158.249 111.321 158.249z m264.867 10.492c82.307 11.1 134.865-76.773 145.517-143.31 10.654-66.12-42.639-143.309-100.667-156.52-58.443-13.468-130.517 79.94-137.775 140.75-7.677 74.63 10.652 148.844 92.544 159.4z m325.584 112.121c0-31.829-26.135-127.667-124.117-127.667-97.886 0-111.321 90.432-111.321 154.41 0 61 5.022 145.869 127.315 143.31 121.782-2.975 108.472-138.192 108.472-170.181zM857.587325 717.445s-127.315-98.526-201.561-204.729c-100.669-156.841-243.755-92.991-291.482-13.467-47.759 80.324-121.973 130.61-132.434 144.046-10.652 13.211-153.546 90.432-121.717 231.183 31.989 140.751 143.31 138.192 143.31 138.192s81.891 8.093 177.442-13.211 177.442 5.118 177.442 5.118 222.1 74.63 283.549-68.68c60.747-143.726-34.548-217.94-34.548-217.94z" fill="#2319DC" p-id="13633"></path></svg>`;
|
||||
|
||||
const bingIcon = `<svg t="1764829699470" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="32042" width="200" height="200"><path d="M340.582 70.11L102.537 0.683V851.9L340.65 643.345V70.11zM102.537 851.763l238.045 171.623 580.881-340.924V411.785L102.537 851.83z" fill="#409EFF" p-id="32043"></path><path d="M409.463 255.386l113.733 238.933 138.854 56.866 259.413-139.4-506.06-156.331z" fill="#409EFF" p-id="32044"></path></svg>`;
|
||||
|
||||
const duckduckgoIcon = `<svg t="1764829739655" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="34262" width="200" height="200"><path d="M28.16 509.44c0 268.8 217.6 486.4 486.4 486.4s486.4-217.6 486.4-486.4-217.6-486.4-486.4-486.4-486.4 217.6-486.4 486.4z" fill="#CC6633" p-id="34263"></path><path d="M64 512c0 247.296 200.704 448 448 448s448-200.704 448-448-200.704-448-448-448S64 264.704 64 512z" fill="#FFFFFF" p-id="34264"></path><path d="M934.912 516.096c0 199.68-138.24 367.616-324.096 412.672-11.264-21.504-22.016-42.496-31.232-59.904 31.744 28.16 70.144 38.912 91.648 25.6 27.648-16.896 37.376-78.848-5.632-135.168-13.312 0.512-30.72 2.56-50.176 8.192-27.648 8.192-48.128 20.992-61.44 30.72-11.776-17.92-28.16-48.64-36.352-89.6-6.656-33.28-5.12-61.952-2.56-81.408 14.848 10.24 113.664 43.52 162.816 42.496s129.536-30.72 120.832-54.784-88.576 20.992-172.544 13.312c-61.952-5.632-72.704-33.28-58.88-53.76 17.408-25.6 48.64 4.608 100.352-10.752 51.712-15.36 124.416-43.008 151.04-58.368 61.952-34.816-26.112-49.152-46.592-39.424-19.968 9.216-88.064 26.624-120.32 34.304 17.92-62.976-25.088-172.544-73.216-220.672-15.872-15.872-39.424-25.6-66.56-30.72-10.24-14.336-27.136-28.16-51.2-40.448-46.08-24.064-98.304-32.768-149.504-24.064h-2.56c-6.144 1.024-9.728 3.584-14.848 4.096 6.144 0.512 29.184 11.264 44.032 17.408-7.168 3.072-16.896 4.608-24.576 7.68-3.072 0.512-5.632 1.024-8.704 2.56-7.168 3.072-12.8 15.36-12.288 21.504 34.816-3.584 86.528-1.024 124.416 10.24-26.624 3.584-51.2 10.752-69.12 19.968-0.512 0.512-1.024 0.512-2.048 1.024-2.048 1.024-4.608 1.536-6.144 2.56-56.832 29.696-81.92 99.84-67.072 183.808 13.312 75.776 69.12 336.384 95.232 460.288-164.352-56.32-282.624-214.016-282.624-399.36 0-235.008 190.464-424.96 424.96-424.96s424.96 190.464 424.96 424.96z" fill="#DE5833" p-id="34265"></path><path d="M357.376 446.976c0 17.408 14.336 31.744 31.744 31.744s31.744-14.336 31.744-31.744c0-17.408-14.336-31.744-31.744-31.744s-31.744 14.336-31.744 31.744zM599.552 401.408c-14.848-0.512-27.648 11.264-28.16 26.112-0.512 14.848 11.264 27.648 26.112 28.16h2.048c14.848 0 27.136-12.288 27.136-27.136s-12.288-27.136-27.136-27.136z m-201.728-45.568s-23.552-10.752-46.592 3.584c-23.04 14.848-22.016 29.696-22.016 29.696s-12.288-27.136 20.48-40.448c32.256-13.824 48.64 7.168 48.128 7.168z m218.112-2.048s-16.896-9.728-30.208-9.728c-27.136 0.512-34.816 12.288-34.816 12.288s4.608-28.672 39.424-23.04c11.264 2.56 20.992 9.728 25.6 20.48z" fill="#336699" p-id="34266"></path><path d="M549.376 522.24c24.576-9.728 35.328-9.728 74.24-17.408 24.576-5.12 56.832-11.776 94.72-23.552 30.208-9.216 36.864-13.824 56.32-15.36 26.112-2.048 62.464 1.024 66.56 15.36 2.048 6.656-4.608 13.824-10.24 20.48-14.336 16.384-32.256 22.016-61.44 30.72-36.352 11.264-38.912 11.264-51.2 15.36-58.368 18.432-55.296 23.552-76.8 25.6-38.912 3.584-60.928-12.8-71.68 0-6.656 8.192-4.608 22.016 0 30.72 6.656 11.776 19.968 15.36 40.96 20.48 25.6 6.144 46.08 5.632 51.2 5.12 18.432-1.024 31.744-4.608 51.2-10.24 40.96-11.776 52.736-21.504 71.68-15.36 3.584 1.024 19.456 6.144 20.48 15.36 2.048 18.432-53.76 43.52-102.4 51.2-47.104 7.168-86.528-2.56-97.28-5.12-7.168-2.048-19.968-6.144-46.08-15.36-29.696-10.24-37.376-13.824-46.08-20.48-9.216-7.168-23.04-17.92-25.6-35.84-2.56-18.432 9.216-33.28 15.36-40.96 9.216-11.264 21.504-20.992 46.08-30.72z" fill="#FDD20A" p-id="34267"></path><path d="M523.776 798.72c4.096-3.584 9.216-7.168 15.36-10.24 11.776-6.144 22.528-9.216 30.72-10.24-1.536 3.584-3.584 6.656-5.12 10.24 14.336-7.168 29.696-13.824 46.08-20.48 25.088-10.24 49.152-18.432 71.68-25.6 7.68 12.288 22.016 36.864 25.6 71.68 3.584 35.328-5.12 63.488-10.24 76.8-6.144 3.584-32.768 17.408-66.56 10.24s-51.712-30.208-56.32-35.84c-1.536 5.12-3.584 10.24-5.12 15.36-5.632 1.024-14.848 2.048-25.6 0-11.776-2.048-20.48-7.168-25.6-10.24-24.064 11.776-47.616 24.064-71.68 35.84-3.584 4.608-9.728 6.656-15.36 5.12-7.68-2.048-10.24-9.728-10.24-10.24-5.632-16.896-11.264-37.888-15.36-61.44-4.096-25.088-5.12-47.616-5.12-66.56-2.048-6.144 0.512-12.8 5.12-15.36s9.728-0.512 10.24 0c18.944 2.56 44.544 7.68 71.68 20.48 13.824 6.656 26.112 13.824 35.84 20.48z" fill="#66CC33" p-id="34268"></path></svg>`;
|
||||
|
||||
const bilibiliIcon = `<svg t="1733289600000" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5088" width="200" height="200"><path d="M306.005333 117.632L444.330667 256h135.296l138.368-138.325333a42.666667 42.666667 0 0 1 60.373333 60.373333L700.330667 256H789.333333A149.333333 149.333333 0 0 1 938.666667 405.333333v341.333334a149.333333 149.333333 0 0 1-149.333334 149.333333h-554.666666A149.333333 149.333333 0 0 1 85.333333 746.666667v-341.333334A149.333333 149.333333 0 0 1 234.666667 256h88.96L245.632 177.962667a42.666667 42.666667 0 0 1 60.373333-60.373334zM789.333333 341.333333h-554.666666a64 64 0 0 0-63.701334 57.856L170.666667 405.333333v341.333334a64 64 0 0 0 57.856 63.701333L234.666667 810.666667h554.666666a64 64 0 0 0 63.701334-57.856L853.333333 746.666667v-341.333334A64 64 0 0 0 789.333333 341.333333zM341.333333 469.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v85.333333a42.666667 42.666667 0 0 1-85.333333 0v-85.333333a42.666667 42.666667 0 0 1 42.666666-42.666667z m341.333334 0a42.666667 42.666667 0 0 1 42.666666 42.666667v85.333333a42.666667 42.666667 0 0 1-85.333333 0v-85.333333a42.666667 42.666667 0 0 1 42.666667-42.666667z" fill="#00A1D6" p-id="5089"></path></svg>`;
|
||||
|
||||
export const DEFAULT_BACKGROUND_IMAGE = "https://picsum.photos/1920/1080?grayscale&blur=2";
|
||||
|
||||
export const PRESET_WALLPAPERS: PresetWallpaper[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
type: 'image',
|
||||
url: 'https://tc-new.z.wiki/autoupload/f/JPb3wcBYRgvdgjBZlDTRdWSEpzNQ5XwArLwhNo1hcymyl5f0KlZfm6UsKj-HyTuv/20250828/JmPj/3840X2160/light-background.png/webp'
|
||||
},
|
||||
{
|
||||
name: 'Mountains',
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1920&q=80',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=200&q=60'
|
||||
},
|
||||
{
|
||||
name: 'Nebula',
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?auto=format&fit=crop&w=1920&q=80',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?auto=format&fit=crop&w=200&q=60'
|
||||
},
|
||||
{
|
||||
name: 'City',
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=1920&q=80',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=200&q=60'
|
||||
},
|
||||
{
|
||||
name: 'Rain',
|
||||
type: 'video',
|
||||
url: 'https://assets.mixkit.co/videos/preview/mixkit-rain-falling-on-the-window-glass-1634-large.mp4',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1515694346937-94d85e41e6f0?auto=format&fit=crop&w=200&q=60'
|
||||
}
|
||||
];
|
||||
|
||||
export const SEARCH_ENGINES: SearchEngine[] = [
|
||||
{
|
||||
name: 'Google',
|
||||
urlPattern: 'https://www.google.com/search?q=',
|
||||
icon: googleIcon
|
||||
},
|
||||
{
|
||||
name: 'Baidu',
|
||||
urlPattern: 'https://www.baidu.com/s?wd=',
|
||||
icon: baiduIcon
|
||||
},
|
||||
{
|
||||
name: 'Bing',
|
||||
urlPattern: 'https://www.bing.com/search?q=',
|
||||
icon: bingIcon
|
||||
},
|
||||
{
|
||||
name: 'DuckDuckGo',
|
||||
urlPattern: 'https://duckduckgo.com/?q=',
|
||||
icon: duckduckgoIcon
|
||||
},
|
||||
{
|
||||
name: 'Bilibili',
|
||||
urlPattern: 'https://search.bilibili.com/all?keyword=',
|
||||
icon: bilibiliIcon
|
||||
},
|
||||
];
|
||||
|
||||
export const THEMES = [
|
||||
{ name: 'Neon Blue', hex: '#3b82f6' },
|
||||
{ name: 'Electric Purple', hex: '#a855f7' },
|
||||
{ name: 'Emerald Green', hex: '#10b981' },
|
||||
{ name: 'Sunset Orange', hex: '#f97316' },
|
||||
{ name: 'Hot Pink', hex: '#ec4899' },
|
||||
{ name: 'Cyan Future', hex: '#06b6d4' },
|
||||
{ name: 'Crimson Red', hex: '#ef4444' },
|
||||
{ name: 'Golden', hex: '#eab308' },
|
||||
];
|
||||
|
||||
export const ANIMATION_DURATION = "duration-500 ease-out";
|
||||
79
context/ToastContext.tsx
Normal file
79
context/ToastContext.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { XIcon, CheckIcon, AlertCircleIcon, InfoIcon } from '../components/Icons';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info', duration = 3500) => {
|
||||
const id = Date.now().toString() + Math.random().toString();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
}, [removeToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-3 w-full max-w-sm pointer-events-none px-4 md:px-0">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-xl bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/10 shadow-[0_8px_30px_rgba(0,0,0,0.5)] animate-in slide-in-from-top-5 fade-in duration-300 text-white group"
|
||||
>
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
{toast.type === 'success' && <CheckIcon className="w-5 h-5 text-green-400" />}
|
||||
{toast.type === 'error' && <AlertCircleIcon className="w-5 h-5 text-red-400" />}
|
||||
{toast.type === 'warning' && <AlertCircleIcon className="w-5 h-5 text-yellow-400" />}
|
||||
{toast.type === 'info' && <InfoIcon className="w-5 h-5 text-blue-400" />}
|
||||
</div>
|
||||
<p className="text-sm font-medium flex-1 break-words leading-tight opacity-90">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="p-1 -mt-1 -mr-1 rounded-md hover:bg-white/10 text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
45
i18n/I18nContext.tsx
Normal file
45
i18n/I18nContext.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { Language, Translation } from './types';
|
||||
import { en } from './locales/en';
|
||||
import { zh } from './locales/zh';
|
||||
|
||||
interface I18nContextType {
|
||||
language: Language;
|
||||
t: Translation;
|
||||
setLanguage: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||
|
||||
const translations: Record<Language, Translation> = {
|
||||
en,
|
||||
zh,
|
||||
};
|
||||
|
||||
interface I18nProviderProps {
|
||||
children: ReactNode;
|
||||
language: Language;
|
||||
onLanguageChange: (lang: Language) => void;
|
||||
}
|
||||
|
||||
export const I18nProvider: React.FC<I18nProviderProps> = ({
|
||||
children,
|
||||
language,
|
||||
onLanguageChange
|
||||
}) => {
|
||||
const t = translations[language];
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ language, t, setLanguage: onLanguageChange }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTranslation = () => {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error('useTranslation must be used within an I18nProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
228
i18n/README.md
Normal file
228
i18n/README.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 多语言架构使用指南 (i18n Architecture Guide)
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
i18n/
|
||||
├── types.ts # 类型定义
|
||||
├── locales/
|
||||
│ ├── en.ts # 英文翻译
|
||||
│ └── zh.ts # 中文翻译
|
||||
├── I18nContext.tsx # Context 和 Provider
|
||||
├── index.ts # 导出文件
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 在组件中使用翻译
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t.settings}</h1>
|
||||
<p>{t.appearance}</p>
|
||||
<button onClick={() => setLanguage('zh')}>切换到中文</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 添加新的翻译键
|
||||
|
||||
#### 步骤 1: 在 `types.ts` 中添加类型定义
|
||||
|
||||
```typescript
|
||||
export interface Translation {
|
||||
// ... 现有的键
|
||||
myNewKey: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 步骤 2: 在 `locales/en.ts` 中添加英文翻译
|
||||
|
||||
```typescript
|
||||
export const en: Translation = {
|
||||
// ... 现有的翻译
|
||||
myNewKey: 'My New Text',
|
||||
};
|
||||
```
|
||||
|
||||
#### 步骤 3: 在 `locales/zh.ts` 中添加中文翻译
|
||||
|
||||
```typescript
|
||||
export const zh: Translation = {
|
||||
// ... 现有的翻译
|
||||
myNewKey: '我的新文本',
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 在组件中使用新的翻译键
|
||||
|
||||
```tsx
|
||||
const MyComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div>{t.myNewKey}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## 📝 已支持的语言
|
||||
|
||||
- **English (en)** - 英语
|
||||
- **简体中文 (zh)** - Simplified Chinese
|
||||
|
||||
## 🎯 已翻译的组件
|
||||
|
||||
以下组件已经集成了多语言支持:
|
||||
|
||||
- ✅ **ThemeSettings** - 主题设置(包含语言切换器)
|
||||
- ⏳ **SettingsModal** - 设置模态框(待更新)
|
||||
- ⏳ **SearchBox** - 搜索框(待更新)
|
||||
- ⏳ **SearchEngineManager** - 搜索引擎管理器(待更新)
|
||||
- ⏳ **WallpaperManager** - 壁纸管理器(待更新)
|
||||
- ⏳ **GlobalContextMenu** - 全局右键菜单(待更新)
|
||||
- ⏳ **ErrorBoundary** - 错误边界(待更新)
|
||||
- ⏳ **Clock** - 时钟(待更新)
|
||||
|
||||
## 🔧 API 参考
|
||||
|
||||
### `useTranslation()` Hook
|
||||
|
||||
返回一个包含以下属性的对象:
|
||||
|
||||
- **`t`**: `Translation` - 当前语言的翻译对象
|
||||
- **`language`**: `Language` - 当前语言 ('en' | 'zh')
|
||||
- **`setLanguage`**: `(lang: Language) => void` - 切换语言的函数
|
||||
|
||||
### `I18nProvider` Component
|
||||
|
||||
Props:
|
||||
- **`language`**: `Language` - 当前语言
|
||||
- **`onLanguageChange`**: `(lang: Language) => void` - 语言变化回调
|
||||
- **`children`**: `ReactNode` - 子组件
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 保持翻译键的一致性
|
||||
|
||||
使用描述性的键名,例如:
|
||||
- ✅ `searchEngineDeleted`
|
||||
- ❌ `msg1`
|
||||
|
||||
### 2. 避免在翻译中使用 HTML
|
||||
|
||||
如果需要格式化文本,使用多个翻译键:
|
||||
|
||||
```tsx
|
||||
// ❌ 不推荐
|
||||
myKey: '<strong>Bold</strong> text'
|
||||
|
||||
// ✅ 推荐
|
||||
myKeyBold: 'Bold'
|
||||
myKeyText: 'text'
|
||||
|
||||
// 在组件中使用
|
||||
<div><strong>{t.myKeyBold}</strong> {t.myKeyText}</div>
|
||||
```
|
||||
|
||||
### 3. 为长文本使用描述性键名
|
||||
|
||||
```typescript
|
||||
// ✅ 好的命名
|
||||
errorMessage: 'The application encountered an unexpected error...'
|
||||
|
||||
// ❌ 不好的命名
|
||||
error: 'The application encountered an unexpected error...'
|
||||
```
|
||||
|
||||
### 4. 组织相关的翻译键
|
||||
|
||||
在 `Translation` 接口中使用注释分组:
|
||||
|
||||
```typescript
|
||||
export interface Translation {
|
||||
// Common
|
||||
settings: string;
|
||||
appearance: string;
|
||||
|
||||
// Theme Settings
|
||||
themeColor: string;
|
||||
showSeconds: string;
|
||||
|
||||
// Error Messages
|
||||
errorMessage: string;
|
||||
somethingWentWrong: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 🌍 添加新语言
|
||||
|
||||
### 步骤 1: 在 `types.ts` 中添加语言类型
|
||||
|
||||
```typescript
|
||||
export type Language = 'en' | 'zh' | 'ja'; // 添加 'ja' 日语
|
||||
```
|
||||
|
||||
### 步骤 2: 创建新的语言文件
|
||||
|
||||
创建 `locales/ja.ts`:
|
||||
|
||||
```typescript
|
||||
import { Translation } from '../types';
|
||||
|
||||
export const ja: Translation = {
|
||||
settings: '設定',
|
||||
appearance: '外観',
|
||||
// ... 其他翻译
|
||||
};
|
||||
```
|
||||
|
||||
### 步骤 3: 在 `I18nContext.tsx` 中注册新语言
|
||||
|
||||
```typescript
|
||||
const translations: Record<Language, Translation> = {
|
||||
en,
|
||||
zh,
|
||||
ja, // 添加日语
|
||||
};
|
||||
```
|
||||
|
||||
### 步骤 4: 在 `ThemeSettings` 中添加语言选项
|
||||
|
||||
```tsx
|
||||
<button onClick={() => handleLanguageChange('ja')}>
|
||||
日本語
|
||||
</button>
|
||||
```
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 问题:翻译不显示
|
||||
|
||||
**解决方案:**
|
||||
1. 确保组件在 `I18nProvider` 内部
|
||||
2. 检查是否正确导入了 `useTranslation`
|
||||
3. 验证翻译键在所有语言文件中都存在
|
||||
|
||||
### 问题:TypeScript 错误
|
||||
|
||||
**解决方案:**
|
||||
1. 确保所有语言文件都实现了 `Translation` 接口
|
||||
2. 运行 `npx tsc --noEmit` 检查类型错误
|
||||
3. 确保新添加的键在 `types.ts` 中有定义
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- `types.ts` - UserSettings 类型(包含 language 字段)
|
||||
- `App.tsx` - I18nProvider 集成
|
||||
- `utils/storage.ts` - 语言偏好持久化
|
||||
|
||||
## 🎉 完成!
|
||||
|
||||
现在你已经了解了如何使用和扩展多语言架构。如果有任何问题,请参考现有组件的实现或查看本文档。
|
||||
4
i18n/index.ts
Normal file
4
i18n/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './I18nContext';
|
||||
export { en } from './locales/en';
|
||||
export { zh } from './locales/zh';
|
||||
95
i18n/locales/en.ts
Normal file
95
i18n/locales/en.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Translation } from '../types';
|
||||
|
||||
export const en: Translation = {
|
||||
// Common
|
||||
settings: 'Settings',
|
||||
appearance: 'Appearance',
|
||||
searchEngines: 'Search Engines',
|
||||
|
||||
// Theme Settings
|
||||
themeColor: 'Theme Color',
|
||||
showSeconds: 'Show Seconds',
|
||||
use24HourFormat: '24-Hour Format',
|
||||
maskBlurEffect: 'Mask Blur Effect',
|
||||
searchHistory: 'Search History',
|
||||
backgroundBlur: 'Background Blur',
|
||||
searchBoxOpacity: 'Search Box Opacity',
|
||||
|
||||
// Wallpaper Settings
|
||||
wallpaperSettings: 'Wallpaper Settings',
|
||||
uploadImageVideo: 'Upload Image/Video',
|
||||
enterImageVideoUrl: 'Enter image or video URL...',
|
||||
apply: 'Apply',
|
||||
cover: 'Cover',
|
||||
contain: 'Contain',
|
||||
fill: 'Fill',
|
||||
repeat: 'Repeat',
|
||||
center: 'Center',
|
||||
deleteWallpaper: 'Delete wallpaper',
|
||||
|
||||
// Search Engine Manager
|
||||
addCustomEngine: 'Add Custom Engine',
|
||||
editSearchEngine: 'Edit Search Engine',
|
||||
name: 'Name',
|
||||
searchUrl: 'Search URL (use %s or append directly)',
|
||||
svgIconCode: 'SVG Icon Code',
|
||||
optional: 'optional',
|
||||
preview: 'Preview',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
add: 'Add',
|
||||
current: 'Current',
|
||||
setDefault: 'Set Default',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
|
||||
// Search Box
|
||||
search: 'Search',
|
||||
searchOn: 'Search on',
|
||||
recentSearches: 'Recent Searches',
|
||||
clearHistory: 'Clear History',
|
||||
|
||||
// Context Menu
|
||||
copy: 'Copy',
|
||||
cut: 'Cut',
|
||||
paste: 'Paste',
|
||||
|
||||
// Error Boundary
|
||||
somethingWentWrong: 'Something went wrong',
|
||||
errorMessage: 'The application encountered an unexpected error. Please try refreshing the page or resetting the app.',
|
||||
retry: 'Retry',
|
||||
refreshPage: 'Refresh Page',
|
||||
|
||||
// Toast Messages
|
||||
searchEngineDeleted: 'Search engine deleted',
|
||||
searchEngineUpdated: 'Search engine updated successfully',
|
||||
newSearchEngineAdded: 'New search engine added',
|
||||
duplicateEngineName: 'This search engine name already exists, please use a different name',
|
||||
customWallpaperApplied: 'Custom wallpaper applied',
|
||||
wallpaperUploaded: 'Wallpaper uploaded and applied successfully',
|
||||
wallpaperDeleted: 'Custom wallpaper deleted',
|
||||
fileSizeExceeded: 'File size cannot exceed 3.5MB. Consider using URL method instead.',
|
||||
unsupportedFileType: 'Unsupported file type. Only supports: JPEG, PNG, GIF, WebP, SVG, MP4, WebM, OGG',
|
||||
fileContentMismatch: 'File content does not match type',
|
||||
storageFull: 'Insufficient storage space! File too large to save. Consider using URL method.',
|
||||
invalidUrlFormat: 'Invalid URL format',
|
||||
unsupportedProtocol: 'Only HTTP or HTTPS protocol links are supported',
|
||||
invalidSearchUrl: 'Generated search URL is invalid',
|
||||
copyFailed: 'Copy failed',
|
||||
cutFailed: 'Cut failed',
|
||||
cannotReadClipboard: 'Cannot read clipboard',
|
||||
|
||||
// Clock
|
||||
monday: 'Monday',
|
||||
tuesday: 'Tuesday',
|
||||
wednesday: 'Wednesday',
|
||||
thursday: 'Thursday',
|
||||
friday: 'Friday',
|
||||
saturday: 'Saturday',
|
||||
sunday: 'Sunday',
|
||||
|
||||
// Language
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
chinese: '简体中文',
|
||||
};
|
||||
95
i18n/locales/zh.ts
Normal file
95
i18n/locales/zh.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Translation } from '../types';
|
||||
|
||||
export const zh: Translation = {
|
||||
// Common
|
||||
settings: '设置',
|
||||
appearance: '外观',
|
||||
searchEngines: '搜索引擎',
|
||||
|
||||
// Theme Settings
|
||||
themeColor: '主题颜色',
|
||||
showSeconds: '显示秒数',
|
||||
use24HourFormat: '24小时制',
|
||||
maskBlurEffect: '遮罩层毛玻璃',
|
||||
searchHistory: '搜索历史记录',
|
||||
backgroundBlur: '背景模糊度',
|
||||
searchBoxOpacity: '搜索框不透明度',
|
||||
|
||||
// Wallpaper Settings
|
||||
wallpaperSettings: '壁纸设置',
|
||||
uploadImageVideo: '上传图片/视频',
|
||||
enterImageVideoUrl: '输入图片或视频链接...',
|
||||
apply: '应用',
|
||||
cover: '填充',
|
||||
contain: '适应',
|
||||
fill: '拉伸',
|
||||
repeat: '平铺',
|
||||
center: '居中',
|
||||
deleteWallpaper: '删除壁纸',
|
||||
|
||||
// Search Engine Manager
|
||||
addCustomEngine: '添加自定义引擎',
|
||||
editSearchEngine: '编辑搜索引擎',
|
||||
name: '名称',
|
||||
searchUrl: '搜索 URL (使用 %s 或直接结尾)',
|
||||
svgIconCode: 'SVG 图标代码',
|
||||
optional: '可选',
|
||||
preview: '预览',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
add: '添加',
|
||||
current: '当前使用',
|
||||
setDefault: '设为默认',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
|
||||
// Search Box
|
||||
search: 'Search',
|
||||
searchOn: 'Search on',
|
||||
recentSearches: '最近搜索',
|
||||
clearHistory: '清空历史记录',
|
||||
|
||||
// Context Menu
|
||||
copy: '复制',
|
||||
cut: '剪切',
|
||||
paste: '粘贴',
|
||||
|
||||
// Error Boundary
|
||||
somethingWentWrong: '出错了',
|
||||
errorMessage: '应用遇到了一个意外错误。请尝试刷新页面或重置应用。',
|
||||
retry: '重试',
|
||||
refreshPage: '刷新页面',
|
||||
|
||||
// Toast Messages
|
||||
searchEngineDeleted: '搜索引擎已删除',
|
||||
searchEngineUpdated: '搜索引擎更新成功',
|
||||
newSearchEngineAdded: '新搜索引擎已添加',
|
||||
duplicateEngineName: '该搜索引擎名称已存在,请使用其他名称',
|
||||
customWallpaperApplied: '自定义壁纸已应用',
|
||||
wallpaperUploaded: '壁纸上传并应用成功',
|
||||
wallpaperDeleted: '自定义壁纸已删除',
|
||||
fileSizeExceeded: '文件大小不能超过 3.5MB。建议使用URL方式添加。',
|
||||
unsupportedFileType: '不支持的文件类型。仅支持:JPEG, PNG, GIF, WebP, SVG, MP4, WebM, OGG',
|
||||
fileContentMismatch: '文件内容与类型不匹配',
|
||||
storageFull: '存储空间不足!文件过大,无法保存。建议使用 URL 方式。',
|
||||
invalidUrlFormat: '无效的 URL 格式',
|
||||
unsupportedProtocol: '仅支持 HTTP 或 HTTPS 协议的链接',
|
||||
invalidSearchUrl: '生成的搜索 URL 无效',
|
||||
copyFailed: '复制失败',
|
||||
cutFailed: '剪切失败',
|
||||
cannotReadClipboard: '无法读取剪贴板',
|
||||
|
||||
// Clock
|
||||
monday: '星期一',
|
||||
tuesday: '星期二',
|
||||
wednesday: '星期三',
|
||||
thursday: '星期四',
|
||||
friday: '星期五',
|
||||
saturday: '星期六',
|
||||
sunday: '星期日',
|
||||
|
||||
// Language
|
||||
language: '语言',
|
||||
english: 'English',
|
||||
chinese: '简体中文',
|
||||
};
|
||||
95
i18n/types.ts
Normal file
95
i18n/types.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export type Language = 'en' | 'zh';
|
||||
|
||||
export interface Translation {
|
||||
// Common
|
||||
settings: string;
|
||||
appearance: string;
|
||||
searchEngines: string;
|
||||
|
||||
// Theme Settings
|
||||
themeColor: string;
|
||||
showSeconds: string;
|
||||
use24HourFormat: string;
|
||||
maskBlurEffect: string;
|
||||
searchHistory: string;
|
||||
backgroundBlur: string;
|
||||
searchBoxOpacity: string;
|
||||
|
||||
// Wallpaper Settings
|
||||
wallpaperSettings: string;
|
||||
uploadImageVideo: string;
|
||||
enterImageVideoUrl: string;
|
||||
apply: string;
|
||||
cover: string;
|
||||
contain: string;
|
||||
fill: string;
|
||||
repeat: string;
|
||||
center: string;
|
||||
deleteWallpaper: string;
|
||||
|
||||
// Search Engine Manager
|
||||
addCustomEngine: string;
|
||||
editSearchEngine: string;
|
||||
name: string;
|
||||
searchUrl: string;
|
||||
svgIconCode: string;
|
||||
optional: string;
|
||||
preview: string;
|
||||
cancel: string;
|
||||
save: string;
|
||||
add: string;
|
||||
current: string;
|
||||
setDefault: string;
|
||||
edit: string;
|
||||
delete: string;
|
||||
|
||||
// Search Box
|
||||
search: string;
|
||||
searchOn: string;
|
||||
recentSearches: string;
|
||||
clearHistory: string;
|
||||
|
||||
// Context Menu
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
|
||||
// Error Boundary
|
||||
somethingWentWrong: string;
|
||||
errorMessage: string;
|
||||
retry: string;
|
||||
refreshPage: string;
|
||||
|
||||
// Toast Messages
|
||||
searchEngineDeleted: string;
|
||||
searchEngineUpdated: string;
|
||||
newSearchEngineAdded: string;
|
||||
duplicateEngineName: string;
|
||||
customWallpaperApplied: string;
|
||||
wallpaperUploaded: string;
|
||||
wallpaperDeleted: string;
|
||||
fileSizeExceeded: string;
|
||||
unsupportedFileType: string;
|
||||
fileContentMismatch: string;
|
||||
storageFull: string;
|
||||
invalidUrlFormat: string;
|
||||
unsupportedProtocol: string;
|
||||
invalidSearchUrl: string;
|
||||
copyFailed: string;
|
||||
cutFailed: string;
|
||||
cannotReadClipboard: string;
|
||||
|
||||
// Clock
|
||||
monday: string;
|
||||
tuesday: string;
|
||||
wednesday: string;
|
||||
thursday: string;
|
||||
friday: string;
|
||||
saturday: string;
|
||||
sunday: string;
|
||||
|
||||
// Language
|
||||
language: string;
|
||||
english: string;
|
||||
chinese: string;
|
||||
}
|
||||
39
index.html
Normal file
39
index.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AeroStart</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;600&family=JetBrains+Mono:wght@300;400&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
overflow: hidden; /* Prevent scroll bars */
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Settings Modal */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
index.tsx
Normal file
18
index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { ToastProvider } from './context/ToastContext';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "AeroStart",
|
||||
"description": "A high-fidelity, advanced material design homepage featuring a precision clock and search interface.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "aerostart",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
1574
pnpm-lock.yaml
generated
Normal file
1574
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
40
types.ts
Normal file
40
types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
|
||||
export interface SearchEngine {
|
||||
name: string;
|
||||
urlPattern: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type BackgroundType = 'image' | 'video';
|
||||
|
||||
export type WallpaperFit = 'cover' | 'contain' | 'fill' | 'repeat' | 'center';
|
||||
|
||||
export interface PresetWallpaper {
|
||||
id?: string;
|
||||
name: string;
|
||||
type: BackgroundType;
|
||||
url: string;
|
||||
thumbnail?: string;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export type Language = 'en' | 'zh';
|
||||
|
||||
export interface UserSettings {
|
||||
use24HourFormat: boolean;
|
||||
showSeconds: boolean;
|
||||
backgroundBlur: number;
|
||||
searchEngines: SearchEngine[];
|
||||
selectedEngine: string;
|
||||
themeColor: string;
|
||||
searchOpacity: number;
|
||||
enableMaskBlur: boolean;
|
||||
backgroundUrl: string;
|
||||
backgroundType: BackgroundType;
|
||||
wallpaperFit: WallpaperFit;
|
||||
customWallpapers: PresetWallpaper[];
|
||||
enableSearchHistory: boolean;
|
||||
searchHistory: string[];
|
||||
language: Language;
|
||||
}
|
||||
122
utils/storage.ts
Normal file
122
utils/storage.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { UserSettings } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'aerostart_settings';
|
||||
const DEBOUNCE_DELAY = 100; // 100ms debounce delay
|
||||
|
||||
// Debounce timer
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Load user settings from Local Storage
|
||||
* @param defaultSettings Default settings
|
||||
* @returns Merged user settings
|
||||
*/
|
||||
export const loadSettings = (defaultSettings: UserSettings): UserSettings => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
// Merge default settings with stored settings to ensure new config items have default values
|
||||
return {
|
||||
...defaultSettings,
|
||||
...parsed,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return defaultSettings;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate data size in bytes
|
||||
* @param data Data to calculate
|
||||
* @returns Data size in bytes
|
||||
*/
|
||||
const getDataSize = (data: string): number => {
|
||||
return new Blob([data]).size;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check localStorage available space
|
||||
* @param dataSize Data size to store (bytes)
|
||||
* @returns Whether there is enough space
|
||||
*/
|
||||
const checkStorageQuota = (dataSize: number): boolean => {
|
||||
// localStorage is typically limited to 5-10MB
|
||||
const QUOTA_LIMIT = 5 * 1024 * 1024; // 5MB safe limit
|
||||
|
||||
try {
|
||||
// Calculate currently used space
|
||||
let currentSize = 0;
|
||||
for (let key in localStorage) {
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
currentSize += getDataSize(localStorage[key] + key);
|
||||
}
|
||||
}
|
||||
|
||||
return (currentSize + dataSize) < QUOTA_LIMIT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Immediately save user settings to Local Storage (without debounce)
|
||||
* @param settings User settings
|
||||
* @throws Error when storage space is insufficient
|
||||
*/
|
||||
const saveSettingsImmediate = (settings: UserSettings): void => {
|
||||
try {
|
||||
const settingsJson = JSON.stringify(settings);
|
||||
const dataSize = getDataSize(settingsJson);
|
||||
|
||||
// Check storage quota
|
||||
if (!checkStorageQuota(dataSize)) {
|
||||
throw new Error('QUOTA_EXCEEDED');
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, settingsJson);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'QUOTA_EXCEEDED') {
|
||||
console.error('Insufficient storage space, cannot save settings');
|
||||
throw error;
|
||||
} else if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
console.error('localStorage quota exceeded, cannot save settings');
|
||||
throw new Error('QUOTA_EXCEEDED');
|
||||
} else {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save user settings to Local Storage (with debounce)
|
||||
* @param settings User settings
|
||||
*/
|
||||
export const saveSettings = (settings: UserSettings): void => {
|
||||
// Clear previous timer
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
debounceTimer = setTimeout(() => {
|
||||
saveSettingsImmediate(settings);
|
||||
debounceTimer = null;
|
||||
}, DEBOUNCE_DELAY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all stored settings
|
||||
*/
|
||||
export const clearSettings = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear settings:', error);
|
||||
}
|
||||
};
|
||||
126
utils/suggestions.ts
Normal file
126
utils/suggestions.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
|
||||
let callbackCount = 0;
|
||||
|
||||
// Store and manage AbortController
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
export const fetchSuggestions = (engine: string, query: string): Promise<string[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!query || !query.trim()) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bilibili uses fetch API (via Vite proxy)
|
||||
if (engine === 'Bilibili') {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
const url = `/bilibili?term=${encodedQuery}`;
|
||||
|
||||
// Cancel previous request
|
||||
const requestKey = `${engine}-${query}`;
|
||||
if (abortControllers.has(requestKey)) {
|
||||
abortControllers.get(requestKey)?.abort();
|
||||
}
|
||||
|
||||
// Create new AbortController
|
||||
const controller = new AbortController();
|
||||
abortControllers.set(requestKey, controller);
|
||||
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Clean up AbortController
|
||||
abortControllers.delete(requestKey);
|
||||
|
||||
try {
|
||||
// Bilibili return format: {code: 0, result: {tag: [{value: "suggestion text"}, ...]}}
|
||||
if (data.code === 0 && data.result && data.result.tag) {
|
||||
const suggestions = data.result.tag.map((item: any) => item.value);
|
||||
resolve(suggestions);
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
} catch (e) {
|
||||
resolve([]);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Clean up AbortController
|
||||
abortControllers.delete(requestKey);
|
||||
|
||||
// If request was cancelled, do nothing
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
resolve([]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Other search engines use JSONP
|
||||
const callbackName = `jsonp_cb_${Date.now()}_${callbackCount++}`;
|
||||
const script = document.createElement('script');
|
||||
let timeoutId: any;
|
||||
|
||||
const cleanup = () => {
|
||||
if ((window as any)[callbackName]) delete (window as any)[callbackName];
|
||||
if (document.body.contains(script)) document.body.removeChild(script);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
// Resolve empty on timeout to avoid breaking UI
|
||||
resolve([]);
|
||||
}, 3000);
|
||||
|
||||
(window as any)[callbackName] = (data: any) => {
|
||||
cleanup();
|
||||
try {
|
||||
if (engine === 'Google') {
|
||||
// Google (client=youtube) returns ["query", ["sug1", "sug2"], ...]
|
||||
resolve(data[1].map((item: any) => Array.isArray(item) ? item[0] : item));
|
||||
} else if (engine === 'Baidu') {
|
||||
// Baidu returns {q: "query", s: ["sug1", "sug2"]}
|
||||
resolve(data.s);
|
||||
} else if (engine === 'Bing') {
|
||||
// Bing returns ["query", ["sug1", "sug2"]]
|
||||
resolve(data[1].map((item: any) => item));
|
||||
} else if (engine === 'DuckDuckGo') {
|
||||
// DDG returns [{phrase: "sug1"}, ...]
|
||||
resolve(data.map((item: any) => item.phrase));
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
} catch (e) {
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
|
||||
let url = '';
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
|
||||
if (engine === 'Google') {
|
||||
// client=youtube supports JSONP
|
||||
url = `https://suggestqueries.google.com/complete/search?client=youtube&q=${encodedQuery}&jsonp=${callbackName}`;
|
||||
} else if (engine === 'Baidu') {
|
||||
url = `https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=${encodedQuery}&cb=${callbackName}`;
|
||||
} else if (engine === 'Bing') {
|
||||
url = `https://api.bing.com/osjson.aspx?query=${encodedQuery}&JsonType=callback&JsonCallback=${callbackName}`;
|
||||
} else if (engine === 'DuckDuckGo') {
|
||||
url = `https://duckduckgo.com/ac/?q=${encodedQuery}&callback=${callbackName}&type=list`;
|
||||
} else {
|
||||
// Unsupported engine
|
||||
cleanup();
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
script.src = url;
|
||||
script.onerror = () => {
|
||||
cleanup();
|
||||
resolve([]);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
};
|
||||
9
vite-env.d.ts
vendored
Normal file
9
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module '*.svg?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/bilibili': {
|
||||
target: 'https://s.search.bilibili.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/bilibili/, '/main/suggest'),
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user