Merge pull request #145 from LamLarryyyy/feat/favorites-page

feat: add dedicated My Favourites page with grid view
This commit is contained in:
Kuek Hao Yang
2026-04-07 22:08:08 +08:00
committed by GitHub
6 changed files with 325 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { Suspense } from 'react';
import {
FavoritesPageContent,
FavoritesPageFallback,
} from '@/components/favorites/FavoritesPageContent';
export default function Favorites() {
return (
<Suspense fallback={<FavoritesPageFallback />}>
<FavoritesPageContent />
</Suspense>
);
}
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { Suspense } from 'react';
import {
FavoritesPageContent,
FavoritesPageFallback,
} from '@/components/favorites/FavoritesPageContent';
export default function PremiumFavorites() {
return (
<Suspense fallback={<FavoritesPageFallback isPremium />}>
<FavoritesPageContent isPremium />
</Suspense>
);
}
+109
View File
@@ -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<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[] = 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 <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,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 (
<div
className={
isPremium
? 'min-h-screen bg-black'
: 'min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed'
}
>
<Navbar
onReset={() => router.push(isPremium ? '/premium' : '/')}
isPremiumMode={isPremium}
/>
<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={isPremium} />
</main>
<FavoritesSidebar isPremium={isPremium} />
<WatchHistorySidebar isPremium={isPremium} />
<ConfirmDialog
isOpen={isClearDialogOpen}
title="清空收藏"
message="确定要清空所有收藏吗?此操作不可撤销。"
confirmText="清空"
cancelText="取消"
onConfirm={() => {
clearFavorites();
setIsClearDialogOpen(false);
}}
onCancel={() => setIsClearDialogOpen(false)}
dangerous
/>
</div>
);
}
interface FavoritesPageFallbackProps {
isPremium?: boolean;
}
export function FavoritesPageFallback({
isPremium = false,
}: FavoritesPageFallbackProps) {
return (
<div
className={`min-h-screen flex items-center justify-center${
isPremium ? ' bg-black' : ''
}`}
>
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent" />
</div>
);
}
@@ -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.Trash size={16} />
</button>
)}
</div>
</div>
</div>
);
}
+9
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,14 @@ 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="我的收藏"
data-focusable
>
<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"