Save workspace changes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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]'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
267
resources/js/components/artwork/ArtworkFormatBadges.jsx
Normal file
267
resources/js/components/artwork/ArtworkFormatBadges.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
162
resources/js/components/artwork/AuthorBioPopover.jsx
Normal file
162
resources/js/components/artwork/AuthorBioPopover.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
resources/js/components/artwork/AuthorBioPopover.test.jsx
Normal file
68
resources/js/components/artwork/AuthorBioPopover.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
79
resources/js/components/artwork/CreatorSpotlight.test.jsx
Normal file
79
resources/js/components/artwork/CreatorSpotlight.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
48
resources/js/components/artwork/WorldParticipationBadge.jsx
Normal file
48
resources/js/components/artwork/WorldParticipationBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user