Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -222,11 +222,27 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
// Count a view on every page load.
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {})
|
||||
const postView = () => {
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
postView()
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(postView, { timeout: 1500 })
|
||||
return () => window.cancelIdleCallback(handle)
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(postView, 1200)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
@@ -327,7 +343,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -342,7 +358,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share pill */}
|
||||
@@ -403,7 +419,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -418,7 +434,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
|
||||
export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
@@ -34,7 +35,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
|
||||
<p className="mt-1 text-xs text-soft">{NUMBER_FORMATTER.format(followersCount)} followers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
|
||||
|
||||
{!isAuthenticated && (
|
||||
<p className="mt-3 text-center text-xs text-soft">
|
||||
<a href="/login" className="text-accent hover:underline">Sign in</a> to medal this artwork
|
||||
<a href="/login" className="text-accent underline hover:no-underline">Sign in</a> to medal this artwork
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function Crumb({ href, children, current = false }) {
|
||||
if (current) {
|
||||
return (
|
||||
<span
|
||||
className={`${base} text-white/30`}
|
||||
className={`${base} text-white/55`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
@@ -30,7 +30,7 @@ function Crumb({ href, children, current = false }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
|
||||
className={`${base} text-white/55 hover:text-white/80 transition-colors duration-150`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -4,10 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
const numberFormatter = new Intl.NumberFormat(undefined, {
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
@@ -29,27 +30,26 @@ function formatRelativeTime(value) {
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
|
||||
if (absSeconds < 60) return relativeTimeFormatter.format(diffSeconds, 'second')
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
|
||||
if (Math.abs(diffMinutes) < 60) return relativeTimeFormatter.format(diffMinutes, 'minute')
|
||||
|
||||
const diffHours = Math.round(diffSeconds / 3600)
|
||||
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
|
||||
if (Math.abs(diffHours) < 24) return relativeTimeFormatter.format(diffHours, 'hour')
|
||||
|
||||
const diffDays = Math.round(diffSeconds / 86400)
|
||||
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
|
||||
if (Math.abs(diffDays) < 7) return relativeTimeFormatter.format(diffDays, 'day')
|
||||
|
||||
const diffWeeks = Math.round(diffSeconds / 604800)
|
||||
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
|
||||
if (Math.abs(diffWeeks) < 5) return relativeTimeFormatter.format(diffWeeks, 'week')
|
||||
|
||||
const diffMonths = Math.round(diffSeconds / 2629800)
|
||||
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
|
||||
if (Math.abs(diffMonths) < 12) return relativeTimeFormatter.format(diffMonths, 'month')
|
||||
|
||||
const diffYears = Math.round(diffSeconds / 31557600)
|
||||
return rtf.format(diffYears, 'year')
|
||||
return relativeTimeFormatter.format(diffYears, 'year')
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
|
||||
@@ -5,20 +5,44 @@ import ReactionBar from '../comments/ReactionBar'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 365) return `${days}d ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
function formatAbsoluteDate(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
return ABSOLUTE_DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatAbsoluteDateTime(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatCommentTime(primaryLabel, createdAt) {
|
||||
return primaryLabel || formatAbsoluteDate(createdAt)
|
||||
}
|
||||
|
||||
/* ── Icons ─────────────────────────────────────────────────────────────────── */
|
||||
@@ -135,10 +159,10 @@ function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, dept
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
|
||||
title={formatAbsoluteDateTime(reply.created_at)}
|
||||
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
|
||||
>
|
||||
{reply.time_ago || timeAgo(reply.created_at)}
|
||||
{formatCommentTime(reply.time_ago, reply.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
@@ -292,10 +316,10 @@ function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
title={formatAbsoluteDateTime(comment.created_at)}
|
||||
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
|
||||
>
|
||||
{comment.time_ago || timeAgo(comment.created_at)}
|
||||
{formatCommentTime(comment.time_ago, comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,33 @@ import React, { useState } from 'react'
|
||||
|
||||
const COLLAPSE_AT = 560
|
||||
|
||||
function stripTags(value) {
|
||||
return String(value || '')
|
||||
.replace(/<\/?(?:html|head|body|title|meta|link|script|style)[^>]*>/gi, '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function sanitizeDescriptionHtml(value) {
|
||||
const html = String(value || '').trim()
|
||||
|
||||
if (!html) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/<\/?(?:html|head|body|title|meta|link|script|style)\b/i.test(html)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
export default function ArtworkDescription({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const content = (artwork?.description || '').trim()
|
||||
const contentHtml = (artwork?.description_html || '').trim()
|
||||
const contentHtml = sanitizeDescriptionHtml(artwork?.description_html || '')
|
||||
const collapsed = content.length > COLLAPSE_AT && !expanded
|
||||
const fallbackText = contentHtml ? stripTags(contentHtml) : content
|
||||
|
||||
if (content.length === 0) return null
|
||||
|
||||
@@ -20,7 +42,8 @@ export default function ArtworkDescription({ artwork }) {
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml || escapeHtml(fallbackText) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,3 +59,12 @@ export default function ArtworkDescription({ artwork }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ import React, { useMemo } from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
import ArtworkFormatBadges from './ArtworkFormatBadges'
|
||||
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatCount(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
@@ -12,7 +19,7 @@ function formatCount(value) {
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
return ABSOLUTE_DATE_FORMATTER.format(new Date(value))
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ArtworkFormatBadges from './ArtworkFormatBadges'
|
||||
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return n.toLocaleString()
|
||||
return NUMBER_FORMATTER.format(n)
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
function formatDate(value, useRelative = true) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
const d = new Date(value)
|
||||
if (!useRelative) return ABSOLUTE_DATE_FORMATTER.format(d)
|
||||
const now = Date.now()
|
||||
const diff = now - d.getTime()
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
if (days === 0) return 'Today'
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days < 30) return `${days} days ago`
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
return ABSOLUTE_DATE_FORMATTER.format(d)
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
@@ -46,9 +55,14 @@ function InfoRow({ label, value }) {
|
||||
}
|
||||
|
||||
export default function ArtworkDetailsPanel({ artwork, stats }) {
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const width = artwork?.dimensions?.width || artwork?.width || 0
|
||||
const height = artwork?.dimensions?.height || artwork?.height || 0
|
||||
const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null
|
||||
const resolution = width > 0 && height > 0 ? `${NUMBER_FORMATTER.format(width)} × ${NUMBER_FORMATTER.format(height)}` : null
|
||||
|
||||
useEffect(() => {
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
@@ -86,7 +100,7 @@ export default function ArtworkDetailsPanel({ artwork, stats }) {
|
||||
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
|
||||
</div>
|
||||
) : null}
|
||||
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
|
||||
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at, hydrated)} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onError={(event) => {
|
||||
event.currentTarget.onerror = null
|
||||
setShowBackdrop(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
import React, { useEffect, useMemo, 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'
|
||||
@@ -77,7 +77,7 @@ function RailCard({ item }) {
|
||||
<img
|
||||
src={item.thumb || FALLBACK}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="220px"
|
||||
sizes="(min-width: 1280px) 210px, (min-width: 640px) 220px, 240px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className={`h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
|
||||
loading="lazy"
|
||||
@@ -339,74 +339,18 @@ function Rail({ title, emoji, items, seeAllHref }) {
|
||||
|
||||
/* ── Main export ─────────────────────────────────────────────── */
|
||||
|
||||
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
const [similarApiItems, setSimilarApiItems] = useState([])
|
||||
const [similarLoaded, setSimilarLoaded] = useState(false)
|
||||
const [trendingItems, setTrendingItems] = useState([])
|
||||
|
||||
export default function ArtworkRecommendationsRails({ artwork, related = [], similarApiData = [], trendingData = [] }) {
|
||||
const relatedCards = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
|
||||
}, [related])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
const similarApiItems = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(similarApiData) ? similarApiData : []).map(normalizeSimilar).filter(Boolean))
|
||||
}, [similarApiData])
|
||||
|
||||
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 trendingItems = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(trendingData) ? trendingData : []).map(normalizeRankItem).filter(Boolean))
|
||||
}, [trendingData])
|
||||
|
||||
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
|
||||
|
||||
@@ -415,11 +359,10 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
}, [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])
|
||||
}, [similarApiItems, tagBasedFallback, trendingItems])
|
||||
|
||||
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
|
||||
|
||||
@@ -428,11 +371,9 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
const categoryName = artwork?.categories?.[0]?.name
|
||||
const trendingLabel = categoryName
|
||||
? `Trending in ${categoryName}`
|
||||
: 'Trending'
|
||||
: 'Trending on Skinbase'
|
||||
|
||||
const trendingHref = categoryName
|
||||
? `/discover/trending`
|
||||
: '/discover/trending'
|
||||
const trendingHref = '/discover/trending'
|
||||
|
||||
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import ArtworkRecommendationsRails from './ArtworkRecommendationsRails'
|
||||
|
||||
describe('ArtworkRecommendationsRails', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('/similar-ai')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ data: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (String(url).includes('/api/rank/category/5?type=trending')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: 11,
|
||||
title: 'Star map drift',
|
||||
urls: { direct: '/art/11/star-map-drift' },
|
||||
author: { name: 'Pilot' },
|
||||
thumbnail_url: '/thumbs/11.webp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ data: [] }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads recommendation rails after mount', async () => {
|
||||
render(
|
||||
<ArtworkRecommendationsRails
|
||||
artwork={{
|
||||
id: 69827,
|
||||
user: { name: 'Pilot' },
|
||||
categories: [{ id: 5, name: 'Sci-Fi' }],
|
||||
}}
|
||||
related={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Trending in Sci-Fi')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/art/69827/similar-ai', { credentials: 'same-origin' })
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/rank/category/5?type=trending', { credentials: 'same-origin' })
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import AuthorBioPopover from './AuthorBioPopover'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
@@ -91,7 +92,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
{NUMBER_FORMATTER.format(followersCount)} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
@@ -152,7 +153,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<a href={profileUrl} aria-label={isGroupPublisher ? 'View more related works' : `View all from ${authorName}`} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user