shared ui: poster cards, content rows, scrollers, lazy mount
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user