feat(ui): add settings menu and mask opacity control

- Add SettingsMenu component for quick navigation between settings sections
- Add mask opacity control to adjust overlay transparency
- Optimize search suggestions with immediate response and smooth animations
- Refine component styles for more compact interface
- Fix Translation interface type definition for maskOpacity
- Unify Bilibili API path to simplify environment handling

🤖 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 14:45:36 +08:00
parent 7a1815069a
commit a6daa5d728
14 changed files with 387 additions and 225 deletions

View File

@@ -3,10 +3,11 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
import Clock from './components/Clock'; import Clock from './components/Clock';
import SearchBox from './components/SearchBox'; import SearchBox from './components/SearchBox';
import SettingsModal from './components/SettingsModal'; import SettingsModal from './components/SettingsModal';
import SettingsMenu from './components/SettingsMenu';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import GlobalContextMenu from './components/GlobalContextMenu'; import GlobalContextMenu from './components/GlobalContextMenu';
import { SettingsIcon } from './components/Icons'; import { SettingsIcon } from './components/Icons';
import { UserSettings, WallpaperFit } from './types'; import { UserSettings, WallpaperFit, SettingsSection } from './types';
import { PRESET_WALLPAPERS, SEARCH_ENGINES, THEMES } from './constants'; import { PRESET_WALLPAPERS, SEARCH_ENGINES, THEMES } from './constants';
import { loadSettings, saveSettings } from './utils/storage'; import { loadSettings, saveSettings } from './utils/storage';
import { I18nProvider } from './i18n'; import { I18nProvider } from './i18n';
@@ -21,6 +22,7 @@ const DEFAULT_SETTINGS: UserSettings = {
themeColor: THEMES[0].hex, themeColor: THEMES[0].hex,
searchOpacity: 0.8, searchOpacity: 0.8,
enableMaskBlur: false, enableMaskBlur: false,
maskOpacity: 0.2,
backgroundUrl: PRESET_WALLPAPERS[0].url, backgroundUrl: PRESET_WALLPAPERS[0].url,
backgroundType: PRESET_WALLPAPERS[0].type, backgroundType: PRESET_WALLPAPERS[0].type,
wallpaperFit: 'cover', wallpaperFit: 'cover',
@@ -35,6 +37,8 @@ type ViewMode = 'search' | 'dashboard';
const App: React.FC = () => { const App: React.FC = () => {
// State for settings visibility // State for settings visibility
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isSettingsMenuOpen, setIsSettingsMenuOpen] = useState(false);
const [activeSettingsSection, setActiveSettingsSection] = useState<SettingsSection>('general');
// State for view mode (Search Panel vs Dashboard Panel) // State for view mode (Search Panel vs Dashboard Panel)
const [viewMode, setViewMode] = useState<ViewMode>('search'); const [viewMode, setViewMode] = useState<ViewMode>('search');
@@ -157,6 +161,12 @@ const App: React.FC = () => {
setSettings(prev => ({ ...prev, language: lang })); setSettings(prev => ({ ...prev, language: lang }));
}; };
const handleSettingsMenuSelect = (section: SettingsSection) => {
setActiveSettingsSection(section);
setIsSettingsMenuOpen(false);
setIsSettingsOpen(true);
};
return ( return (
<ErrorBoundary> <ErrorBoundary>
<I18nProvider language={settings.language} onLanguageChange={handleLanguageChange}> <I18nProvider language={settings.language} onLanguageChange={handleLanguageChange}>
@@ -195,10 +205,15 @@ const App: React.FC = () => {
</div> </div>
{/* Overlay to ensure text readability */} {/* Overlay to ensure text readability */}
<div className={` <div
absolute inset-0 bg-black/40 className={`
absolute inset-0 transition-opacity duration-500
${settings.enableMaskBlur ? 'backdrop-blur-sm' : ''} ${settings.enableMaskBlur ? 'backdrop-blur-sm' : ''}
`} /> `}
style={{
backgroundColor: `rgba(0, 0, 0, ${settings.maskOpacity})`
}}
/>
{/* {/*
Main Content Area (Search View) Main Content Area (Search View)
@@ -262,17 +277,23 @@ const App: React.FC = () => {
</div> </div>
{/* Top Right Settings Button - Only visible in Dashboard */} {/* Top Right Settings Button - Only visible in Dashboard */}
<div className="absolute top-6 right-6"> <div className="absolute top-6 right-6 z-50">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent clicking dashboard background e.stopPropagation(); // Prevent clicking dashboard background
setIsSettingsOpen(true); setIsSettingsMenuOpen(!isSettingsMenuOpen);
}} }}
className="group p-3 rounded-full bg-black/20 hover:bg-white/20 backdrop-blur-md border border-white/5 hover:border-white/30 transition-all duration-300 shadow-lg" className={`group p-2 rounded-full bg-black/30 hover:bg-white/20 backdrop-blur-md border border-white/10 hover:border-white/30 transition-all duration-300 shadow-lg ${isSettingsMenuOpen ? 'bg-white/20 border-white/30' : ''}`}
aria-label="Settings" aria-label="Settings"
> >
<SettingsIcon className="w-6 h-6 text-white/70 group-hover:text-white group-hover:rotate-90 transition-all duration-500" /> <SettingsIcon className={`w-5 h-5 text-white/70 group-hover:text-white transition-all duration-500 ${isSettingsMenuOpen ? 'rotate-90 text-white' : ''}`} />
</button> </button>
<SettingsMenu
isOpen={isSettingsMenuOpen}
onClose={() => setIsSettingsMenuOpen(false)}
onSelectSection={handleSettingsMenuSelect}
/>
</div> </div>
</div> </div>
@@ -283,6 +304,7 @@ const App: React.FC = () => {
onClose={() => setIsSettingsOpen(false)} onClose={() => setIsSettingsOpen(false)}
settings={settings} settings={settings}
onUpdateSettings={setSettings} onUpdateSettings={setSettings}
section={activeSettingsSection}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View File

@@ -190,7 +190,7 @@ const GlobalContextMenu: React.FC = () => {
<div <div
ref={menuRef} ref={menuRef}
className=" className="
fixed z-[9999] min-w-[140px] py-1.5 fixed z-[9999] min-w-[120px] py-1
rounded-xl bg-[#1a1a1a]/95 backdrop-blur-xl rounded-xl bg-[#1a1a1a]/95 backdrop-blur-xl
border border-white/10 shadow-[0_10px_40px_rgba(0,0,0,0.6)] 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 animate-in fade-in zoom-in-95 duration-200 origin-top-left
@@ -200,39 +200,39 @@ const GlobalContextMenu: React.FC = () => {
left: position.x, left: position.x,
}} }}
> >
<div className="flex flex-col gap-0.5 px-1.5"> <div className="flex flex-col gap-0.5 px-1">
<button <button
onClick={handleCopy} onClick={handleCopy}
className=" className="
flex items-center gap-3 px-3 py-2 text-sm text-white/90 rounded-lg flex items-center gap-2.5 px-2.5 py-2 text-xs text-white/90 rounded-lg
hover:bg-white/10 transition-colors w-full text-left group hover:bg-white/10 transition-colors w-full text-left group
" "
> >
<CopyIcon className="w-4 h-4 text-white/60 group-hover:text-white" /> <CopyIcon className="w-3.5 h-3.5 text-white/60 group-hover:text-white" />
<span>{t.copy}</span> <span>{t.copy}</span>
</button> </button>
<button <button
onClick={handleCut} onClick={handleCut}
className=" className="
flex items-center gap-3 px-3 py-2 text-sm text-white/90 rounded-lg flex items-center gap-2.5 px-2.5 py-2 text-xs text-white/90 rounded-lg
hover:bg-white/10 transition-colors w-full text-left group hover:bg-white/10 transition-colors w-full text-left group
" "
> >
<ScissorsIcon className="w-4 h-4 text-white/60 group-hover:text-white" /> <ScissorsIcon className="w-3.5 h-3.5 text-white/60 group-hover:text-white" />
<span>{t.cut}</span> <span>{t.cut}</span>
</button> </button>
<div className="h-[1px] bg-white/10 my-1 mx-1" /> <div className="h-[1px] bg-white/10 my-0.5 mx-1" />
<button <button
onClick={handlePaste} onClick={handlePaste}
className=" className="
flex items-center gap-3 px-3 py-2 text-sm text-white/90 rounded-lg flex items-center gap-2.5 px-2.5 py-2 text-xs text-white/90 rounded-lg
hover:bg-white/10 transition-colors w-full text-left group hover:bg-white/10 transition-colors w-full text-left group
" "
> >
<ClipboardIcon className="w-4 h-4 text-white/60 group-hover:text-white" /> <ClipboardIcon className="w-3.5 h-3.5 text-white/60 group-hover:text-white" />
<span>{t.paste}</span> <span>{t.paste}</span>
</button> </button>
</div> </div>

View File

@@ -35,6 +35,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [animationKey, setAnimationKey] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -77,21 +78,27 @@ const SearchBox: React.FC<SearchBoxProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Fetch suggestions with debounce // Fetch suggestions immediately without debounce
useEffect(() => { useEffect(() => {
const timer = setTimeout(async () => {
if (query.trim()) { if (query.trim()) {
const results = await fetchSuggestions(selectedEngine.name, query); fetchSuggestions(selectedEngine.name, query).then(results => {
setSuggestions(results.slice(0, 8)); // Only update if results are different to avoid unnecessary re-renders
setSuggestions(prev => {
const newSuggestions = results.slice(0, 8);
// Trigger animation only when content actually changes
if (JSON.stringify(prev) !== JSON.stringify(newSuggestions)) {
setAnimationKey(k => k + 1);
}
return newSuggestions;
});
setShowSuggestions(true); setShowSuggestions(true);
setSelectedIndex(-1);
});
} else { } else {
setSuggestions([]); setSuggestions([]);
setShowSuggestions(false); setShowSuggestions(false);
}
setSelectedIndex(-1); setSelectedIndex(-1);
}, 200); }
return () => clearTimeout(timer);
}, [query, selectedEngine.name]); }, [query, selectedEngine.name]);
const performSearch = useCallback((text: string) => { const performSearch = useCallback((text: string) => {
@@ -382,20 +389,48 @@ const SearchBox: React.FC<SearchBoxProps> = ({
{/* Suggestions / History Dropdown */} {/* Suggestions / History Dropdown */}
{((showSuggestions && suggestions.length > 0) || (showHistoryDropdown && history.length > 0)) && ( {((showSuggestions && suggestions.length > 0) || (showHistoryDropdown && history.length > 0)) && (
<div className=" <div
key={animationKey}
className="
absolute top-full left-0 right-0 mt-2 absolute top-full left-0 right-0 mt-2
bg-black/80 backdrop-blur-3xl bg-black/20 backdrop-blur-2xl
border border-white/10 border border-white/10
rounded-2xl shadow-[0_30px_60px_-12px_rgba(0,0,0,0.8)] 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 overflow-hidden z-40
"> origin-top
"
style={{
animation: 'dropdownSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards'
}}
>
<style>{`
@keyframes dropdownSlideIn {
from {
opacity: 0;
transform: translateY(-8px) scaleY(0.95);
}
to {
opacity: 1;
transform: translateY(0) scaleY(1);
}
}
@keyframes itemFadeIn {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`}</style>
{/* Suggestions List */} {/* Suggestions List */}
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<div className="flex flex-col gap-0.5 p-2"> <div className="flex flex-col gap-0.5 p-2">
{suggestions.map((suggestion, index) => ( {suggestions.map((suggestion, index) => (
<button <button
key={index} key={`${animationKey}-${index}`}
// Prevent input blur when clicking // Prevent input blur when clicking
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSuggestionClick(suggestion)} onClick={() => handleSuggestionClick(suggestion)}
@@ -406,6 +441,10 @@ const SearchBox: React.FC<SearchBoxProps> = ({
? 'bg-white/10 text-white shadow-sm translate-x-1' ? 'bg-white/10 text-white shadow-sm translate-x-1'
: 'text-white/70 hover:bg-white/5 hover:text-white'} : 'text-white/70 hover:bg-white/5 hover:text-white'}
`} `}
style={{
animation: `itemFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards ${index * 0.03}s`,
opacity: 0
}}
> >
<SearchIcon <SearchIcon
className={`w-4 h-4 transition-all duration-300`} className={`w-4 h-4 transition-all duration-300`}
@@ -428,7 +467,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({
<div className="flex flex-col gap-0.5 px-2"> <div className="flex flex-col gap-0.5 px-2">
{history.map((item, index) => ( {history.map((item, index) => (
<button <button
key={item + index} key={`history-${index}`}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSuggestionClick(item)} onClick={() => handleSuggestionClick(item)}
onContextMenu={handleElementContextMenu} onContextMenu={handleElementContextMenu}
@@ -438,6 +477,10 @@ const SearchBox: React.FC<SearchBoxProps> = ({
? 'bg-white/10 text-white shadow-sm translate-x-1' ? 'bg-white/10 text-white shadow-sm translate-x-1'
: 'text-white/60 hover:bg-white/5 hover:text-white'} : 'text-white/60 hover:bg-white/5 hover:text-white'}
`} `}
style={{
animation: `itemFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards ${index * 0.03}s`,
opacity: 0
}}
> >
<HistoryIcon <HistoryIcon
className={`w-4 h-4 transition-all duration-300`} className={`w-4 h-4 transition-all duration-300`}

View File

@@ -140,9 +140,9 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
}, []); }, []);
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/40 uppercase tracking-wider">{t.searchEngines}</h3> <span className="text-xs font-semibold text-white/50 uppercase tracking-wider">{t.searchEngines}</span>
{!isAddingEngine && ( {!isAddingEngine && (
<button <button
onClick={() => { onClick={() => {
@@ -171,7 +171,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
className={` className={`
flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5 group transition-all duration-200 flex items-center justify-between p-2.5 rounded-lg bg-white/5 border border-white/5 group transition-all duration-200
${draggedIndex === index ? 'opacity-40 border-dashed border-white/30' : ''} ${draggedIndex === index ? 'opacity-40 border-dashed border-white/30' : ''}
${editingOriginalName === engine.name ? 'ring-1 ring-white/30 bg-white/10' : ''} ${editingOriginalName === engine.name ? 'ring-1 ring-white/30 bg-white/10' : ''}
`} `}
@@ -184,7 +184,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
<div className="flex flex-col overflow-hidden"> <div className="flex flex-col overflow-hidden">
<span className="text-sm font-medium text-white/90 truncate">{engine.name}</span> <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> <span className="text-[10px] text-white/40 truncate font-mono">{engine.urlPattern}</span>
</div> </div>
</div> </div>
@@ -246,7 +246,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
value={newEngineName} value={newEngineName}
onChange={(e) => setNewEngineName(e.target.value)} onChange={(e) => setNewEngineName(e.target.value)}
placeholder="e.g.: Google" 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" className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-white/30 focus:outline-none transition-colors"
autoFocus autoFocus
/> />
</div> </div>
@@ -257,7 +257,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
value={newEngineUrl} value={newEngineUrl}
onChange={(e) => setNewEngineUrl(e.target.value)} onChange={(e) => setNewEngineUrl(e.target.value)}
placeholder="https://example.com/search?q=" 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" className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-white/30 focus:outline-none transition-colors font-mono"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">

View File

@@ -0,0 +1,66 @@
import React, { useRef, useEffect } from 'react';
import { SettingsIcon, ImageIcon, SearchIcon } from './Icons';
import { SettingsSection } from '../types';
import { useTranslation } from '../i18n';
interface SettingsMenuProps {
isOpen: boolean;
onClose: () => void;
onSelectSection: (section: SettingsSection) => void;
}
const SettingsMenu: React.FC<SettingsMenuProps> = ({ isOpen, onClose, onSelectSection }) => {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const menuItems: { id: SettingsSection; label: string; icon: React.ReactNode }[] = [
{ id: 'general', label: t.appearance, icon: <SettingsIcon className="w-3.5 h-3.5" /> },
{ id: 'wallpaper', label: t.wallpaperSettings, icon: <ImageIcon className="w-3.5 h-3.5" /> },
{ id: 'search', label: t.searchEngines, icon: <SearchIcon className="w-3.5 h-3.5" /> },
];
return (
<div
ref={menuRef}
className="absolute top-full right-0 mt-2 w-40 rounded-xl bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/10 shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200 origin-top-right z-50"
>
<div className="p-1">
{menuItems.map((item) => (
<button
key={item.id}
onClick={(e) => {
e.stopPropagation();
onSelectSection(item.id);
}}
className="w-full flex items-center gap-2.5 px-2.5 py-2 text-xs text-white/80 hover:text-white hover:bg-white/10 rounded-lg transition-colors group"
>
<span className="text-white/60 group-hover:text-white transition-colors">
{item.icon}
</span>
{item.label}
</button>
))}
</div>
</div>
);
};
export default SettingsMenu;

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { XIcon } from './Icons'; import { XIcon } from './Icons';
import { UserSettings } from '../types'; import { UserSettings, SettingsSection } from '../types';
import ThemeSettings from './ThemeSettings'; import ThemeSettings from './ThemeSettings';
import WallpaperManager from './WallpaperManager'; import WallpaperManager from './WallpaperManager';
import SearchEngineManager from './SearchEngineManager'; import SearchEngineManager from './SearchEngineManager';
@@ -12,9 +11,10 @@ interface SettingsModalProps {
onClose: () => void; onClose: () => void;
settings: UserSettings; settings: UserSettings;
onUpdateSettings: (newSettings: UserSettings) => void; onUpdateSettings: (newSettings: UserSettings) => void;
section: SettingsSection;
} }
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings, onUpdateSettings }) => { const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings, onUpdateSettings, section }) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!isOpen) return null; if (!isOpen) return null;
@@ -25,6 +25,19 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings
e.stopPropagation(); e.stopPropagation();
}; };
const getTitle = () => {
switch (section) {
case 'general':
return t.appearance;
case 'wallpaper':
return t.wallpaperSettings;
case 'search':
return t.searchEngines;
default:
return t.settings;
}
};
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4" className="fixed inset-0 z-50 flex items-center justify-center p-4"
@@ -38,7 +51,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings
{/* Modal content */} {/* Modal content */}
<div className=" <div className="
relative w-full max-w-md rounded-3xl relative w-full max-w-md rounded-2xl
bg-[#1a1a1a]/90 backdrop-blur-xl bg-[#1a1a1a]/90 backdrop-blur-xl
border border-white/10 border border-white/10
shadow-[0_20px_50px_rgba(0,0,0,0.5)] shadow-[0_20px_50px_rgba(0,0,0,0.5)]
@@ -46,35 +59,30 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings
max-h-[85vh] flex flex-col overflow-hidden max-h-[85vh] flex flex-col overflow-hidden
"> ">
{/* Header - fixed */} {/* Header - fixed */}
<div className="flex items-center justify-between px-6 pt-6 pb-4 shrink-0 z-10"> <div className="flex items-center justify-between px-5 pt-5 pb-3 shrink-0 z-10">
<h2 className="text-2xl font-light tracking-wide">{t.settings}</h2> <h2 className="text-lg font-medium tracking-wide text-white/90">{getTitle()}</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors text-white/70 hover:text-white" className="p-1.5 rounded-full hover:bg-white/10 transition-colors text-white/60 hover:text-white"
> >
<XIcon className="w-6 h-6" /> <XIcon className="w-5 h-5" />
</button> </button>
</div> </div>
{/* Scrollable area */} {/* Scrollable area */}
<div className="flex-1 overflow-y-auto px-6 pb-6 custom-scrollbar"> <div className="flex-1 overflow-y-auto px-6 pb-6 custom-scrollbar">
<div className="space-y-8"> <div className="space-y-8">
{section === 'general' && (
{/* 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} /> <ThemeSettings settings={settings} onUpdateSettings={onUpdateSettings} />
)}
{/* Wallpaper manager component */} {section === 'wallpaper' && (
<WallpaperManager settings={settings} onUpdateSettings={onUpdateSettings} /> <WallpaperManager settings={settings} onUpdateSettings={onUpdateSettings} />
</div> )}
<div className="h-[1px] bg-white/10 w-full" /> {section === 'search' && (
{/* Search engine manager component */}
<SearchEngineManager settings={settings} onUpdateSettings={onUpdateSettings} /> <SearchEngineManager settings={settings} onUpdateSettings={onUpdateSettings} />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -37,6 +37,10 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
onUpdateSettings({ ...settings, searchOpacity: parseFloat(e.target.value) }); onUpdateSettings({ ...settings, searchOpacity: parseFloat(e.target.value) });
}; };
const handleMaskOpacityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onUpdateSettings({ ...settings, maskOpacity: parseFloat(e.target.value) });
};
const handleThemeChange = (colorHex: string) => { const handleThemeChange = (colorHex: string) => {
onUpdateSettings({ ...settings, themeColor: colorHex }); onUpdateSettings({ ...settings, themeColor: colorHex });
}; };
@@ -46,15 +50,15 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-5">
{/* Language Selection */} {/* Language Selection */}
<div className="space-y-3"> <div className="space-y-4">
<span className="text-white/80 font-light block">{t.language}</span> <span className="text-xs font-semibold text-white/50 uppercase tracking-wider block">{t.language}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleLanguageChange('en')} onClick={() => handleLanguageChange('en')}
className={` className={`
flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
${settings.language === 'en' ${settings.language === 'en'
? 'bg-white text-black' ? 'bg-white text-black'
: 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'} : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'}
@@ -65,7 +69,7 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
<button <button
onClick={() => handleLanguageChange('zh')} onClick={() => handleLanguageChange('zh')}
className={` className={`
flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
${settings.language === 'zh' ${settings.language === 'zh'
? 'bg-white text-black' ? 'bg-white text-black'
: 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'} : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white'}
@@ -77,15 +81,15 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
</div> </div>
{/* Theme color */} {/* Theme color */}
<div className="space-y-3"> <div className="space-y-4">
<span className="text-white/80 font-light block">{t.themeColor}</span> <span className="text-xs font-semibold text-white/50 uppercase tracking-wider block">{t.themeColor}</span>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{THEMES.map((theme) => ( {THEMES.map((theme) => (
<button <button
key={theme.hex} key={theme.hex}
onClick={() => handleThemeChange(theme.hex)} onClick={() => handleThemeChange(theme.hex)}
className={` className={`
w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300 w-6 h-6 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'} ${settings.themeColor === theme.hex ? 'ring-2 ring-white scale-110' : 'hover:scale-110 opacity-80 hover:opacity-100'}
`} `}
style={{ backgroundColor: theme.hex }} style={{ backgroundColor: theme.hex }}
@@ -100,55 +104,55 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
</div> </div>
{/* Toggle settings */} {/* Toggle settings */}
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-white/80 font-light">{t.showSeconds}</span> <span className="text-sm text-white/70">{t.showSeconds}</span>
<button <button
onClick={toggleSeconds} onClick={toggleSeconds}
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10" className="w-10 h-5 rounded-full transition-colors duration-300 relative bg-white/10"
style={{ backgroundColor: settings.showSeconds ? settings.themeColor : undefined }} 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'}`} /> <div className={`absolute top-1 w-3 h-3 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.showSeconds ? 'left-6' : 'left-1'}`} />
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-white/80 font-light">{t.use24HourFormat}</span> <span className="text-sm text-white/70">{t.use24HourFormat}</span>
<button <button
onClick={toggle24Hour} onClick={toggle24Hour}
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10" className="w-10 h-5 rounded-full transition-colors duration-300 relative bg-white/10"
style={{ backgroundColor: settings.use24HourFormat ? settings.themeColor : undefined }} 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'}`} /> <div className={`absolute top-1 w-3 h-3 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.use24HourFormat ? 'left-6' : 'left-1'}`} />
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-white/80 font-light">{t.maskBlurEffect}</span> <span className="text-sm text-white/70">{t.maskBlurEffect}</span>
<button <button
onClick={toggleMaskBlur} onClick={toggleMaskBlur}
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10" className="w-10 h-5 rounded-full transition-colors duration-300 relative bg-white/10"
style={{ backgroundColor: settings.enableMaskBlur ? settings.themeColor : undefined }} 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'}`} /> <div className={`absolute top-1 w-3 h-3 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.enableMaskBlur ? 'left-6' : 'left-1'}`} />
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-white/80 font-light">{t.searchHistory}</span> <span className="text-sm text-white/70">{t.searchHistory}</span>
<button <button
onClick={toggleSearchHistory} onClick={toggleSearchHistory}
className="w-12 h-6 rounded-full transition-colors duration-300 relative bg-white/10" className="w-10 h-5 rounded-full transition-colors duration-300 relative bg-white/10"
style={{ backgroundColor: settings.enableSearchHistory ? settings.themeColor : undefined }} 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'}`} /> <div className={`absolute top-1 w-3 h-3 rounded-full bg-white transition-transform duration-300 shadow-md ${settings.enableSearchHistory ? 'left-6' : 'left-1'}`} />
</button> </button>
</div> </div>
</div> </div>
{/* Background blur slider */} {/* Background blur slider */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between text-sm text-white/60 font-light"> <div className="flex justify-between text-xs text-white/50 font-medium uppercase tracking-wider">
<span>{t.backgroundBlur}</span> <span>{t.backgroundBlur}</span>
<span>{settings.backgroundBlur}px</span> <span>{settings.backgroundBlur}px</span>
</div> </div>
@@ -165,7 +169,7 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
{/* Search box opacity slider */} {/* Search box opacity slider */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between text-sm text-white/60 font-light"> <div className="flex justify-between text-xs text-white/50 font-medium uppercase tracking-wider">
<span>{t.searchBoxOpacity}</span> <span>{t.searchBoxOpacity}</span>
<span>{Math.round(settings.searchOpacity * 100)}%</span> <span>{Math.round(settings.searchOpacity * 100)}%</span>
</div> </div>
@@ -179,6 +183,23 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
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" 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>
{/* Mask opacity slider */}
<div className="space-y-3">
<div className="flex justify-between text-xs text-white/50 font-medium uppercase tracking-wider">
<span>{t.maskOpacity}</span>
<span>{Math.round(settings.maskOpacity * 100)}%</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.maskOpacity}
onChange={handleMaskOpacityChange}
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> </div>
); );
}; };

View File

@@ -180,8 +180,8 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
}, [settings.customWallpapers]); }, [settings.customWallpapers]);
return ( return (
<div className="space-y-3"> <div className="space-y-5">
<span className="text-white/80 font-light block">{t.wallpaperSettings}</span> <span className="text-xs font-semibold text-white/50 uppercase tracking-wider block">{t.wallpaperSettings}</span>
{/* Wallpaper grid */} {/* Wallpaper grid */}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
@@ -231,9 +231,9 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => fileInputRef.current?.click()} 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" className="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-colors text-xs text-white/80 hover:text-white"
> >
<UploadIcon className="w-4 h-4" /> <UploadIcon className="w-3.5 h-3.5" />
<span>{t.uploadImageVideo}</span> <span>{t.uploadImageVideo}</span>
</button> </button>
<input <input
@@ -249,21 +249,21 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40"> <div className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40">
<ImageIcon className="w-4 h-4" /> <ImageIcon className="w-3.5 h-3.5" />
</div> </div>
<input <input
type="text" type="text"
value={customUrl} value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)} onChange={(e) => setCustomUrl(e.target.value)}
placeholder={t.enterImageVideoUrl} 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" className="w-full bg-black/20 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white focus:border-white/30 focus:outline-none transition-colors"
onKeyDown={(e) => e.key === 'Enter' && handleCustomUrlApply()} onKeyDown={(e) => e.key === 'Enter' && handleCustomUrlApply()}
/> />
</div> </div>
<button <button
onClick={handleCustomUrlApply} onClick={handleCustomUrlApply}
disabled={!customUrl.trim()} 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" className="px-3 py-1.5 bg-white text-black text-xs font-bold rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{t.apply} {t.apply}
</button> </button>
@@ -278,9 +278,9 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
key={fit.value} key={fit.value}
onClick={() => onUpdateSettings({ ...settings, wallpaperFit: fit.value })} onClick={() => onUpdateSettings({ ...settings, wallpaperFit: fit.value })}
className={` className={`
flex-1 px-3 py-1.5 text-xs rounded-lg whitespace-nowrap transition-colors border flex-1 px-2.5 py-1 text-[10px] uppercase tracking-wide rounded-md whitespace-nowrap transition-colors border
${settings.wallpaperFit === fit.value || (!settings.wallpaperFit && fit.value === 'cover') ${settings.wallpaperFit === fit.value || (!settings.wallpaperFit && fit.value === 'cover')
? 'bg-white text-black font-medium border-white' ? 'bg-white text-black font-bold border-white'
: 'bg-transparent text-white/60 border-transparent hover:text-white hover:bg-white/10'} : 'bg-transparent text-white/60 border-transparent hover:text-white hover:bg-white/10'}
`} `}
> >

View File

@@ -14,6 +14,7 @@ export const en: Translation = {
searchHistory: 'Search History', searchHistory: 'Search History',
backgroundBlur: 'Background Blur', backgroundBlur: 'Background Blur',
searchBoxOpacity: 'Search Box Opacity', searchBoxOpacity: 'Search Box Opacity',
maskOpacity: 'Mask Opacity',
// Wallpaper Settings // Wallpaper Settings
wallpaperSettings: 'Wallpaper Settings', wallpaperSettings: 'Wallpaper Settings',

View File

@@ -14,6 +14,7 @@ export const zh: Translation = {
searchHistory: '搜索历史记录', searchHistory: '搜索历史记录',
backgroundBlur: '背景模糊度', backgroundBlur: '背景模糊度',
searchBoxOpacity: '搜索框不透明度', searchBoxOpacity: '搜索框不透明度',
maskOpacity: '遮罩层不透明度',
// Wallpaper Settings // Wallpaper Settings
wallpaperSettings: '壁纸设置', wallpaperSettings: '壁纸设置',

View File

@@ -14,6 +14,7 @@ export interface Translation {
searchHistory: string; searchHistory: string;
backgroundBlur: string; backgroundBlur: string;
searchBoxOpacity: string; searchBoxOpacity: string;
maskOpacity: string;
// Wallpaper Settings // Wallpaper Settings
wallpaperSettings: string; wallpaperSettings: string;

View File

@@ -30,6 +30,7 @@ export interface UserSettings {
themeColor: string; themeColor: string;
searchOpacity: number; searchOpacity: number;
enableMaskBlur: boolean; enableMaskBlur: boolean;
maskOpacity: number;
backgroundUrl: string; backgroundUrl: string;
backgroundType: BackgroundType; backgroundType: BackgroundType;
wallpaperFit: WallpaperFit; wallpaperFit: WallpaperFit;
@@ -38,3 +39,5 @@ export interface UserSettings {
searchHistory: string[]; searchHistory: string[];
language: Language; language: Language;
} }
export type SettingsSection = 'general' | 'wallpaper' | 'search';

View File

@@ -24,11 +24,7 @@ export const fetchSuggestions = (engine: string, query: string): Promise<string[
// Bilibili uses fetch (via Vite proxy in dev, Vercel Function in production) // Bilibili uses fetch (via Vite proxy in dev, Vercel Function in production)
if (engine === 'Bilibili') { if (engine === 'Bilibili') {
// In production (Vercel), use /api/bilibili; in development, use /bilibili (Vite proxy) const url = `/api/bilibili?term=${encodeURIComponent(query)}`;
const isDev = import.meta.env.DEV;
const url = isDev
? `/bilibili?term=${encodeURIComponent(query)}`
: `/api/bilibili?term=${encodeURIComponent(query)}`;
fetch(url) fetch(url)
.then(response => response.json()) .then(response => response.json())

View File

@@ -7,10 +7,10 @@ export default defineConfig({
port: 3000, port: 3000,
host: '0.0.0.0', host: '0.0.0.0',
proxy: { proxy: {
'/bilibili': { '/api/bilibili': {
target: 'https://s.search.bilibili.com', target: 'https://s.search.bilibili.com',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/bilibili/, '/main/suggest'), rewrite: (path) => path.replace(/^\/api\/bilibili/, '/main/suggest'),
secure: false, secure: false,
} }
} }