From da36da655c71ebf201e024597c3aa2ec3b8017df Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sat, 29 Nov 2025 22:39:45 +0800 Subject: [PATCH] feat: Introduce adult content browsing with dynamic tag management, aggregated categories, and a video content grid. --- app/api/adult/category/route.ts | 117 ++++++++++++++++++++ app/api/adult/types/route.ts | 152 ++++++++++++++++++++++++++ app/secret/page.tsx | 10 +- components/adult/AdultContent.tsx | 97 ++++++++++++++++ components/adult/AdultContentGrid.tsx | 112 +++++++++++++++++++ lib/constants/adult-tags.ts | 7 ++ lib/hooks/useAdultContent.ts | 69 ++++++++++++ lib/hooks/useAdultTagManager.ts | 145 ++++++++++++++++++++++++ 8 files changed, 702 insertions(+), 7 deletions(-) create mode 100644 app/api/adult/category/route.ts create mode 100644 app/api/adult/types/route.ts create mode 100644 components/adult/AdultContent.tsx create mode 100644 components/adult/AdultContentGrid.tsx create mode 100644 lib/constants/adult-tags.ts create mode 100644 lib/hooks/useAdultContent.ts create mode 100644 lib/hooks/useAdultTagManager.ts diff --git a/app/api/adult/category/route.ts b/app/api/adult/category/route.ts new file mode 100644 index 0000000..20eea4f --- /dev/null +++ b/app/api/adult/category/route.ts @@ -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(); // 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 } + ); + } +} diff --git a/app/api/adult/types/route.ts b/app/api/adult/types/route.ts new file mode 100644 index 0000000..67fdcf7 --- /dev/null +++ b/app/api/adult/types/route.ts @@ -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 } + ); + } +} diff --git a/app/secret/page.tsx b/app/secret/page.tsx index 903cf7a..a848d24 100644 --- a/app/secret/page.tsx +++ b/app/secret/page.tsx @@ -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() { )} - {/* Empty State - Adult content zone */} + {/* Adult Content - Trending and Latest */} {!loading && !hasSearched && ( -
-

18+ 专区

-

- 此区域的搜索记录不会显示在首页历史 -

-
+ )} diff --git a/components/adult/AdultContent.tsx b/components/adult/AdultContent.tsx new file mode 100644 index 0000000..4314fb0 --- /dev/null +++ b/components/adult/AdultContent.tsx @@ -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 ( +
+
+
+
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ { + setSelectedTag(tagId); + }} + onTagDelete={handleDeleteTag} + onToggleManager={() => setShowTagManager(!showTagManager)} + onRestoreDefaults={handleRestoreDefaults} + onNewTagInputChange={setNewTagInput} + onAddTag={handleAddTag} + onDragEnd={handleDragEnd} + onJustAddedTagHandled={() => setJustAddedTag(false)} + /> + +
+ ); +} diff --git a/components/adult/AdultContentGrid.tsx b/components/adult/AdultContentGrid.tsx new file mode 100644 index 0000000..4200437 --- /dev/null +++ b/components/adult/AdultContentGrid.tsx @@ -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; + loadMoreRef: React.RefObject; +} + +export function AdultContentGrid({ + videos, + loading, + hasMore, + onVideoClick, + prefetchRef, + loadMoreRef, +}: AdultContentGridProps) { + return ( +
+
+ {videos.map((video) => ( +
onVideoClick?.(video)} + style={{ + contain: 'layout style paint', + contentVisibility: 'auto' + }} + > + +
+ {video.vod_pic ? ( + {video.vod_name} + ) : ( +
+ 无封面 +
+ )} + {video.vod_remarks && ( +
+ + {video.vod_remarks} + +
+ )} +
+
+

+ {video.vod_name} +

+ {video.type_name && ( +

+ {video.type_name} +

+ )} +
+
+
+ ))} +
+ + {/* Prefetch Trigger */} + {hasMore && !loading &&
} + + {loading && ( +
+
+
+ )} + + {/* Intersection Observer Target */} + {hasMore && !loading &&
} + + {!loading && !hasMore && videos.length > 0 && ( +

+ 没有更多内容了 +

+ )} + + {!loading && videos.length === 0 && ( +

+ 暂无内容 +

+ )} +
+ ); +} diff --git a/lib/constants/adult-tags.ts b/lib/constants/adult-tags.ts new file mode 100644 index 0000000..c95324f --- /dev/null +++ b/lib/constants/adult-tags.ts @@ -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'; diff --git a/lib/hooks/useAdultContent.ts b/lib/hooks/useAdultContent.ts new file mode 100644 index 0000000..d9dab78 --- /dev/null +++ b/lib/hooks/useAdultContent.ts @@ -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([]); + 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, + }; +} diff --git a/lib/hooks/useAdultTagManager.ts b/lib/hooks/useAdultTagManager.ts new file mode 100644 index 0000000..be39236 --- /dev/null +++ b/lib/hooks/useAdultTagManager.ts @@ -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([]); + 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(); + if (Array.isArray(data.tags)) { + data.tags.forEach((t: Tag) => apiTagMap.set(t.id, t)); + } + + const mergedTags: Tag[] = []; + const processedIds = new Set(); + + // 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, + }; +}