From ddcce8341a0cb5eb60c18d31b6937631f5aafff4 Mon Sep 17 00:00:00 2001 From: LamLarryyyy Date: Mon, 6 Apr 2026 17:20:32 +0800 Subject: [PATCH] feat: add dedicated My Favourites page with grid view Add a full-page favourites view accessible via heart icon in the navbar. Supports sorting by date/title, clear all, infinite scroll pagination, and separate pages for normal (/favorites) and premium (/premium/favorites) modes. Co-Authored-By: Claude Sonnet 4.6 --- app/favorites/page.tsx | 73 +++++++++++++ app/premium/favorites/page.tsx | 73 +++++++++++++ components/favorites/FavoritesGrid.tsx | 105 +++++++++++++++++++ components/favorites/FavoritesPageHeader.tsx | 84 +++++++++++++++ components/layout/Navbar.tsx | 8 ++ 5 files changed, 343 insertions(+) create mode 100644 app/favorites/page.tsx create mode 100644 app/premium/favorites/page.tsx create mode 100644 components/favorites/FavoritesGrid.tsx create mode 100644 components/favorites/FavoritesPageHeader.tsx diff --git a/app/favorites/page.tsx b/app/favorites/page.tsx new file mode 100644 index 0000000..b335e5d --- /dev/null +++ b/app/favorites/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Suspense, useState, useMemo } 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 { useFavoritesStore } from '@/lib/store/favorites-store'; + +function FavoritesPage() { + const router = useRouter(); + const { favorites, clearFavorites } = useFavoritesStore(); + 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; // already newest-first from store + }, [favorites, sortBy]); + + return ( +
+ router.push('/')} /> + +
+ setIsClearDialogOpen(true)} + /> + + +
+ + + + + { + clearFavorites(); + setIsClearDialogOpen(false); + }} + onCancel={() => setIsClearDialogOpen(false)} + dangerous + /> +
+ ); +} + +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..212f74d --- /dev/null +++ b/app/premium/favorites/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Suspense, useState, useMemo } 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 { usePremiumFavoritesStore } from '@/lib/store/favorites-store'; + +function PremiumFavoritesPage() { + const router = useRouter(); + const { favorites, clearFavorites } = usePremiumFavoritesStore(); + 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('/premium')} isPremiumMode /> + +
+ setIsClearDialogOpen(true)} + /> + + +
+ + + + + { + clearFavorites(); + setIsClearDialogOpen(false); + }} + onCancel={() => setIsClearDialogOpen(false)} + dangerous + /> +
+ ); +} + +export default function PremiumFavorites() { + return ( + +
+ + }> + +
+ ); +} diff --git a/components/favorites/FavoritesGrid.tsx b/components/favorites/FavoritesGrid.tsx new file mode 100644 index 0000000..46e7037 --- /dev/null +++ b/components/favorites/FavoritesGrid.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect, 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[] = 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, + })); + + // 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 && ( +