From bd3d4ad357255b5c52a75b4e094d543125b6d6ed Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:48:25 +0800 Subject: [PATCH] Add favourite buttons to library list and series detail pages (#13) Co-authored-by: Cursor Agent Co-authored-by: truewhile --- web/src/components/MediaFavouriteButton.tsx | 57 ++++++++++++++++++++ web/src/hooks/useFavourites.ts | 50 +++++++++++++++++ web/src/pages/LibraryMediaSections.tsx | 7 +-- web/src/pages/LibraryPage.tsx | 50 ++++++++++++++++- web/src/pages/LibrarySeriesDetailHeader.tsx | 36 +++++++++---- web/src/pages/LibrarySeriesDetailSection.tsx | 12 +++++ 6 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 web/src/components/MediaFavouriteButton.tsx create mode 100644 web/src/hooks/useFavourites.ts diff --git a/web/src/components/MediaFavouriteButton.tsx b/web/src/components/MediaFavouriteButton.tsx new file mode 100644 index 0000000..e9b1f3b --- /dev/null +++ b/web/src/components/MediaFavouriteButton.tsx @@ -0,0 +1,57 @@ +import { Heart } from 'lucide-react' + +type MediaFavouriteButtonProps = { + favourite: boolean + onToggle: () => void + disabled?: boolean + variant?: 'compact' | 'inline' + className?: string +} + +export function MediaFavouriteButton({ + favourite, + onToggle, + disabled = false, + variant = 'inline', + className = '', +}: MediaFavouriteButtonProps) { + if (variant === 'compact') { + return ( + + ) + } + + return ( + + ) +} diff --git a/web/src/hooks/useFavourites.ts b/web/src/hooks/useFavourites.ts new file mode 100644 index 0000000..9bfd95d --- /dev/null +++ b/web/src/hooks/useFavourites.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from 'react' +import toast from 'react-hot-toast' + +import { playbackAPI } from '../api/playback' + +export function useFavourites() { + const [favouriteIDs, setFavouriteIDs] = useState>(() => new Set()) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + playbackAPI + .listFavourites() + .then((items) => { + if (!cancelled) { + setFavouriteIDs(new Set((items ?? []).map((item) => item.id))) + } + }) + .catch(() => { + if (!cancelled) toast.error('加载收藏状态失败') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const isFavourite = useCallback((mediaID: string) => favouriteIDs.has(mediaID), [favouriteIDs]) + + const toggleFavourite = useCallback(async (mediaID: string) => { + try { + const state = await playbackAPI.toggleFavourite(mediaID) + setFavouriteIDs((current) => { + const next = new Set(current) + if (state) next.add(mediaID) + else next.delete(mediaID) + return next + }) + toast.success(state ? '已加入我的收藏' : '已取消收藏') + return state + } catch { + toast.error('收藏操作失败') + return favouriteIDs.has(mediaID) + } + }, [favouriteIDs]) + + return { isFavourite, toggleFavourite, loading } +} diff --git a/web/src/pages/LibraryMediaSections.tsx b/web/src/pages/LibraryMediaSections.tsx index 0b7b04f..fd4000b 100644 --- a/web/src/pages/LibraryMediaSections.tsx +++ b/web/src/pages/LibraryMediaSections.tsx @@ -11,7 +11,7 @@ type LibraryMediaSectionsProps = { seriesCards: SeriesCard[] selectedSeries: SeriesCard | null loading: boolean - movieActions: (media: Media) => ReactNode + cardActions: (media: Media) => ReactNode onSeriesClick: (series: SeriesCard) => void } @@ -21,7 +21,7 @@ export function LibraryMediaSections({ seriesCards, selectedSeries, loading, - movieActions, + cardActions, onSeriesClick, }: LibraryMediaSectionsProps) { return ( @@ -29,7 +29,7 @@ export function LibraryMediaSections({ {!isSeries && items.length > 0 && (
{items.map((media) => ( - + ))}
)} @@ -45,6 +45,7 @@ export function LibraryMediaSections({ key={series.key} media={series.rep} count={series.count} + actions={cardActions(series.rep)} onClick={() => onSeriesClick(series)} /> ))} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 301a099..ecadd12 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react' import { useLocation, useParams, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' @@ -14,6 +14,7 @@ import { } from '../utils/mediaSort' import { LibraryPageDialogs } from './LibraryPageDialogs' import { PageBackButton } from '../components/PageBackButton' +import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import { LibraryPageHeader } from './LibraryPageHeader' import { LibraryMediaSections } from './LibraryMediaSections' import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection' @@ -21,12 +22,17 @@ import { useLibraryData } from './useLibraryData' import { useLibraryScanStatus } from './useLibraryScanStatus' import { useLibrarySeriesSelection } from './useLibrarySeriesSelection' import { useLibraryAdminActions } from './useLibraryAdminActions' +import { usePermission } from '../hooks/usePermission' +import { useFavourites } from '../hooks/useFavourites' export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const role = useAuthStore((s) => s.user?.role) + const canFavorite = usePermission('can_favorite') + const { isFavourite, toggleFavourite } = useFavourites() + const [favouriteBusyID, setFavouriteBusyID] = useState('') const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false) const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false) @@ -169,6 +175,39 @@ export function LibraryPage() { setManualMovie, }) + const handleToggleFavourite = async (mediaID: string) => { + if (!canFavorite || favouriteBusyID) return + setFavouriteBusyID(mediaID) + try { + await toggleFavourite(mediaID) + } finally { + setFavouriteBusyID('') + } + } + + const cardActions = (media: Media): ReactNode => { + const actions: ReactNode[] = [] + if (canFavorite) { + actions.push( + { + void handleToggleFavourite(media.id) + }} + />, + ) + } + const adminActions = movieActions(media) + if (adminActions) { + actions.push({adminActions}) + } + if (actions.length === 0) return undefined + return <>{actions} + } + if (loading) { return (
@@ -216,7 +255,7 @@ export function LibraryPage() { seriesCards={sortedSeriesCards} selectedSeries={selectedSeries} loading={loading} - movieActions={movieActions} + cardActions={cardActions} onSeriesClick={handleSeriesClick} /> @@ -229,6 +268,13 @@ export function LibraryPage() { loadingEpisodes={loadingSeriesEpisodes} playbackFrom={`${location.pathname}${location.search}`} isAdmin={role === 'admin'} + canFavorite={canFavorite} + favourite={selectedSeries ? isFavourite(selectedSeries.rep.id) : false} + favouriteBusy={!!selectedSeries && favouriteBusyID === selectedSeries.rep.id} + onToggleFavourite={() => { + if (!selectedSeries) return + void handleToggleFavourite(selectedSeries.rep.id) + }} seriesToolBusy={seriesToolBusy} onBack={clearSelectedSeries} onSmartScrape={handleSeriesSmartScrape} diff --git a/web/src/pages/LibrarySeriesDetailHeader.tsx b/web/src/pages/LibrarySeriesDetailHeader.tsx index 511d19c..6bd8543 100644 --- a/web/src/pages/LibrarySeriesDetailHeader.tsx +++ b/web/src/pages/LibrarySeriesDetailHeader.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, import { imageURL } from '../api/client' import { ExternalPlayerButton } from '../components/ExternalPlayerButton' +import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import type { Media } from '../types' import { seriesTitle, type SeriesCard } from '../utils/groupSeries' @@ -13,6 +14,10 @@ type LibrarySeriesDetailHeaderProps = { allEpisodes: Media[] playbackFrom: string isAdmin: boolean + canFavorite: boolean + favourite: boolean + favouriteBusy: boolean + onToggleFavourite: () => void seriesToolBusy: string onBack: () => void onSmartScrape: () => void @@ -30,6 +35,10 @@ export function LibrarySeriesDetailHeader({ allEpisodes, playbackFrom, isAdmin, + canFavorite, + favourite, + favouriteBusy, + onToggleFavourite, seriesToolBusy, onBack, onSmartScrape, @@ -75,15 +84,24 @@ export function LibrarySeriesDetailHeader({ {series.rep.overview || '暂无简介'}

- {firstEpisode && ( -
- - - 从第一集开始播放 - - -
- )} +
+ {firstEpisode && ( + <> + + + 从第一集开始播放 + + + + )} + {canFavorite && ( + + )} +
{isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && (
diff --git a/web/src/pages/LibrarySeriesDetailSection.tsx b/web/src/pages/LibrarySeriesDetailSection.tsx index 3dd1966..d24ecee 100644 --- a/web/src/pages/LibrarySeriesDetailSection.tsx +++ b/web/src/pages/LibrarySeriesDetailSection.tsx @@ -19,6 +19,10 @@ type LibrarySeriesDetailSectionProps = { loadingEpisodes: boolean playbackFrom: string isAdmin: boolean + canFavorite: boolean + favourite: boolean + favouriteBusy: boolean + onToggleFavourite: () => void seriesToolBusy: string onBack: () => void onSmartScrape: () => void @@ -40,6 +44,10 @@ export function LibrarySeriesDetailSection({ loadingEpisodes, playbackFrom, isAdmin, + canFavorite, + favourite, + favouriteBusy, + onToggleFavourite, seriesToolBusy, onBack, onSmartScrape, @@ -66,6 +74,10 @@ export function LibrarySeriesDetailSection({ allEpisodes={allEpisodes} playbackFrom={playbackFrom} isAdmin={isAdmin} + canFavorite={canFavorite} + favourite={favourite} + favouriteBusy={favouriteBusy} + onToggleFavourite={onToggleFavourite} seriesToolBusy={seriesToolBusy} onBack={onBack} onSmartScrape={onSmartScrape}