diff --git a/app/favorites/page.tsx b/app/favorites/page.tsx new file mode 100644 index 0000000..2402e97 --- /dev/null +++ b/app/favorites/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Suspense } from 'react'; +import { + FavoritesPageContent, + FavoritesPageFallback, +} from '@/components/favorites/FavoritesPageContent'; + +export default function Favorites() { + return ( + }> + + + ); +} diff --git a/app/premium/favorites/page.tsx b/app/premium/favorites/page.tsx new file mode 100644 index 0000000..758acbf --- /dev/null +++ b/app/premium/favorites/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Suspense } from 'react'; +import { + FavoritesPageContent, + FavoritesPageFallback, +} from '@/components/favorites/FavoritesPageContent'; + +export default function PremiumFavorites() { + return ( + }> + + + ); +} diff --git a/components/favorites/FavoritesGrid.tsx b/components/favorites/FavoritesGrid.tsx new file mode 100644 index 0000000..6ca3ec9 --- /dev/null +++ b/components/favorites/FavoritesGrid.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react'; +import { VideoCard } from '@/components/search/VideoCard'; +import { FavoritesEmptyState } from './FavoritesEmptyState'; +import type { FavoriteItem, Video } from '@/lib/types'; + +interface FavoritesGridProps { + favorites: FavoriteItem[]; + isPremium?: boolean; +} + +export const FavoritesGrid = memo(function FavoritesGrid({ + favorites, + isPremium = false +}: FavoritesGridProps) { + const [activeCardId, setActiveCardId] = useState(null); + const [visibleCount, setVisibleCount] = useState(24); + const loadMoreRef = useRef(null); + const observerRef = useRef(null); + + // Convert FavoriteItem to Video format + const videos: Video[] = useMemo( + () => + favorites.map((favorite) => ({ + vod_id: favorite.videoId, + vod_name: favorite.title, + vod_pic: favorite.poster, + vod_remarks: favorite.remarks, + vod_year: favorite.year, + type_name: favorite.type, + source: favorite.source, + sourceName: favorite.sourceName, + })), + [favorites] + ); + + // Setup intersection observer for infinite scroll + useEffect(() => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + + observerRef.current = new IntersectionObserver( + (entries) => { + const first = entries[0]; + if (first.isIntersecting && visibleCount < videos.length) { + setVisibleCount((prev) => Math.min(prev + 24, videos.length)); + } + }, + { threshold: 0.1 } + ); + + if (loadMoreRef.current) { + observerRef.current.observe(loadMoreRef.current); + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [visibleCount, videos.length]); + + const handleCardClick = useCallback(( + e: React.MouseEvent, + cardId: string, + videoUrl: string + ) => { + setActiveCardId(cardId); + }, []); + + if (videos.length === 0) { + return ; + } + + return ( + <> + + {videos.slice(0, visibleCount).map((video) => { + const cardId = `${video.source}:${video.vod_id}`; + const videoUrl = `/player?id=${video.vod_id}&source=${video.source}&title=${encodeURIComponent(video.vod_name)}${isPremium ? '&premium=1' : ''}`; + const isActive = activeCardId === cardId; + + return ( + + ); + })} + + + {/* Load more trigger */} + {visibleCount < videos.length && ( + + )} + > + ); +}); diff --git a/components/favorites/FavoritesPageContent.tsx b/components/favorites/FavoritesPageContent.tsx new file mode 100644 index 0000000..33fcbe0 --- /dev/null +++ b/components/favorites/FavoritesPageContent.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Navbar } from '@/components/layout/Navbar'; +import { FavoritesPageHeader } from '@/components/favorites/FavoritesPageHeader'; +import { FavoritesGrid } from '@/components/favorites/FavoritesGrid'; +import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; +import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { useFavorites } from '@/lib/store/favorites-store'; + +interface FavoritesPageContentProps { + isPremium?: boolean; +} + +export function FavoritesPageContent({ + isPremium = false, +}: FavoritesPageContentProps) { + const router = useRouter(); + const { favorites, clearFavorites } = useFavorites(isPremium); + const [sortBy, setSortBy] = useState<'date' | 'title'>('date'); + const [isClearDialogOpen, setIsClearDialogOpen] = useState(false); + + const sortedFavorites = useMemo(() => { + if (sortBy === 'title') { + return [...favorites].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')); + } + + return favorites; + }, [favorites, sortBy]); + + return ( + + router.push(isPremium ? '/premium' : '/')} + isPremiumMode={isPremium} + /> + + + setIsClearDialogOpen(true)} + /> + + + + + + + + { + clearFavorites(); + setIsClearDialogOpen(false); + }} + onCancel={() => setIsClearDialogOpen(false)} + dangerous + /> + + ); +} + +interface FavoritesPageFallbackProps { + isPremium?: boolean; +} + +export function FavoritesPageFallback({ + isPremium = false, +}: FavoritesPageFallbackProps) { + return ( + + + + ); +} diff --git a/components/favorites/FavoritesPageHeader.tsx b/components/favorites/FavoritesPageHeader.tsx new file mode 100644 index 0000000..0d039d1 --- /dev/null +++ b/components/favorites/FavoritesPageHeader.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { Icons } from '@/components/ui/Icon'; + +interface FavoritesPageHeaderProps { + count: number; + sortBy: 'date' | 'title'; + onSortChange: (sort: 'date' | 'title') => void; + onClearAll: () => void; +} + +export function FavoritesPageHeader({ + count, + sortBy, + onSortChange, + onClearAll +}: FavoritesPageHeaderProps) { + const router = useRouter(); + + return ( + + router.back()} + className="inline-flex items-center gap-2 text-[var(--accent-color)] hover:underline mb-4 cursor-pointer" + > + + + + 返回上一页 + + + + + + + + 我的收藏 + + 共 {count} 个视频 + + + + + + {/* Sort buttons */} + + onSortChange('date')} + className={`px-3 py-1.5 rounded-[var(--radius-full)] text-sm transition-all ${ + sortBy === 'date' + ? 'bg-[var(--accent-color)] text-white' + : 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]' + }`} + > + 最新添加 + + onSortChange('title')} + className={`px-3 py-1.5 rounded-[var(--radius-full)] text-sm transition-all ${ + sortBy === 'title' + ? 'bg-[var(--accent-color)] text-white' + : 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]' + }`} + > + 标题排序 + + + + {/* Clear all button */} + {count > 0 && ( + + + 清空收藏 + + )} + + + + ); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 0fbb83d..7c119a8 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -17,6 +17,7 @@ interface NavbarProps { export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { const settingsHref = isPremiumMode ? '/premium/settings' : '/settings'; + const favoritesHref = isPremiumMode ? '/premium/favorites' : '/favorites'; const [session] = useState(() => getSession()); const { iptvEnabled } = useRuntimeFeatures(); @@ -104,6 +105,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { > + + +
+ 共 {count} 个视频 +