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 SearchBox from './components/SearchBox';
import SettingsModal from './components/SettingsModal';
import SettingsMenu from './components/SettingsMenu';
import ErrorBoundary from './components/ErrorBoundary';
import GlobalContextMenu from './components/GlobalContextMenu';
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 { loadSettings, saveSettings } from './utils/storage';
import { I18nProvider } from './i18n';
@@ -21,6 +22,7 @@ const DEFAULT_SETTINGS: UserSettings = {
themeColor: THEMES[0].hex,
searchOpacity: 0.8,
enableMaskBlur: false,
maskOpacity: 0.2,
backgroundUrl: PRESET_WALLPAPERS[0].url,
backgroundType: PRESET_WALLPAPERS[0].type,
wallpaperFit: 'cover',
@@ -35,6 +37,8 @@ type ViewMode = 'search' | 'dashboard';
const App: React.FC = () => {
// State for settings visibility
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)
const [viewMode, setViewMode] = useState<ViewMode>('search');
@@ -157,6 +161,12 @@ const App: React.FC = () => {
setSettings(prev => ({ ...prev, language: lang }));
};
const handleSettingsMenuSelect = (section: SettingsSection) => {
setActiveSettingsSection(section);
setIsSettingsMenuOpen(false);
setIsSettingsOpen(true);
};
return (
<ErrorBoundary>
<I18nProvider language={settings.language} onLanguageChange={handleLanguageChange}>
@@ -168,123 +178,135 @@ const App: React.FC = () => {
{/* Context Menu Global Listener */}
<GlobalContextMenu />
{/* Background Layer */}
<div
className={`absolute inset-0 transition-all duration-700 ease-[cubic-bezier(0.25,0.4,0.25,1)] overflow-hidden ${bgLoaded ? 'opacity-100' : 'opacity-0'}`}
style={{
filter: `blur(${isSearchActive ? settings.backgroundBlur : 0}px)`,
transform: isSearchActive ? 'scale(1.05)' : 'scale(1)',
}}
>
{settings.backgroundType === 'video' ? (
<video
key={settings.backgroundUrl}
className={`absolute inset-0 w-full h-full ${getVideoClass(settings.wallpaperFit)}`}
src={settings.backgroundUrl}
autoPlay
loop
muted
playsInline
/>
) : (
<div
className="absolute inset-0 w-full h-full"
style={getBackgroundStyle(settings.wallpaperFit)}
/>
)}
</div>
{/* Background Layer */}
<div
className={`absolute inset-0 transition-all duration-700 ease-[cubic-bezier(0.25,0.4,0.25,1)] overflow-hidden ${bgLoaded ? 'opacity-100' : 'opacity-0'}`}
style={{
filter: `blur(${isSearchActive ? settings.backgroundBlur : 0}px)`,
transform: isSearchActive ? 'scale(1.05)' : 'scale(1)',
}}
>
{settings.backgroundType === 'video' ? (
<video
key={settings.backgroundUrl}
className={`absolute inset-0 w-full h-full ${getVideoClass(settings.wallpaperFit)}`}
src={settings.backgroundUrl}
autoPlay
loop
muted
playsInline
/>
) : (
<div
className="absolute inset-0 w-full h-full"
style={getBackgroundStyle(settings.wallpaperFit)}
/>
)}
</div>
{/* Overlay to ensure text readability */}
<div className={`
absolute inset-0 bg-black/40
${settings.enableMaskBlur ? 'backdrop-blur-sm' : ''}
`} />
{/* Overlay to ensure text readability */}
<div
className={`
absolute inset-0 transition-opacity duration-500
${settings.enableMaskBlur ? 'backdrop-blur-sm' : ''}
`}
style={{
backgroundColor: `rgba(0, 0, 0, ${settings.maskOpacity})`
}}
/>
{/*
{/*
Main Content Area (Search View)
Pointer events are disabled when not visible to prevent interaction with hidden elements
*/}
<div
className={`
<div
className={`
absolute inset-0 z-10 flex flex-col items-center pt-[18vh] w-full px-4 space-y-8
transition-all duration-500 ease-in-out
${viewMode === 'search' ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none'}
`}
>
{/* Clock Component */}
<ErrorBoundary>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-150">
<Clock
showSeconds={settings.showSeconds}
use24HourFormat={settings.use24HourFormat}
/>
</div>
</ErrorBoundary>
>
{/* Clock Component */}
<ErrorBoundary>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-150">
<Clock
showSeconds={settings.showSeconds}
use24HourFormat={settings.use24HourFormat}
/>
</div>
</ErrorBoundary>
{/* Search Input Component */}
<ErrorBoundary>
<div className="w-full max-w-xl animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-300">
<SearchBox
engines={settings.searchEngines}
selectedEngineName={settings.selectedEngine}
onSelectEngine={handleSelectEngine}
themeColor={settings.themeColor}
opacity={settings.searchOpacity}
onInteractionChange={setIsSearchActive}
enableHistory={settings.enableSearchHistory}
history={settings.searchHistory}
onUpdateHistory={handleUpdateHistory}
/>
</div>
</ErrorBoundary>
</div>
{/* Search Input Component */}
<ErrorBoundary>
<div className="w-full max-w-xl animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-300">
<SearchBox
engines={settings.searchEngines}
selectedEngineName={settings.selectedEngine}
onSelectEngine={handleSelectEngine}
themeColor={settings.themeColor}
opacity={settings.searchOpacity}
onInteractionChange={setIsSearchActive}
enableHistory={settings.enableSearchHistory}
history={settings.searchHistory}
onUpdateHistory={handleUpdateHistory}
/>
</div>
</ErrorBoundary>
</div>
{/*
{/*
Dashboard Panel (Under Development)
Visible only in dashboard mode
*/}
<div
className={`
<div
className={`
absolute inset-0 z-10 flex flex-col items-center justify-center
transition-all duration-500 ease-in-out
${viewMode === 'dashboard' ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-105 pointer-events-none'}
`}
>
<div className="relative group cursor-default">
<h1 className="text-4xl md:text-5xl font-extralight tracking-[0.2em] text-white/30 group-hover:text-white/50 transition-colors duration-500 select-none">
DASHBOARD
</h1>
<div className="absolute -bottom-4 left-0 w-full flex justify-center">
<span className="text-xs font-mono text-white/20 tracking-widest uppercase bg-white/5 px-2 py-0.5 rounded">
Under Construction
</span>
>
<div className="relative group cursor-default">
<h1 className="text-4xl md:text-5xl font-extralight tracking-[0.2em] text-white/30 group-hover:text-white/50 transition-colors duration-500 select-none">
DASHBOARD
</h1>
<div className="absolute -bottom-4 left-0 w-full flex justify-center">
<span className="text-xs font-mono text-white/20 tracking-widest uppercase bg-white/5 px-2 py-0.5 rounded">
Under Construction
</span>
</div>
</div>
{/* Top Right Settings Button - Only visible in Dashboard */}
<div className="absolute top-6 right-6 z-50">
<button
onClick={(e) => {
e.stopPropagation(); // Prevent clicking dashboard background
setIsSettingsMenuOpen(!isSettingsMenuOpen);
}}
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"
>
<SettingsIcon className={`w-5 h-5 text-white/70 group-hover:text-white transition-all duration-500 ${isSettingsMenuOpen ? 'rotate-90 text-white' : ''}`} />
</button>
<SettingsMenu
isOpen={isSettingsMenuOpen}
onClose={() => setIsSettingsMenuOpen(false)}
onSelectSection={handleSettingsMenuSelect}
/>
</div>
</div>
{/* Top Right Settings Button - Only visible in Dashboard */}
<div className="absolute top-6 right-6">
<button
onClick={(e) => {
e.stopPropagation(); // Prevent clicking dashboard background
setIsSettingsOpen(true);
}}
className="group p-3 rounded-full bg-black/20 hover:bg-white/20 backdrop-blur-md border border-white/5 hover:border-white/30 transition-all duration-300 shadow-lg"
aria-label="Settings"
>
<SettingsIcon className="w-6 h-6 text-white/70 group-hover:text-white group-hover:rotate-90 transition-all duration-500" />
</button>
</div>
</div>
{/* Settings Modal */}
<ErrorBoundary>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
settings={settings}
onUpdateSettings={setSettings}
/>
</ErrorBoundary>
{/* Settings Modal */}
<ErrorBoundary>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
settings={settings}
onUpdateSettings={setSettings}
section={activeSettingsSection}
/>
</ErrorBoundary>
</div>
</I18nProvider>
</ErrorBoundary>

View File

@@ -190,7 +190,7 @@ const GlobalContextMenu: React.FC = () => {
<div
ref={menuRef}
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
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
@@ -200,39 +200,39 @@ const GlobalContextMenu: React.FC = () => {
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
onClick={handleCopy}
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
"
>
<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>
</button>
<button
onClick={handleCut}
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
"
>
<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>
</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
onClick={handlePaste}
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
"
>
<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>
</button>
</div>

View File

@@ -35,6 +35,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({
const [selectedIndex, setSelectedIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [animationKey, setAnimationKey] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@@ -77,21 +78,27 @@ const SearchBox: React.FC<SearchBoxProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Fetch suggestions with debounce
// Fetch suggestions immediately without debounce
useEffect(() => {
const timer = setTimeout(async () => {
if (query.trim()) {
const results = await fetchSuggestions(selectedEngine.name, query);
setSuggestions(results.slice(0, 8));
if (query.trim()) {
fetchSuggestions(selectedEngine.name, query).then(results => {
// 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);
} else {
setSuggestions([]);
setShowSuggestions(false);
}
setSelectedIndex(-1);
});
} else {
setSuggestions([]);
setShowSuggestions(false);
setSelectedIndex(-1);
}, 200);
return () => clearTimeout(timer);
}
}, [query, selectedEngine.name]);
const performSearch = useCallback((text: string) => {
@@ -382,20 +389,48 @@ const SearchBox: React.FC<SearchBoxProps> = ({
{/* 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
">
<div
key={animationKey}
className="
absolute top-full left-0 right-0 mt-2
bg-black/20 backdrop-blur-2xl
border border-white/10
rounded-2xl shadow-[0_30px_60px_-12px_rgba(0,0,0,0.8)]
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 */}
{showSuggestions && suggestions.length > 0 && (
<div className="flex flex-col gap-0.5 p-2">
{suggestions.map((suggestion, index) => (
<button
key={index}
key={`${animationKey}-${index}`}
// Prevent input blur when clicking
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSuggestionClick(suggestion)}
@@ -406,6 +441,10 @@ const SearchBox: React.FC<SearchBoxProps> = ({
? 'bg-white/10 text-white shadow-sm translate-x-1'
: '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
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">
{history.map((item, index) => (
<button
key={item + index}
key={`history-${index}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSuggestionClick(item)}
onContextMenu={handleElementContextMenu}
@@ -438,6 +477,10 @@ const SearchBox: React.FC<SearchBoxProps> = ({
? 'bg-white/10 text-white shadow-sm translate-x-1'
: '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
className={`w-4 h-4 transition-all duration-300`}

View File

@@ -84,11 +84,11 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
const isAdding = !editingOriginalName;
if (isAdding || isRenaming) {
const exists = settings.searchEngines.some(e => e.name === name);
if (exists) {
showToast(t.duplicateEngineName, 'error');
return;
}
const exists = settings.searchEngines.some(e => e.name === name);
if (exists) {
showToast(t.duplicateEngineName, 'error');
return;
}
}
const newEngine: SearchEngine = {
@@ -101,21 +101,21 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
let updatedSelected = settings.selectedEngine;
if (editingOriginalName) {
// Update existing
const index = updatedEngines.findIndex(e => e.name === editingOriginalName);
if (index !== -1) {
updatedEngines[index] = newEngine;
}
// 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');
// 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');
// Add new
updatedEngines.push(newEngine);
showToast(t.newSearchEngineAdded, 'success');
}
onUpdateSettings({
@@ -140,17 +140,17 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
}, []);
return (
<div className="space-y-4">
<div className="space-y-5">
<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 && (
<button
onClick={() => {
setEditingOriginalName(null);
setNewEngineName('');
setNewEngineUrl('');
setNewEngineIcon('');
setIsAddingEngine(true);
setEditingOriginalName(null);
setNewEngineName('');
setNewEngineUrl('');
setNewEngineIcon('');
setIsAddingEngine(true);
}}
className="p-1.5 rounded-full hover:bg-white/10 transition-colors"
style={{ color: settings.themeColor }}
@@ -171,7 +171,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
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
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' : ''}
${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">
<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>
@@ -236,7 +236,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
<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}
{editingOriginalName ? t.editSearchEngine : t.addCustomEngine}
</span>
</div>
<div className="space-y-1">
@@ -246,7 +246,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
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"
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
/>
</div>
@@ -257,7 +257,7 @@ const SearchEngineManager: React.FC<SearchEngineManagerProps> = ({ settings, onU
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"
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 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 { XIcon } from './Icons';
import { UserSettings } from '../types';
import { UserSettings, SettingsSection } from '../types';
import ThemeSettings from './ThemeSettings';
import WallpaperManager from './WallpaperManager';
import SearchEngineManager from './SearchEngineManager';
@@ -12,9 +11,10 @@ interface SettingsModalProps {
onClose: () => void;
settings: UserSettings;
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();
if (!isOpen) return null;
@@ -25,6 +25,19 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings
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 (
<div
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 */}
<div className="
relative w-full max-w-md rounded-3xl
relative w-full max-w-md rounded-2xl
bg-[#1a1a1a]/90 backdrop-blur-xl
border border-white/10
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
">
{/* 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>
<div className="flex items-center justify-between px-5 pt-5 pb-3 shrink-0 z-10">
<h2 className="text-lg font-medium tracking-wide text-white/90">{getTitle()}</h2>
<button
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>
</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 */}
{section === 'general' && (
<ThemeSettings settings={settings} onUpdateSettings={onUpdateSettings} />
)}
{/* Wallpaper manager component */}
{section === 'wallpaper' && (
<WallpaperManager settings={settings} onUpdateSettings={onUpdateSettings} />
</div>
)}
<div className="h-[1px] bg-white/10 w-full" />
{/* Search engine manager component */}
<SearchEngineManager settings={settings} onUpdateSettings={onUpdateSettings} />
{section === 'search' && (
<SearchEngineManager settings={settings} onUpdateSettings={onUpdateSettings} />
)}
</div>
</div>
</div>

View File

@@ -37,6 +37,10 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
onUpdateSettings({ ...settings, searchOpacity: parseFloat(e.target.value) });
};
const handleMaskOpacityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onUpdateSettings({ ...settings, maskOpacity: parseFloat(e.target.value) });
};
const handleThemeChange = (colorHex: string) => {
onUpdateSettings({ ...settings, themeColor: colorHex });
};
@@ -46,15 +50,15 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
};
return (
<div className="space-y-6">
<div className="space-y-5">
{/* Language Selection */}
<div className="space-y-3">
<span className="text-white/80 font-light block">{t.language}</span>
<div className="space-y-4">
<span className="text-xs font-semibold text-white/50 uppercase tracking-wider 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
flex-1 px-3 py-1.5 rounded-lg text-xs 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'}
@@ -65,7 +69,7 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
<button
onClick={() => handleLanguageChange('zh')}
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'
? 'bg-white text-black'
: '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>
{/* Theme color */}
<div className="space-y-3">
<span className="text-white/80 font-light block">{t.themeColor}</span>
<div className="space-y-4">
<span className="text-xs font-semibold text-white/50 uppercase tracking-wider 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
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'}
`}
style={{ backgroundColor: theme.hex }}
@@ -100,55 +104,55 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
</div>
{/* Toggle settings */}
<div className="space-y-4">
<div className="space-y-3">
<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
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 }}
>
<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>
</div>
<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
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 }}
>
<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>
</div>
<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
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 }}
>
<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>
</div>
<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
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 }}
>
<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>
</div>
</div>
{/* Background blur slider */}
<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>{settings.backgroundBlur}px</span>
</div>
@@ -165,7 +169,7 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdateSetting
{/* Search box opacity slider */}
<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>{Math.round(settings.searchOpacity * 100)}%</span>
</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"
/>
</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>
);
};

View File

@@ -180,8 +180,8 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
}, [settings.customWallpapers]);
return (
<div className="space-y-3">
<span className="text-white/80 font-light block">{t.wallpaperSettings}</span>
<div className="space-y-5">
<span className="text-xs font-semibold text-white/50 uppercase tracking-wider block">{t.wallpaperSettings}</span>
{/* Wallpaper grid */}
<div className="grid grid-cols-3 gap-2">
@@ -231,9 +231,9 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
<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"
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>
</button>
<input
@@ -249,21 +249,21 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
<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" />
<ImageIcon className="w-3.5 h-3.5" />
</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"
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()}
/>
</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"
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}
</button>
@@ -278,9 +278,9 @@ const WallpaperManager: React.FC<WallpaperManagerProps> = ({ settings, onUpdateS
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
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')
? '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'}
`}
>

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ export interface UserSettings {
themeColor: string;
searchOpacity: number;
enableMaskBlur: boolean;
maskOpacity: number;
backgroundUrl: string;
backgroundType: BackgroundType;
wallpaperFit: WallpaperFit;
@@ -38,3 +39,5 @@ export interface UserSettings {
searchHistory: string[];
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)
if (engine === 'Bilibili') {
// In production (Vercel), use /api/bilibili; in development, use /bilibili (Vite proxy)
const isDev = import.meta.env.DEV;
const url = isDev
? `/bilibili?term=${encodeURIComponent(query)}`
: `/api/bilibili?term=${encodeURIComponent(query)}`;
const url = `/api/bilibili?term=${encodeURIComponent(query)}`;
fetch(url)
.then(response => response.json())

View File

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