mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 18:44:44 +08:00
feat: implement favorites
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/* eslint-disable react-hooks/exhaustive-deps, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import { generateStorageKey, getAllPlayRecords } from '@/lib/db.client';
|
||||
import {
|
||||
generateStorageKey,
|
||||
getAllPlayRecords,
|
||||
isFavorited,
|
||||
toggleFavorite,
|
||||
} from '@/lib/db.client';
|
||||
import { VideoDetail } from '@/lib/video';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
@@ -18,6 +24,7 @@ function DetailPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playRecord, setPlayRecord] = useState<PlayRecord | null>(null);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
// 当接口缺失标题时,使用 URL 中的 title 参数作为后备
|
||||
const fallbackTitle = searchParams.get('title') || '';
|
||||
@@ -64,6 +71,14 @@ function DetailPageClient() {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(source, id);
|
||||
setPlayRecord(allRecords[key] || null);
|
||||
|
||||
// 检查收藏状态
|
||||
try {
|
||||
const fav = await isFavorited(source, id);
|
||||
setFavorited(fav);
|
||||
} catch (checkErr) {
|
||||
console.error('检查收藏状态失败:', checkErr);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取详情失败');
|
||||
} finally {
|
||||
@@ -74,6 +89,26 @@ function DetailPageClient() {
|
||||
fetchData();
|
||||
}, [searchParams]);
|
||||
|
||||
// 切换收藏状态
|
||||
const handleToggleFavorite = async () => {
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
if (!source || !id || !detail) return;
|
||||
|
||||
try {
|
||||
const newState = await toggleFavorite(source, id, {
|
||||
title: detail.videoInfo.title,
|
||||
source_name: detail.videoInfo.source_name,
|
||||
cover: detail.videoInfo.cover || '',
|
||||
total_episodes: detail.episodes?.length || 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(newState);
|
||||
} catch (err) {
|
||||
console.error('切换收藏失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/detail'>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
@@ -214,21 +249,20 @@ function DetailPageClient() {
|
||||
</>
|
||||
)}
|
||||
{/* 爱心按钮 */}
|
||||
<button className='flex items-center justify-center w-10 h-10 bg-pink-400 hover:bg-pink-500 rounded-full transition-colors'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-5 w-5 text-white'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
|
||||
/>
|
||||
</svg>
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-colors ${
|
||||
favorited
|
||||
? 'bg-gray-300 hover:bg-gray-400'
|
||||
: 'bg-gray-400 hover:bg-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 stroke-[2] ${
|
||||
favorited ? 'text-red-500' : 'text-white'
|
||||
}`}
|
||||
fill={favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* 播放记录进度条 */}
|
||||
|
||||
200
src/app/page.tsx
200
src/app/page.tsx
@@ -11,12 +11,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
// 客户端收藏 API
|
||||
import { getAllFavorites } from '@/lib/db.client';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import CollectionCard from '@/components/CollectionCard';
|
||||
import ContinueWatching from '@/components/ContinueWatching';
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
interface DoubanItem {
|
||||
title: string;
|
||||
@@ -58,6 +62,18 @@ function HomeClient() {
|
||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 收藏夹数据
|
||||
type FavoriteItem = {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: number;
|
||||
source_name: string;
|
||||
};
|
||||
|
||||
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDoubanData = async () => {
|
||||
try {
|
||||
@@ -86,6 +102,32 @@ function HomeClient() {
|
||||
fetchDoubanData();
|
||||
}, []);
|
||||
|
||||
// 当切换到收藏夹时加载收藏数据
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'favorites') return;
|
||||
|
||||
(async () => {
|
||||
const all = await getAllFavorites();
|
||||
// 根据保存时间排序(从近到远)
|
||||
const sorted = Object.entries(all)
|
||||
.sort(([, a], [, b]) => b.save_time - a.save_time)
|
||||
.map(([key, fav]) => {
|
||||
const plusIndex = key.indexOf('+');
|
||||
const source = key.slice(0, plusIndex);
|
||||
const id = key.slice(plusIndex + 1);
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
title: fav.title,
|
||||
poster: fav.cover,
|
||||
episodes: fav.total_episodes,
|
||||
source_name: fav.source_name,
|
||||
} as FavoriteItem;
|
||||
});
|
||||
setFavoriteItems(sorted);
|
||||
})();
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className='px-10 py-8'>
|
||||
@@ -102,76 +144,100 @@ function HomeClient() {
|
||||
</div>
|
||||
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 推荐 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
推荐
|
||||
</h2>
|
||||
<ScrollableRow scrollDistance={800}>
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.title} className='min-w-[280px] w-72'>
|
||||
<CollectionCard
|
||||
title={collection.title}
|
||||
icon={collection.icon}
|
||||
href={collection.href}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 继续观看 */}
|
||||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
热门电影
|
||||
</h2>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotMovies.map((movie, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<DemoCard title={movie.title} poster={movie.poster} />
|
||||
{activeTab === 'favorites' ? (
|
||||
// 收藏夹视图
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
我的收藏
|
||||
</h2>
|
||||
<div className='flex flex-wrap gap-x-8 gap-y-20 px-4'>
|
||||
{favoriteItems.map((item) => (
|
||||
<div key={item.id + item.source} className='w-44'>
|
||||
<VideoCard {...item} from='favorites' />
|
||||
</div>
|
||||
))}
|
||||
{favoriteItems.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8'>
|
||||
暂无收藏内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
// 首页视图
|
||||
<>
|
||||
{/* 推荐 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
推荐
|
||||
</h2>
|
||||
<ScrollableRow scrollDistance={800}>
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.title} className='min-w-[280px] w-72'>
|
||||
<CollectionCard
|
||||
title={collection.title}
|
||||
icon={collection.icon}
|
||||
href={collection.href}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门剧集 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotTvShows.map((show, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<DemoCard title={show.title} poster={show.poster} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
{/* 继续观看 */}
|
||||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
热门电影
|
||||
</h2>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotMovies.map((movie, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<DemoCard title={movie.title} poster={movie.poster} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门剧集 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotTvShows.map((show, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<DemoCard title={show.title} poster={show.poster} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
@@ -11,7 +12,9 @@ import {
|
||||
deletePlayRecord,
|
||||
generateStorageKey,
|
||||
getAllPlayRecords,
|
||||
isFavorited,
|
||||
savePlayRecord,
|
||||
toggleFavorite,
|
||||
} from '@/lib/db.client';
|
||||
import { VideoDetail } from '@/lib/video';
|
||||
|
||||
@@ -83,6 +86,9 @@ function PlayPageClient() {
|
||||
// 总集数:从 detail 中获取,保证随 detail 更新而变化
|
||||
const totalEpisodes = detail?.episodes?.length || 0;
|
||||
|
||||
// 收藏状态
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||
const resumeTimeRef = useRef<number | null>(null);
|
||||
|
||||
@@ -973,6 +979,37 @@ function PlayPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// 每当 source 或 id 变化时检查收藏状态
|
||||
useEffect(() => {
|
||||
if (!currentSource || !currentId) return;
|
||||
(async () => {
|
||||
try {
|
||||
const fav = await isFavorited(currentSource, currentId);
|
||||
setFavorited(fav);
|
||||
} catch (err) {
|
||||
console.error('检查收藏状态失败:', err);
|
||||
}
|
||||
})();
|
||||
}, [currentSource, currentId]);
|
||||
|
||||
// 切换收藏
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!currentSource || !currentId) return;
|
||||
|
||||
try {
|
||||
const newState = await toggleFavorite(currentSource, currentId, {
|
||||
title: videoTitle,
|
||||
source_name: detail?.videoInfo.source_name || '',
|
||||
cover: videoCover || '',
|
||||
total_episodes: totalEpisodes || 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(newState);
|
||||
} catch (err) {
|
||||
console.error('切换收藏失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='min-h-screen bg-black flex items-center justify-center'>
|
||||
@@ -1067,8 +1104,24 @@ function PlayPageClient() {
|
||||
|
||||
{/* 中央标题及集数信息 */}
|
||||
<div className='text-center'>
|
||||
<div className='text-white font-semibold text-lg truncate max-w-xs mx-auto'>
|
||||
{videoTitle}
|
||||
<div className='flex items-center justify-center gap-2 max-w-xs mx-auto'>
|
||||
<span className='text-white font-semibold text-lg truncate'>
|
||||
{videoTitle}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite();
|
||||
}}
|
||||
className='flex-shrink-0'
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 stroke-[2] ${
|
||||
favorited ? 'text-red-500' : 'text-gray-300'
|
||||
}`}
|
||||
fill={favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{totalEpisodes > 1 && (
|
||||
|
||||
@@ -125,7 +125,7 @@ function SearchPageClient() {
|
||||
</div>
|
||||
) : showResults ? (
|
||||
// 搜索结果
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
<div className='flex flex-wrap gap-x-8 gap-y-20 px-4'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-44'>
|
||||
<VideoCard {...item} from='search' />
|
||||
|
||||
@@ -66,7 +66,7 @@ const DemoCard = ({ title, poster }: DemoCardProps) => {
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden'>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
@@ -90,7 +90,7 @@ const DemoCard = ({ title, poster }: DemoCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
{/* 信息层 */}
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center'>
|
||||
{title}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Heart } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { deletePlayRecord } from '@/lib/db.client';
|
||||
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client';
|
||||
|
||||
interface VideoCardProps {
|
||||
id: string;
|
||||
@@ -84,9 +84,45 @@ export default function VideoCard({
|
||||
onDelete,
|
||||
}: VideoCardProps) {
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
const [deleted, setDeleted] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// 检查初始收藏状态
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const fav = await isFavorited(source, id);
|
||||
setFavorited(fav);
|
||||
} catch (err) {
|
||||
/* eslint-disable no-console */
|
||||
console.error('检查收藏状态失败:', err);
|
||||
}
|
||||
})();
|
||||
// 仅在组件挂载或 source/id 变化时运行
|
||||
}, [source, id]);
|
||||
|
||||
// 切换收藏状态
|
||||
const handleToggleFavorite = async (
|
||||
e: React.MouseEvent<HTMLSpanElement | SVGElement, MouseEvent>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
const newState = await toggleFavorite(source, id, {
|
||||
title,
|
||||
source_name,
|
||||
cover: poster,
|
||||
total_episodes: episodes ?? 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(newState);
|
||||
} catch (err) {
|
||||
/* eslint-disable no-console */
|
||||
console.error('切换收藏失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除对应播放记录
|
||||
const handleDeleteRecord = async (
|
||||
e: React.MouseEvent<HTMLSpanElement | SVGElement, MouseEvent>
|
||||
@@ -99,16 +135,16 @@ export default function VideoCard({
|
||||
|
||||
// 通知父组件更新
|
||||
onDelete?.();
|
||||
|
||||
// 若父组件未处理,可本地隐藏
|
||||
setDeleted(true);
|
||||
} catch (err) {
|
||||
/* eslint-disable no-console */
|
||||
console.error('删除播放记录失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return deleted ? null : (
|
||||
const inFavorites = from === 'favorites';
|
||||
const hideCheckCircle = inFavorites;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/detail?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
title
|
||||
@@ -116,14 +152,14 @@ export default function VideoCard({
|
||||
>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden'>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
|
||||
<Image src={poster} alt={title} fill className='object-cover' />
|
||||
|
||||
{/* Hover 效果 */}
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group pointer-events-none'>
|
||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
className={`transition-all duration-200 pointer-events-auto ${
|
||||
playHover ? 'scale-110' : ''
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
@@ -143,21 +179,37 @@ export default function VideoCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
|
||||
<span
|
||||
onClick={handleDeleteRecord}
|
||||
title='标记已看'
|
||||
className='inline-flex items-center justify-center'
|
||||
>
|
||||
<CheckCircleCustom />
|
||||
</span>
|
||||
<span className='inline-flex items-center justify-center'>
|
||||
<Heart className='h-6 w-6 text-white/90 stroke-[2]' />
|
||||
</span>
|
||||
{!hideCheckCircle && (
|
||||
<span
|
||||
onClick={handleDeleteRecord}
|
||||
title='标记已看'
|
||||
className='inline-flex items-center justify-center pointer-events-auto'
|
||||
>
|
||||
<CheckCircleCustom />
|
||||
</span>
|
||||
)}
|
||||
{favorited && (
|
||||
<span className='inline-flex w-6 h-6 pointer-events-none' />
|
||||
)}
|
||||
{!favorited && (
|
||||
<span
|
||||
onClick={handleToggleFavorite}
|
||||
title={favorited ? '移除收藏' : '加入收藏'}
|
||||
className='inline-flex items-center justify-center pointer-events-auto'
|
||||
>
|
||||
<Heart
|
||||
className={`h-6 w-6 stroke-[2] ${
|
||||
favorited ? 'text-red-500' : 'text-white/90'
|
||||
}`}
|
||||
fill={favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 集数指示器 - 绿色小圆球 */}
|
||||
{episodes && (
|
||||
{episodes && episodes > 1 && (
|
||||
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>{episodes}</span>
|
||||
</div>
|
||||
@@ -174,7 +226,7 @@ export default function VideoCard({
|
||||
)}
|
||||
|
||||
{/* 当前播放集数 */}
|
||||
{currentEpisode && (
|
||||
{currentEpisode && episodes && episodes > 1 && (
|
||||
<div className='absolute top-2 left-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>
|
||||
{currentEpisode}
|
||||
@@ -184,7 +236,7 @@ export default function VideoCard({
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center'>
|
||||
{title}
|
||||
@@ -198,6 +250,24 @@ export default function VideoCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 收藏夹始终显示红心 */}
|
||||
{favorited && (
|
||||
<div className='absolute bottom-4 right-4 flex items-center'>
|
||||
<span
|
||||
onClick={handleToggleFavorite}
|
||||
title={favorited ? '移除收藏' : '加入收藏'}
|
||||
className='inline-flex items-center justify-center'
|
||||
>
|
||||
<Heart
|
||||
className={`h-6 w-6 stroke-[2] ${
|
||||
favorited ? 'text-red-500' : 'text-white/90'
|
||||
}`}
|
||||
fill={favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -263,3 +263,177 @@ export async function clearSearchHistory(): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
}
|
||||
|
||||
// ---------------- 收藏相关 API ----------------
|
||||
|
||||
// 收藏数据结构
|
||||
export interface Favorite {
|
||||
title: string;
|
||||
source_name: string;
|
||||
cover: string;
|
||||
total_episodes: number;
|
||||
save_time: number;
|
||||
user_id: number; // 本地存储情况下恒为 0
|
||||
}
|
||||
|
||||
// 收藏在 localStorage 中使用的 key
|
||||
const FAVORITES_KEY = 'moontv_favorites';
|
||||
|
||||
/**
|
||||
* 获取全部收藏
|
||||
*/
|
||||
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||
// 数据库模式
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
return fetchFromApi<Record<string, Favorite>>('/api/favorites');
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, Favorite>;
|
||||
} catch (err) {
|
||||
console.error('读取收藏失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存收藏
|
||||
*/
|
||||
export async function saveFavorite(
|
||||
source: string,
|
||||
id: string,
|
||||
favorite: Omit<Favorite, 'user_id'>
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
const fullFavorite: Favorite = { ...favorite, user_id: 0 };
|
||||
|
||||
// 数据库模式
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
try {
|
||||
const res = await fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, favorite: fullFavorite }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error('保存收藏到数据库失败:', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端保存收藏到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFavorites = await getAllFavorites();
|
||||
allFavorites[key] = fullFavorite;
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
||||
} catch (err) {
|
||||
console.error('保存收藏失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收藏
|
||||
*/
|
||||
export async function deleteFavorite(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库模式
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
try {
|
||||
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error('删除收藏到数据库失败:', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端删除收藏到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFavorites = await getAllFavorites();
|
||||
delete allFavorites[key];
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
||||
} catch (err) {
|
||||
console.error('删除收藏失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已收藏
|
||||
*/
|
||||
export async function isFavorited(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库模式
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
try {
|
||||
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return !!data;
|
||||
} catch (err) {
|
||||
console.error('检查收藏状态失败:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
const allFavorites = await getAllFavorites();
|
||||
return !!allFavorites[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
* 返回切换后的状态(true = 已收藏)
|
||||
*/
|
||||
export async function toggleFavorite(
|
||||
source: string,
|
||||
id: string,
|
||||
favoriteData?: Omit<Favorite, 'user_id'>
|
||||
): Promise<boolean> {
|
||||
const already = await isFavorited(source, id);
|
||||
|
||||
if (already) {
|
||||
await deleteFavorite(source, id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!favoriteData) {
|
||||
throw new Error('收藏数据缺失');
|
||||
}
|
||||
|
||||
await saveFavorite(source, id, favoriteData);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Favorite {
|
||||
title: string;
|
||||
cover: string;
|
||||
user_id: number; // 用户ID,localStorage情况下全部为0
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
|
||||
Reference in New Issue
Block a user