mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user