Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

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

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}

View File

@@ -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 '—'
}

View File

@@ -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>
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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' })
})
})

View File

@@ -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>