From ddcce8341a0cb5eb60c18d31b6937631f5aafff4 Mon Sep 17 00:00:00 2001 From: LamLarryyyy Date: Mon, 6 Apr 2026 17:20:32 +0800 Subject: [PATCH 1/2] 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 && ( + - }> - + }> + ); } diff --git a/app/premium/favorites/page.tsx b/app/premium/favorites/page.tsx index 212f74d..758acbf 100644 --- a/app/premium/favorites/page.tsx +++ b/app/premium/favorites/page.tsx @@ -1,73 +1,15 @@ '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 - /> -
- ); -} +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 index 46e7037..6ca3ec9 100644 --- a/components/favorites/FavoritesGrid.tsx +++ b/components/favorites/FavoritesGrid.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useCallback, useEffect, memo } from 'react'; +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'; @@ -20,16 +20,20 @@ export const FavoritesGrid = memo(function FavoritesGrid({ 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, - })); + 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(() => { 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 index b4fd4fa..0d039d1 100644 --- a/components/favorites/FavoritesPageHeader.tsx +++ b/components/favorites/FavoritesPageHeader.tsx @@ -73,7 +73,7 @@ export function FavoritesPageHeader({ onClick={onClearAll} className="px-4 py-2 rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all text-sm flex items-center gap-2" > - + 清空收藏 )} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 5d6a982..7c119a8 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -109,6 +109,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { href={favoritesHref} className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer" aria-label="我的收藏" + data-focusable >