From c4886421773d18e50786a36a8e1a8363f5ab593e Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 28 Nov 2025 20:25:33 +0800 Subject: [PATCH] feat: implement drag-and-drop tag reordering and introduce a secret page for adult content search. --- README.md | 11 ++ app/globals.css | 11 +- app/secret/page.tsx | 81 ++++++++++ components/home/PopularFeatures.tsx | 10 +- components/home/TagManager.tsx | 195 ++++++++++++++++++++----- components/home/hooks/useTagManager.ts | 13 ++ lib/api/adult-sources.ts | 67 +++++++++ lib/hooks/useSecretHomePage.ts | 83 +++++++++++ package-lock.json | 56 +++++++ package.json | 3 + 10 files changed, 494 insertions(+), 36 deletions(-) create mode 100644 app/secret/page.tsx create mode 100644 lib/api/adult-sources.ts create mode 100644 lib/hooks/useSecretHomePage.ts diff --git a/README.md b/README.md index d219352..78f863d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ - **语义化 HTML**:使用语义化标签提升可访问性 - **高对比度**:确保 4.5:1 的文字对比度 +## 🔒 隐藏模式 + +本项目包含一个隐藏的"成人模式",仅通过特定操作激活: + +1. 在首页点击"管理标签" +2. 在输入框中输入 **"色情"** 并添加 +3. 点击新添加的 **"色情"** 标签 +4. 系统将自动跳转至隐藏模式页面 + +> **注意**:隐藏模式下的内容源与主页完全隔离,互不干扰。 + ## 🛠 技术栈 ### 前端核心 diff --git a/app/globals.css b/app/globals.css index ad547f4..f338ec5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,4 +6,13 @@ @import './styles/effects.css'; @import './styles/glass.css'; @import "./styles/video-player.css"; -@import "./styles/search-history.css"; \ No newline at end of file +@import "./styles/search-history.css"; +@keyframes jiggle { + 0% { transform: rotate(-1deg); } + 50% { transform: rotate(1deg); } + 100% { transform: rotate(-1deg); } +} + +.animate-jiggle { + animation: jiggle 0.2s infinite; +} diff --git a/app/secret/page.tsx b/app/secret/page.tsx new file mode 100644 index 0000000..dbadeff --- /dev/null +++ b/app/secret/page.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Suspense } from 'react'; +import { SearchForm } from '@/components/search/SearchForm'; +import { NoResults } from '@/components/search/NoResults'; +import { Navbar } from '@/components/layout/Navbar'; +import { SearchResults } from '@/components/home/SearchResults'; +import { useSecretHomePage } from '@/lib/hooks/useSecretHomePage'; + +function SecretHomePage() { + const { + query, + hasSearched, + loading, + results, + availableSources, + completedSources, + totalSources, + handleSearch, + handleReset, + } = useSecretHomePage(); + + return ( +
+ {/* Glass Navbar */} + + + {/* Search Form - Separate from navbar */} +
+ +
+ + {/* Main Content */} +
+ {/* Results Section */} + {(results.length >= 1 || (!loading && results.length > 0)) && ( + + )} + + {/* No Results */} + {!loading && hasSearched && results.length === 0 && ( + + )} + + {/* Empty State - Just show a dark placeholder or nothing for secret mode */} + {!loading && !hasSearched && ( +
+

Secret Mode Active

+
+ )} +
+
+ ); +} + +export default function SecretPage() { + return ( + +
+ + }> + +
+ ); +} diff --git a/components/home/PopularFeatures.tsx b/components/home/PopularFeatures.tsx index e9b77f1..00ceb60 100644 --- a/components/home/PopularFeatures.tsx +++ b/components/home/PopularFeatures.tsx @@ -26,6 +26,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) { handleAddTag, handleDeleteTag, handleRestoreDefaults, + handleDragEnd, } = useTagManager(); const { @@ -49,12 +50,19 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) { selectedTag={selectedTag} showTagManager={showTagManager} newTagInput={newTagInput} - onTagSelect={setSelectedTag} + onTagSelect={(tagId) => { + if (tagId === 'custom_色情' || tags.find(t => t.id === tagId)?.label === '色情') { + window.location.href = '/secret'; + return; + } + setSelectedTag(tagId); + }} onTagDelete={handleDeleteTag} onToggleManager={() => setShowTagManager(!showTagManager)} onRestoreDefaults={handleRestoreDefaults} onNewTagInputChange={setNewTagInput} onAddTag={handleAddTag} + onDragEnd={handleDragEnd} /> void; onNewTagInputChange: (value: string) => void; onAddTag: () => void; + onDragEnd: (event: DragEndEvent) => void; +} + +function SortableTag({ + tag, + selectedTag, + showTagManager, + onTagSelect, + onTagDelete, +}: { + tag: Tag; + selectedTag: string; + showTagManager: boolean; + onTagSelect: (id: string) => void; + onTagDelete: (id: string) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tag.id, disabled: !showTagManager }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : 1, + opacity: isDragging ? 0.3 : 1, + }; + + return ( +
+
+ + {showTagManager && ( + + )} +
+
+ ); } export function TagManager({ @@ -37,8 +118,46 @@ export function TagManager({ onRestoreDefaults, onNewTagInputChange, onAddTag, + onDragEnd, }: TagManagerProps) { - const isCustomTag = (tagId: string) => tagId.startsWith('custom_'); + const scrollContainerRef = useRef(null); + const prevTagsLength = useRef(tags.length); + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Auto-scroll to end when new tag is added + useEffect(() => { + if (tags.length > prevTagsLength.current) { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: scrollContainerRef.current.scrollWidth, + behavior: 'smooth', + }); + } + } + prevTagsLength.current = tags.length; + }, [tags.length]); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null); + onDragEnd(event); + }; + + const activeTag = tags.find((t) => t.id === activeId); return ( <> @@ -83,35 +202,43 @@ export function TagManager({ )} {/* Tag Filter */} -
- {tags.map((tag) => ( -
- - {showTagManager && isCustomTag(tag.id) && ( - - )} -
- ))} -
+ + ) : null} + + ); } diff --git a/components/home/hooks/useTagManager.ts b/components/home/hooks/useTagManager.ts index 2b5803b..751fdbc 100644 --- a/components/home/hooks/useTagManager.ts +++ b/components/home/hooks/useTagManager.ts @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import { DragEndEvent } from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; const DEFAULT_TAGS = [ { id: 'popular', label: '热门', value: '热门' }, @@ -69,6 +71,16 @@ export function useTagManager() { setShowTagManager(false); }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = tags.findIndex((tag) => tag.id === active.id); + const newIndex = tags.findIndex((tag) => tag.id === over.id); + saveTags(arrayMove(tags, oldIndex, newIndex)); + } + }; + return { tags, selectedTag, @@ -80,5 +92,6 @@ export function useTagManager() { handleAddTag, handleDeleteTag, handleRestoreDefaults, + handleDragEnd, }; } diff --git a/lib/api/adult-sources.ts b/lib/api/adult-sources.ts new file mode 100644 index 0000000..8d2386b --- /dev/null +++ b/lib/api/adult-sources.ts @@ -0,0 +1,67 @@ +import type { VideoSource } from '@/lib/types'; + +export const ADULT_SOURCES: VideoSource[] = [ + { + id: 'ck', + name: 'CK', + baseUrl: 'https://www.ckzy1.com/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 1 + }, + { + id: 'jkun', + name: 'jkun', + baseUrl: 'https://jkunzyapi.com/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 2 + }, + { + id: 'souav', + name: 'souav', + baseUrl: 'https://api.souavzy.vip/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 3 + }, + { + id: '155', + name: '155', + baseUrl: 'https://155api.com/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 4 + }, + { + id: 'lsb', + name: 'lsb', + baseUrl: 'https://apilsbzy1.com/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 5 + }, + { + id: 'hsck', + name: '黄色仓库', + baseUrl: 'https://hsckzy.vip/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 6 + }, + { + id: 'yutu', + name: '玉兔', + baseUrl: 'https://yutuzy10.com/api.php/provide/vod', + searchPath: '', + detailPath: '', + enabled: true, + priority: 7 + } +]; diff --git a/lib/hooks/useSecretHomePage.ts b/lib/hooks/useSecretHomePage.ts new file mode 100644 index 0000000..339647e --- /dev/null +++ b/lib/hooks/useSecretHomePage.ts @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect } 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'; + +export function useSecretHomePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { loadFromCache, saveToCache } = useSearchCache(); + const hasLoadedCache = useRef(false); + + const [query, setQuery] = useState(''); + const [hasSearched, setHasSearched] = useState(false); + const [currentSortBy, setCurrentSortBy] = useState('default'); + + // Search stream hook + const { + loading, + results, + availableSources, + completedSources, + totalSources, + performSearch, + resetSearch, + loadCachedResults, + applySorting, + } = useParallelSearch( + saveToCache, + (q: string) => router.replace(`/secret?q=${encodeURIComponent(q)}`, { scroll: false }) + ); + + // Re-sort results when sort preference changes + useEffect(() => { + if (hasSearched && results.length > 0) { + applySorting(currentSortBy as any); + } + }, [currentSortBy, applySorting, hasSearched, results.length]); + + // Load cached results on mount + useEffect(() => { + if (hasLoadedCache.current) return; + hasLoadedCache.current = true; + + const urlQuery = searchParams.get('q'); + // Note: We might want to separate cache for secret mode, but for now sharing or not using cache might be safer. + // However, useSearchCache uses localStorage which is shared. + // If we want to avoid leaking secret searches to normal history, we might want to disable cache or use a different key. + // For simplicity and "hidden" nature, maybe we don't load cache from normal mode? + // But the user asked for "same as original page". + + if (urlQuery) { + setQuery(urlQuery); + handleSearch(urlQuery); + } + }, [searchParams]); + + const handleSearch = (searchQuery: string) => { + setQuery(searchQuery); + setHasSearched(true); + // Always use ADULT_SOURCES + performSearch(searchQuery, ADULT_SOURCES, currentSortBy as any); + }; + + const handleReset = () => { + setHasSearched(false); + setQuery(''); + resetSearch(); + router.replace('/secret', { scroll: false }); + }; + + return { + query, + hasSearched, + loading, + results, + availableSources, + completedSources, + totalSources, + handleSearch, + handleReset, + }; +} diff --git a/package-lock.json b/package-lock.json index a2272d2..6bf01e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "kvideo", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.5.0", "next": "16.0.3", "react": "19.2.0", @@ -279,6 +282,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", diff --git a/package.json b/package.json index dd61d42..4fcf25b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.5.0", "next": "16.0.3", "react": "19.2.0",