feat: Implement custom source editing and refactor source management to use full

This commit is contained in:
kuekhaoyang
2025-11-22 20:57:38 +08:00
parent 976bd3bc12
commit 64636a3da9
11 changed files with 180 additions and 55 deletions
+10 -2
View File
@@ -27,11 +27,19 @@ async function handleDetailRequest(id: string | null, source: string | null, met
);
}
const sourceConfig = getSourceById(source);
let sourceConfig;
// If source is an object (from POST), use it
if (typeof source === 'object') {
sourceConfig = source;
} else {
// If source is a string ID (from GET), try to look it up
sourceConfig = getSourceById(source);
}
if (!sourceConfig) {
return NextResponse.json(
{ error: 'Invalid source ID' },
{ error: 'Invalid source configuration' },
{ status: 400 }
);
}
+6 -6
View File
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
async start(controller) {
try {
const body = await request.json();
const { query, sources: sourceIds, page = 1 } = body;
const { query, sources: sourceConfigs, page = 1 } = body;
// Validate input
if (!query || typeof query !== 'string' || query.trim().length === 0) {
@@ -28,15 +28,15 @@ export async function POST(request: NextRequest) {
return;
}
// Get source configurations
const sources = sourceIds
.map((id: string) => getSourceById(id))
.filter((source: any): source is NonNullable<typeof source> => source !== undefined);
// Use provided sources or fallback to empty (client should provide them)
const sources = Array.isArray(sourceConfigs) && sourceConfigs.length > 0
? sourceConfigs
: [];
if (sources.length === 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: 'No valid sources'
message: 'No valid sources provided'
})}\n\n`));
controller.close();
return;
+4 -1
View File
@@ -67,7 +67,10 @@ function HomePage() {
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
setHasSearched(true);
performSearch(searchQuery, currentSortBy as any);
const settings = settingsStore.getSettings();
// Filter enabled sources
const enabledSources = settings.sources.filter(s => s.enabled);
performSearch(searchQuery, enabledSources, currentSortBy as any);
};
const handleReset = () => {
+14 -1
View File
@@ -10,6 +10,7 @@ export function useSettingsPage() {
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
useEffect(() => {
const settings = settingsStore.getSettings();
@@ -23,8 +24,17 @@ export function useSettingsPage() {
};
const handleAddSource = (source: VideoSource) => {
const updated = [...sources, source];
const exists = sources.some(s => s.id === source.id);
const updated = exists
? sources.map(s => s.id === source.id ? source : s)
: [...sources, source];
handleSourcesChange(updated);
setEditingSource(null);
};
const handleEditSource = (source: VideoSource) => {
setEditingSource(source);
setIsAddModalOpen(true);
};
const handleSortChange = (newSort: SortOption) => {
@@ -78,6 +88,7 @@ export function useSettingsPage() {
setIsImportModalOpen,
setIsResetDialogOpen,
setIsRestoreDefaultsDialogOpen,
setEditingSource,
handleSourcesChange,
handleAddSource,
handleSortChange,
@@ -85,5 +96,7 @@ export function useSettingsPage() {
handleImport,
handleRestoreDefaults,
handleResetAll,
editingSource,
handleEditSource,
};
}
+13 -2
View File
@@ -31,6 +31,9 @@ export default function SettingsPage() {
handleImport,
handleRestoreDefaults,
handleResetAll,
editingSource,
handleEditSource,
setEditingSource,
} = useSettingsPage();
return (
@@ -44,7 +47,11 @@ export default function SettingsPage() {
sources={sources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => setIsAddModalOpen(true)}
onAddSource={() => {
setEditingSource(null);
setIsAddModalOpen(true);
}}
onEditSource={handleEditSource}
/>
{/* Sort Options */}
@@ -64,9 +71,13 @@ export default function SettingsPage() {
{/* Modals */}
<AddSourceModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
onClose={() => {
setIsAddModalOpen(false);
setEditingSource(null);
}}
onAdd={handleAddSource}
existingIds={sources.map(s => s.id)}
initialValues={editingSource}
/>
<ExportModal
+5 -3
View File
@@ -10,14 +10,16 @@ interface AddSourceModalProps {
onClose: () => void;
onAdd: (source: VideoSource) => void;
existingIds: string[];
initialValues?: VideoSource | null;
}
export function AddSourceModal({ isOpen, onClose, onAdd, existingIds }: AddSourceModalProps) {
export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialValues }: AddSourceModalProps) {
const { name, setName, url, setUrl, error, handleSubmit } = useAddSourceForm({
isOpen,
existingIds,
onAdd,
onClose,
initialValues,
});
if (!isOpen) return null;
@@ -34,7 +36,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds }: AddSourc
}`}
>
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
<ModalHeader title="添加自定义源" onClose={onClose} />
<ModalHeader title={initialValues ? "编辑视频源" : "添加自定义源"} onClose={onClose} />
<form onSubmit={handleSubmit} className="space-y-4">
<div>
@@ -83,7 +85,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds }: AddSourc
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"
>
{initialValues ? "保存" : "添加"}
</button>
</div>
</form>
+32 -22
View File
@@ -5,37 +5,33 @@ import type { VideoSource } from '@/lib/types';
interface SourceManagerProps {
sources: VideoSource[];
onSourcesChange: (sources: VideoSource[]) => void;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onReorder: (id: string, direction: 'up' | 'down') => void;
onEdit?: (source: VideoSource) => void;
defaultIds: string[];
}
export function SourceManager({ sources, onSourcesChange }: SourceManagerProps) {
export function SourceManager({
sources,
onToggle,
onDelete,
onReorder,
onEdit,
defaultIds
}: 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);
onToggle(id);
};
const handleDelete = (id: string) => {
const updated = sources.filter(s => s.id !== id);
onSourcesChange(updated);
onDelete(id);
};
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);
onReorder(id, direction);
};
return (
@@ -55,8 +51,8 @@ export function SourceManager({ sources, onSourcesChange }: SourceManagerProps)
>
<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)]'
? 'bg-[var(--accent-color)]'
: 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
}`}
/>
<span
@@ -101,6 +97,20 @@ export function SourceManager({ sources, onSourcesChange }: SourceManagerProps)
</svg>
</button>
{/* Edit Button - Only for custom sources */}
{onEdit && !defaultIds.includes(source.id) && (
<button
onClick={() => onEdit(source)}
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="Edit source"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
{/* Delete Button */}
<button
onClick={() => handleDelete(source.id)}
+67 -3
View File
@@ -1,12 +1,14 @@
import { useState } from 'react';
import { SourceManager } from '@/components/settings/SourceManager';
import type { VideoSource } from '@/lib/types';
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
interface SourceSettingsProps {
sources: VideoSource[];
onSourcesChange: (sources: VideoSource[]) => void;
onRestoreDefaults: () => void;
onAddSource: () => void;
onEditSource?: (source: VideoSource) => void;
}
export function SourceSettings({
@@ -14,8 +16,46 @@ export function SourceSettings({
onSourcesChange,
onRestoreDefaults,
onAddSource,
onEditSource,
}: SourceSettingsProps) {
const [showAllSources, setShowAllSources] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const filteredSources = sources.filter(source =>
source.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
source.baseUrl.toLowerCase().includes(searchQuery.toLowerCase())
);
const displayedSources = showAllSources || searchQuery
? filteredSources
: filteredSources.slice(0, 10);
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 handleReorder = (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="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
@@ -39,11 +79,35 @@ export function SourceSettings({
<p className="text-sm text-[var(--text-color-secondary)] mb-6">
</p>
{/* Search Bar */}
<div className="relative mb-4">
<input
type="text"
placeholder="搜索源..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 pl-10 rounded-[var(--radius-xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] placeholder-[var(--text-color-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-color)] transition-all duration-200"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-color-secondary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<SourceManager
sources={showAllSources ? sources : sources.slice(0, 10)}
onSourcesChange={onSourcesChange}
sources={displayedSources}
onToggle={handleToggle}
onDelete={handleDelete}
onReorder={handleReorder}
onEdit={onEditSource}
defaultIds={DEFAULT_SOURCES.map(s => s.id)}
/>
{sources.length > 10 && (
{!searchQuery && 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"
+26 -12
View File
@@ -12,20 +12,26 @@ interface UseAddSourceFormProps {
existingIds: string[];
onAdd: (source: VideoSource) => void;
onClose: () => void;
initialValues?: VideoSource | null;
}
export function useAddSourceForm({ isOpen, existingIds, onAdd, onClose }: UseAddSourceFormProps) {
export function useAddSourceForm({ isOpen, existingIds, onAdd, onClose, initialValues }: UseAddSourceFormProps) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
setName('');
setUrl('');
if (initialValues) {
setName(initialValues.name);
setUrl(initialValues.baseUrl);
} else {
setName('');
setUrl('');
}
setError('');
}
}, [isOpen]);
}, [isOpen, initialValues]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -43,20 +49,28 @@ export function useAddSourceForm({ isOpen, existingIds, onAdd, onClose }: UseAdd
return;
}
const id = name.toLowerCase().replace(/[^a-z0-9]/g, '-');
if (existingIds.includes(id)) {
setError('此源名称已存在');
return;
let id = initialValues?.id;
// Only generate new ID if not editing or if name changed (optional, maybe keep ID stable?)
// For now, let's keep ID stable if editing, unless we want to allow re-generating ID.
// But if we re-generate ID, we lose history/preferences for that ID.
// So better to keep ID if editing.
if (!id) {
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,
searchPath: initialValues?.searchPath || '',
detailPath: initialValues?.detailPath || '',
enabled: initialValues?.enabled ?? true,
priority: initialValues?.priority || existingIds.length + 1,
};
onAdd(newSource);
+1 -1
View File
@@ -14,7 +14,7 @@ interface ParallelSearchResult {
completedSources: number;
totalSources: number;
totalVideosFound: number;
performSearch: (query: string, sortBy?: SortOption) => Promise<void>;
performSearch: (query: string, sources?: any[], sortBy?: SortOption) => Promise<void>;
resetSearch: () => void;
loadCachedResults: (results: Video[], sources: any[]) => void;
applySorting: (sortBy: SortOption) => void;
+2 -2
View File
@@ -28,7 +28,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
const abortControllerRef = useRef<AbortController | null>(null);
const performSearch = useCallback(async (searchQuery: string, sortBy: SortOption = 'default') => {
const performSearch = useCallback(async (searchQuery: string, sources: any[] = [], sortBy: SortOption = 'default') => {
if (!searchQuery.trim()) return;
// Abort any ongoing search
@@ -47,7 +47,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
const response = await fetch('/api/search-parallel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: searchQuery, sources: SOURCE_IDS }),
body: JSON.stringify({ query: searchQuery, sources: sources }),
signal: abortControllerRef.current.signal,
});