mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-13 15:53:44 +08:00
Merge pull request #145 from LamLarryyyy/feat/favorites-page
feat: add dedicated My Favourites page with grid view
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user