mirror of
https://github.com/handsomezhuzhu/AeroStart.git
synced 2026-02-20 20:10:15 +00:00
Implement a modern, customizable browser start page with comprehensive features: - Multi-theme support with 8 preset color schemes - Custom wallpaper system supporting images and videos with multiple fit modes - Integrated search functionality with 5 major search engines (Google, Baidu, Bing, DuckDuckGo, Bilibili) - Real-time clock component with 12/24 hour format options - Dynamic background blur effect during search for enhanced focus - Complete i18n system with English and Chinese language support - Responsive design with smooth animations and transitions - Local storage integration for persistent user preferences - Context menu system for quick settings access - Toast notification system for user feedback - Error boundary for robust error handling Tech Stack: - React 19 with TypeScript - Vite 6 for build tooling - Tailwind CSS for styling - Local storage for data persistence Project Structure: - Core components: Clock, SearchBox, SettingsModal, ThemeSettings, WallpaperManager - Utility modules: storage management, search suggestions - Context providers: Toast notifications, i18n - Type definitions and constants configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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; |