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:
kuekhaoyang
2025-11-19 11:53:37 +08:00
parent f7c32485b9
commit fad6041df0
13 changed files with 1307 additions and 58 deletions
+23 -2
View File
@@ -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>
+131
View File
@@ -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
+243
View File
@@ -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>
);
}
+25 -22
View File
@@ -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
+26 -24
View File
@@ -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 */}
+153
View File
@@ -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>
</>
);
}
+135
View File
@@ -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>
</>
);
}
+143
View File
@@ -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>
</>
);
}
+122
View File
@@ -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>
);
}
+6 -1
View File
@@ -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>
+24 -9
View File
@@ -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,
};
}
+180
View File
@@ -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));
});
}
}
},
};
+96
View File
@@ -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;
});
}
}