159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useQueries } from '@tanstack/react-query'
|
|
import { Trash2 } from '../../lib/icons'
|
|
import { fetchLetterboxdList, type LetterboxdListItem } from '../../api/letterboxd'
|
|
import { getMovieFull } from '../../api/tmdb'
|
|
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
|
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
|
import { useLetterboxdLists, type SavedLetterboxdList } from '../../stores/letterboxd-lists-store'
|
|
import ContentRow from './ContentRow'
|
|
|
|
interface Props {
|
|
saved: SavedLetterboxdList
|
|
}
|
|
|
|
const RSS_STALE = 6 * 60 * 60 * 1000
|
|
|
|
/**
|
|
* One imported Letterboxd list rendered as a content row. Lazy-mounts
|
|
* once scrolled near the viewport so importing 5 lists doesn't burn
|
|
* 100+ TMDB requests on home page load.
|
|
*/
|
|
export default function LetterboxdListRow({ saved }: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [near, setNear] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return
|
|
const el = containerRef.current
|
|
const obs = new IntersectionObserver(
|
|
entries => {
|
|
for (const e of entries) {
|
|
if (e.isIntersecting) {
|
|
setNear(true)
|
|
obs.disconnect()
|
|
return
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: '200px' },
|
|
)
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [])
|
|
|
|
return (
|
|
<div ref={containerRef}>
|
|
{near ? <Mounted saved={saved} /> : <Placeholder saved={saved} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Mounted({ saved }: Props) {
|
|
const remove = useLetterboxdLists(s => s.remove)
|
|
const rss = useQuery({
|
|
queryKey: ['letterboxd', 'list', saved.url],
|
|
queryFn: () => fetchLetterboxdList(saved.url),
|
|
staleTime: RSS_STALE,
|
|
})
|
|
const data = rss.data
|
|
// Cap at 30 entries to keep the request burst reasonable; serious
|
|
// lists with 100 films would slam TMDB on first mount otherwise.
|
|
const entries: LetterboxdListItem[] = useMemo(
|
|
() => (data?.items || []).slice(0, 30),
|
|
[data?.items],
|
|
)
|
|
const ids = useMemo(
|
|
() => entries.map(e => e.tmdbId).filter((x): x is string => !!x).map(Number),
|
|
[entries],
|
|
)
|
|
const tmdbQueries = useQueries({
|
|
queries: ids.map(id => ({
|
|
queryKey: ['tmdb', 'movie-full', id],
|
|
queryFn: () => getMovieFull(id),
|
|
staleTime: 24 * 60 * 60 * 1000,
|
|
})),
|
|
})
|
|
const libraryByTmdbId = useLibraryByTmdbId()
|
|
const items = useMemo(() => {
|
|
const tmdbMap = new Map<number, any>()
|
|
ids.forEach((id, i) => {
|
|
const d = tmdbQueries[i]?.data
|
|
if (d) tmdbMap.set(id, { ...d, media_type: 'movie' })
|
|
})
|
|
// Preserve list order; drop items the proxy returned without a TMDB id.
|
|
const ordered: any[] = []
|
|
for (const e of entries) {
|
|
if (!e.tmdbId) continue
|
|
const d = tmdbMap.get(Number(e.tmdbId))
|
|
if (d) ordered.push(d)
|
|
}
|
|
return mapTmdbToJf(ordered, libraryByTmdbId.data)
|
|
}, [entries, ids, tmdbQueries, libraryByTmdbId.data])
|
|
|
|
if (rss.isLoading) return <Placeholder saved={saved} />
|
|
if (!data) {
|
|
return (
|
|
<section className="mb-10 px-7">
|
|
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight mb-1">
|
|
{saved.customTitle || 'Letterboxd list'}
|
|
</h2>
|
|
<p className="text-[12px] text-text-3 mb-3">
|
|
Couldn't load this list. The proxy may be down, or the URL is private.
|
|
</p>
|
|
<button
|
|
onClick={() => remove(saved.url)}
|
|
className="text-[11px] text-text-4 hover:text-red-300 transition tracking-tight focus-ring rounded"
|
|
>
|
|
Remove
|
|
</button>
|
|
</section>
|
|
)
|
|
}
|
|
if (items.length === 0) return null
|
|
|
|
const matched = items.filter(i => (i as any)._inLibrary).length
|
|
const subtitle = `${matched} of ${items.length} in your library · from Letterboxd`
|
|
|
|
return (
|
|
<div className="relative group/llb">
|
|
<ContentRow
|
|
title={saved.customTitle || data.title}
|
|
subtitle={subtitle}
|
|
items={items}
|
|
layoutKey={`letterboxd_${saved.url}`}
|
|
/>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm('Remove this Letterboxd list?')) remove(saved.url)
|
|
}}
|
|
title="Remove this list"
|
|
className="absolute top-3 right-7 w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-red-300 hover:bg-elevated/80 opacity-0 group-hover/llb:opacity-100 transition focus-ring"
|
|
>
|
|
<Trash2 size={13} stroke={2} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Placeholder({ saved }: Props) {
|
|
return (
|
|
<section className="mb-10">
|
|
<div className="px-7 mb-3.5">
|
|
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight">
|
|
{saved.customTitle || 'Letterboxd list'}
|
|
</h2>
|
|
<p className="text-[12px] text-text-3 mt-0.5 truncate max-w-md">{saved.url}</p>
|
|
</div>
|
|
<div className="px-7 flex gap-3">
|
|
{Array.from({ length: 6 }).map((_, j) => (
|
|
<div key={j} className="shrink-0 w-[160px]">
|
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|