Add favourite buttons to library list and series detail pages (#13)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-02 13:48:25 +08:00
committed by GitHub
parent bd5bfdcc10
commit bd3d4ad357
6 changed files with 198 additions and 14 deletions
@@ -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 (
<button
type="button"
title={favourite ? '取消收藏' : '加入收藏'}
disabled={disabled}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggle()
}}
className={
'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 shadow-sm backdrop-blur transition hover:bg-red-50 disabled:opacity-50 ' +
(favourite ? 'text-red-500' : 'text-gray-700 hover:text-red-500') +
(className ? ` ${className}` : '')
}
>
<Heart size={13} fill={favourite ? 'currentColor' : 'none'} />
</button>
)
}
return (
<button
type="button"
disabled={disabled}
onClick={onToggle}
className={
'btn-outline gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50') +
(className ? ` ${className}` : '')
}
>
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
</button>
)
}
+50
View File
@@ -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<Set<string>>(() => 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 }
}
+4 -3
View File
@@ -11,7 +11,7 @@ type LibraryMediaSectionsProps = {
seriesCards: SeriesCard[] seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null selectedSeries: SeriesCard | null
loading: boolean loading: boolean
movieActions: (media: Media) => ReactNode cardActions: (media: Media) => ReactNode
onSeriesClick: (series: SeriesCard) => void onSeriesClick: (series: SeriesCard) => void
} }
@@ -21,7 +21,7 @@ export function LibraryMediaSections({
seriesCards, seriesCards,
selectedSeries, selectedSeries,
loading, loading,
movieActions, cardActions,
onSeriesClick, onSeriesClick,
}: LibraryMediaSectionsProps) { }: LibraryMediaSectionsProps) {
return ( return (
@@ -29,7 +29,7 @@ export function LibraryMediaSections({
{!isSeries && items.length > 0 && ( {!isSeries && items.length > 0 && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8"> <div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{items.map((media) => ( {items.map((media) => (
<MediaCard key={media.id} media={media} actions={movieActions(media)} /> <MediaCard key={media.id} media={media} actions={cardActions(media)} />
))} ))}
</div> </div>
)} )}
@@ -45,6 +45,7 @@ export function LibraryMediaSections({
key={series.key} key={series.key}
media={series.rep} media={series.rep}
count={series.count} count={series.count}
actions={cardActions(series.rep)}
onClick={() => onSeriesClick(series)} onClick={() => onSeriesClick(series)}
/> />
))} ))}
+48 -2
View File
@@ -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 { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
@@ -14,6 +14,7 @@ import {
} from '../utils/mediaSort' } from '../utils/mediaSort'
import { LibraryPageDialogs } from './LibraryPageDialogs' import { LibraryPageDialogs } from './LibraryPageDialogs'
import { PageBackButton } from '../components/PageBackButton' import { PageBackButton } from '../components/PageBackButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import { LibraryPageHeader } from './LibraryPageHeader' import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryMediaSections } from './LibraryMediaSections' import { LibraryMediaSections } from './LibraryMediaSections'
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection' import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
@@ -21,12 +22,17 @@ import { useLibraryData } from './useLibraryData'
import { useLibraryScanStatus } from './useLibraryScanStatus' import { useLibraryScanStatus } from './useLibraryScanStatus'
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection' import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
import { useLibraryAdminActions } from './useLibraryAdminActions' import { useLibraryAdminActions } from './useLibraryAdminActions'
import { usePermission } from '../hooks/usePermission'
import { useFavourites } from '../hooks/useFavourites'
export function LibraryPage() { export function LibraryPage() {
const { id = '' } = useParams() const { id = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation() const location = useLocation()
const role = useAuthStore((s) => s.user?.role) 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 [scrapeDialogOpen, setScrapeDialogOpen] = useState(false)
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false) const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
@@ -169,6 +175,39 @@ export function LibraryPage() {
setManualMovie, 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(
<MediaFavouriteButton
key="favourite"
variant="compact"
favourite={isFavourite(media.id)}
disabled={favouriteBusyID === media.id}
onToggle={() => {
void handleToggleFavourite(media.id)
}}
/>,
)
}
const adminActions = movieActions(media)
if (adminActions) {
actions.push(<Fragment key="admin-actions">{adminActions}</Fragment>)
}
if (actions.length === 0) return undefined
return <>{actions}</>
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-32"> <div className="flex items-center justify-center py-32">
@@ -216,7 +255,7 @@ export function LibraryPage() {
seriesCards={sortedSeriesCards} seriesCards={sortedSeriesCards}
selectedSeries={selectedSeries} selectedSeries={selectedSeries}
loading={loading} loading={loading}
movieActions={movieActions} cardActions={cardActions}
onSeriesClick={handleSeriesClick} onSeriesClick={handleSeriesClick}
/> />
@@ -229,6 +268,13 @@ export function LibraryPage() {
loadingEpisodes={loadingSeriesEpisodes} loadingEpisodes={loadingSeriesEpisodes}
playbackFrom={`${location.pathname}${location.search}`} playbackFrom={`${location.pathname}${location.search}`}
isAdmin={role === 'admin'} 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} seriesToolBusy={seriesToolBusy}
onBack={clearSelectedSeries} onBack={clearSelectedSeries}
onSmartScrape={handleSeriesSmartScrape} onSmartScrape={handleSeriesSmartScrape}
+27 -9
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search,
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types' import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries' import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
@@ -13,6 +14,10 @@ type LibrarySeriesDetailHeaderProps = {
allEpisodes: Media[] allEpisodes: Media[]
playbackFrom: string playbackFrom: string
isAdmin: boolean isAdmin: boolean
canFavorite: boolean
favourite: boolean
favouriteBusy: boolean
onToggleFavourite: () => void
seriesToolBusy: string seriesToolBusy: string
onBack: () => void onBack: () => void
onSmartScrape: () => void onSmartScrape: () => void
@@ -30,6 +35,10 @@ export function LibrarySeriesDetailHeader({
allEpisodes, allEpisodes,
playbackFrom, playbackFrom,
isAdmin, isAdmin,
canFavorite,
favourite,
favouriteBusy,
onToggleFavourite,
seriesToolBusy, seriesToolBusy,
onBack, onBack,
onSmartScrape, onSmartScrape,
@@ -75,15 +84,24 @@ export function LibrarySeriesDetailHeader({
{series.rep.overview || '暂无简介'} {series.rep.overview || '暂无简介'}
</p> </p>
{firstEpisode && ( <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2"> {firstEpisode && (
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex"> <>
<Play size={16} fill="currentColor" /> <Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
从第一集开始播放 <Play size={16} fill="currentColor" />
</Link> 从第一集开始播放
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" /> </Link>
</div> <ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
)} </>
)}
{canFavorite && (
<MediaFavouriteButton
favourite={favourite}
disabled={favouriteBusy}
onToggle={onToggleFavourite}
/>
)}
</div>
{isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && ( {isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm"> <div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
@@ -19,6 +19,10 @@ type LibrarySeriesDetailSectionProps = {
loadingEpisodes: boolean loadingEpisodes: boolean
playbackFrom: string playbackFrom: string
isAdmin: boolean isAdmin: boolean
canFavorite: boolean
favourite: boolean
favouriteBusy: boolean
onToggleFavourite: () => void
seriesToolBusy: string seriesToolBusy: string
onBack: () => void onBack: () => void
onSmartScrape: () => void onSmartScrape: () => void
@@ -40,6 +44,10 @@ export function LibrarySeriesDetailSection({
loadingEpisodes, loadingEpisodes,
playbackFrom, playbackFrom,
isAdmin, isAdmin,
canFavorite,
favourite,
favouriteBusy,
onToggleFavourite,
seriesToolBusy, seriesToolBusy,
onBack, onBack,
onSmartScrape, onSmartScrape,
@@ -66,6 +74,10 @@ export function LibrarySeriesDetailSection({
allEpisodes={allEpisodes} allEpisodes={allEpisodes}
playbackFrom={playbackFrom} playbackFrom={playbackFrom}
isAdmin={isAdmin} isAdmin={isAdmin}
canFavorite={canFavorite}
favourite={favourite}
favouriteBusy={favouriteBusy}
onToggleFavourite={onToggleFavourite}
seriesToolBusy={seriesToolBusy} seriesToolBusy={seriesToolBusy}
onBack={onBack} onBack={onBack}
onSmartScrape={onSmartScrape} onSmartScrape={onSmartScrape}