Files
jellybloom/src/components/ui/LetterboxdListRow.tsx
T

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>
)
}