feat: implement drag-and-drop tag reordering and introduce a secret page for adult content search.

This commit is contained in:
kuekhaoyang
2025-11-28 20:25:33 +08:00
parent 1ff6487629
commit c488642177
10 changed files with 494 additions and 36 deletions
+11
View File
@@ -74,6 +74,17 @@
- **语义化 HTML**:使用语义化标签提升可访问性
- **高对比度**:确保 4.5:1 的文字对比度
## 🔒 隐藏模式
本项目包含一个隐藏的"成人模式",仅通过特定操作激活:
1. 在首页点击"管理标签"
2. 在输入框中输入 **"色情"** 并添加
3. 点击新添加的 **"色情"** 标签
4. 系统将自动跳转至隐藏模式页面
> **注意**:隐藏模式下的内容源与主页完全隔离,互不干扰。
## 🛠 技术栈
### 前端核心
+10 -1
View File
@@ -6,4 +6,13 @@
@import './styles/effects.css';
@import './styles/glass.css';
@import "./styles/video-player.css";
@import "./styles/search-history.css";
@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;
}
+81
View File
@@ -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 (
<div className="min-h-screen bg-black">
{/* Glass Navbar */}
<Navbar onReset={handleReset} />
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
transform: 'translate3d(0, 0, 0)',
zIndex: 1000
}}>
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
isLoading={loading}
initialQuery={query}
currentSource=""
checkedSources={completedSources}
totalSources={totalSources}
/>
</div>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Results Section */}
{(results.length >= 1 || (!loading && results.length > 0)) && (
<SearchResults
results={results}
availableSources={availableSources}
loading={loading}
/>
)}
{/* No Results */}
{!loading && hasSearched && results.length === 0 && (
<NoResults onReset={handleReset} />
)}
{/* Empty State - Just show a dark placeholder or nothing for secret mode */}
{!loading && !hasSearched && (
<div className="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)]">
<p>Secret Mode Active</p>
</div>
)}
</main>
</div>
);
}
export default function SecretPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<SecretHomePage />
</Suspense>
);
}
+9 -1
View File
@@ -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}
/>
<MovieGrid
+161 -34
View File
@@ -1,11 +1,25 @@
/**
* TagManager - Tag management UI component
* Handles custom tag creation, deletion, and filtering
*/
'use client';
import { useEffect, useRef, useState } from 'react';
import { Icons } from '@/components/ui/Icon';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface Tag {
id: string;
@@ -24,6 +38,73 @@ interface TagManagerProps {
onRestoreDefaults: () => 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 (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="relative flex-shrink-0"
>
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
<button
onClick={() => !showTagManager && onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
}
`}
>
{tag.label}
</button>
{showTagManager && (
<button
onClick={(e) => {
e.stopPropagation();
onTagDelete(tag.id);
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors rounded-[var(--radius-full)] cursor-pointer z-20 shadow-sm"
>
<Icons.X size={14} />
</button>
)}
</div>
</div>
);
}
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<HTMLDivElement>(null);
const prevTagsLength = useRef(tags.length);
const [activeId, setActiveId] = useState<string | null>(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 */}
<div className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide">
{tags.map((tag) => (
<div key={tag.id} className="relative flex-shrink-0">
<button
onClick={() => onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
}
`}
>
{tag.label}
</button>
{showTagManager && isCustomTag(tag.id) && (
<button
onClick={(e) => {
e.stopPropagation();
onTagDelete(tag.id);
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors rounded-[var(--radius-full)] cursor-pointer"
>
<Icons.X size={14} />
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
ref={scrollContainerRef}
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
>
<SortableContext
items={tags.map(t => t.id)}
strategy={horizontalListSortingStrategy}
>
{tags.map((tag) => (
<SortableTag
key={tag.id}
tag={tag}
selectedTag={selectedTag}
showTagManager={showTagManager}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
/>
))}
</SortableContext>
</div>
<DragOverlay>
{activeId && activeTag ? (
<div className="relative flex-shrink-0 animate-jiggle">
<button className="px-6 py-2.5 text-sm font-semibold whitespace-nowrap rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white shadow-xl scale-110 cursor-grabbing border border-transparent">
{activeTag.label}
</button>
)}
</div>
))}
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
</>
);
}
+13
View File
@@ -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,
};
}
+67
View File
@@ -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
}
];
+83
View File
@@ -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,
};
}
+56
View File
@@ -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",
+3
View File
@@ -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",