mirror of
https://github.com/handsomezhuzhu/AeroStart.git
synced 2026-02-20 20:10: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:
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;
|
||||
Reference in New Issue
Block a user