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
+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,
};
}