mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-17 09:43:43 +08:00
feat: Implement source management modals and settings store
- Added AddSourceModal for adding new video sources with validation. - Introduced ExportModal for exporting settings and history options. - Created ImportModal for importing settings from JSON files. - Developed SourceManager for managing video sources, including enabling/disabling, deleting, and reordering. - Established settings store to manage application settings, including default sources and sorting options. - Implemented sorting utility functions for video search results based on various criteria.
This commit is contained in:
+23
-2
@@ -17,6 +17,7 @@ import { useSearchCache } from '@/lib/hooks/useSearchCache';
|
||||
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
|
||||
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
|
||||
import { useSourceBadges } from '@/lib/hooks/useSourceBadges';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
function HomePage() {
|
||||
const router = useRouter();
|
||||
@@ -26,6 +27,7 @@ function HomePage() {
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [currentSortBy, setCurrentSortBy] = useState('default');
|
||||
|
||||
// Search stream hook
|
||||
const {
|
||||
@@ -38,11 +40,18 @@ function HomePage() {
|
||||
performSearch,
|
||||
resetSearch,
|
||||
loadCachedResults,
|
||||
applySorting,
|
||||
} = useParallelSearch(
|
||||
saveToCache,
|
||||
(q: string) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false })
|
||||
);
|
||||
|
||||
// Load sort preference on mount
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setCurrentSortBy(settings.sortBy);
|
||||
}, []);
|
||||
|
||||
// Source badges hook - filters by video source
|
||||
const {
|
||||
selectedSources,
|
||||
@@ -83,7 +92,7 @@ function HomePage() {
|
||||
const handleSearch = (searchQuery: string) => {
|
||||
setQuery(searchQuery);
|
||||
setHasSearched(true);
|
||||
performSearch(searchQuery);
|
||||
performSearch(searchQuery, currentSortBy as any);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -119,7 +128,19 @@ function HomePage() {
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">视频聚合平台</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ThemeSwitcher />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
|
||||
aria-label="设置"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/>
|
||||
</svg>
|
||||
</Link>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Settings Feature Documentation
|
||||
|
||||
## Overview
|
||||
Comprehensive settings system for KVideo following the Liquid Glass design system principles.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Video Source Management
|
||||
- **View all sources**: Display default and custom video sources with their URLs
|
||||
- **Toggle sources**: Enable/disable sources with animated switches
|
||||
- **Reorder sources**: Change priority using up/down arrows
|
||||
- **Delete sources**: Remove custom sources (default sources can also be removed)
|
||||
- **Add custom sources**: Add new video API sources with validation
|
||||
- **Restore defaults**: One-click restore to default source configuration
|
||||
|
||||
### 2. Search Result Sorting
|
||||
Users can select how search results should be sorted:
|
||||
- **默认排序** (Default): Original order
|
||||
- **按相关性** (By Relevance): Most relevant results first
|
||||
- **延迟低到高** (Latency: Low to High): Fastest responding sources first
|
||||
- **发布时间(新到旧)** (Release Date: Newest First)
|
||||
- **发布时间(旧到新)** (Release Date: Oldest First)
|
||||
- **按评分(高到低)** (By Rating: High to Low)
|
||||
- **按名称(A-Z)** (By Name: A-Z)
|
||||
- **按名称(Z-A)** (By Name: Z-A)
|
||||
|
||||
### 3. Data Management
|
||||
|
||||
#### Export Settings
|
||||
- Export app configuration to JSON file
|
||||
- Options to include:
|
||||
- Search history
|
||||
- Watch history
|
||||
- Source configuration (always included)
|
||||
- Downloaded as timestamped JSON file
|
||||
|
||||
#### Import Settings
|
||||
- Import previously exported configuration
|
||||
- Automatic validation
|
||||
- Auto-refresh after successful import
|
||||
|
||||
#### Reset All Data
|
||||
- Clear all settings
|
||||
- Clear search history
|
||||
- Clear watch history
|
||||
- Clear all cookies
|
||||
- Clear all cache
|
||||
- Restore to factory defaults
|
||||
|
||||
## Components
|
||||
|
||||
### Core Components
|
||||
- `SourceManager.tsx` - Manage video sources with toggle, reorder, delete
|
||||
- `AddSourceModal.tsx` - Modal dialog for adding custom sources
|
||||
- `ExportModal.tsx` - Export settings with history options
|
||||
- `ImportModal.tsx` - Import settings from JSON file
|
||||
- `ConfirmDialog.tsx` - Reusable confirmation dialog (updated)
|
||||
|
||||
### Store
|
||||
- `settings-store.ts` - Settings state management and persistence
|
||||
|
||||
## Design System Compliance
|
||||
|
||||
All components strictly follow the Liquid Glass design system:
|
||||
|
||||
### Visual Elements
|
||||
- **Glass Effect**: `backdrop-blur-xl`, `saturate(180%)`
|
||||
- **Border Radius**: Only `rounded-[var(--radius-2xl)]` and `rounded-[var(--radius-full)]`
|
||||
- **Colors**: CSS variables for theme consistency
|
||||
- **Shadows**: `shadow-[var(--shadow-sm)]` and `shadow-[var(--shadow-md)]`
|
||||
|
||||
### Animations
|
||||
- **Modal Entry/Exit**: Fade + scale transforms with cubic-bezier easing
|
||||
- **Switch Toggle**: 0.4s fluid transition with `cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
- **Button Hover**: Smooth color transitions with brightness changes
|
||||
- **List Items**: Staggered animations for visual hierarchy
|
||||
|
||||
### Interactive Elements
|
||||
- **Checkboxes**: Custom styled with smooth check animation
|
||||
- **Switches**: Animated toggle with sliding thumb
|
||||
- **Buttons**: Glass morphism with hover lift effects
|
||||
- **Inputs**: Glass background with focus ring animation
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### State Management
|
||||
```typescript
|
||||
interface AppSettings {
|
||||
sources: VideoSource[];
|
||||
sortBy: SortOption;
|
||||
searchHistory: boolean;
|
||||
watchHistory: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Local Storage Keys
|
||||
- `kvideo-settings` - Main settings object
|
||||
- `kvideo-search-history` - Search history array
|
||||
- `kvideo-watch-history` - Watch history array
|
||||
|
||||
### Data Flow
|
||||
1. Settings loaded from localStorage on mount
|
||||
2. Changes immediately persisted to localStorage
|
||||
3. Export creates downloadable JSON blob
|
||||
4. Import validates and applies configuration
|
||||
5. Reset clears all storage and reloads page
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Semantic HTML structure
|
||||
- ARIA labels on all interactive elements
|
||||
- Keyboard navigation support
|
||||
- Focus management in modals
|
||||
- High contrast mode support
|
||||
- Screen reader friendly
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- Modern browsers with CSS backdrop-filter support
|
||||
- LocalStorage API
|
||||
- File API for export/import
|
||||
- Cookie manipulation for reset
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Cloud sync for settings across devices
|
||||
- Source health monitoring
|
||||
- Advanced filtering options
|
||||
- Bulk source import from URL
|
||||
- Settings backup scheduling
|
||||
- Theme customization
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { settingsStore, sortOptions, getDefaultSources, type SortOption } from '@/lib/store/settings-store';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { SourceManager } from '@/components/settings/SourceManager';
|
||||
import { AddSourceModal } from '@/components/settings/AddSourceModal';
|
||||
import { ExportModal } from '@/components/settings/ExportModal';
|
||||
import { ImportModal } from '@/components/settings/ImportModal';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [sources, setSources] = useState<VideoSource[]>([]);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('default');
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources);
|
||||
setSortBy(settings.sortBy);
|
||||
}, []);
|
||||
|
||||
const handleSourcesChange = (newSources: VideoSource[]) => {
|
||||
setSources(newSources);
|
||||
settingsStore.saveSettings({ sources: newSources, sortBy, searchHistory: true, watchHistory: true });
|
||||
};
|
||||
|
||||
const handleAddSource = (source: VideoSource) => {
|
||||
const updated = [...sources, source];
|
||||
handleSourcesChange(updated);
|
||||
};
|
||||
|
||||
const handleSortChange = (newSort: SortOption) => {
|
||||
setSortBy(newSort);
|
||||
settingsStore.saveSettings({ sources, sortBy: newSort, searchHistory: true, watchHistory: true });
|
||||
};
|
||||
|
||||
const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => {
|
||||
const data = settingsStore.exportSettings(includeSearchHistory || includeWatchHistory);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `kvideo-settings-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = (jsonString: string): boolean => {
|
||||
const success = settingsStore.importSettings(jsonString);
|
||||
if (success) {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources);
|
||||
setSortBy(settings.sortBy);
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
const handleRestoreDefaults = () => {
|
||||
const defaults = getDefaultSources();
|
||||
handleSourcesChange(defaults);
|
||||
setIsRestoreDefaultsDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
settingsStore.resetToDefaults();
|
||||
setIsResetDialogOpen(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-2 text-[var(--accent-color)] hover:underline mb-4"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
返回上一页
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)]">
|
||||
<svg className="w-6 h-6 text-[var(--text-color)]" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--text-color)]">设置</h1>
|
||||
<p className="text-[var(--text-color-secondary)]">管理应用程序配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Management */}
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)]">视频源管理</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsRestoreDefaultsDialogOpen(true)}
|
||||
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-sm font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white text-sm font-semibold hover:brightness-110 hover:-translate-y-0.5 shadow-[var(--shadow-sm)] transition-all duration-200"
|
||||
>
|
||||
+ 添加源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-6">
|
||||
管理视频来源,调整优先级和启用状态
|
||||
</p>
|
||||
<SourceManager
|
||||
sources={showAllSources ? sources : sources.slice(0, 10)}
|
||||
onSourcesChange={handleSourcesChange}
|
||||
/>
|
||||
{sources.length > 10 && (
|
||||
<button
|
||||
onClick={() => setShowAllSources(!showAllSources)}
|
||||
className="w-full mt-4 px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-sm font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
|
||||
>
|
||||
{showAllSources ? '收起' : `显示全部 (${sources.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4">搜索结果排序</h2>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
选择搜索结果的默认排序方式
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{(Object.keys(sortOptions) as SortOption[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => handleSortChange(option)}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 ${
|
||||
sortBy === option
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{sortOptions[option]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Management */}
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4">数据管理</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setIsExportModalOpen(true)}
|
||||
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 flex items-center justify-between"
|
||||
>
|
||||
<span>导出设置</span>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsImportModalOpen(true)}
|
||||
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 flex items-center justify-between"
|
||||
>
|
||||
<span>导入设置</span>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-all duration-200 flex items-center justify-between"
|
||||
>
|
||||
<span>清除所有数据</span>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<AddSourceModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
onAdd={handleAddSource}
|
||||
existingIds={sources.map(s => s.id)}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
<ImportModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isRestoreDefaultsDialogOpen}
|
||||
title="恢复默认源"
|
||||
message="这将重置所有视频源为默认配置。自定义源将被删除。是否继续?"
|
||||
confirmText="恢复"
|
||||
cancelText="取消"
|
||||
onConfirm={handleRestoreDefaults}
|
||||
onCancel={() => setIsRestoreDefaultsDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isResetDialogOpen}
|
||||
title="清除所有数据"
|
||||
message="这将删除所有设置、历史记录、Cookie 和缓存。此操作不可撤销。是否继续?"
|
||||
confirmText="清除"
|
||||
cancelText="取消"
|
||||
onConfirm={handleResetAll}
|
||||
onCancel={() => setIsResetDialogOpen(false)}
|
||||
dangerous
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function SearchForm({
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim() && !isLoading) {
|
||||
if (query.trim()) {
|
||||
// Add to search history before searching
|
||||
addSearch(query.trim());
|
||||
onSearch(query);
|
||||
@@ -118,33 +118,36 @@ export function SearchForm({
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-base sm:text-lg pr-24 md:pr-32 truncate"
|
||||
className="text-base sm:text-lg pr-28 sm:pr-36 md:pr-44 truncate"
|
||||
aria-label="搜索视频内容"
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-controls="search-history-dropdown"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-20 md:right-32 top-1/2 -translate-y-1/2 p-3 md:p-2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors touch-manipulation"
|
||||
aria-label="清除搜索"
|
||||
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-2 text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity touch-manipulation"
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<Icons.X size={20} />
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
variant="primary"
|
||||
className="px-3 sm:px-4 md:px-6"
|
||||
>
|
||||
<Icons.X size={20} />
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-3 sm:px-4 md:px-8"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
<span className="hidden sm:inline">搜索</span>
|
||||
</span>
|
||||
</Button>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
<span className="hidden sm:inline">搜索</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search History Dropdown */}
|
||||
<SearchHistoryDropdown
|
||||
|
||||
@@ -57,7 +57,7 @@ const VideoCard = memo(({
|
||||
prefetch={false}
|
||||
>
|
||||
<Card
|
||||
className="p-0 overflow-hidden group cursor-pointer flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)]"
|
||||
className="p-0 overflow-hidden group cursor-pointer flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] hover:shadow-lg transition-shadow"
|
||||
hover={false} // Disable default hover scale to improve performance
|
||||
style={{
|
||||
willChange: 'transform',
|
||||
@@ -117,33 +117,35 @@ const VideoCard = memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay - Show on hover (desktop) or when active (mobile) - Simplified for performance */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60" // Removed gradient for solid semi-transparent overlay
|
||||
style={{
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{/* Mobile indicator when active */}
|
||||
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${
|
||||
isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{/* Mobile indicator when active */}
|
||||
{isActive && (
|
||||
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
|
||||
再次点击播放 →
|
||||
</div>
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - Fixed height section */}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
interface AddSourceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (source: VideoSource) => void;
|
||||
existingIds: string[];
|
||||
}
|
||||
|
||||
export function AddSourceModal({ isOpen, onClose, onAdd, existingIds }: AddSourceModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setUrl('');
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!name.trim() || !url.trim()) {
|
||||
setError('请填写所有字段');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError('请输入有效的 URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = name.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
if (existingIds.includes(id)) {
|
||||
setError('此源名称已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
const newSource: VideoSource = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
baseUrl: url.trim(),
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: existingIds.length + 1,
|
||||
};
|
||||
|
||||
onAdd(newSource);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${
|
||||
isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-[var(--text-color)]">
|
||||
添加自定义源
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="source-name" className="block mb-2 font-medium text-[var(--text-color)]">
|
||||
源名称
|
||||
</label>
|
||||
<input
|
||||
id="source-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:新视频源"
|
||||
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="source-url" className="block mb-2 font-medium text-[var(--text-color)]">
|
||||
API 地址
|
||||
</label>
|
||||
<input
|
||||
id="source-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/api.php/provide/vod"
|
||||
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-[var(--radius-2xl)] px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-semibold hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)] transition-all duration-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white font-semibold hover:brightness-110 hover:-translate-y-0.5 shadow-[var(--shadow-sm)] transition-all duration-200"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (includeSearchHistory: boolean, includeWatchHistory: boolean) => void;
|
||||
}
|
||||
|
||||
export function ExportModal({ isOpen, onClose, onExport }: ExportModalProps) {
|
||||
const [includeSearchHistory, setIncludeSearchHistory] = useState(true);
|
||||
const [includeWatchHistory, setIncludeWatchHistory] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIncludeSearchHistory(true);
|
||||
setIncludeWatchHistory(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExport = () => {
|
||||
onExport(includeSearchHistory, includeWatchHistory);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${
|
||||
isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-[var(--text-color)]">
|
||||
导出设置
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<p className="text-[var(--text-color-secondary)] text-sm">
|
||||
选择要导出的内容:
|
||||
</p>
|
||||
|
||||
{/* Checkbox for Search History */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSearchHistory}
|
||||
onChange={(e) => setIncludeSearchHistory(e.target.checked)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className={`w-6 h-6 rounded-[0.6rem] border-2 flex items-center justify-center transition-all duration-200 ${
|
||||
includeSearchHistory
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)]'
|
||||
: 'border-[var(--text-color-secondary)]'
|
||||
}`}>
|
||||
{includeSearchHistory && (
|
||||
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[var(--text-color)]">搜索历史</span>
|
||||
</label>
|
||||
|
||||
{/* Checkbox for Watch History */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeWatchHistory}
|
||||
onChange={(e) => setIncludeWatchHistory(e.target.checked)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className={`w-6 h-6 rounded-[0.6rem] border-2 flex items-center justify-center transition-all duration-200 ${
|
||||
includeWatchHistory
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)]'
|
||||
: 'border-[var(--text-color-secondary)]'
|
||||
}`}>
|
||||
{includeWatchHistory && (
|
||||
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[var(--text-color)]">观看历史</span>
|
||||
</label>
|
||||
|
||||
<div className="text-xs text-[var(--text-color-secondary)] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] p-3 mt-4">
|
||||
注意:源设置将始终包含在导出中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-semibold hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)] transition-all duration-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex-1 px-6 py-3 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white font-semibold hover:brightness-110 hover:-translate-y-0.5 shadow-[var(--shadow-sm)] transition-all duration-200"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface ImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (jsonString: string) => boolean;
|
||||
}
|
||||
|
||||
export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
const isValid = onImport(content);
|
||||
|
||||
if (isValid) {
|
||||
setSuccess(true);
|
||||
setError('');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
setError('导入失败:文件格式无效');
|
||||
setSuccess(false);
|
||||
}
|
||||
} catch {
|
||||
setError('导入失败:无法读取文件');
|
||||
setSuccess(false);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${
|
||||
isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-[var(--text-color)]">
|
||||
导入设置
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-color-secondary)] text-sm mb-4">
|
||||
选择之前导出的设置文件(JSON 格式)
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={success}
|
||||
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border-2 border-dashed border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] hover:border-[var(--accent-color)] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span>点击选择文件</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-[var(--radius-2xl)] px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mt-4 text-sm text-green-600 bg-green-50 dark:bg-green-900/20 rounded-[var(--radius-2xl)] px-4 py-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
导入成功!正在刷新页面...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={success}
|
||||
className="w-full px-6 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-semibold hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)] disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
interface SourceManagerProps {
|
||||
sources: VideoSource[];
|
||||
onSourcesChange: (sources: VideoSource[]) => void;
|
||||
}
|
||||
|
||||
export function SourceManager({ sources, onSourcesChange }: SourceManagerProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
const updated = sources.map(s =>
|
||||
s.id === id ? { ...s, enabled: !s.enabled } : s
|
||||
);
|
||||
onSourcesChange(updated);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const updated = sources.filter(s => s.id !== id);
|
||||
onSourcesChange(updated);
|
||||
};
|
||||
|
||||
const handlePriorityChange = (id: string, direction: 'up' | 'down') => {
|
||||
const currentIndex = sources.findIndex(s => s.id === id);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= sources.length) return;
|
||||
|
||||
const updated = [...sources];
|
||||
[updated[currentIndex], updated[newIndex]] = [updated[newIndex], updated[currentIndex]];
|
||||
|
||||
// Update priorities
|
||||
updated.forEach((s, idx) => s.priority = idx + 1);
|
||||
onSourcesChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source, index) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-4 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Toggle Switch */}
|
||||
<button
|
||||
onClick={() => handleToggle(source.id)}
|
||||
className="relative inline-block w-12 h-7 flex-shrink-0 cursor-pointer"
|
||||
aria-label={`Toggle ${source.name}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-0 rounded-[var(--radius-full)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
source.enabled
|
||||
? 'bg-[var(--accent-color)]'
|
||||
: 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-[var(--radius-full)] shadow-sm transition-transform duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
source.enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Source Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--text-color)] truncate">
|
||||
{source.name}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-color-secondary)] truncate">
|
||||
{source.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Priority Controls */}
|
||||
<button
|
||||
onClick={() => handlePriorityChange(source.id, 'up')}
|
||||
disabled={index === 0}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
|
||||
aria-label="Move up"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handlePriorityChange(source.id, 'down')}
|
||||
disabled={index === sources.length - 1}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={() => handleDelete(source.id)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all duration-200"
|
||||
aria-label="Delete source"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface ConfirmDialogProps {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
@@ -24,6 +25,7 @@ export function ConfirmDialog({
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
variant = 'warning',
|
||||
dangerous = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -66,6 +68,9 @@ export function ConfirmDialog({
|
||||
warning: 'bg-[var(--accent-color)] hover:brightness-110',
|
||||
info: 'bg-blue-500 hover:bg-blue-600',
|
||||
};
|
||||
|
||||
// Use dangerous prop to override variant
|
||||
const finalVariant = dangerous ? 'danger' : variant;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,7 +119,7 @@ export function ConfirmDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className={`min-w-[100px] ${variantStyles[variant]}`}
|
||||
className={`min-w-[100px] ${variantStyles[finalVariant]}`}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
|
||||
import { calculateRelevanceScore } from '@/lib/utils/search';
|
||||
import { sortVideos } from '@/lib/utils/sort';
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
|
||||
interface Video {
|
||||
vod_id: string;
|
||||
@@ -28,9 +30,10 @@ export interface ParallelSearchResult {
|
||||
completedSources: number;
|
||||
totalSources: number;
|
||||
totalVideosFound: number;
|
||||
performSearch: (query: string) => Promise<void>;
|
||||
performSearch: (query: string, sortBy?: SortOption) => Promise<void>;
|
||||
resetSearch: () => void;
|
||||
loadCachedResults: (results: Video[], sources: any[]) => void;
|
||||
applySorting: (sortBy: SortOption) => void;
|
||||
}
|
||||
|
||||
export function useParallelSearch(
|
||||
@@ -50,7 +53,7 @@ export function useParallelSearch(
|
||||
/**
|
||||
* Perform parallel search with streaming results
|
||||
*/
|
||||
const performSearch = useCallback(async (searchQuery: string) => {
|
||||
const performSearch = useCallback(async (searchQuery: string, sortBy: SortOption = 'default') => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
// Abort any ongoing search
|
||||
@@ -183,13 +186,17 @@ export function useParallelSearch(
|
||||
|
||||
console.log('[useParallelSearch] Available sources:', sources);
|
||||
|
||||
// Cache results - wait a bit for current results to be in state
|
||||
setTimeout(() => {
|
||||
setResults((currentResults) => {
|
||||
onCacheUpdate(searchQuery, currentResults, sources);
|
||||
return currentResults;
|
||||
});
|
||||
}, 100);
|
||||
// Apply final sorting after all results are received
|
||||
setResults((currentResults) => {
|
||||
const sorted = sortVideos(currentResults, sortBy);
|
||||
|
||||
// Cache results
|
||||
setTimeout(() => {
|
||||
onCacheUpdate(searchQuery, sorted, sources);
|
||||
}, 100);
|
||||
|
||||
return sorted;
|
||||
});
|
||||
}
|
||||
else if (data.type === 'error') {
|
||||
console.error('Search error:', data.message);
|
||||
@@ -236,6 +243,13 @@ export function useParallelSearch(
|
||||
setTotalVideosFound(cachedResults.length);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Apply sorting to current results
|
||||
*/
|
||||
const applySorting = useCallback((sortBy: SortOption) => {
|
||||
setResults((currentResults) => sortVideos(currentResults, sortBy));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
results,
|
||||
@@ -246,5 +260,6 @@ export function useParallelSearch(
|
||||
performSearch,
|
||||
resetSearch,
|
||||
loadCachedResults,
|
||||
applySorting,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Settings Store - Manages application settings and preferences
|
||||
*/
|
||||
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export type SortOption =
|
||||
| 'default'
|
||||
| 'relevance'
|
||||
| 'latency-asc'
|
||||
| 'date-desc'
|
||||
| 'date-asc'
|
||||
| 'rating-desc'
|
||||
| 'name-asc'
|
||||
| 'name-desc';
|
||||
|
||||
export interface AppSettings {
|
||||
sources: VideoSource[];
|
||||
sortBy: SortOption;
|
||||
searchHistory: boolean;
|
||||
watchHistory: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'kvideo-settings';
|
||||
const SEARCH_HISTORY_KEY = 'kvideo-search-history';
|
||||
const WATCH_HISTORY_KEY = 'kvideo-watch-history';
|
||||
|
||||
export const sortOptions: Record<SortOption, string> = {
|
||||
'default': '默认排序',
|
||||
'relevance': '按相关性',
|
||||
'latency-asc': '延迟低到高',
|
||||
'date-desc': '发布时间(新到旧)',
|
||||
'date-asc': '发布时间(旧到新)',
|
||||
'rating-desc': '按评分(高到低)',
|
||||
'name-asc': '按名称(A-Z)',
|
||||
'name-desc': '按名称(Z-A)',
|
||||
};
|
||||
|
||||
export const getDefaultSources = (): VideoSource[] => {
|
||||
// Import from video-sources to get all 38 default sources
|
||||
return [
|
||||
{ id: 'feifan', name: '非凡资源', baseUrl: 'http://ffzy5.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 1 },
|
||||
{ id: 'wolong', name: '卧龙资源', baseUrl: 'https://wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 2 },
|
||||
{ id: 'zuida', name: '最大资源', baseUrl: 'https://api.zuidapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 3 },
|
||||
{ id: 'baiduyun', name: '百度云资源', baseUrl: 'https://api.apibdzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 4 },
|
||||
{ id: 'baofeng', name: '暴风资源', baseUrl: 'https://bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 5 },
|
||||
{ id: 'jisu', name: '极速资源', baseUrl: 'https://jszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 6 },
|
||||
{ id: 'tianya', name: '天涯资源', baseUrl: 'https://tyyszy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 7 },
|
||||
{ id: 'wujin', name: '无尽资源', baseUrl: 'https://api.wujinapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 8 },
|
||||
{ id: 'modu', name: '魔都资源', baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 9 },
|
||||
{ id: 'sanliuling', name: '360资源', baseUrl: 'https://360zy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 10 },
|
||||
{ id: 'dytt', name: '电影天堂', baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 11 },
|
||||
{ id: 'ruyi', name: '如意资源', baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 12 },
|
||||
{ id: 'wangwang', name: '旺旺资源', baseUrl: 'https://wwzy.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 13 },
|
||||
{ id: 'hongniu', name: '红牛资源', baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 14 },
|
||||
{ id: 'guangsu', name: '光速资源', baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 15 },
|
||||
{ id: 'ikun', name: 'iKun资源', baseUrl: 'https://ikunzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 16 },
|
||||
{ id: 'youku', name: '优酷资源', baseUrl: 'https://api.ukuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 17 },
|
||||
{ id: 'huya', name: '虎牙资源', baseUrl: 'https://www.huyaapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 18 },
|
||||
{ id: 'xinlang', name: '新浪资源', baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 19 },
|
||||
{ id: 'lezi', name: '乐子资源', baseUrl: 'https://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 20 },
|
||||
{ id: 'haihua', name: '海豚资源', baseUrl: 'https://hhzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 21 },
|
||||
{ id: 'jiangyu', name: '鲸鱼资源', baseUrl: 'https://jyzyapi.com/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 22 },
|
||||
{ id: 'yilingba', name: '1080资源', baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php', searchPath: '', detailPath: '', enabled: true, priority: 23 },
|
||||
{ id: 'aidan', name: '爱蛋资源', baseUrl: 'https://lovedan.net/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 24 },
|
||||
{ id: 'leba', name: '乐播资源', baseUrl: 'https://lbapi9.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 25 },
|
||||
{ id: 'moduzy', name: '魔都影视', baseUrl: 'https://www.moduzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 26 },
|
||||
{ id: 'feifanapi', name: '非凡API', baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 27 },
|
||||
{ id: 'feifancj', name: '非凡采集', baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 28 },
|
||||
{ id: 'feifancj2', name: '非凡采集HTTPS', baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 29 },
|
||||
{ id: 'feifan1', name: '非凡线路1', baseUrl: 'http://ffzy1.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 30 },
|
||||
{ id: 'wolong2', name: '卧龙采集', baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 31 },
|
||||
{ id: 'baofeng2', name: '暴风APP', baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 32 },
|
||||
{ id: 'wujin2', name: '无尽ME', baseUrl: 'https://api.wujinapi.me/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 33 },
|
||||
{ id: 'tianyazy', name: '天涯海角', baseUrl: 'https://tyyszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 34 },
|
||||
{ id: 'guangsu2', name: '光速HTTP', baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 35 },
|
||||
{ id: 'xinlang2', name: '新浪HTTPS', baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 36 },
|
||||
{ id: 'yilingba2', name: '1080JSON', baseUrl: 'https://api.1080zyku.com/inc/apijson.php', searchPath: '', detailPath: '', enabled: true, priority: 37 },
|
||||
{ id: 'lezi2', name: '乐子HTTP', baseUrl: 'http://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 38 },
|
||||
];
|
||||
};
|
||||
|
||||
export const settingsStore = {
|
||||
getSettings(): AppSettings {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
sources: getDefaultSources(),
|
||||
sortBy: 'default',
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (!stored) {
|
||||
return {
|
||||
sources: getDefaultSources(),
|
||||
sortBy: 'default',
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return {
|
||||
sources: getDefaultSources(),
|
||||
sortBy: 'default',
|
||||
searchHistory: true,
|
||||
watchHistory: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings(settings: AppSettings): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
},
|
||||
|
||||
exportSettings(includeHistory: boolean = true): string {
|
||||
const settings = this.getSettings();
|
||||
const exportData: Record<string, unknown> = {
|
||||
settings,
|
||||
};
|
||||
|
||||
if (includeHistory && typeof window !== 'undefined') {
|
||||
const searchHistory = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||
const watchHistory = localStorage.getItem(WATCH_HISTORY_KEY);
|
||||
|
||||
if (searchHistory) exportData.searchHistory = JSON.parse(searchHistory);
|
||||
if (watchHistory) exportData.watchHistory = JSON.parse(watchHistory);
|
||||
}
|
||||
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
},
|
||||
|
||||
importSettings(jsonString: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
if (data.settings) {
|
||||
this.saveSettings(data.settings);
|
||||
}
|
||||
|
||||
if (data.searchHistory && typeof window !== 'undefined') {
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(data.searchHistory));
|
||||
}
|
||||
|
||||
if (data.watchHistory && typeof window !== 'undefined') {
|
||||
localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(data.watchHistory));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
resetToDefaults(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(SETTINGS_KEY);
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
localStorage.removeItem(WATCH_HISTORY_KEY);
|
||||
|
||||
// Clear all cookies
|
||||
document.cookie.split(";").forEach((c) => {
|
||||
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
|
||||
});
|
||||
|
||||
// Clear cache if available
|
||||
if ('caches' in window) {
|
||||
caches.keys().then((names) => {
|
||||
names.forEach(name => caches.delete(name));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Sort utility functions for search results
|
||||
*/
|
||||
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
|
||||
interface Video {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic?: string;
|
||||
vod_remarks?: string;
|
||||
vod_year?: string;
|
||||
type_name?: string;
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
isNew?: boolean;
|
||||
vod_play_url?: string;
|
||||
vod_actor?: string;
|
||||
vod_director?: string;
|
||||
relevanceScore?: number;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
export function sortVideos(videos: Video[], sortBy: SortOption): Video[] {
|
||||
const sorted = [...videos];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'relevance':
|
||||
// Sort by relevance score (highest first)
|
||||
return sorted.sort((a, b) => {
|
||||
const scoreA = (a as any).relevanceScore || 0;
|
||||
const scoreB = (b as any).relevanceScore || 0;
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
case 'latency-asc':
|
||||
// Sort by latency (lowest first)
|
||||
return sorted.sort((a, b) => {
|
||||
const latencyA = a.latency || 99999;
|
||||
const latencyB = b.latency || 99999;
|
||||
return latencyA - latencyB;
|
||||
});
|
||||
|
||||
case 'date-desc':
|
||||
// Sort by year (newest first)
|
||||
return sorted.sort((a, b) => {
|
||||
const yearA = parseInt(a.vod_year || '0');
|
||||
const yearB = parseInt(b.vod_year || '0');
|
||||
return yearB - yearA;
|
||||
});
|
||||
|
||||
case 'date-asc':
|
||||
// Sort by year (oldest first)
|
||||
return sorted.sort((a, b) => {
|
||||
const yearA = parseInt(a.vod_year || '0');
|
||||
const yearB = parseInt(b.vod_year || '0');
|
||||
return yearA - yearB;
|
||||
});
|
||||
|
||||
case 'rating-desc':
|
||||
// Sort by rating if available (placeholder for future implementation)
|
||||
return sorted.sort((a, b) => {
|
||||
const ratingA = (a as any).vod_score || 0;
|
||||
const ratingB = (b as any).vod_score || 0;
|
||||
return ratingB - ratingA;
|
||||
});
|
||||
|
||||
case 'name-asc':
|
||||
// Sort by name A-Z
|
||||
return sorted.sort((a, b) => {
|
||||
return a.vod_name.localeCompare(b.vod_name, 'zh-CN');
|
||||
});
|
||||
|
||||
case 'name-desc':
|
||||
// Sort by name Z-A
|
||||
return sorted.sort((a, b) => {
|
||||
return b.vod_name.localeCompare(a.vod_name, 'zh-CN');
|
||||
});
|
||||
|
||||
case 'default':
|
||||
default:
|
||||
// Default: by relevance then latency
|
||||
return sorted.sort((a, b) => {
|
||||
const scoreA = (a as any).relevanceScore || 0;
|
||||
const scoreB = (b as any).relevanceScore || 0;
|
||||
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
|
||||
const latencyA = a.latency || 99999;
|
||||
const latencyB = b.latency || 99999;
|
||||
return latencyA - latencyB;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user