Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -42,6 +42,15 @@ function DownloadArrowIcon() {
)
}
function ChartIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7 15.5 10.5 12l3 2.5 4.5-6" />
</svg>
)
}
/* ShareIcon removed — now provided by ArtworkShareButton */
function FlagIcon() {
@@ -204,6 +213,8 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
}, [artwork?.id, artwork?.stats?.bookmarks, stats?.bookmarks])
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const analyticsUrl = artwork?.management?.analytics_url
|| (artwork?.viewer?.is_owner ? `/studio/artworks/${artwork.id}/analytics` : null)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
@@ -337,6 +348,16 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
{/* Share pill */}
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" isLoggedIn={isLoggedIn} />
{analyticsUrl ? (
<a
href={analyticsUrl}
className="inline-flex items-center gap-2 rounded-full border border-sky-400/30 bg-sky-400/12 px-5 py-2.5 text-sm font-medium text-sky-100 transition-all duration-200 hover:border-sky-300/45 hover:bg-sky-400/18 hover:text-white"
>
<ChartIcon />
Statistics
</a>
) : null}
{/* Report pill */}
<button
type="button"
@@ -403,6 +424,17 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
{/* Share */}
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
{analyticsUrl ? (
<a
href={analyticsUrl}
aria-label="Open artwork statistics"
title="Statistics"
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/30 bg-sky-400/12 px-3.5 py-2 text-xs font-medium text-sky-100 transition-all hover:border-sky-300/45 hover:bg-sky-400/18 hover:text-white"
>
<ChartIcon />
</a>
) : null}
{/* Report */}
<button
type="button"

View File

@@ -493,11 +493,15 @@ export default function ArtworkCard({
|| item.content_type_slug
|| ''
)
const category = decodeHtml(item.category || item.category_name || '')
const category = decodeHtml(
(typeof item.category === 'string' ? item.category : item.category?.name)
|| item.category_name
|| ''
)
const width = Number(item.width ?? 0)
const height = Number(item.height ?? 0)
const resolution = decodeHtml(item.resolution || ((width > 0 && height > 0) ? `${width}x${height}` : ''))
const href = item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
const href = item.canonical_url || item.urls?.canonical || item.urls?.direct || item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
const downloadHref = item.download_url || item.downloadHref || (item.id ? `/download/artwork/${item.id}` : href)
const cardLabel = `${title} by ${author}`
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
import ArtworkFormatBadges from './ArtworkFormatBadges'
function formatCount(value) {
const number = Number(value || 0)
@@ -60,9 +61,10 @@ export default function ArtworkDetailsDrawer({ isOpen, onClose, artwork, stats }
</div>
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5 sm:col-span-2">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Upload date</dt>

View File

@@ -1,4 +1,5 @@
import React from 'react'
import ArtworkFormatBadges from './ArtworkFormatBadges'
function formatCount(value) {
const n = Number(value || 0)
@@ -76,7 +77,15 @@ export default function ArtworkDetailsPanel({ artwork, stats }) {
{/* Info rows */}
<div className="mt-4 divide-y divide-white/[0.05]">
{resolution && <InfoRow label="Resolution" value={resolution} />}
{resolution ? (
<div className="py-2">
<div className="flex items-center justify-between gap-4">
<span className="text-xs uppercase tracking-wider text-white/35">Resolution</span>
<span className="text-sm font-medium text-white/80">{resolution}</span>
</div>
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
) : null}
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
</div>
</section>

View File

@@ -0,0 +1,267 @@
import React from 'react'
const RESOLUTION_TIERS = [
{ label: '8K', width: 7680, height: 4320, tone: 'amber' },
{ label: '5K', width: 5120, height: 2880, tone: 'violet' },
{ label: '4K', width: 3840, height: 2160, tone: 'sky' },
{ label: 'QHD', width: 2560, height: 1440, tone: 'emerald' },
{ label: 'Full HD', width: 1920, height: 1080, tone: 'cyan' },
{ label: 'HD', width: 1280, height: 720, tone: 'slate' },
]
const ASPECT_RATIOS = [
{ label: '21:9', ratio: 21 / 9 },
{ label: '16:10', ratio: 16 / 10 },
{ label: '16:9', ratio: 16 / 9 },
{ label: '3:2', ratio: 3 / 2 },
{ label: '4:3', ratio: 4 / 3 },
{ label: '1:1', ratio: 1 },
{ label: '4:5', ratio: 4 / 5 },
{ label: '3:4', ratio: 3 / 4 },
{ label: '9:16', ratio: 9 / 16 },
]
const TONE_CLASSES = {
amber: 'border-amber-400/25 bg-amber-400/10 text-amber-100',
violet: 'border-violet-400/25 bg-violet-400/10 text-violet-100',
sky: 'border-sky-400/25 bg-sky-400/10 text-sky-100',
emerald: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100',
cyan: 'border-cyan-400/25 bg-cyan-400/10 text-cyan-100',
slate: 'border-white/10 bg-white/[0.04] text-white/80',
}
function ScreenIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25h6m-3 0v2.25m-7.5-15h15A1.5 1.5 0 0 1 21 6v9A1.5 1.5 0 0 1 19.5 16.5h-15A1.5 1.5 0 0 1 3 15V6A1.5 1.5 0 0 1 4.5 4.5Z" />
</svg>
)
}
function RatioIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 8.25V6A1.5 1.5 0 0 1 6 4.5h2.25m7.5 0H18A1.5 1.5 0 0 1 19.5 6v2.25m0 7.5V18A1.5 1.5 0 0 1 18 19.5h-2.25m-7.5 0H6A1.5 1.5 0 0 1 4.5 18v-2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
</svg>
)
}
function FormatIcon({ className, variant }) {
if (variant === 'ultrawide') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="3.75" y="7.5" width="16.5" height="9" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 12h9" />
</svg>
)
}
if (variant === 'vertical') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="7.25" y="3.75" width="9.5" height="16.5" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 7.5v9" />
</svg>
)
}
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 18c2.5-5.5 5-8.25 7.5-8.25S17 12.5 19.5 18" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 6.75h9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4.5h6v4.5H9z" />
</svg>
)
}
function OrientationIcon({ className, orientation }) {
if (orientation === 'square') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="5.5" y="5.5" width="13" height="13" rx="2.25" />
</svg>
)
}
if (orientation === 'portrait') {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="7.25" y="4.5" width="9.5" height="15" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
</svg>
)
}
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className={className}>
<rect x="4.5" y="7.25" width="15" height="9.5" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
</svg>
)
}
function Badge({ label, tone, icon }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${TONE_CLASSES[tone] || TONE_CLASSES.slate}`}>
<span className="text-current/90">{icon}</span>
<span>{label}</span>
</span>
)
}
function pickResolutionTier(width, height) {
const longSide = Math.max(width, height)
const shortSide = Math.min(width, height)
for (const tier of RESOLUTION_TIERS) {
if (longSide >= tier.width && shortSide >= tier.height) {
return tier
}
}
return null
}
function pickOrientation(width, height) {
if (width === height) {
return {
key: 'orientation-square',
label: 'Square',
tone: 'amber',
icon: <OrientationIcon className="h-3.5 w-3.5" orientation="square" />,
isSquare: true,
}
}
if (width > height) {
return {
key: 'orientation-landscape',
label: 'Landscape',
tone: 'emerald',
icon: <OrientationIcon className="h-3.5 w-3.5" orientation="landscape" />,
isSquare: false,
}
}
return {
key: 'orientation-portrait',
label: 'Portrait',
tone: 'violet',
icon: <OrientationIcon className="h-3.5 w-3.5" orientation="portrait" />,
isSquare: false,
}
}
function pickAspectRatio(width, height) {
const ratio = width / height
let best = null
for (const candidate of ASPECT_RATIOS) {
const delta = Math.abs(ratio - candidate.ratio) / candidate.ratio
if (delta > 0.03) {
continue
}
if (best === null || delta < best.delta) {
best = { ...candidate, delta }
}
}
return best
}
function pickSemanticFormat(width, height, aspectRatio, orientation) {
if (!orientation || orientation.isSquare) {
return null
}
const ratio = width / height
if (ratio >= 2.1) {
return {
key: 'semantic-ultrawide',
label: 'Ultrawide',
tone: 'sky',
icon: <FormatIcon className="h-3.5 w-3.5" variant="ultrawide" />,
}
}
if (ratio <= 0.75) {
return {
key: 'semantic-vertical',
label: 'Vertical',
tone: 'violet',
icon: <FormatIcon className="h-3.5 w-3.5" variant="vertical" />,
}
}
if (aspectRatio && ['4:3', '3:2', '16:10'].includes(aspectRatio.label)) {
return {
key: 'semantic-classic',
label: 'Classic',
tone: 'amber',
icon: <FormatIcon className="h-3.5 w-3.5" variant="classic" />,
}
}
return null
}
export function getArtworkFormatBadges(width, height) {
if (!(width > 0 && height > 0)) {
return []
}
const badges = []
const orientation = pickOrientation(width, height)
const resolutionTier = pickResolutionTier(width, height)
if (resolutionTier) {
badges.push({
key: `resolution-${resolutionTier.label}`,
label: resolutionTier.label,
tone: resolutionTier.tone,
icon: <ScreenIcon className="h-3.5 w-3.5" />,
})
}
if (orientation) {
badges.push(orientation)
}
const aspectRatio = pickAspectRatio(width, height)
const semanticFormat = pickSemanticFormat(width, height, aspectRatio, orientation)
if (semanticFormat) {
badges.push(semanticFormat)
}
if (aspectRatio && !orientation?.isSquare) {
badges.push({
key: `ratio-${aspectRatio.label}`,
label: aspectRatio.label,
tone: 'slate',
icon: <RatioIcon className="h-3.5 w-3.5" />,
})
}
return badges
}
export default function ArtworkFormatBadges({ width, height, className = '' }) {
const badges = getArtworkFormatBadges(width, height)
if (badges.length === 0) {
return null
}
return (
<div className={`flex flex-wrap gap-2 ${className}`.trim()}>
{badges.map((badge) => (
<Badge key={badge.key} label={badge.label} tone={badge.tone} icon={badge.icon} />
))}
</div>
)
}

View File

@@ -1,11 +1,12 @@
import React from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
import WorldParticipationBadge from './WorldParticipationBadge'
export default function ArtworkMeta({ artwork }) {
const publisher = artwork?.publisher || null
const credits = artwork?.credits || {}
const primaryAuthor = credits?.primary_author || artwork?.user || null
const contributors = Array.isArray(credits?.contributors) ? credits.contributors : []
const worldParticipation = Array.isArray(artwork?.world_participation) ? artwork.world_participation : []
return (
<div>
@@ -17,12 +18,6 @@ export default function ArtworkMeta({ artwork }) {
<span className="font-semibold">{publisher.name}</span>
</a>
) : null}
{primaryAuthor ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Primary author</span>
{primaryAuthor.profile_url ? <a href={primaryAuthor.profile_url} className="font-semibold text-white hover:text-sky-200">{primaryAuthor.name || primaryAuthor.username}</a> : <span className="font-semibold text-white">{primaryAuthor.name || primaryAuthor.username}</span>}
</span>
) : null}
{contributors.length > 0 ? (
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
@@ -45,6 +40,7 @@ export default function ArtworkMeta({ artwork }) {
<div className="mt-3">
<ArtworkBreadcrumbs artwork={artwork} />
</div>
<WorldParticipationBadge items={worldParticipation} />
</div>
)
}

View File

@@ -1,4 +1,5 @@
import React from 'react'
import ArtworkFormatBadges from './ArtworkFormatBadges'
function formatCount(value) {
const number = Number(value || 0)
@@ -35,6 +36,7 @@ export default function ArtworkStats({ artwork, stats: statsProp }) {
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2 sm:block">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
</dl>
</section>

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
function galleryUrlFor(author) {
if (!author?.username) return null
return `/@${author.username}/gallery`
}
export default function AuthorBioPopover({ author }) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [bio, setBio] = useState(undefined)
const [error, setError] = useState('')
const username = author?.username || ''
const profileUrl = author?.profile_url || (username ? `/@${username}` : null)
const galleryUrl = galleryUrlFor(author)
useEffect(() => {
if (!open) return undefined
function onKeyDown(event) {
if (event.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [open])
async function loadBio() {
if (!username || loading || bio !== undefined) {
return
}
setLoading(true)
setError('')
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/ai-biography`, {
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
if (!response.ok) {
throw new Error(`Failed to load biography (${response.status})`)
}
const payload = await response.json()
setBio(payload?.data?.text || null)
} catch {
setError('Biography is unavailable right now.')
setBio(null)
} finally {
setLoading(false)
}
}
if (!username || !profileUrl) {
return null
}
const dialog = open ? createPortal(
<div className="fixed inset-0 z-[220] overflow-y-auto">
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
<div className="flex min-h-screen items-center justify-center p-4 sm:p-6 lg:p-8">
<div
role="dialog"
aria-modal="true"
aria-label={`About ${author?.name || author?.username || 'author'}`}
className="relative z-[221] flex max-h-[min(88vh,52rem)] w-full max-w-2xl flex-col overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/96 p-5 shadow-[0_36px_100px_rgba(2,6,23,0.75)] backdrop-blur-xl sm:p-6 lg:p-7"
>
<button
type="button"
aria-label="Close author biography overlay"
onClick={() => setOpen(false)}
className="absolute inset-0"
/>
<div className="relative z-10 flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/70">About the author</p>
<p className="mt-1 text-xl font-semibold text-white sm:text-2xl">{author?.name || author?.username}</p>
<p className="text-sm text-white/40 sm:text-base">@{username}</p>
</div>
<button
type="button"
aria-label="Close author biography"
onClick={() => setOpen(false)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/60 transition hover:bg-white/[0.08] hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative z-10 mt-5 min-h-0 flex-1 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03]">
<div className="max-h-full overflow-y-auto p-4 text-[15px] leading-8 text-white/85 sm:p-5 sm:text-base lg:text-[17px] lg:leading-8">
{loading ? <p className="text-white/60">Loading biography...</p> : null}
{!loading && error ? <p className="text-rose-200/90">{error}</p> : null}
{!loading && !error && bio ? <p>{bio}</p> : null}
{!loading && !error && bio === null ? <p className="text-white/60">No public biography available yet.</p> : null}
</div>
</div>
<div className="relative z-10 mt-5 flex shrink-0 flex-wrap gap-3">
<a
href={profileUrl}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/[0.08]"
>
View profile
</a>
{galleryUrl ? (
<a
href={galleryUrl}
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-300/10 px-4 py-2.5 text-sm font-medium text-sky-100 transition hover:border-sky-300/30 hover:bg-sky-300/16"
>
Open gallery
</a>
) : null}
</div>
</div>
</div>
</div>,
document.body,
) : null
return (
<span className="relative inline-flex items-center">
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open ? 'true' : 'false'}
aria-label={`More about ${author?.name || author?.username || 'this author'}`}
onClick={() => {
const nextOpen = !open
setOpen(nextOpen)
if (!open) {
void loadBio()
}
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-sky-300/20 bg-sky-300/8 text-sky-100/80 transition hover:border-sky-300/35 hover:bg-sky-300/14 hover:text-sky-50"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25 12 12v4.5m0-8.25h.008v.008H12V8.25Zm9 3.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
{dialog}
</span>
)
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AuthorBioPopover from './AuthorBioPopover'
describe('AuthorBioPopover', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('loads and shows the public biography when opened', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
data: {
text: 'Gregor has spent decades building a public portfolio across wallpapers and digital art.',
},
}),
})
render(
<AuthorBioPopover author={{ name: 'Gregor', username: 'gregor', profile_url: '/@gregor' }} />,
)
await user.click(screen.getByRole('button', { name: /more about gregor/i }))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/profile/gregor/ai-biography',
expect.objectContaining({ credentials: 'same-origin' }),
)
expect(await screen.findByText(/spent decades building a public portfolio/i)).not.toBeNull()
expect(screen.getByRole('link', { name: /view profile/i }).getAttribute('href')).toBe('/@gregor')
expect(screen.getByRole('link', { name: /open gallery/i }).getAttribute('href')).toBe('/@gregor/gallery')
})
it('shows a fallback message when no public biography exists', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ data: null }),
})
render(
<AuthorBioPopover author={{ name: 'Gregor', username: 'gregor', profile_url: '/@gregor' }} />,
)
await user.click(screen.getByRole('button', { name: /more about gregor/i }))
expect(await screen.findByText(/no public biography available yet/i)).not.toBeNull()
})
})

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'
import AuthorBioPopover from './AuthorBioPopover'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
@@ -15,6 +16,9 @@ function toCard(item) {
id: item?.id || item?.slug || item?.url,
title: item?.title,
author: item?.author,
authorId: Number(item?.author_id || 0),
publisherType: item?.publisher_type || 'user',
publisherId: Number(item?.publisher_id || 0),
url: item?.url,
thumb: item?.thumb,
thumbSrcSet: item?.thumb_srcset,
@@ -28,21 +32,33 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
const [followersCount, setFollowersCount] = useState(Number(isGroupPublisher ? publisher?.followers_count || 0 : artwork?.user?.followers_count || 0))
const user = artwork?.credits?.primary_author || artwork?.user || {}
const primaryAuthor = artwork?.credits?.primary_author || null
const bioAuthor = isGroupPublisher ? primaryAuthor : user
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
const authorName = isGroupPublisher ? (publisher?.name || 'Group') : (user.name || user.username || 'Artist')
const profileUrl = isGroupPublisher ? (publisher?.profile_url || '#') : (user.profile_url || (user.username ? `/@${user.username}` : '#'))
const avatar = (isGroupPublisher ? publisher?.avatar_url : user.avatar_url) || presentSq?.url || AVATAR_FALLBACK
const creatorItems = useMemo(() => {
const currentAuthorId = Number(user?.id || 0)
const currentPublisherId = Number(publisher?.id || user?.id || 0)
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
const sameAuthor = String(item?.author || '').trim().toLowerCase() === String(authorName || '').trim().toLowerCase()
const notCurrent = item?.url && item.url !== artwork?.canonical_url
return sameAuthor && notCurrent
if (!notCurrent) {
return false
}
if (isGroupPublisher) {
return item?.publisher_type === 'group' && Number(item?.publisher_id || 0) === currentPublisherId
}
return Number(item?.author_id || 0) === currentAuthorId
})
const source = filtered.length > 0 ? filtered : (Array.isArray(related) ? related : [])
return source.slice(0, 12).map(toCard)
}, [related, authorName, artwork?.canonical_url])
return filtered.slice(0, 12).map(toCard)
}, [related, isGroupPublisher, publisher?.id, user?.id, artwork?.canonical_url])
return (
<>
@@ -62,11 +78,18 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
/>
</a>
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{!isGroupPublisher && user.username && <p className="text-xs text-white/40">@{user.username}</p>}
{isGroupPublisher && artwork?.credits?.primary_author ? <p className="text-xs text-white/40">Primary author: {artwork.credits.primary_author.name || artwork.credits.primary_author.username}</p> : null}
<div className="relative mt-3 w-full px-10 text-center">
<a href={profileUrl} className="block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{!isGroupPublisher && user.username ? <p className="text-xs text-white/40">@{user.username}</p> : null}
{isGroupPublisher && primaryAuthor ? <p className="text-xs text-white/40">Primary author: {primaryAuthor.name || primaryAuthor.username}</p> : null}
{bioAuthor?.username ? (
<span className="absolute right-0 top-1/2 -translate-y-1/2">
<AuthorBioPopover author={bioAuthor} />
</span>
) : null}
</div>
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>

View File

@@ -0,0 +1,79 @@
import React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import CreatorSpotlight from './CreatorSpotlight'
describe('CreatorSpotlight related rail', () => {
afterEach(() => {
cleanup()
})
it('shows only artworks from the same author id', () => {
render(
<CreatorSpotlight
artwork={{
canonical_url: '/art/470/words',
viewer: { id: 2 },
user: { id: 2, name: 'psych0', username: 'psych0', profile_url: '/@psych0', followers_count: 25 },
credits: {},
}}
presentSq={{ url: '/thumb/current.jpg' }}
related={[
{
id: 101,
title: 'Same author work',
author: 'Completely different display name',
author_id: 2,
publisher_type: 'user',
publisher_id: 2,
url: '/art/101/same-author-work',
thumb: '/thumb/101.jpg',
},
{
id: 202,
title: 'Wrong author work',
author: 'psych0',
author_id: 99,
publisher_type: 'user',
publisher_id: 99,
url: '/art/202/wrong-author-work',
thumb: '/thumb/202.jpg',
},
]}
/>,
)
expect(screen.getByText(/more from psych0/i)).not.toBeNull()
expect(screen.getByRole('link', { name: /same author work/i }).getAttribute('href')).toBe('/art/101/same-author-work')
expect(screen.queryByRole('link', { name: /wrong author work/i })).toBeNull()
})
it('hides the rail when there are no same-author works', () => {
render(
<CreatorSpotlight
artwork={{
canonical_url: '/art/470/words',
viewer: { id: 2 },
user: { id: 2, name: 'psych0', username: 'psych0', profile_url: '/@psych0', followers_count: 25 },
credits: {},
}}
presentSq={{ url: '/thumb/current.jpg' }}
related={[
{
id: 202,
title: 'Wrong author work',
author: 'psych0',
author_id: 99,
publisher_type: 'user',
publisher_id: 99,
url: '/art/202/wrong-author-work',
thumb: '/thumb/202.jpg',
},
]}
/>,
)
expect(screen.queryByText(/more from psych0/i)).toBeNull()
expect(screen.queryByRole('link', { name: /wrong author work/i })).toBeNull()
})
})

View File

@@ -0,0 +1,48 @@
import React from 'react'
function toneClasses(tone) {
switch (tone) {
case 'featured':
return 'border-amber-300/30 bg-amber-400/12 text-amber-50 hover:border-amber-300/45 hover:bg-amber-400/18'
case 'community':
return 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/40 hover:bg-sky-300/15'
case 'curated':
return 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100 hover:border-emerald-300/40 hover:bg-emerald-400/15'
default:
return 'border-white/10 bg-white/[0.04] text-white hover:border-white/20 hover:bg-white/[0.07]'
}
}
export default function WorldParticipationBadge({ items = [] }) {
const badges = Array.isArray(items) ? items : []
if (badges.length === 0) {
return null
}
return (
<div className="mt-4 flex flex-wrap items-center gap-2.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">World participation</span>
{badges.map((item) => {
const label = item?.badge_label || item?.world_title || 'World participation'
const badgeClassName = `inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition ${toneClasses(item?.tone)}`
if (item?.world_url) {
return (
<a key={`${item.world_id}-${item.status || item.tone || 'world'}`} href={item.world_url} className={badgeClassName}>
<i className="fa-solid fa-globe text-[11px]" />
<span>{label}</span>
</a>
)
}
return (
<span key={`${item.world_id}-${item.status || item.tone || 'world'}`} className={badgeClassName}>
<i className="fa-solid fa-globe text-[11px]" />
<span>{label}</span>
</span>
)
})}
</div>
)
}