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 <[email protected]>
This commit is contained in:
LamLarryyyy
2026-04-07 14:37:16 +08:00
co-authored by Claude Sonnet 4.6
parent 38fe8e00fb
commit ddcce8341a
5 changed files with 343 additions and 0 deletions
+73
View File
@@ -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 (
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
<Navbar onReset={() => router.push('/')} />
<main className="max-w-7xl mx-auto px-4 py-8 space-y-8">
<FavoritesPageHeader
count={favorites.length}
sortBy={sortBy}
onSortChange={setSortBy}
onClearAll={() => setIsClearDialogOpen(true)}
/>
<FavoritesGrid favorites={sortedFavorites} />
</main>
<FavoritesSidebar />
<WatchHistorySidebar />
<ConfirmDialog
isOpen={isClearDialogOpen}
title="清空收藏"
message="确定要清空所有收藏吗?此操作不可撤销。"
confirmText="清空"
cancelText="取消"
onConfirm={() => {
clearFavorites();
setIsClearDialogOpen(false);
}}
onCancel={() => setIsClearDialogOpen(false)}
dangerous
/>
</div>
);
}
export default function Favorites() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<FavoritesPage />
</Suspense>
);
}
+73
View File
@@ -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 (
<div className="min-h-screen bg-black">
<Navbar onReset={() => router.push('/premium')} isPremiumMode />
<main className="max-w-7xl mx-auto px-4 py-8 space-y-8">
<FavoritesPageHeader
count={favorites.length}
sortBy={sortBy}
onSortChange={setSortBy}
onClearAll={() => setIsClearDialogOpen(true)}
/>
<FavoritesGrid favorites={sortedFavorites} isPremium />
</main>
<FavoritesSidebar isPremium />
<WatchHistorySidebar isPremium />
<ConfirmDialog
isOpen={isClearDialogOpen}
title="清空收藏"
message="确定要清空所有收藏吗?此操作不可撤销。"
confirmText="清空"
cancelText="取消"
onConfirm={() => {
clearFavorites();
setIsClearDialogOpen(false);
}}
onCancel={() => setIsClearDialogOpen(false)}
dangerous
/>
</div>
);
}
export default function PremiumFavorites() {
return (
<Suspense fallback={
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<PremiumFavoritesPage />
</Suspense>
);
}
+105
View File
@@ -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<string | null>(null);
const [visibleCount, setVisibleCount] = useState(24);
const loadMoreRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(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 <FavoritesEmptyState />;
}
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-6">
{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 (
<VideoCard
key={cardId}
video={video}
videoUrl={videoUrl}
cardId={cardId}
isActive={isActive}
onCardClick={handleCardClick}
isPremium={isPremium}
/>
);
})}
</div>
{/* Load more trigger */}
{visibleCount < videos.length && (
<div
ref={loadMoreRef}
className="h-20 w-full flex items-center justify-center opacity-0 pointer-events-none"
aria-hidden="true"
/>
)}
</>
);
});
@@ -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 (
<div>
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-[var(--accent-color)] hover:underline mb-4 cursor-pointer"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<div className="w-12 h-12 flex items-center justify-center rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)]">
<Icons.Heart size={24} className="text-[var(--text-color)]" />
</div>
<div>
<h1 className="text-3xl font-bold text-[var(--text-color)]"></h1>
<p className="text-[var(--text-color-secondary)]">
{count}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Sort buttons */}
<div className="flex items-center gap-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] p-1">
<button
onClick={() => 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)]'
}`}
>
</button>
<button
onClick={() => 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)]'
}`}
>
</button>
</div>
{/* Clear all button */}
{count > 0 && (
<button
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"
>
<Icons.Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
);
}
+8
View File
@@ -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<AuthSession | null>(() => getSession());
const { iptvEnabled } = useRuntimeFeatures();
@@ -104,6 +105,13 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
>
<Icons.Github size={20} />
</a>
<Link
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="我的收藏"
>
<Icons.Heart size={20} />
</Link>
<Link
href={settingsHref}
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"