feat: Introduce adult content browsing with dynamic tag management, aggregated categories, and a video content grid.

This commit is contained in:
kuekhaoyang
2025-11-29 22:39:45 +08:00
parent 7489ff6887
commit da36da655c
8 changed files with 702 additions and 7 deletions
+117
View File
@@ -0,0 +1,117 @@
import { NextResponse } from 'next/server';
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const categoryParam = searchParams.get('category') || ''; // Format: "sourceId:typeId" or just "typeId" or empty
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
try {
// Parse category parameter which might contain multiple sources
// Format: "source1:id1,source2:id2" or just "typeId" (legacy)
const sourceMap = new Map<string, string>(); // sourceId -> typeId
if (categoryParam) {
const parts = categoryParam.split(',');
parts.forEach(part => {
if (part.includes(':')) {
const [sId, tId] = part.split(':');
sourceMap.set(sId, tId);
} else {
// Legacy format: just typeId, assume first enabled source
const firstSource = ADULT_SOURCES.find(s => s.enabled);
if (firstSource) {
sourceMap.set(firstSource.id, part);
}
}
});
}
// Determine which sources to fetch
// If specific sources requested via category, use those
// Otherwise (e.g. "Recommend"), use all enabled sources
let targetSources = [];
if (sourceMap.size > 0) {
targetSources = ADULT_SOURCES.filter(s => sourceMap.has(s.id) && s.enabled);
} else {
targetSources = ADULT_SOURCES.filter(s => s.enabled);
}
if (targetSources.length === 0) {
return NextResponse.json({ videos: [], error: 'No enabled sources' }, { status: 500 });
}
// Fetch from all target sources concurrently
const fetchPromises = targetSources.map(async (source) => {
try {
const url = new URL(source.baseUrl);
url.searchParams.set('ac', 'detail');
url.searchParams.set('pg', page.toString());
// Set category parameter if specific type requested for this source
if (sourceMap.has(source.id)) {
url.searchParams.set('t', sourceMap.get(source.id)!);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s timeout
const response = await fetch(url.toString(), {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
next: { revalidate: 1800 }, // Cache for 30 minutes
});
clearTimeout(timeoutId);
if (!response.ok) return [];
const data = await response.json();
return (data.list || []).map((item: any) => ({
vod_id: item.vod_id,
vod_name: item.vod_name,
vod_pic: item.vod_pic,
vod_remarks: item.vod_remarks,
type_name: item.type_name,
source: source.id,
}));
} catch (error) {
console.error(`Failed to fetch from ${source.name}:`, error);
return [];
}
});
const results = await Promise.all(fetchPromises);
// Interleave results: [A1, B1, C1, A2, B2, C2, ...]
const interleavedVideos = [];
const maxLen = Math.max(...results.map(r => r.length));
for (let i = 0; i < maxLen; i++) {
for (let j = 0; j < results.length; j++) {
if (results[j][i]) {
interleavedVideos.push(results[j][i]);
}
}
}
// Apply limit after interleaving
// Note: Since we fetch 'limit' from EACH source, the total could be huge
// We should slice the final result. However, 'limit' param usually means per page.
// If we want consistent pagination, we should return all fetched items (limit * sources)
// But to respect the client's requested limit roughly, we can slice.
// Actually, for "load more" to work properly with multiple sources,
// we should probably return everything we fetched for this "page" index.
return NextResponse.json({ videos: interleavedVideos });
} catch (error) {
console.error('Category content error:', error);
return NextResponse.json(
{ videos: [], error: 'Failed to fetch category content' },
{ status: 500 }
);
}
}
+152
View File
@@ -0,0 +1,152 @@
import { NextResponse } from 'next/server';
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
export const revalidate = 3600; // Cache for 1 hour
interface Category {
type_id: number;
type_name: string;
}
interface SourceCategories {
sourceId: string;
sourceName: string;
categories: Category[];
}
export async function GET() {
try {
const enabledSources = ADULT_SOURCES.filter(s => s.enabled);
const results = await Promise.allSettled(
enabledSources.map(async (source) => {
try {
const url = new URL(source.baseUrl);
url.searchParams.set('ac', 'list');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
const response = await fetch(url.toString(), {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
next: { revalidate: 3600 }
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return {
sourceId: source.id,
sourceName: source.name,
categories: data.class || []
};
} catch (error) {
console.error(`Failed to fetch categories from ${source.name}:`, error);
return null;
}
})
);
const allTags = [];
// Add "Recommend" tag first
allTags.push({
id: 'recommend',
label: '今日推荐',
value: ''
});
// Map to store merged categories: type_name -> Array of "sourceId:typeId"
// Using a more complex structure to support fuzzy matching
interface MergedCategory {
label: string;
values: string[];
}
const mergedCategories: MergedCategory[] = [];
// Helper to clean label for comparison (remove common noise words)
const cleanLabel = (label: string) => {
return label.replace(/[视频片区专]/g, '');
};
// Helper to check if two labels match fuzzily (>= 2 chars overlap)
const isFuzzyMatch = (label1: string, label2: string) => {
const clean1 = cleanLabel(label1);
const clean2 = cleanLabel(label2);
// If either is too short after cleaning, require exact match
if (clean1.length < 2 || clean2.length < 2) {
return clean1 === clean2;
}
// Check for 2+ char overlap
let overlapCount = 0;
const set1 = new Set(clean1.split(''));
for (const char of clean2) {
if (set1.has(char)) {
overlapCount++;
}
}
return overlapCount >= 2;
};
// Process results
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
const { sourceId, categories } = result.value;
categories.forEach((cat: Category) => {
const typeName = cat.type_name.trim();
const value = `${sourceId}:${cat.type_id}`;
// Try to find a fuzzy match in existing categories
let matched = false;
for (const existing of mergedCategories) {
if (isFuzzyMatch(existing.label, typeName)) {
existing.values.push(value);
// Update label to the longer one if the new one is longer (usually more descriptive)
// Or keep the shorter one? Let's keep the one that is "cleaner" or just the first one.
// Let's stick to the first one for stability.
matched = true;
break;
}
}
if (!matched) {
mergedCategories.push({
label: typeName,
values: [value]
});
}
});
}
});
// Convert merged categories to tags
mergedCategories.forEach((cat) => {
// Create a unique ID based on the label (using base64 to be safe)
const id = Buffer.from(cat.label).toString('base64');
allTags.push({
id,
label: cat.label,
value: cat.values.join(',') // Join multiple sources with comma
});
});
return NextResponse.json({ tags: allTags });
} catch (error) {
console.error('Failed to aggregate categories:', error);
return NextResponse.json(
{ tags: [], error: 'Failed to fetch categories' },
{ status: 500 }
);
}
}
+3 -7
View File
@@ -6,6 +6,7 @@ import { NoResults } from '@/components/search/NoResults';
import { Navbar } from '@/components/layout/Navbar';
import { SearchResults } from '@/components/home/SearchResults';
import { useSecretHomePage } from '@/lib/hooks/useSecretHomePage';
import { AdultContent } from '@/components/adult/AdultContent';
function SecretHomePage() {
const {
@@ -58,14 +59,9 @@ function SecretHomePage() {
<NoResults onReset={handleReset} />
)}
{/* Empty State - Adult content zone */}
{/* Adult Content - Trending and Latest */}
{!loading && !hasSearched && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-lg text-[var(--text-color)] mb-2">18+ </p>
<p className="text-sm text-[var(--text-color-secondary)]">
</p>
</div>
<AdultContent onSearch={handleSearch} />
)}
</main>
</div>
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { useState, useEffect } from 'react';
import { TagManager } from '@/components/home/TagManager';
import { AdultContentGrid } from './AdultContentGrid';
import { useAdultTagManager } from '@/lib/hooks/useAdultTagManager';
import { useAdultContent } from '@/lib/hooks/useAdultContent';
interface AdultContentProps {
onSearch?: (query: string) => void;
}
export function AdultContent({ onSearch }: AdultContentProps) {
const {
tags,
selectedTag,
newTagInput,
showTagManager,
justAddedTag,
setSelectedTag,
setNewTagInput,
setShowTagManager,
setJustAddedTag,
handleAddTag,
handleDeleteTag,
handleRestoreDefaults,
handleDragEnd,
loading: tagsLoading,
} = useAdultTagManager();
// Get the category value from selected tag
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
const {
videos,
loading: contentLoading,
hasMore,
prefetchRef,
loadMoreRef,
} = useAdultContent(categoryValue);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleVideoClick = (video: any) => {
if (onSearch) {
onSearch(video.vod_name);
}
};
if (!mounted || tagsLoading) {
return (
<div className="animate-fade-in">
<div className="mb-6 h-8 w-24 bg-[var(--glass-bg)] rounded animate-pulse" />
<div className="mb-8 h-10 w-full bg-[var(--glass-bg)] rounded animate-pulse" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="aspect-[2/3] bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] animate-pulse" />
))}
</div>
</div>
);
}
return (
<div className="animate-fade-in">
<TagManager
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
newTagInput={newTagInput}
justAddedTag={justAddedTag}
onTagSelect={(tagId) => {
setSelectedTag(tagId);
}}
onTagDelete={handleDeleteTag}
onToggleManager={() => setShowTagManager(!showTagManager)}
onRestoreDefaults={handleRestoreDefaults}
onNewTagInputChange={setNewTagInput}
onAddTag={handleAddTag}
onDragEnd={handleDragEnd}
onJustAddedTagHandled={() => setJustAddedTag(false)}
/>
<AdultContentGrid
videos={videos}
loading={contentLoading}
hasMore={hasMore}
onVideoClick={handleVideoClick}
prefetchRef={prefetchRef}
loadMoreRef={loadMoreRef}
/>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import React from 'react';
import { Card } from '@/components/ui/Card';
interface AdultVideo {
vod_id: string | number;
vod_name: string;
vod_pic?: string;
vod_remarks?: string;
type_name?: string;
source: string;
}
interface AdultContentGridProps {
videos: AdultVideo[];
loading: boolean;
hasMore: boolean;
onVideoClick?: (video: AdultVideo) => void;
prefetchRef: React.RefObject<HTMLDivElement | null>;
loadMoreRef: React.RefObject<HTMLDivElement | null>;
}
export function AdultContentGrid({
videos,
loading,
hasMore,
onVideoClick,
prefetchRef,
loadMoreRef,
}: AdultContentGridProps) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{videos.map((video) => (
<div
key={`${video.source}-${video.vod_id}`}
className="group cursor-pointer"
onClick={() => onVideoClick?.(video)}
style={{
contain: 'layout style paint',
contentVisibility: 'auto'
}}
>
<Card hover className="overflow-hidden p-0 h-full" blur={false}>
<div className="relative aspect-[2/3] overflow-hidden bg-[var(--glass-bg)] rounded-[var(--radius-2xl)]">
{video.vod_pic ? (
<Image
src={video.vod_pic}
alt={video.vod_name}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
className="object-cover transition-transform duration-300 group-hover:scale-105 rounded-[var(--radius-2xl)]"
loading="eager"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center text-[var(--text-color-secondary)]">
</div>
)}
{video.vod_remarks && (
<div className="absolute top-2 right-2 bg-black/80 px-2.5 py-1.5 flex items-center gap-1.5 rounded-[var(--radius-full)]">
<span className="text-xs font-bold text-white">
{video.vod_remarks}
</span>
</div>
)}
</div>
<div className="p-3">
<h3 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 group-hover:text-[var(--accent-color)] transition-colors">
{video.vod_name}
</h3>
{video.type_name && (
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
{video.type_name}
</p>
)}
</div>
</Card>
</div>
))}
</div>
{/* Prefetch Trigger */}
{hasMore && !loading && <div ref={prefetchRef} className="h-1" />}
{loading && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
)}
{/* Intersection Observer Target */}
{hasMore && !loading && <div ref={loadMoreRef} className="h-20" />}
{!loading && !hasMore && videos.length > 0 && (
<p className="text-center text-[var(--text-color-secondary)] py-8">
</p>
)}
{!loading && videos.length === 0 && (
<p className="text-center text-[var(--text-color-secondary)] py-8">
</p>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { Tag } from '@/components/home/SortableTag';
// 成人模式默认标签配置
// 现在完全由API动态获取,此处仅保留类型定义和Storage Key
export const ADULT_DEFAULT_TAGS: Tag[] = [];
export const ADULT_STORAGE_KEY = 'kvideo_adult_custom_tags';
+69
View File
@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react';
import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
interface AdultVideo {
vod_id: string | number;
vod_name: string;
vod_pic?: string;
vod_remarks?: string;
type_name?: string;
source: string;
}
const PAGE_LIMIT = 20;
export function useAdultContent(categoryValue: string) {
const [videos, setVideos] = useState<AdultVideo[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const loadVideos = useCallback(async (pageNum: number, append = false) => {
if (loading) return;
setLoading(true);
try {
const response = await fetch(
`/api/adult/category?category=${encodeURIComponent(categoryValue)}&page=${pageNum}&limit=${PAGE_LIMIT}`
);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
const newVideos = data.videos || [];
setVideos(prev => append ? [...prev, ...newVideos] : newVideos);
setHasMore(newVideos.length === PAGE_LIMIT);
} catch (error) {
console.error('Failed to load videos:', error);
setHasMore(false);
} finally {
setLoading(false);
}
}, [loading, categoryValue]);
useEffect(() => {
setPage(1);
setVideos([]);
setHasMore(true);
loadVideos(1, false);
}, [categoryValue]); // eslint-disable-line react-hooks/exhaustive-deps
const { prefetchRef, loadMoreRef } = useInfiniteScroll({
hasMore,
loading,
page,
onLoadMore: (nextPage) => {
setPage(nextPage);
loadVideos(nextPage, true);
},
});
return {
videos,
loading,
hasMore,
prefetchRef,
loadMoreRef,
};
}
+145
View File
@@ -0,0 +1,145 @@
import { useState, useEffect, useCallback } from 'react';
import { Tag } from '@/components/home/SortableTag';
import { DragEndEvent } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { ADULT_STORAGE_KEY } from '@/lib/constants/adult-tags';
export function useAdultTagManager() {
const [tags, setTags] = useState<Tag[]>([]);
const [selectedTag, setSelectedTag] = useState('recommend');
const [showTagManager, setShowTagManager] = useState(false);
const [newTagInput, setNewTagInput] = useState('');
const [justAddedTag, setJustAddedTag] = useState(false);
const [loading, setLoading] = useState(true);
// Fetch tags from API
useEffect(() => {
const fetchTags = async () => {
try {
setLoading(true);
const response = await fetch('/api/adult/types');
const data = await response.json();
if (data.tags && Array.isArray(data.tags)) {
// Load saved order from local storage
const savedTagsJson = localStorage.getItem(ADULT_STORAGE_KEY);
if (savedTagsJson) {
try {
const savedTags = JSON.parse(savedTagsJson);
// Merge API tags with saved order
// 1. Keep saved tags that still exist in API
// 2. Add new API tags to the end
const apiTagMap = new Map<string, Tag>();
if (Array.isArray(data.tags)) {
data.tags.forEach((t: Tag) => apiTagMap.set(t.id, t));
}
const mergedTags: Tag[] = [];
const processedIds = new Set<string>();
// Process saved tags
if (Array.isArray(savedTags)) {
savedTags.forEach((savedTag: Tag) => {
if (apiTagMap.has(savedTag.id)) {
mergedTags.push(apiTagMap.get(savedTag.id)!);
processedIds.add(savedTag.id);
}
});
}
// Add remaining API tags
data.tags.forEach((tag: Tag) => {
if (!processedIds.has(tag.id)) {
mergedTags.push(tag);
}
});
setTags(mergedTags);
} catch (e) {
console.error('Failed to parse saved tags', e);
setTags(data.tags);
}
} else {
setTags(data.tags);
}
}
} catch (error) {
console.error('Failed to fetch adult tags:', error);
} finally {
setLoading(false);
}
};
fetchTags();
}, []);
// Save tags to local storage whenever they change
useEffect(() => {
if (tags.length > 0 && !loading) {
localStorage.setItem(ADULT_STORAGE_KEY, JSON.stringify(tags));
}
}, [tags, loading]);
const handleAddTag = () => {
// Custom tag adding is disabled for dynamic tags mode as we fetch all available tags
// But we keep the function signature for compatibility
};
const handleDeleteTag = (tagId: string) => {
// Instead of deleting, we could hide it, but for now let's just remove it from the list
// It will reappear if local storage is cleared or if we implement a "hidden tags" feature
// For now, simple removal from current view
const newTags = tags.filter((t) => t.id !== tagId);
setTags(newTags);
if (selectedTag === tagId) {
setSelectedTag(newTags[0]?.id || '');
}
};
const handleRestoreDefaults = async () => {
setLoading(true);
localStorage.removeItem(ADULT_STORAGE_KEY);
try {
const response = await fetch('/api/adult/types');
const data = await response.json();
if (data.tags) {
setTags(data.tags);
setSelectedTag('recommend');
}
} catch (error) {
console.error('Failed to restore tags:', error);
} finally {
setLoading(false);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setTags((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
return {
tags,
selectedTag,
newTagInput,
showTagManager,
justAddedTag,
loading,
setSelectedTag,
setNewTagInput,
setShowTagManager,
setJustAddedTag,
handleAddTag,
handleDeleteTag,
handleRestoreDefaults,
handleDragEnd,
};
}