feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
365
resources/js/components/artwork/ArtworkRecommendationsRails.jsx
Normal file
365
resources/js/components/artwork/ArtworkRecommendationsRails.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
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 (
|
||||
<article className="w-[240px] shrink-0 snap-start sm:w-[220px] lg:w-[200px] xl:w-[210px] 2xl:w-[220px]">
|
||||
<a
|
||||
href={item.url}
|
||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-neutral-900">
|
||||
{/* Gloss sheen */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||
|
||||
<img
|
||||
src={item.thumb || FALLBACK}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="220px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
{/* Bottom info overlay */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img
|
||||
src={item.authorAvatar || AVATAR_FALLBACK}
|
||||
alt={item.author}
|
||||
className="w-5 h-5 rounded-full object-cover shrink-0 ring-1 ring-white/20"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
<span className="truncate">{item.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="sr-only">{item.title} by {item.author}</span>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Scroll arrow button ─────────────────────────────────────── */
|
||||
|
||||
function ScrollBtn({ direction, onClick, visible }) {
|
||||
if (!visible) return null
|
||||
const isLeft = direction === 'left'
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={`Scroll ${direction}`}
|
||||
className={`absolute top-1/2 z-30 -translate-y-1/2 hidden lg:flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white ring-1 ring-white/10 backdrop-blur-md transition hover:bg-black/80 ${isLeft ? 'left-2' : 'right-2'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{isLeft
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<section>
|
||||
<div className="mb-5 flex items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{emoji && <span className="mr-1.5">{emoji}</span>}{title}
|
||||
</h2>
|
||||
{seeAllHref && (
|
||||
<a href={seeAllHref} className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Permanent edge fades for infinite illusion */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 w-24 bg-gradient-to-r from-[#0F1724] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-20 w-24 bg-gradient-to-l from-[#0F1724] to-transparent" />
|
||||
|
||||
<ScrollBtn direction="left" onClick={() => scroll('left')} visible={true} />
|
||||
<ScrollBtn direction="right" onClick={() => scroll('right')} visible={true} />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 overflow-x-auto px-4 pb-3 sm:px-6 lg:px-8 scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{loopItems.map((item, idx) => (
|
||||
<RailCard key={`${item.id || item.url}-${idx}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<div className="space-y-14">
|
||||
<Rail title="Similar Artworks" emoji="✨" items={similarItems} />
|
||||
<Rail title={trendingLabel} emoji="🔥" items={trendingRailItems} seeAllHref={trendingHref} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user