import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react' const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' /* ── normalizers ─────────────────────────────────────────────────── */ function normalizeRelated(item) { if (!item?.url) return null return { id: item.id || item.slug || item.url, title: item.title || 'Untitled', author: item.author || 'Artist', authorAvatar: item.author_avatar || null, url: item.url, thumb: item.thumb || null, thumbSrcSet: item.thumb_srcset || null, } } function normalizeSimilar(item) { if (!item?.url) return null return { id: item.id || item.slug || item.url, title: item.title || 'Untitled', author: item.author || 'Artist', authorAvatar: item.author_avatar || null, url: item.url, thumb: item.thumb || null, thumbSrcSet: item.thumb_srcset || null, } } function normalizeRankItem(item) { const url = item?.urls?.direct || item?.urls?.web || item?.url || null if (!url) return null return { id: item.id || item.slug || url, title: item.title || 'Untitled', author: item?.author?.name || 'Artist', authorAvatar: item?.author?.avatar_url || null, url, thumb: item.thumbnail_url || item.thumb || null, thumbSrcSet: null, } } function dedupeByUrl(items) { const seen = new Set() return items.filter((item) => { if (!item?.url || seen.has(item.url)) return false seen.add(item.url) return true }) } /* ── Large art card (matches homepage style) ─────────────────── */ function RailCard({ item }) { return (
{/* Gloss sheen */}
{item.title { e.currentTarget.src = FALLBACK }} /> {/* Bottom info overlay */}
{item.title}
{item.author} { e.currentTarget.src = AVATAR_FALLBACK }} /> {item.author}
{item.title} by {item.author}
) } /* ── Scroll arrow button ─────────────────────────────────────── */ function ScrollBtn({ direction, onClick, visible }) { if (!visible) return null const isLeft = direction === 'left' return ( ) } /* ── Rail section (infinite loop + mouse-wheel scroll) ───────── */ function Rail({ title, emoji, items, seeAllHref }) { const scrollRef = useRef(null) const isResettingRef = useRef(false) const scrollEndTimer = useRef(null) const itemCount = items.length /* Triple items so we can loop seamlessly: [clone|original|clone] */ const loopItems = useMemo(() => { if (!items.length) return [] return [...items, ...items, ...items] }, [items]) /* Pixel width of one item-set (measured from the DOM) */ const getSetWidth = useCallback(() => { const el = scrollRef.current if (!el || el.children.length < itemCount + 1) return 0 return el.children[itemCount].offsetLeft - el.children[0].offsetLeft }, [itemCount]) /* Centre on the middle (real) set after mount / data change */ useEffect(() => { const el = scrollRef.current if (!el || !itemCount) return requestAnimationFrame(() => { const sw = getSetWidth() if (sw) { el.style.scrollBehavior = 'auto' el.scrollLeft = sw el.style.scrollBehavior = '' } }) }, [loopItems, getSetWidth, itemCount]) /* After scroll settles, silently jump back to the middle set if in a clone zone */ const resetIfNeeded = useCallback(() => { if (isResettingRef.current) return const el = scrollRef.current if (!el || !itemCount) return const setW = getSetWidth() if (setW === 0) return if (el.scrollLeft < setW) { isResettingRef.current = true el.style.scrollBehavior = 'auto' el.scrollLeft += setW el.style.scrollBehavior = '' requestAnimationFrame(() => { isResettingRef.current = false }) } else if (el.scrollLeft >= setW * 2) { isResettingRef.current = true el.style.scrollBehavior = 'auto' el.scrollLeft -= setW el.style.scrollBehavior = '' requestAnimationFrame(() => { isResettingRef.current = false }) } }, [getSetWidth, itemCount]) /* Scroll listener: debounced boundary check + resize re-centre */ useEffect(() => { const el = scrollRef.current if (!el) return const onScroll = () => { clearTimeout(scrollEndTimer.current) scrollEndTimer.current = setTimeout(resetIfNeeded, 80) } el.addEventListener('scroll', onScroll, { passive: true }) const onResize = () => { const sw = getSetWidth() if (sw) { el.style.scrollBehavior = 'auto' el.scrollLeft = sw el.style.scrollBehavior = '' } } window.addEventListener('resize', onResize) return () => { el.removeEventListener('scroll', onScroll) window.removeEventListener('resize', onResize) clearTimeout(scrollEndTimer.current) } }, [loopItems, resetIfNeeded, getSetWidth]) /* Mouse-wheel → horizontal scroll (re-attach when items arrive) */ useEffect(() => { const el = scrollRef.current if (!el || !loopItems.length) return const onWheel = (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.preventDefault() el.scrollLeft += e.deltaY } } el.addEventListener('wheel', onWheel, { passive: false }) return () => el.removeEventListener('wheel', onWheel) }, [loopItems]) const scroll = useCallback((dir) => { const el = scrollRef.current if (!el) return const amount = el.clientWidth * 0.75 el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' }) }, []) if (!items.length) return null return (

{emoji && {emoji}}{title}

{seeAllHref && ( See all → )}
{/* Permanent edge fades for infinite illusion */}
scroll('left')} visible={true} /> scroll('right')} visible={true} />
{loopItems.map((item, idx) => ( ))}
) } /* ── Main export ─────────────────────────────────────────────── */ export default function ArtworkRecommendationsRails({ artwork, related = [] }) { const [similarApiItems, setSimilarApiItems] = useState([]) const [similarLoaded, setSimilarLoaded] = useState(false) const [trendingItems, setTrendingItems] = useState([]) const relatedCards = useMemo(() => { return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean)) }, [related]) useEffect(() => { let isCancelled = false const loadSimilar = async () => { if (!artwork?.id) { setSimilarApiItems([]) setSimilarLoaded(true) return } try { const response = await fetch(`/api/art/${artwork.id}/similar`, { credentials: 'same-origin' }) if (!response.ok) throw new Error('similar fetch failed') const payload = await response.json() const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean)) if (!isCancelled) { setSimilarApiItems(items) setSimilarLoaded(true) } } catch { if (!isCancelled) { setSimilarApiItems([]) setSimilarLoaded(true) } } } loadSimilar() return () => { isCancelled = true } }, [artwork?.id]) useEffect(() => { let isCancelled = false const loadTrending = async () => { const categoryId = artwork?.categories?.[0]?.id if (!categoryId) { setTrendingItems([]) return } try { const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' }) if (!response.ok) throw new Error('trending fetch failed') const payload = await response.json() const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean)) if (!isCancelled) setTrendingItems(items) } catch { if (!isCancelled) setTrendingItems([]) } } loadTrending() return () => { isCancelled = true } }, [artwork?.categories]) const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase() const tagBasedFallback = useMemo(() => { return relatedCards.filter((item) => String(item.author || '').trim().toLowerCase() !== authorName) }, [relatedCards, authorName]) const similarItems = useMemo(() => { if (!similarLoaded) return [] if (similarApiItems.length > 0) return similarApiItems.slice(0, 12) if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12) return trendingItems.slice(0, 12) }, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems]) const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems]) if (similarItems.length === 0 && trendingRailItems.length === 0) return null const categoryName = artwork?.categories?.[0]?.name const trendingLabel = categoryName ? `Trending in ${categoryName}` : 'Trending' const trendingHref = categoryName ? `/discover/trending` : '/discover/trending' return (
) }