mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: Implement custom source editing and refactor source management to use full
This commit is contained in:
+10
-2
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user