feat: Implement customizable adult sources for secret mode with a dedicated settings page and improve settings persistence logic.

This commit is contained in:
kuekhaoyang
2025-12-24 15:50:13 +08:00
parent f847342cb5
commit 968133d6f9
8 changed files with 300 additions and 8 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ function SecretHomePage() {
return (
<div className="min-h-screen bg-black">
{/* Glass Navbar */}
<Navbar onReset={handleReset} />
<Navbar onReset={handleReset} isSecretMode={true} />
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { settingsStore, getDefaultAdultSources, type SortOption } from '@/lib/store/settings-store';
import type { VideoSource } from '@/lib/types';
export function useSecretSettingsPage() {
const [adultSources, setAdultSources] = useState<VideoSource[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('default');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
useEffect(() => {
const settings = settingsStore.getSettings();
setAdultSources(settings.adultSources || []);
setSortBy(settings.sortBy);
}, []);
const handleSourcesChange = (newSources: VideoSource[]) => {
setAdultSources(newSources);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
adultSources: newSources,
});
};
const handleAddSource = (source: VideoSource) => {
const exists = adultSources.some(s => s.id === source.id);
const updated = exists
? adultSources.map(s => s.id === source.id ? source : s)
: [...adultSources, source];
handleSourcesChange(updated);
setEditingSource(null);
};
const handleEditSource = (source: VideoSource) => {
setEditingSource(source);
setIsAddModalOpen(true);
};
const handleRestoreDefaults = () => {
const defaults = getDefaultAdultSources();
handleSourcesChange(defaults);
setIsRestoreDefaultsDialogOpen(false);
};
return {
adultSources,
sortBy,
isAddModalOpen,
isRestoreDefaultsDialogOpen,
setIsAddModalOpen,
setIsRestoreDefaultsDialogOpen,
setEditingSource,
handleSourcesChange,
handleAddSource,
handleRestoreDefaults,
editingSource,
handleEditSource,
};
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { AddSourceModal } from '@/components/settings/AddSourceModal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { AdultSourceSettings } from '@/components/settings/AdultSourceSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader';
import { useSecretSettingsPage } from './hooks/useSecretSettingsPage';
import Link from 'next/link';
export default function SecretSettingsPage() {
const {
adultSources,
isAddModalOpen,
isRestoreDefaultsDialogOpen,
setIsAddModalOpen,
setIsRestoreDefaultsDialogOpen,
handleSourcesChange,
handleAddSource,
handleRestoreDefaults,
editingSource,
handleEditSource,
setEditingSource,
} = useSecretSettingsPage();
return (
<div className="min-h-screen bg-black">
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
{/* Custom Header for Secret Settings */}
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/secret"
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 cursor-pointer"
aria-label="返回"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-[var(--text-color)]"></h1>
<p className="text-sm text-[var(--text-color-secondary)]"></p>
</div>
</div>
</div>
</div>
{/* Adult Source Management */}
<AdultSourceSettings
sources={adultSources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => {
setEditingSource(null);
setIsAddModalOpen(true);
}}
onEditSource={handleEditSource}
/>
</div>
{/* Modals */}
<AddSourceModal
isOpen={isAddModalOpen}
onClose={() => {
setIsAddModalOpen(false);
setEditingSource(null);
}}
onAdd={handleAddSource}
existingIds={adultSources.map(s => s.id)}
initialValues={editingSource}
/>
<ConfirmDialog
isOpen={isRestoreDefaultsDialogOpen}
title="恢复默认成人源"
message="这将重置所有成人源为默认配置。自定义源将被删除。是否继续?"
confirmText="恢复"
cancelText="取消"
onConfirm={handleRestoreDefaults}
onCancel={() => setIsRestoreDefaultsDialogOpen(false)}
/>
</div>
);
}
+10
View File
@@ -32,7 +32,9 @@ export function useSettingsPage() {
const handleSourcesChange = (newSources: VideoSource[]) => {
setSources(newSources);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources: newSources,
sortBy,
searchHistory: true,
@@ -58,7 +60,9 @@ export function useSettingsPage() {
const handleSortChange = (newSort: SortOption) => {
setSortBy(newSort);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy: newSort,
searchHistory: true,
@@ -70,7 +74,9 @@ export function useSettingsPage() {
const handlePasswordToggle = (enabled: boolean) => {
setPasswordAccess(enabled);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy,
searchHistory: true,
@@ -83,7 +89,9 @@ export function useSettingsPage() {
const handleAddPassword = (password: string) => {
const updated = [...accessPasswords, password];
setAccessPasswords(updated);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy,
searchHistory: true,
@@ -96,7 +104,9 @@ export function useSettingsPage() {
const handleRemovePassword = (password: string) => {
const updated = accessPasswords.filter(p => p !== password);
setAccessPasswords(updated);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy,
searchHistory: true,
+6 -3
View File
@@ -5,9 +5,12 @@ import { Icons } from '@/components/ui/Icon';
interface NavbarProps {
onReset: () => void;
isSecretMode?: boolean;
}
export function Navbar({ onReset }: NavbarProps) {
export function Navbar({ onReset, isSecretMode = false }: NavbarProps) {
const settingsHref = isSecretMode ? '/secret/settings' : '/settings';
return (
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
@@ -19,7 +22,7 @@ export function Navbar({ onReset }: NavbarProps) {
}}>
<div className="flex items-center justify-between gap-2 sm:gap-4">
<Link
href="/"
href={isSecretMode ? '/secret' : '/'}
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
onClick={onReset}
>
@@ -49,7 +52,7 @@ export function Navbar({ onReset }: NavbarProps) {
<Icons.Github size={20} />
</a>
<Link
href="/settings"
href={settingsHref}
className="w-8 h-8 sm:w-10 sm: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 cursor-pointer"
aria-label="设置"
>
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { SourceManager } from '@/components/settings/SourceManager';
import type { VideoSource } from '@/lib/types';
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
interface AdultSourceSettingsProps {
sources: VideoSource[];
onSourcesChange: (sources: VideoSource[]) => void;
onRestoreDefaults: () => void;
onAddSource: () => void;
onEditSource?: (source: VideoSource) => void;
}
export function AdultSourceSettings({
sources,
onSourcesChange,
onRestoreDefaults,
onAddSource,
onEditSource,
}: AdultSourceSettingsProps) {
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">
<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={onRestoreDefaults}
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 cursor-pointer"
>
</button>
<button
onClick={onAddSource}
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 cursor-pointer"
>
+
</button>
</div>
</div>
<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-2xl)] 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={displayedSources}
onToggle={handleToggle}
onDelete={handleDelete}
onReorder={handleReorder}
onEdit={onEditSource}
defaultIds={ADULT_SOURCES.map(s => s.id)}
/>
{!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 cursor-pointer"
>
{showAllSources ? '收起' : `显示全部 (${sources.length})`}
</button>
)}
</div>
);
}
+10 -4
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchCache } from '@/lib/hooks/useSearchCache';
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
import { settingsStore } from '@/lib/store/settings-store';
export function useSecretHomePage() {
const router = useRouter();
@@ -14,6 +14,12 @@ export function useSecretHomePage() {
const [hasSearched, setHasSearched] = useState(false);
const [currentSortBy, setCurrentSortBy] = useState('default');
// Get adult sources from settings store (supports user customization)
const enabledAdultSources = useMemo(() => {
const settings = settingsStore.getSettings();
return settings.adultSources.filter(s => s.enabled);
}, []);
// Search stream hook
const {
loading,
@@ -58,8 +64,8 @@ export function useSecretHomePage() {
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
setHasSearched(true);
// Always use ADULT_SOURCES
performSearch(searchQuery, ADULT_SOURCES, currentSortBy as any);
// Use enabled adult sources from settings
performSearch(searchQuery, enabledAdultSources, currentSortBy as any);
};
const handleReset = () => {
+7
View File
@@ -4,6 +4,7 @@
import type { VideoSource } from '@/lib/types';
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
export type SortOption =
| 'default'
@@ -17,6 +18,7 @@ export type SortOption =
export interface AppSettings {
sources: VideoSource[];
adultSources: VideoSource[];
sortBy: SortOption;
searchHistory: boolean;
watchHistory: boolean;
@@ -29,12 +31,14 @@ import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY }
const SETTINGS_KEY = 'kvideo-settings';
export const getDefaultSources = (): VideoSource[] => DEFAULT_SOURCES;
export const getDefaultAdultSources = (): VideoSource[] => ADULT_SOURCES;
export const settingsStore = {
getSettings(): AppSettings {
if (typeof window === 'undefined') {
return {
sources: getDefaultSources(),
adultSources: getDefaultAdultSources(),
sortBy: 'default',
searchHistory: true,
watchHistory: true,
@@ -47,6 +51,7 @@ export const settingsStore = {
if (!stored) {
return {
sources: getDefaultSources(),
adultSources: getDefaultAdultSources(),
sortBy: 'default',
searchHistory: true,
watchHistory: true,
@@ -60,6 +65,7 @@ export const settingsStore = {
// Validate that parsed data has all required properties
return {
sources: Array.isArray(parsed.sources) ? parsed.sources : getDefaultSources(),
adultSources: Array.isArray(parsed.adultSources) ? parsed.adultSources : getDefaultAdultSources(),
sortBy: parsed.sortBy || 'default',
searchHistory: parsed.searchHistory !== undefined ? parsed.searchHistory : true,
watchHistory: parsed.watchHistory !== undefined ? parsed.watchHistory : true,
@@ -69,6 +75,7 @@ export const settingsStore = {
} catch {
return {
sources: getDefaultSources(),
adultSources: getDefaultAdultSources(),
sortBy: 'default',
searchHistory: true,
watchHistory: true,