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:
ZyphrZero
2025-12-05 02:55:50 +08:00
parent aa197e4e48
commit 56dd6d8bf2
31 changed files with 5270 additions and 0 deletions

78
components/Clock.tsx Normal file
View 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;

View 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;

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;