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 */}

{ e.currentTarget.src = FALLBACK }}
/>
{/* Bottom info overlay */}
{item.title}

{ 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 suppressClickTimerRef = useRef(null)
const touchStartRef = useRef({ x: 0, y: 0 })
const draggedRef = useRef(false)
const suppressClickRef = useRef(false)
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])
/* Scroll step based on rendered card width + gap for predictable smooth motion */
const getStepWidth = useCallback(() => {
const el = scrollRef.current
if (!el || el.children.length < 2) return el ? el.clientWidth * 0.75 : 0
return el.children[1].offsetLeft - el.children[0].offsetLeft
}, [])
/* 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])
/* Keep user in the centre segment before scripted smooth scroll starts */
const normalizeToMiddle = useCallback(() => {
const el = scrollRef.current
if (!el || !itemCount) return
const setW = getSetWidth()
if (setW === 0) return
if (el.scrollLeft < setW || el.scrollLeft >= setW * 2) {
el.style.scrollBehavior = 'auto'
el.scrollLeft = ((el.scrollLeft % setW) + setW) % setW + setW
el.style.scrollBehavior = ''
}
}, [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)
clearTimeout(suppressClickTimerRef.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
normalizeToMiddle()
const step = getStepWidth()
const amount = step > 0 ? step * 2 : el.clientWidth * 0.75
el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' })
clearTimeout(scrollEndTimer.current)
scrollEndTimer.current = setTimeout(resetIfNeeded, 260)
}, [getStepWidth, normalizeToMiddle, resetIfNeeded])
/* Prevent accidental link activation after horizontal swipe on touch devices */
const onTouchStart = useCallback((e) => {
if (!e.touches?.length) return
const t = e.touches[0]
touchStartRef.current = { x: t.clientX, y: t.clientY }
draggedRef.current = false
}, [])
const onTouchMove = useCallback((e) => {
if (!e.touches?.length) return
const t = e.touches[0]
const dx = Math.abs(t.clientX - touchStartRef.current.x)
const dy = Math.abs(t.clientY - touchStartRef.current.y)
if (dx > 10 && dx > dy) {
draggedRef.current = true
}
}, [])
const onTouchEnd = useCallback(() => {
if (!draggedRef.current) return
suppressClickRef.current = true
clearTimeout(suppressClickTimerRef.current)
suppressClickTimerRef.current = setTimeout(() => {
suppressClickRef.current = false
}, 260)
}, [])
const onClickCapture = useCallback((e) => {
if (!suppressClickRef.current) return
const link = e.target?.closest?.('a')
if (link) {
e.preventDefault()
e.stopPropagation()
}
}, [])
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-ai`, { 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'
const similarHref = artwork?.name
? `/search?q=${encodeURIComponent(artwork.name)}`
: '/search'
return (
)
}