diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 3ba5cce..8002a82 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -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(null); const [playRecord, setPlayRecord] = useState(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 (
@@ -214,21 +249,20 @@ function DetailPageClient() { )} {/* 爱心按钮 */} -
{/* 播放记录进度条 */} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9b3e410..74dca04 100644 --- a/src/app/page.tsx +++ b/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([]); const [loading, setLoading] = useState(true); + // 收藏夹数据 + type FavoriteItem = { + id: string; + source: string; + title: string; + poster: string; + episodes: number; + source_name: string; + }; + + const [favoriteItems, setFavoriteItems] = useState([]); + 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 (
@@ -102,76 +144,100 @@ function HomeClient() {
- {/* 推荐 */} -
-

- 推荐 -

- - {collections.map((collection) => ( -
- -
- ))} -
-
- - {/* 继续观看 */} - - - {/* 热门电影 */} -
-

- 热门电影 -

- - {loading - ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
- )) - : // 显示真实数据 - hotMovies.map((movie, index) => ( -
- + {activeTab === 'favorites' ? ( + // 收藏夹视图 +
+

+ 我的收藏 +

+
+ {favoriteItems.map((item) => ( +
+ +
+ ))} + {favoriteItems.length === 0 && ( +
+ 暂无收藏内容 +
+ )} +
+
+ ) : ( + // 首页视图 + <> + {/* 推荐 */} +
+

+ 推荐 +

+ + {collections.map((collection) => ( +
+
))} -
-
+ +
- {/* 热门剧集 */} -
-

- 热门剧集 -

- - {loading - ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
- )) - : // 显示真实数据 - hotTvShows.map((show, index) => ( -
- -
- ))} -
-
+ {/* 继续观看 */} + + + {/* 热门电影 */} +
+

+ 热门电影 +

+ + {loading + ? // 加载状态显示灰色占位数据 + Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+ )) + : // 显示真实数据 + hotMovies.map((movie, index) => ( +
+ +
+ ))} +
+
+ + {/* 热门剧集 */} +
+

+ 热门剧集 +

+ + {loading + ? // 加载状态显示灰色占位数据 + Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+ )) + : // 显示真实数据 + hotTvShows.map((show, index) => ( +
+ +
+ ))} +
+
+ + )}
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c393aea..30f6fbf 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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(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 (
@@ -1067,8 +1104,24 @@ function PlayPageClient() { {/* 中央标题及集数信息 */}
-
- {videoTitle} +
+ + {videoTitle} + +
{totalEpisodes > 1 && ( diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 962a089..d2015ee 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -125,7 +125,7 @@ function SearchPageClient() {
) : showResults ? ( // 搜索结果 -
+
{searchResults.map((item) => (
diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index 5ac8e5e..0cc0df4 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -66,7 +66,7 @@ const DemoCard = ({ title, poster }: DemoCardProps) => { onClick={handleClick} > {/* 海报图片 - 2:3 比例 */} -
+
{title} {
{/* 信息层 */} -
+
{title} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index cca58a0..57c18fe 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -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 + ) => { + 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 @@ -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 (
{/* 海报图片 - 2:3 比例 */} -
+
{title} {/* Hover 效果 */} -
-
+
+
- - - - - - + {!hideCheckCircle && ( + + + + )} + {favorited && ( + + )} + {!favorited && ( + + + + )}
{/* 集数指示器 - 绿色小圆球 */} - {episodes && ( + {episodes && episodes > 1 && (
{episodes}
@@ -174,7 +226,7 @@ export default function VideoCard({ )} {/* 当前播放集数 */} - {currentEpisode && ( + {currentEpisode && episodes && episodes > 1 && (
{currentEpisode} @@ -184,7 +236,7 @@ export default function VideoCard({
{/* 信息层 */} -
+
{title} @@ -198,6 +250,24 @@ export default function VideoCard({ )}
+ + {/* 收藏夹始终显示红心 */} + {favorited && ( +
+ + + +
+ )}
); diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 5534e73..306809a 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -263,3 +263,177 @@ export async function clearSearchHistory(): Promise { 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> { + // 数据库模式 + if (STORAGE_TYPE === 'database') { + return fetchFromApi>('/api/favorites'); + } + + // localStorage 模式 + if (typeof window === 'undefined') { + return {}; + } + + try { + const raw = localStorage.getItem(FAVORITES_KEY); + if (!raw) return {}; + return JSON.parse(raw) as Record; + } catch (err) { + console.error('读取收藏失败:', err); + return {}; + } +} + +/** + * 保存收藏 + */ +export async function saveFavorite( + source: string, + id: string, + favorite: Omit +): Promise { + 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 { + 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 { + 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 +): Promise { + 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; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index dcf69bb..2fd789e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -22,6 +22,7 @@ export interface Favorite { title: string; cover: string; user_id: number; // 用户ID,localStorage情况下全部为0 + save_time: number; // 记录保存时间(时间戳) } // 存储接口