Save workspace changes
This commit is contained in:
@@ -21,6 +21,7 @@ export default function Topbar({ user = null }) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<a href="/worlds" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Worlds</a>
|
||||
<a href="/groups" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Groups</a>
|
||||
<a href="/community/activity" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Community</a>
|
||||
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ async function fetchPageData(url) {
|
||||
: null;
|
||||
|
||||
return {
|
||||
artworks,
|
||||
artworks: artworks.map(normalizeArtworkItem),
|
||||
nextCursor,
|
||||
nextPageUrl,
|
||||
hasMore,
|
||||
@@ -127,7 +127,7 @@ async function fetchPageData(url) {
|
||||
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
|
||||
|
||||
return {
|
||||
artworks,
|
||||
artworks: artworks.map(normalizeArtworkItem),
|
||||
nextCursor: el.dataset.nextCursor || null,
|
||||
nextPageUrl: el.dataset.nextPageUrl || null,
|
||||
hasMore: null,
|
||||
@@ -142,37 +142,61 @@ function SkeletonCard() {
|
||||
|
||||
// ── Ranking API helpers ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
|
||||
* artwork object shape used by ArtworkCard.
|
||||
* Normalize API / Blade artwork payloads into the internal shape used by the
|
||||
* gallery layout helpers and ArtworkCard.
|
||||
*/
|
||||
function mapRankApiArtwork(item) {
|
||||
const w = item.dimensions?.width ?? null;
|
||||
const h = item.dimensions?.height ?? null;
|
||||
const thumb = item.thumbnail_url ?? null;
|
||||
const webUrl = item.urls?.web ?? item.category?.url ?? null;
|
||||
function normalizeArtworkItem(item) {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
|
||||
const category = item.category && typeof item.category === 'object' ? item.category : null;
|
||||
const author = item.author && typeof item.author === 'object'
|
||||
? item.author
|
||||
: (item.creator && typeof item.creator === 'object' ? item.creator : null);
|
||||
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
|
||||
const w = item.dimensions?.width ?? item.width ?? null;
|
||||
const h = item.dimensions?.height ?? item.height ?? null;
|
||||
const thumb = item.thumbnail_url ?? item.thumb_url ?? item.thumb ?? item.image ?? null;
|
||||
const canonicalUrl = item.canonical_url
|
||||
?? item.urls?.canonical
|
||||
?? item.urls?.direct
|
||||
?? item.url
|
||||
?? item.href
|
||||
?? item.urls?.web
|
||||
?? category?.url
|
||||
?? null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: item.id ?? null,
|
||||
name: item.title ?? item.name ?? null,
|
||||
thumb: thumb,
|
||||
thumb_url: thumb,
|
||||
uname: item.author?.name ?? '',
|
||||
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
|
||||
avatar_url: item.author?.avatar_url ?? null,
|
||||
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
|
||||
published_as_type: publisher?.type ?? null,
|
||||
publisher: publisher,
|
||||
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||
category_name: item.category?.name ?? '',
|
||||
category_slug: item.category?.slug ?? '',
|
||||
name: item.name ?? item.title ?? null,
|
||||
title: item.title ?? item.name ?? null,
|
||||
thumb: item.thumb ?? thumb,
|
||||
thumb_url: item.thumb_url ?? thumb,
|
||||
thumbnail_url: item.thumbnail_url ?? thumb,
|
||||
author,
|
||||
uname: item.uname ?? author?.name ?? item.author_name ?? '',
|
||||
username: item.username ?? (publisher?.type === 'group' ? '' : (author?.username ?? '')),
|
||||
avatar_url: item.avatar_url ?? author?.avatar_url ?? null,
|
||||
profile_url: item.profile_url ?? publisher?.profile_url ?? author?.profile_url ?? null,
|
||||
published_as_type: item.published_as_type ?? publisher?.type ?? null,
|
||||
publisher: publisher ?? null,
|
||||
content_type_name: item.content_type_name ?? category?.content_type_name ?? category?.content_type_slug ?? category?.content_type ?? '',
|
||||
content_type_slug: item.content_type_slug ?? category?.content_type_slug ?? category?.content_type ?? '',
|
||||
category_name: item.category_name ?? category?.name ?? (typeof item.category === 'string' ? item.category : ''),
|
||||
category_slug: item.category_slug ?? category?.slug ?? '',
|
||||
category: typeof item.category === 'string' ? item.category : (category?.name ?? item.category_name ?? ''),
|
||||
slug: item.slug ?? '',
|
||||
url: webUrl,
|
||||
canonical_url: item.canonical_url ?? item.urls?.canonical ?? item.urls?.direct ?? null,
|
||||
url: canonicalUrl,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRankApiArtwork(item) {
|
||||
return normalizeArtworkItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ranked artworks from the ranking API.
|
||||
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
|
||||
@@ -261,7 +285,8 @@ function MasonryGallery({
|
||||
discoveryEndpoint = null,
|
||||
algoVersion: initialAlgoVersion = null,
|
||||
}) {
|
||||
const [artworks, setArtworks] = useState(initialArtworks);
|
||||
const normalizedInitialArtworks = initialArtworks.map(normalizeArtworkItem);
|
||||
const [artworks, setArtworks] = useState(normalizedInitialArtworks);
|
||||
const [nextCursor, setNextCursor] = useState(initialNextCursor);
|
||||
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -279,7 +304,7 @@ function MasonryGallery({
|
||||
// client-side fetch from the ranking API to hydrate the grid.
|
||||
// Satisfies spec: "Fallback: Latest if ranking missing".
|
||||
useEffect(() => {
|
||||
if (initialArtworks.length > 0) return; // SSR artworks already present
|
||||
if (normalizedInitialArtworks.length > 0) return; // SSR artworks already present
|
||||
if (!rankApiEndpoint) return; // no API endpoint configured
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
|
||||
</a>
|
||||
) : null}
|
||||
{type === 'group' && entity.headline ? <p className="mt-1 text-sm text-slate-400">{entity.headline}</p> : null}
|
||||
{type === 'world' && entity.summary ? <p className="mt-1 text-sm text-slate-400">{entity.summary}</p> : null}
|
||||
{entity.username ? <p className="mt-1 text-sm text-slate-500">@{entity.username}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +66,21 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
|
||||
{Number(entity.artworks_count || 0).toLocaleString()} artworks, {Number(entity.members_count || 0).toLocaleString()} members, {Number(entity.followers_count || 0).toLocaleString()} followers
|
||||
</span>
|
||||
) : null}
|
||||
{type === 'world' ? (
|
||||
<span className="text-xs text-slate-400">
|
||||
{Number(entity.relations_count || 0).toLocaleString()} curated links, {Number(entity.approved_submissions_count || 0).toLocaleString()} approved submissions{entity.timeframe_label ? `, ${entity.timeframe_label}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
{type === 'world' && entity.badge_label ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
|
||||
{entity.badge_label}
|
||||
</span>
|
||||
) : null}
|
||||
{type === 'world' && entity.theme_label ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
|
||||
{entity.theme_label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,40 +25,39 @@ function formatYear(value) {
|
||||
|
||||
function iconForType(type) {
|
||||
switch (type) {
|
||||
case 'first_upload':
|
||||
return 'fa-solid fa-seedling'
|
||||
case 'first_featured_artwork':
|
||||
return 'fa-solid fa-star'
|
||||
case 'first_group_release':
|
||||
return 'fa-solid fa-people-group'
|
||||
case 'biggest_download_spike':
|
||||
return 'fa-solid fa-bolt'
|
||||
case 'best_performing_work':
|
||||
return 'fa-solid fa-trophy'
|
||||
case 'most_productive_year':
|
||||
return 'fa-solid fa-calendar-check'
|
||||
case 'yearly_recap':
|
||||
return 'fa-solid fa-chart-column'
|
||||
// v2
|
||||
case 'comeback_minor':
|
||||
return 'fa-solid fa-rotate-right'
|
||||
case 'comeback_major':
|
||||
return 'fa-solid fa-person-walking-arrow-right'
|
||||
case 'comeback_legendary':
|
||||
return 'fa-solid fa-fire-flame-curved'
|
||||
case 'first_upload': return 'fa-solid fa-seedling'
|
||||
case 'first_featured_artwork': return 'fa-solid fa-star'
|
||||
case 'first_group_release': return 'fa-solid fa-people-group'
|
||||
case 'biggest_download_spike': return 'fa-solid fa-bolt'
|
||||
case 'best_performing_work': return 'fa-solid fa-trophy'
|
||||
case 'most_productive_year': return 'fa-solid fa-calendar-check'
|
||||
case 'yearly_recap': return 'fa-solid fa-chart-column'
|
||||
case 'comeback_minor': return 'fa-solid fa-rotate-right'
|
||||
case 'comeback_major': return 'fa-solid fa-person-walking-arrow-right'
|
||||
case 'comeback_legendary': return 'fa-solid fa-fire-flame-curved'
|
||||
case 'upload_streak_3':
|
||||
case 'upload_streak_6':
|
||||
case 'upload_streak_12':
|
||||
return 'fa-solid fa-fire'
|
||||
case 'upload_streak_12': return 'fa-solid fa-fire'
|
||||
case 'active_year_streak_3':
|
||||
case 'active_year_streak_5':
|
||||
return 'fa-solid fa-calendar-days'
|
||||
case 'before_now':
|
||||
return 'fa-solid fa-arrows-rotate'
|
||||
case 'era_started':
|
||||
return 'fa-solid fa-flag'
|
||||
default:
|
||||
return 'fa-solid fa-sparkles'
|
||||
case 'active_year_streak_5': return 'fa-solid fa-calendar-days'
|
||||
case 'before_now': return 'fa-solid fa-arrows-rotate'
|
||||
case 'era_started': return 'fa-solid fa-flag'
|
||||
default: return 'fa-solid fa-sparkles'
|
||||
}
|
||||
}
|
||||
|
||||
function colorForType(type) {
|
||||
switch (type) {
|
||||
case 'first_featured_artwork': return { icon: 'text-amber-200', bg: 'bg-amber-400/12', border: 'border-amber-300/20', accent: 'from-amber-400/60' }
|
||||
case 'best_performing_work': return { icon: 'text-amber-200', bg: 'bg-amber-400/12', border: 'border-amber-300/20', accent: 'from-amber-400/60' }
|
||||
case 'biggest_download_spike': return { icon: 'text-sky-200', bg: 'bg-sky-400/12', border: 'border-sky-300/20', accent: 'from-sky-400/60' }
|
||||
case 'first_upload': return { icon: 'text-emerald-200', bg: 'bg-emerald-400/12', border: 'border-emerald-300/20', accent: 'from-emerald-400/60' }
|
||||
case 'first_group_release': return { icon: 'text-violet-200', bg: 'bg-violet-400/12', border: 'border-violet-300/20', accent: 'from-violet-400/60' }
|
||||
case 'comeback_minor':
|
||||
case 'comeback_major':
|
||||
case 'comeback_legendary': return { icon: 'text-orange-200', bg: 'bg-orange-400/12', border: 'border-orange-300/20', accent: 'from-orange-400/60' }
|
||||
case 'most_productive_year': return { icon: 'text-teal-200', bg: 'bg-teal-400/12', border: 'border-teal-300/20', accent: 'from-teal-400/60' }
|
||||
default: return { icon: 'text-sky-200', bg: 'bg-sky-400/12', border: 'border-sky-300/20', accent: 'from-sky-400/60' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +202,103 @@ function StreaksSection({ streaks }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Yearly Productivity Chart ────────────────────────────────────────────────
|
||||
|
||||
const STATUS_BAR_COLOR = {
|
||||
breakout: { bar: 'bg-emerald-400', label: 'bg-emerald-400/12 text-emerald-200 border-emerald-400/20' },
|
||||
steady: { bar: 'bg-sky-400', label: 'bg-sky-400/12 text-sky-200 border-sky-400/20' },
|
||||
experimental: { bar: 'bg-violet-400', label: 'bg-violet-400/12 text-violet-200 border-violet-400/20' },
|
||||
comeback: { bar: 'bg-amber-400', label: 'bg-amber-400/12 text-amber-200 border-amber-400/20' },
|
||||
quiet: { bar: 'bg-slate-500', label: 'bg-slate-700/60 text-slate-400 border-slate-600/30' },
|
||||
}
|
||||
|
||||
function YearlyProductivityChart({ recaps }) {
|
||||
if (!recaps?.length) return null
|
||||
|
||||
// Sort oldest → newest for the chart
|
||||
const sorted = [...recaps]
|
||||
.filter((r) => r.metrics?.year && r.metrics?.uploads_count != null)
|
||||
.sort((a, b) => (a.metrics.year ?? 0) - (b.metrics.year ?? 0))
|
||||
|
||||
if (!sorted.length) return null
|
||||
|
||||
const maxUploads = Math.max(...sorted.map((r) => r.metrics.uploads_count), 1)
|
||||
|
||||
return (
|
||||
<div className="mt-7 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">Productivity</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">Year-by-year upload activity</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
|
||||
{Object.entries(STATUS_BAR_COLOR)
|
||||
.filter(([key]) => sorted.some((r) => (r.metrics?.year_status ?? 'steady') === key))
|
||||
.map(([key, val]) => (
|
||||
<span key={key} className="flex items-center gap-1.5 capitalize">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${val.bar}`} />
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{sorted.map((item) => {
|
||||
const uploads = item.metrics.uploads_count
|
||||
const pct = Math.max(uploads / maxUploads, uploads > 0 ? 0.018 : 0)
|
||||
const status = item.metrics?.year_status ?? 'steady'
|
||||
const colors = STATUS_BAR_COLOR[status] ?? STATUS_BAR_COLOR.steady
|
||||
const isBest = uploads === maxUploads
|
||||
|
||||
return (
|
||||
<div key={item.metrics.year} className="group grid grid-cols-[3.5rem_minmax(0,1fr)_4rem] items-center gap-3">
|
||||
{/* Year label */}
|
||||
<div className={`text-right text-[13px] font-semibold tabular-nums ${isBest ? 'text-white' : 'text-slate-400'}`}>
|
||||
{item.metrics.year}
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="relative h-7 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 rounded-full ${colors.bar} opacity-80 transition-all duration-500 group-hover:opacity-100`}
|
||||
style={{ width: `${(pct * 100).toFixed(1)}%` }}
|
||||
/>
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute inset-y-0 left-0 flex w-full items-center px-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="text-[11px] font-semibold text-white drop-shadow-sm">
|
||||
{uploads} upload{uploads !== 1 ? 's' : ''}
|
||||
{(item.metrics?.views ?? 0) > 0 ? ` · ${Number(item.metrics.views).toLocaleString()} views` : ''}
|
||||
{(item.metrics?.downloads ?? 0) > 0 ? ` · ${Number(item.metrics.downloads).toLocaleString()} dl` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload count + badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[13px] font-bold tabular-nums ${isBest ? 'text-white' : 'text-slate-300'}`}>{uploads}</span>
|
||||
{isBest && <span className="rounded-full border border-amber-400/25 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-amber-300">best</span>}
|
||||
{(item.metrics?.featured_count ?? 0) > 0 && !isBest && (
|
||||
<span className="rounded-full border border-sky-400/20 bg-sky-400/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-sky-300">
|
||||
<i className="fa-solid fa-star text-[8px]" /> {item.metrics.featured_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="mt-5 flex flex-wrap gap-4 border-t border-white/5 pt-4 text-[12px] text-slate-400">
|
||||
<span><span className="font-semibold text-white">{sorted.length}</span> active years</span>
|
||||
<span><span className="font-semibold text-white">{sorted.reduce((s, r) => s + r.metrics.uploads_count, 0).toLocaleString()}</span> total uploads</span>
|
||||
<span><span className="font-semibold text-white">{Number(sorted.reduce((s, r) => s + (r.metrics.views ?? 0), 0)).toLocaleString()}</span> total views</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── v2: Growth & Evolution ───────────────────────────────────────────────────
|
||||
|
||||
const RELATION_LABELS = {
|
||||
@@ -276,6 +372,7 @@ export default function CreatorJourneySection({ journey, username }) {
|
||||
const highlights = Array.isArray(journey?.highlights) ? journey.highlights : []
|
||||
const timeline = Array.isArray(journey?.timeline) ? journey.timeline.slice(0, 6) : []
|
||||
const recaps = Array.isArray(journey?.yearly_recaps) ? journey.yearly_recaps.slice(0, 3) : []
|
||||
const allRecaps = Array.isArray(journey?.yearly_recaps) ? journey.yearly_recaps : []
|
||||
const eras = Array.isArray(journey?.eras) ? journey.eras : []
|
||||
const evolution = Array.isArray(journey?.evolution) ? journey.evolution : []
|
||||
const streaks = journey?.streaks ?? null
|
||||
@@ -333,11 +430,13 @@ export default function CreatorJourneySection({ journey, username }) {
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5"
|
||||
className="relative overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5 transition-colors hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))]"
|
||||
>
|
||||
{(() => { const c = colorForType(item.type); return <div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${c.accent} via-transparent to-transparent`} /> })()
|
||||
}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-400/12 text-sky-200">
|
||||
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${colorForType(item.type).bg} ${colorForType(item.type).icon}`}>
|
||||
<i className={iconForType(item.type)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -371,7 +470,7 @@ export default function CreatorJourneySection({ journey, username }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-7 grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(18rem,0.95fr)]">
|
||||
<div className="mt-7 grid gap-6">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -387,7 +486,7 @@ export default function CreatorJourneySection({ journey, username }) {
|
||||
return (
|
||||
<div key={item.id} className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-slate-100">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-2xl border ${colorForType(item.type).border} ${colorForType(item.type).bg} ${colorForType(item.type).icon}`}>
|
||||
<i className={iconForType(item.type)} />
|
||||
</div>
|
||||
{index < timeline.length - 1 && <div className="mt-2 h-full w-px bg-white/10" />}
|
||||
@@ -470,6 +569,9 @@ export default function CreatorJourneySection({ journey, username }) {
|
||||
{/* ── v2: Streaks ── */}
|
||||
<StreaksSection streaks={streaks} />
|
||||
|
||||
{/* ── Yearly productivity chart ── */}
|
||||
<YearlyProductivityChart recaps={allRecaps} />
|
||||
|
||||
{/* ── v2: Growth & Evolution ── */}
|
||||
<EvolutionSection evolution={evolution} />
|
||||
</section>
|
||||
|
||||
@@ -2,13 +2,65 @@ import React from 'react'
|
||||
import CreatorJourneySection from '../CreatorJourneySection'
|
||||
|
||||
const SOCIAL_ICONS = {
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
|
||||
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt' },
|
||||
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram' },
|
||||
behance: { icon: 'fa-brands fa-behance', label: 'Behance' },
|
||||
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation' },
|
||||
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube' },
|
||||
website: { icon: 'fa-solid fa-link', label: 'Website' },
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter', hoverClass: 'hover:border-slate-300/30 hover:text-slate-100 hover:bg-white/[0.08]' },
|
||||
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', hoverClass: 'hover:border-green-400/35 hover:text-green-300 hover:bg-green-900/20' },
|
||||
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', hoverClass: 'hover:border-pink-400/35 hover:text-pink-300 hover:bg-pink-900/20' },
|
||||
behance: { icon: 'fa-brands fa-behance', label: 'Behance', hoverClass: 'hover:border-blue-400/35 hover:text-blue-300 hover:bg-blue-900/20' },
|
||||
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation', hoverClass: 'hover:border-orange-400/35 hover:text-orange-300 hover:bg-orange-900/20' },
|
||||
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', hoverClass: 'hover:border-red-400/35 hover:text-red-300 hover:bg-red-900/20' },
|
||||
website: { icon: 'fa-solid fa-link', label: 'Website', hoverClass: 'hover:border-sky-400/35 hover:text-sky-200 hover:bg-sky-900/20' },
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
animals: 'fa-solid fa-paw',
|
||||
birds: 'fa-solid fa-dove',
|
||||
flowers: 'fa-solid fa-seedling',
|
||||
fruit: 'fa-solid fa-apple-whole',
|
||||
'sci-fi': 'fa-solid fa-rocket',
|
||||
scifi: 'fa-solid fa-rocket',
|
||||
fantasy: 'fa-solid fa-dragon',
|
||||
nature: 'fa-solid fa-leaf',
|
||||
landscape: 'fa-solid fa-mountain',
|
||||
abstract: 'fa-solid fa-shapes',
|
||||
architecture: 'fa-solid fa-building',
|
||||
people: 'fa-solid fa-person',
|
||||
portrait: 'fa-solid fa-face-smile',
|
||||
cars: 'fa-solid fa-car',
|
||||
space: 'fa-solid fa-star',
|
||||
games: 'fa-solid fa-gamepad',
|
||||
food: 'fa-solid fa-utensils',
|
||||
travel: 'fa-solid fa-plane',
|
||||
sports: 'fa-solid fa-football',
|
||||
ocean: 'fa-solid fa-water',
|
||||
underwater: 'fa-solid fa-fish',
|
||||
insects: 'fa-solid fa-bug',
|
||||
reptiles: 'fa-solid fa-dragon',
|
||||
cats: 'fa-solid fa-cat',
|
||||
dogs: 'fa-solid fa-dog',
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_ICONS = {
|
||||
photography: 'fa-solid fa-camera',
|
||||
wallpapers: 'fa-solid fa-desktop',
|
||||
'digital art': 'fa-solid fa-wand-magic-sparkles',
|
||||
illustration: 'fa-solid fa-pen-nib',
|
||||
'3d': 'fa-solid fa-cube',
|
||||
vector: 'fa-solid fa-bezier-curve',
|
||||
fractal: 'fa-solid fa-infinity',
|
||||
gif: 'fa-solid fa-film',
|
||||
drawing: 'fa-solid fa-pencil',
|
||||
painting: 'fa-solid fa-paintbrush',
|
||||
photo: 'fa-solid fa-camera',
|
||||
}
|
||||
|
||||
function getCategoryIcon(label) {
|
||||
const key = String(label || '').toLowerCase().trim()
|
||||
return CATEGORY_ICONS[key] ?? null
|
||||
}
|
||||
|
||||
function getContentTypeIcon(label) {
|
||||
const key = String(label || '').toLowerCase().trim()
|
||||
return CONTENT_TYPE_ICONS[key] ?? null
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
@@ -120,11 +172,13 @@ function buildInterestGroups(artworks = []) {
|
||||
|
||||
function InfoRow({ icon, label, children }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
<i className={`fa-solid ${icon} fa-fw text-slate-500 mt-0.5 w-4 text-center`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-slate-500 block mb-0.5">{label}</span>
|
||||
<div className="text-sm text-slate-200">{children}</div>
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/[0.07] bg-white/[0.025] px-3.5 py-3 transition-colors hover:bg-white/[0.045]">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] text-slate-400">
|
||||
<i className={`fa-solid ${icon} fa-fw text-[13px]`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 block">{label}</span>
|
||||
<div className="mt-0.5 text-sm text-slate-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -132,19 +186,21 @@ function InfoRow({ icon, label, children }) {
|
||||
|
||||
function StatCard({ icon, label, value, tone = 'sky' }) {
|
||||
const tones = {
|
||||
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
|
||||
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
|
||||
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
|
||||
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
|
||||
sky: { icon: 'text-sky-300 bg-sky-400/10 border-sky-300/20', bar: 'from-sky-400/60 via-sky-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(56,189,248,0.10)]' },
|
||||
amber: { icon: 'text-amber-200 bg-amber-300/10 border-amber-300/20', bar: 'from-amber-400/60 via-amber-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(251,191,36,0.10)]' },
|
||||
emerald: { icon: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/20', bar: 'from-emerald-400/60 via-emerald-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(52,211,153,0.10)]' },
|
||||
violet: { icon: 'text-violet-200 bg-violet-400/10 border-violet-300/20', bar: 'from-violet-400/60 via-violet-400/20 to-transparent', glow: 'shadow-[0_0_28px_rgba(167,139,250,0.10)]' },
|
||||
}
|
||||
const t = tones[tone] || tones.sky
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)]">
|
||||
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tones[tone] || tones.sky}`}>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
<div className={`relative overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)] ${t.glow}`}>
|
||||
<div className={`absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r ${t.bar}`} />
|
||||
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${t.icon}`}>
|
||||
<i className={`fa-solid ${icon} text-base`} />
|
||||
</div>
|
||||
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</div>
|
||||
<div className="mt-1 text-2xl font-bold tracking-tight text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -221,7 +277,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
id="tabpanel-about"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-about"
|
||||
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
|
||||
className="pt-4 pb-10"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
@@ -233,10 +289,16 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
<div className="space-y-6">
|
||||
<SectionCard icon="fa-solid fa-circle-info" eyebrow="Profile story" title={`About ${displayName}`} className="bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.05))]">
|
||||
{about ? (
|
||||
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
|
||||
<div className="relative">
|
||||
<div className="-mt-2 mb-0 select-none font-serif text-7xl leading-none text-slate-500/20" aria-hidden="true">“</div>
|
||||
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center text-sm text-slate-400">
|
||||
This creator has not written a public bio yet.
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
|
||||
<i className="fa-regular fa-comment-dots text-lg" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">This creator has not written a public bio yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
@@ -372,24 +434,27 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
{recentAchievements.slice(0, 4).map((achievement) => (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4 transition-colors hover:bg-white/[0.05]"
|
||||
className="group relative overflow-hidden rounded-2xl border border-amber-300/10 bg-white/[0.03] px-4 py-4 transition-all hover:border-amber-300/25 hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-amber-400/50 via-amber-400/20 to-transparent" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-300/10 text-amber-100">
|
||||
<i className={`fa-solid ${achievement.icon || 'fa-trophy'}`} />
|
||||
<div className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-amber-300/20 bg-amber-300/10 text-amber-200 shadow-[0_0_18px_rgba(251,191,36,0.12)] transition-shadow group-hover:shadow-[0_0_24px_rgba(251,191,36,0.2)]">
|
||||
<i className={`fa-solid ${achievement.icon || 'fa-trophy'} text-base`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{achievement.name}</div>
|
||||
{achievement.description ? (
|
||||
<div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{achievement.description}</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{achievement.unlocked_at ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
<i className="fa-solid fa-calendar-check text-[10px]" />
|
||||
{formatShortDate(achievement.unlocked_at) || 'Unlocked'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.14em] text-amber-200">
|
||||
<i className="fa-solid fa-bolt text-[9px]" />
|
||||
+{formatNumber(achievement.xp_reward ?? 0)} XP
|
||||
</span>
|
||||
</div>
|
||||
@@ -524,34 +589,48 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
<div className="space-y-5">
|
||||
{interestGroups.categories.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top categories</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
{interestGroups.categories.map((category) => (
|
||||
<span
|
||||
key={category.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm text-slate-200"
|
||||
>
|
||||
<span>{category.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-slate-400">{formatNumber(category.count)}</span>
|
||||
</span>
|
||||
))}
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<i className="fa-solid fa-tag text-slate-600" />
|
||||
Top categories
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{interestGroups.categories.map((category) => {
|
||||
const catIcon = getCategoryIcon(category.label)
|
||||
return (
|
||||
<span
|
||||
key={category.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm text-slate-200 transition-colors hover:bg-white/[0.07]"
|
||||
>
|
||||
{catIcon ? <i className={`${catIcon} text-[12px] text-slate-400`} /> : null}
|
||||
<span>{category.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-slate-400">{formatNumber(category.count)}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{interestGroups.contentTypes.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Preferred formats</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
{interestGroups.contentTypes.map((contentType) => (
|
||||
<span
|
||||
key={contentType.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100"
|
||||
>
|
||||
<span>{contentType.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-sky-100/70">{formatNumber(contentType.count)}</span>
|
||||
</span>
|
||||
))}
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<i className="fa-solid fa-layer-group text-slate-600" />
|
||||
Preferred formats
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{interestGroups.contentTypes.map((contentType) => {
|
||||
const ctIcon = getContentTypeIcon(contentType.label)
|
||||
return (
|
||||
<span
|
||||
key={contentType.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100 transition-colors hover:bg-sky-400/15"
|
||||
>
|
||||
{ctIcon ? <i className={`${ctIcon} text-[12px] text-sky-300/70`} /> : null}
|
||||
<span>{contentType.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-sky-100/70">{formatNumber(contentType.count)}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -572,7 +651,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2.5 text-sm text-slate-300 transition-all hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white"
|
||||
className={`inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2.5 text-sm text-slate-300 transition-all ${si.hoverClass || 'hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white'}`}
|
||||
aria-label={si.label}
|
||||
>
|
||||
<i className={`${si.icon} fa-fw`} />
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function NotificationDropdown({ initialUnreadCount = 0, notificat
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[28rem] overflow-y-auto">
|
||||
<div className="max-h-[28rem] overflow-y-auto nova-scrollbar">
|
||||
{loading ? <div className="px-4 py-6 text-sm text-white/45">Loading notifications…</div> : null}
|
||||
{!loading && items.length === 0 ? <div className="px-4 py-6 text-sm text-white/45">No notifications yet.</div> : null}
|
||||
{!loading && items.map((item) => (
|
||||
|
||||
436
resources/js/components/ui/DateTimePicker.jsx
Normal file
436
resources/js/components/ui/DateTimePicker.jsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const DAY_ABBR = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
function firstWeekday(year, month) {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
return (day + 6) % 7
|
||||
}
|
||||
|
||||
function toISODate(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
function parseDatePart(value) {
|
||||
if (!value) return null
|
||||
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
|
||||
if (!year || !month || !day) return null
|
||||
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
function splitDateTime(value) {
|
||||
if (!value) {
|
||||
return { date: '', time: '' }
|
||||
}
|
||||
|
||||
const [date = '', time = ''] = String(value).split('T')
|
||||
|
||||
return {
|
||||
date,
|
||||
time: time.slice(0, 5),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDateTime(date, time) {
|
||||
if (!date) return ''
|
||||
return `${date}T${time || '00:00'}`
|
||||
}
|
||||
|
||||
function formatDisplay(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const { date, time } = splitDateTime(value)
|
||||
const parsed = parseDatePart(date)
|
||||
|
||||
if (!parsed) return ''
|
||||
|
||||
return `${MONTH_NAMES[parsed.getMonth()].slice(0, 3)} ${parsed.getDate()}, ${parsed.getFullYear()}${time ? ` at ${time}` : ''}`
|
||||
}
|
||||
|
||||
function isSameDay(a, b) {
|
||||
return a?.getFullYear() === b?.getFullYear()
|
||||
&& a?.getMonth() === b?.getMonth()
|
||||
&& a?.getDate() === b?.getDate()
|
||||
}
|
||||
|
||||
function CalendarGrid({ year, month, selectedDate, onSelect, minDate, maxDate }) {
|
||||
const count = daysInMonth(year, month)
|
||||
const start = firstWeekday(year, month)
|
||||
const prevMonth = month - 1 < 0 ? 11 : month - 1
|
||||
const prevYear = month - 1 < 0 ? year - 1 : year
|
||||
const prevCount = daysInMonth(prevYear, prevMonth)
|
||||
const cells = []
|
||||
|
||||
for (let index = start - 1; index >= 0; index -= 1) {
|
||||
cells.push({
|
||||
day: prevCount - index,
|
||||
current: false,
|
||||
date: new Date(prevYear, prevMonth, prevCount - index),
|
||||
})
|
||||
}
|
||||
|
||||
for (let day = 1; day <= count; day += 1) {
|
||||
cells.push({ day, current: true, date: new Date(year, month, day) })
|
||||
}
|
||||
|
||||
let nextDay = 1
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({ day: nextDay, current: false, date: new Date(year, month + 1, nextDay) })
|
||||
nextDay += 1
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="mb-1 grid grid-cols-7">
|
||||
{DAY_ABBR.map((day) => (
|
||||
<div key={day} className="py-1 text-center text-[10px] font-semibold text-slate-500">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-y-0.5">
|
||||
{cells.map((cell, index) => {
|
||||
const iso = toISODate(cell.date)
|
||||
const selected = isSameDay(cell.date, selectedDate)
|
||||
const todayCell = isSameDay(cell.date, today)
|
||||
const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${iso}-${index}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(iso)}
|
||||
className={[
|
||||
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-all',
|
||||
!cell.current ? 'text-slate-600' : '',
|
||||
cell.current && !selected && !disabled ? 'text-white hover:bg-white/10' : '',
|
||||
selected ? 'bg-accent font-semibold text-white shadow shadow-accent/30' : '',
|
||||
todayCell && !selected ? 'text-accent ring-1 ring-accent/50' : '',
|
||||
disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
|
||||
].join(' ')}
|
||||
>
|
||||
{cell.day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DateTimePicker({
|
||||
value = '',
|
||||
onChange,
|
||||
label,
|
||||
placeholder = 'Pick a date and time',
|
||||
error,
|
||||
hint,
|
||||
required = false,
|
||||
clearable = false,
|
||||
id,
|
||||
disabled = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
className = '',
|
||||
}) {
|
||||
const today = new Date()
|
||||
const initial = splitDateTime(value)
|
||||
const initialDate = parseDatePart(initial.date) || today
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 320 })
|
||||
const [viewYear, setViewYear] = useState(initialDate.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
|
||||
const [draftDate, setDraftDate] = useState(initial.date)
|
||||
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
|
||||
|
||||
const triggerRef = useRef(null)
|
||||
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
|
||||
const panelId = `dtp-panel-${inputId}`
|
||||
|
||||
useEffect(() => {
|
||||
const next = splitDateTime(value)
|
||||
setDraftDate(next.date)
|
||||
setDraftTime(next.time || '12:00')
|
||||
|
||||
const nextDate = parseDatePart(next.date)
|
||||
if (nextDate) {
|
||||
setViewYear(nextDate.getFullYear())
|
||||
setViewMonth(nextDate.getMonth())
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const panelWidth = Math.max(rect.width, 320)
|
||||
const panelHeight = 420
|
||||
const openUp = window.innerHeight - rect.bottom < panelHeight + 8 && rect.top > panelHeight + 8
|
||||
|
||||
setDropPos({
|
||||
top: openUp ? rect.top - panelHeight - 4 : rect.bottom + 4,
|
||||
left: Math.min(rect.left, window.innerWidth - panelWidth - 8),
|
||||
width: panelWidth,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
if (disabled) return
|
||||
measure()
|
||||
setOpen(true)
|
||||
}, [disabled, measure])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const handleMouseDown = (event) => {
|
||||
if (!triggerRef.current?.contains(event.target) && !document.getElementById(panelId)?.contains(event.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown)
|
||||
}, [open, panelId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const handleScroll = (event) => {
|
||||
if (document.getElementById(panelId)?.contains(event.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleResize = () => setOpen(false)
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [open, panelId])
|
||||
|
||||
const applyValue = useCallback((date, time) => {
|
||||
onChange?.(date ? mergeDateTime(date, time) : '')
|
||||
}, [onChange])
|
||||
|
||||
const handleDateSelect = (nextDate) => {
|
||||
setDraftDate(nextDate)
|
||||
applyValue(nextDate, draftTime)
|
||||
}
|
||||
|
||||
const handleTimeChange = (event) => {
|
||||
const nextTime = event.target.value
|
||||
setDraftTime(nextTime)
|
||||
applyValue(draftDate, nextTime)
|
||||
}
|
||||
|
||||
const clearValue = (event) => {
|
||||
event.stopPropagation()
|
||||
setDraftDate('')
|
||||
setDraftTime('12:00')
|
||||
onChange?.('')
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11)
|
||||
setViewYear((current) => current - 1)
|
||||
return
|
||||
}
|
||||
|
||||
setViewMonth((current) => current - 1)
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0)
|
||||
setViewYear((current) => current + 1)
|
||||
return
|
||||
}
|
||||
|
||||
setViewMonth((current) => current + 1)
|
||||
}
|
||||
|
||||
const triggerClass = [
|
||||
'relative flex h-[42px] w-full cursor-pointer items-center gap-2 rounded-xl border px-3.5 text-sm transition-all duration-150',
|
||||
'bg-white/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
|
||||
error
|
||||
? 'border-red-500/60 focus-visible:ring-red-500/40'
|
||||
: open
|
||||
? 'border-accent/50 ring-2 ring-accent/40'
|
||||
: 'border-white/12 hover:border-white/22',
|
||||
disabled ? 'pointer-events-none cursor-not-allowed opacity-50' : '',
|
||||
className,
|
||||
].join(' ')
|
||||
|
||||
const selectedDate = parseDatePart(draftDate)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-red-400">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={triggerRef}
|
||||
id={inputId}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={label ?? placeholder}
|
||||
className={triggerClass}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openPicker()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0 text-slate-500" aria-hidden="true">
|
||||
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<circle cx="4.5" cy="9" r="0.75" fill="currentColor" />
|
||||
<circle cx="7" cy="9" r="0.75" fill="currentColor" />
|
||||
<circle cx="9.5" cy="9" r="0.75" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
|
||||
{value ? formatDisplay(value) : placeholder}
|
||||
</span>
|
||||
|
||||
{clearable && value && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={clearValue}
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 transition-colors hover:text-white"
|
||||
aria-label="Clear date and time"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
||||
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
id={panelId}
|
||||
className="fixed z-[500] overflow-hidden rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50"
|
||||
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMonth}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-semibold text-white">{MONTH_NAMES[viewMonth]} {viewYear}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMonth}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CalendarGrid
|
||||
year={viewYear}
|
||||
month={viewMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
/>
|
||||
|
||||
<div className="border-t border-white/8 px-4 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
|
||||
{draftDate ? formatDisplay(mergeDateTime(draftDate, draftTime)).replace(` at ${draftTime}`, '') : 'Pick a day'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-1.5 text-sm text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draftTime}
|
||||
onChange={handleTimeChange}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDateSelect(toISODate(new Date()))}
|
||||
className="text-xs font-medium text-accent transition-colors hover:text-accent/80"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export default function NovaConfirmDialog({
|
||||
open,
|
||||
title = 'Please confirm',
|
||||
message,
|
||||
children,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
confirmTone = 'danger',
|
||||
@@ -65,7 +66,8 @@ export default function NovaConfirmDialog({
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<p className="text-sm leading-6 text-white/70">{message}</p>
|
||||
{message ? <p className="text-sm leading-6 text-white/70">{message}</p> : null}
|
||||
{children ? <div className={message ? 'mt-4' : ''}>{children}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
|
||||
@@ -18,5 +18,6 @@ export { default as Checkbox } from './Checkbox'
|
||||
export { default as Radio, RadioGroup } from './Radio'
|
||||
export { default as Toggle } from './Toggle'
|
||||
export { default as DatePicker } from './DatePicker'
|
||||
export { default as DateTimePicker } from './DateTimePicker'
|
||||
export { default as DateRangePicker } from './DateRangePicker'
|
||||
export { default as Modal } from './Modal'
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function UploadSidebar({
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
onToggleMature,
|
||||
onToggleRights,
|
||||
}) {
|
||||
return (
|
||||
@@ -100,18 +99,6 @@ export default function UploadSidebar({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<Checkbox
|
||||
id="upload-sidebar-mature"
|
||||
checked={Boolean(metadata.isMature)}
|
||||
onChange={(event) => onToggleMature?.(event.target.checked)}
|
||||
variant="accent"
|
||||
size={20}
|
||||
label="Mark this artwork as mature content."
|
||||
hint="Use this for NSFW, explicit, or otherwise age-restricted artwork."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<Checkbox
|
||||
id="upload-sidebar-rights"
|
||||
|
||||
@@ -36,7 +36,7 @@ const wizardSteps = [
|
||||
{ key: 'publish', label: 'Publish' },
|
||||
]
|
||||
|
||||
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}) {
|
||||
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}, eligibleWorlds = []) {
|
||||
const normalizedGroupSlug = String(initialGroupSlug || '').trim()
|
||||
const contributors = Array.isArray(contributorOptionsByGroup?.[normalizedGroupSlug])
|
||||
? contributorOptionsByGroup[normalizedGroupSlug]
|
||||
@@ -58,6 +58,9 @@ function createInitialMetadata(initialGroupSlug = '', currentUserId = null, cont
|
||||
primaryAuthorUserId: defaultPrimaryAuthor,
|
||||
contributorUserIds: [],
|
||||
contributorCredits: {},
|
||||
worldSubmissions: Array.isArray(eligibleWorlds)
|
||||
? eligibleWorlds.map((world) => ({ ...world, selected: Boolean(world.selected), note: world.note || '' }))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +110,7 @@ export default function UploadWizard({
|
||||
chunkRequestTimeoutMs,
|
||||
contentTypes = [],
|
||||
suggestedTags = [],
|
||||
eligibleWorlds = [],
|
||||
groupOptions = [],
|
||||
contributorOptionsByGroup = {},
|
||||
initialGroupSlug = '',
|
||||
@@ -137,7 +141,7 @@ export default function UploadWizard({
|
||||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
|
||||
|
||||
// ── Metadata state ────────────────────────────────────────────────────────
|
||||
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
|
||||
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup, eligibleWorlds))
|
||||
|
||||
// ── Refs ──────────────────────────────────────────────────────────────────
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
@@ -449,7 +453,7 @@ export default function UploadWizard({
|
||||
setPrimaryFile(null)
|
||||
setScreenshots([])
|
||||
setSelectedScreenshotIndex(0)
|
||||
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
|
||||
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup, eligibleWorlds))
|
||||
setIsUploadLocked(false)
|
||||
hasAutoAdvancedRef.current = false
|
||||
setPublishMode('now')
|
||||
@@ -461,7 +465,7 @@ export default function UploadWizard({
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
})
|
||||
setActiveStep(1)
|
||||
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup])
|
||||
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup, eligibleWorlds])
|
||||
|
||||
const goToStep = useCallback((step) => {
|
||||
if (step >= 1 && step <= highestUnlockedStep) setActiveStep(step)
|
||||
@@ -472,6 +476,14 @@ export default function UploadWizard({
|
||||
// Complete / success screen
|
||||
if (machine.state === machineStates.complete) {
|
||||
const wasScheduled = machine.lastAction === 'schedule'
|
||||
const studioArtworksUrl = '/studio/artworks'
|
||||
const artworkUrl = resolvedArtworkId
|
||||
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
|
||||
: '/'
|
||||
const studioArtworkUrl = resolvedArtworkId
|
||||
? `/studio/artworks/${resolvedArtworkId}/edit`
|
||||
: studioArtworksUrl
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98 }}
|
||||
@@ -502,14 +514,24 @@ export default function UploadWizard({
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
{!wasScheduled && (
|
||||
<a
|
||||
href={resolvedArtworkId
|
||||
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
|
||||
: '/'}
|
||||
href={artworkUrl}
|
||||
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
|
||||
>
|
||||
View artwork
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={studioArtworksUrl}
|
||||
className="rounded-lg ring-1 ring-sky-300/35 bg-sky-400/12 px-4 py-2 text-sm font-medium text-sky-50 hover:bg-sky-400/20 transition"
|
||||
>
|
||||
View in studio
|
||||
</a>
|
||||
<a
|
||||
href={studioArtworkUrl}
|
||||
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm font-medium text-white hover:bg-white/15 transition"
|
||||
>
|
||||
Edit artwork in studio
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
@@ -628,7 +650,6 @@ export default function UploadWizard({
|
||||
onChangeTitle={(value) => setMeta({ title: value })}
|
||||
onChangeTags={(value) => setMeta({ tags: value })}
|
||||
onChangeDescription={(value) => setMeta({ description: value })}
|
||||
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
|
||||
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
|
||||
/>
|
||||
)
|
||||
@@ -645,6 +666,7 @@ export default function UploadWizard({
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
fileMetadata={fileMetadata}
|
||||
metadata={metadata}
|
||||
eligibleWorlds={Array.isArray(metadata.worldSubmissions) ? metadata.worldSubmissions : []}
|
||||
canPublish={canPublish}
|
||||
uploadReady={uploadReady}
|
||||
publishMode={publishMode}
|
||||
@@ -658,6 +680,20 @@ export default function UploadWizard({
|
||||
currentContributorOptions={currentContributorOptions}
|
||||
allRootCategoryOptions={allRootCategoryOptions}
|
||||
filteredCategoryTree={filteredCategoryTree}
|
||||
onToggleWorldSubmission={(worldId) => setMetadata((current) => ({
|
||||
...current,
|
||||
worldSubmissions: (Array.isArray(current.worldSubmissions) ? current.worldSubmissions : []).map((world) => (
|
||||
Number(world.id) === Number(worldId) && !world.selection_locked
|
||||
? { ...world, selected: !world.selected }
|
||||
: world
|
||||
)),
|
||||
}))}
|
||||
onChangeWorldSubmissionNote={(worldId, note) => setMetadata((current) => ({
|
||||
...current,
|
||||
worldSubmissions: (Array.isArray(current.worldSubmissions) ? current.worldSubmissions : []).map((world) => (
|
||||
Number(world.id) === Number(worldId) ? { ...world, note } : world
|
||||
)),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,20 +121,32 @@ async function completeStep1ToReady() {
|
||||
})
|
||||
}
|
||||
|
||||
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
|
||||
async function completeRequiredDetails({ title = 'My Art' } = {}) {
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), title)
|
||||
await userEvent.click(screen.getByRole('button', { name: /art .* open/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /root .* choose/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /sub .* choose/i }))
|
||||
await userEvent.type(screen.getByLabelText(/search or add tags/i), 'fantasy{enter}')
|
||||
if (mature) {
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
}
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
})
|
||||
}
|
||||
|
||||
function queryPrimaryPublishButton() {
|
||||
return screen
|
||||
.queryAllByRole('button', { name: /^publish now$/i })
|
||||
.find((button) => !button.hasAttribute('aria-pressed')) || null
|
||||
}
|
||||
|
||||
function getPrimaryPublishButton() {
|
||||
const button = queryPrimaryPublishButton()
|
||||
if (!button) {
|
||||
throw new Error('Primary publish action button not found')
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
describe('UploadWizard step flow', () => {
|
||||
let originalImage
|
||||
let originalScrollTo
|
||||
@@ -311,7 +323,7 @@ describe('UploadWizard step flow', () => {
|
||||
await completeStep1ToReady()
|
||||
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
|
||||
|
||||
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
|
||||
expect(queryPrimaryPublishButton()?.disabled).toBe(true)
|
||||
|
||||
await completeRequiredDetails({ title: 'My Art' })
|
||||
|
||||
@@ -320,12 +332,12 @@ describe('UploadWizard step flow', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const publish = screen.getByRole('button', { name: /^publish$/i })
|
||||
const publish = getPrimaryPublishButton()
|
||||
expect(publish.disabled).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
|
||||
await userEvent.click(getPrimaryPublishButton())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -335,7 +347,7 @@ describe('UploadWizard step flow', () => {
|
||||
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('includes the mature flag in the final publish payload when selected', async () => {
|
||||
it('hides the mature content checkbox in the details step', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 311, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
|
||||
|
||||
@@ -343,28 +355,7 @@ describe('UploadWizard step flow', () => {
|
||||
|
||||
await screen.findByText(/artwork details/i)
|
||||
|
||||
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const publish = screen.getByRole('button', { name: /^publish$/i })
|
||||
expect(publish.disabled).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/311/publish',
|
||||
expect.objectContaining({ is_mature: true }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(screen.queryByLabelText(/mark this artwork as mature content/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('includes contributor credit metadata in the final publish payload', async () => {
|
||||
@@ -399,12 +390,12 @@ describe('UploadWizard step flow', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const publish = screen.getByRole('button', { name: /^publish$/i })
|
||||
const publish = getPrimaryPublishButton()
|
||||
expect(publish.disabled).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
|
||||
await userEvent.click(getPrimaryPublishButton())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -444,17 +435,49 @@ describe('UploadWizard step flow', () => {
|
||||
await screen.findByText(/artwork details/i)
|
||||
|
||||
const publishAs = screen.getByRole('combobox', { name: /publishing identity/i })
|
||||
expect(screen.getByRole('option', { name: /personal profile/i })).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(publishAs)
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('option', { name: /personal profile/i })).not.toBeNull()
|
||||
expect(screen.getByRole('option', { name: /warp collective/i })).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(publishAs, 'warp-collective')
|
||||
await userEvent.click(screen.getByRole('option', { name: /warp collective/i }))
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('combobox', { name: /primary author/i })).not.toBeNull()
|
||||
expect(screen.getByText(/contributors/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows studio manager and editor links after publishing', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 315, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
|
||||
|
||||
await completeStep1ToReady()
|
||||
await screen.findByText(/artwork details/i)
|
||||
await completeRequiredDetails({ title: 'Studio Linked Piece' })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPrimaryPublishButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(getPrimaryPublishButton())
|
||||
})
|
||||
|
||||
const studioManagerLink = await screen.findByRole('link', { name: /view in studio/i })
|
||||
expect(studioManagerLink.getAttribute('href')).toBe('/studio/artworks')
|
||||
|
||||
const studioEditLink = screen.getByRole('link', { name: /edit artwork in studio/i })
|
||||
expect(studioEditLink.getAttribute('href')).toBe('/studio/artworks/315/edit')
|
||||
})
|
||||
|
||||
it('keeps mobile sticky action bar visible class', async () => {
|
||||
installAxiosStubs()
|
||||
await renderWizard({ initialDraftId: 306 })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadSidebar from '../UploadSidebar'
|
||||
import { NovaSelect } from '../../ui'
|
||||
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,6 @@ export default function Step2Details({
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
onToggleMature,
|
||||
onToggleRights,
|
||||
}) {
|
||||
const [isContentTypeChooserOpen, setIsContentTypeChooserOpen] = useState(() => !metadata.contentType)
|
||||
@@ -488,34 +488,33 @@ export default function Step2Details({
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Publishing identity</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
label="Publishing identity"
|
||||
value={metadata.group || ''}
|
||||
onChange={(event) => onGroupChange?.(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
>
|
||||
<option value="">Personal profile</option>
|
||||
{groupOptions.map((group) => (
|
||||
<option key={group.slug} value={group.slug}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(nextValue) => onGroupChange?.(String(nextValue || ''))}
|
||||
options={[
|
||||
{ value: '', label: 'Personal profile' },
|
||||
...groupOptions.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
className="mt-2 bg-black/20"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{metadata.group && (
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Primary author</span>
|
||||
<select
|
||||
value={metadata.primaryAuthorUserId || ''}
|
||||
onChange={(event) => onPrimaryAuthorChange?.(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
>
|
||||
{currentContributorOptions.map((user) => (
|
||||
<option key={user.id} value={user.id}>{user.name || user.username}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect
|
||||
label="Primary author"
|
||||
value={metadata.primaryAuthorUserId || null}
|
||||
onChange={(nextValue) => onPrimaryAuthorChange?.(nextValue == null ? '' : String(nextValue))}
|
||||
options={currentContributorOptions.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name || user.username,
|
||||
}))}
|
||||
searchable={false}
|
||||
className="mt-2 bg-black/20"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-400">The primary author is shown as the lead creator for this group-published artwork.</p>
|
||||
</div>
|
||||
|
||||
@@ -613,7 +612,6 @@ export default function Step2Details({
|
||||
onChangeTitle={onChangeTitle}
|
||||
onChangeTags={onChangeTags}
|
||||
onChangeDescription={onChangeDescription}
|
||||
onToggleMature={onToggleMature}
|
||||
onToggleRights={onToggleRights}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import WorldSubmissionSelector from '../../worlds/WorldSubmissionSelector'
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -51,6 +52,7 @@ export default function Step3Publish({
|
||||
fileMetadata,
|
||||
// Metadata
|
||||
metadata,
|
||||
eligibleWorlds = [],
|
||||
// Readiness
|
||||
canPublish,
|
||||
uploadReady,
|
||||
@@ -67,6 +69,8 @@ export default function Step3Publish({
|
||||
// Category tree (for label lookup)
|
||||
allRootCategoryOptions = [],
|
||||
filteredCategoryTree = [],
|
||||
onToggleWorldSubmission,
|
||||
onChangeWorldSubmissionNote,
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||
@@ -219,6 +223,14 @@ export default function Step3Publish({
|
||||
</div>
|
||||
|
||||
{/* ── Visibility selector ────────────────────────────────────────── */}
|
||||
<WorldSubmissionSelector
|
||||
title="Add to Worlds"
|
||||
description="Attach this artwork to active worlds for creator participation. These placements stay separate from editorial curated relations."
|
||||
options={eligibleWorlds}
|
||||
onToggle={onToggleWorldSubmission}
|
||||
onNoteChange={onChangeWorldSubmissionNote}
|
||||
/>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
|
||||
52
resources/js/components/worlds/WorldCard.jsx
Normal file
52
resources/js/components/worlds/WorldCard.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
|
||||
function themeStyle(theme) {
|
||||
return {
|
||||
'--world-accent': theme?.accent_color || '#38bdf8',
|
||||
'--world-accent-secondary': theme?.accent_color_secondary || '#0f172a',
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldCard({ world, compact = false }) {
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={world.public_url}
|
||||
className={`group relative block w-full overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/70 transition duration-300 hover:-translate-y-1 hover:border-white/20 ${compact ? 'p-5' : 'p-6'}`}
|
||||
style={themeStyle(world.theme)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_42%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_94%,black),_rgba(2,6,23,0.94))] opacity-95" />
|
||||
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/80 to-slate-950/10" />
|
||||
|
||||
<div className="relative flex h-full min-h-[16rem] flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-white/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.phase || world.status}</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
|
||||
</div>
|
||||
<h3 className={`mt-4 max-w-xl font-semibold tracking-[-0.03em] text-white ${compact ? 'text-2xl' : 'text-3xl'}`}>{world.title}</h3>
|
||||
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200/85">{world.summary}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1 text-sm text-slate-200/80">
|
||||
{world.timeframe_label ? <div>{world.timeframe_label}</div> : null}
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-white/55">
|
||||
<i className={world.icon_name || 'fa-solid fa-globe'} />
|
||||
<span>{world.theme?.label || world.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition group-hover:bg-white/15">
|
||||
{world.cta_label || 'Open world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
|
||||
function statusTone(item) {
|
||||
return item?.status_label === 'Featured'
|
||||
? 'border-amber-300/30 bg-amber-400/12 text-amber-100'
|
||||
: 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100'
|
||||
}
|
||||
|
||||
export default function WorldCommunitySubmissionsSection({ section }) {
|
||||
if (!section || !Array.isArray(section.items) || section.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{section.items.length} artworks</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.items.map((item) => (
|
||||
<a key={item.id} href={item.url} className="group overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden border-b border-white/10 bg-slate-950/80">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
|
||||
) : (
|
||||
<div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-4">
|
||||
{item.context_label ? <span className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80">{item.context_label}</span> : null}
|
||||
{item.status_label ? <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>{item.status_label}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.meta.map((entry) => (
|
||||
<span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
91
resources/js/components/worlds/WorldHero.jsx
Normal file
91
resources/js/components/worlds/WorldHero.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
|
||||
function styleForWorld(world) {
|
||||
return {
|
||||
'--world-accent': world?.theme?.accent_color || '#38bdf8',
|
||||
'--world-accent-secondary': world?.theme?.accent_color_secondary || '#0f172a',
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedIconName(world) {
|
||||
const icon = String(world?.icon_name || '').trim()
|
||||
|
||||
if (icon) {
|
||||
return icon
|
||||
}
|
||||
|
||||
const themeIcon = String(world?.theme?.icon_name || '').trim()
|
||||
|
||||
return themeIcon || 'fa-solid fa-globe'
|
||||
}
|
||||
|
||||
export default function WorldHero({ world, previewMode = false }) {
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[36px] border border-white/10" style={styleForWorld(world)}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_34%),radial-gradient(circle_at_82%_18%,_color-mix(in_srgb,var(--world-accent-secondary)_68%,transparent),_transparent_42%),linear-gradient(135deg,_rgba(2,6,23,0.92),_rgba(15,23,42,0.82)_45%,_rgba(2,6,23,0.95))]" />
|
||||
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/80 to-slate-950/20" />
|
||||
|
||||
<div className="relative grid gap-10 px-6 py-8 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_20rem] lg:px-10 lg:py-10">
|
||||
<div>
|
||||
{previewMode ? <div className="inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-amber-100">Preview Mode</div> : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.type}</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
|
||||
{world.timeframe_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.timeframe_label}</span> : null}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">{world.title}</h1>
|
||||
{world.tagline ? <p className="mt-4 text-sm uppercase tracking-[0.24em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-6 max-w-3xl text-base leading-7 text-slate-200/86 sm:text-lg">{world.summary}</p> : null}
|
||||
{world.description ? (
|
||||
<div
|
||||
className="prose prose-invert prose-sm mt-5 max-w-3xl prose-p:text-slate-300/88 prose-p:leading-7 prose-headings:text-white prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-500/40 prose-blockquote:text-slate-300 prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:border prose-pre:border-white/[0.06] prose-pre:bg-white/[0.04] prose-hr:border-white/10 prose-ul:text-slate-300/88 prose-ol:text-slate-300/88"
|
||||
dangerouslySetInnerHTML={{ __html: world.description }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{world.cta_url ? <a href={world.cta_url} className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">{world.cta_label || 'Explore'}<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
{world.public_url ? <a href={world.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/12">Canonical page<i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
</div>
|
||||
|
||||
{Array.isArray(world.related_tags) && world.related_tags.length > 0 ? (
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{world.related_tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/12 bg-black/25 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-slate-200/80">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="grid gap-4 self-end">
|
||||
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white shadow-2xl shadow-slate-950/30 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/12 bg-white/10 text-lg text-white">
|
||||
<i className={resolvedIconName(world)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Theme</div>
|
||||
<div className="mt-1 text-lg font-semibold">{world.theme?.label || world.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{world.badge_description ? <p className="mt-4 text-sm leading-6 text-slate-300">{world.badge_description}</p> : null}
|
||||
|
||||
<div className="mt-5 grid gap-3 text-sm text-slate-200/90">
|
||||
{world.timeframe_label ? <div className="flex items-center gap-2"><i className="fa-regular fa-calendar" /><span>{world.timeframe_label}</span></div> : null}
|
||||
{world.edition_year ? <div className="flex items-center gap-2"><i className="fa-solid fa-repeat" /><span>Edition {world.edition_year}</span></div> : null}
|
||||
{world.is_recurring ? <div className="flex items-center gap-2"><i className="fa-solid fa-clock-rotate-left" /><span>Recurring world</span></div> : null}
|
||||
</div>
|
||||
|
||||
{world.badge_url ? <a href={world.badge_url} className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-100 hover:text-white">View badge<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
51
resources/js/components/worlds/WorldSection.jsx
Normal file
51
resources/js/components/worlds/WorldSection.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
|
||||
function EntityCard({ item }) {
|
||||
return (
|
||||
<a href={item.url} className="group rounded-[26px] border border-white/10 bg-white/[0.03] p-4 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden rounded-[22px] border border-white/10 bg-slate-950/70">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
|
||||
) : (
|
||||
<div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />
|
||||
)}
|
||||
{item.avatar ? <img src={item.avatar} alt="" className="absolute bottom-3 left-3 h-12 w-12 rounded-2xl border border-white/15 object-cover shadow-lg shadow-black/40" /> : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{item.context_label ? <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.context_label}</div> : null}
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.meta.map((entry) => (
|
||||
<span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldSection({ section }) {
|
||||
if (!section || !Array.isArray(section.items) || section.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{section.items.length} items</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.items.map((item) => <EntityCard key={`${item.entity_type}-${item.id}`} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
164
resources/js/components/worlds/WorldSubmissionSelector.jsx
Normal file
164
resources/js/components/worlds/WorldSubmissionSelector.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
|
||||
function statusTone(item) {
|
||||
if (item?.is_featured) {
|
||||
return 'border-amber-300/30 bg-amber-400/10 text-amber-100'
|
||||
}
|
||||
|
||||
switch (item?.status) {
|
||||
case 'live':
|
||||
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'removed':
|
||||
return 'border-orange-300/30 bg-orange-400/10 text-orange-100'
|
||||
case 'blocked':
|
||||
return 'border-rose-300/30 bg-rose-400/10 text-rose-100'
|
||||
case 'pending':
|
||||
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
function modeTone(mode) {
|
||||
switch (mode) {
|
||||
case 'manual_approval':
|
||||
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
|
||||
case 'auto_add':
|
||||
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
function dateBadgeLabel(item) {
|
||||
const timeframe = String(item?.timeframe_label || '').trim()
|
||||
const submissionWindow = String(item?.submission_window_label || '').trim()
|
||||
|
||||
if (timeframe && submissionWindow) {
|
||||
return timeframe === submissionWindow ? timeframe : `${submissionWindow} • ${timeframe}`
|
||||
}
|
||||
|
||||
return submissionWindow || timeframe || ''
|
||||
}
|
||||
|
||||
export default function WorldSubmissionSelector({
|
||||
title = 'Add to Worlds',
|
||||
description = 'Attach this artwork to active worlds while keeping community participation separate from curated editorial relations.',
|
||||
options = [],
|
||||
emptyMessage = 'No worlds are currently open for creator participation.',
|
||||
onToggle,
|
||||
onNoteChange,
|
||||
className = '',
|
||||
}) {
|
||||
const items = Array.isArray(options) ? options : []
|
||||
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 bg-white/[0.03] p-5 ${className}`.trim()}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-400">{description}</p>
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{items.filter((item) => item.selected).length} selected</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-5 rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const checked = Boolean(item.selected)
|
||||
const locked = Boolean(item.selection_locked)
|
||||
const combinedDateLabel = dateBadgeLabel(item)
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`overflow-hidden rounded-[24px] border ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !locked && onToggle?.(item.id)}
|
||||
disabled={locked}
|
||||
className="w-full p-4 text-left disabled:cursor-not-allowed disabled:opacity-100"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
{item.status_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
) : null}
|
||||
{item.participation_mode_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>
|
||||
{item.participation_mode_label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{item.tagline ? <p className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.tagline}</p> : null}
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs md:mt-0.5 ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
|
||||
{item.summary ? <p className="text-sm leading-6 text-slate-300 md:col-span-3">{item.summary}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-300 md:col-span-3">
|
||||
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
|
||||
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-white/10 px-4 py-4">
|
||||
{item.submission_guidelines ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
|
||||
<div className="mt-2">{item.submission_guidelines}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.selection_locked_reason ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
|
||||
) : null}
|
||||
|
||||
{item.reviewer_note ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
|
||||
<div className="mt-2 leading-6">{item.reviewer_note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checked && item.submission_note_enabled ? (
|
||||
<label className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={item.note || ''}
|
||||
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
|
||||
disabled={locked}
|
||||
placeholder="Optional note for world moderators: fit, context, challenge angle, or why this artwork belongs here."
|
||||
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition }) {
|
||||
if (!duplicateUrl && !newEditionUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Reuse this world</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Duplicate the current campaign structure or roll it forward into the next edition without rebuilding the curated setup.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{duplicateUrl ? <button type="button" onClick={onDuplicate} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">Duplicate world</button> : null}
|
||||
{newEditionUrl ? <button type="button" onClick={onCreateEdition} disabled={!canCreateEdition} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-100 disabled:cursor-not-allowed disabled:opacity-50">Create next edition</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCreateEdition ? <div className="mt-3 text-xs leading-5 text-slate-500">Next-edition creation unlocks once this world has recurrence data.</div> : null}
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">Template creation is prepared through duplication. A dedicated preset/template browser can be layered on top later without changing the editor data model.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
231
resources/js/components/worlds/editor/WorldMediaUploadField.jsx
Normal file
231
resources/js/components/worlds/editor/WorldMediaUploadField.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return null
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function WorldMediaUploadField({
|
||||
label,
|
||||
slot,
|
||||
value,
|
||||
previewUrl,
|
||||
emptyLabel,
|
||||
helperText,
|
||||
uploadUrl,
|
||||
deleteUrl,
|
||||
worldId = null,
|
||||
onChange,
|
||||
isTemporaryValue = false,
|
||||
accept = 'image/jpeg,image/png,image/webp',
|
||||
maxFileSizeMb = 6,
|
||||
}) {
|
||||
const inputRef = useRef(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [meta, setMeta] = useState(null)
|
||||
|
||||
const csrfToken = useMemo(
|
||||
() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteTemporaryUpload = async (path) => {
|
||||
if (!deleteUrl || !path) return
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
world_id: worldId || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Could not remove uploaded image.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (!file || uploading) return
|
||||
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/webp']
|
||||
if (!allowed.includes(String(file.type || '').toLowerCase())) {
|
||||
setError('Use a JPG, PNG, or WEBP image.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > maxFileSizeMb * 1024 * 1024) {
|
||||
setError(`Image is too large. Maximum allowed size is ${maxFileSizeMb} MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
if (value && isTemporaryValue) {
|
||||
await deleteTemporaryUpload(value)
|
||||
}
|
||||
|
||||
const body = new FormData()
|
||||
body.append('slot', slot)
|
||||
body.append('image', file)
|
||||
if (worldId) {
|
||||
body.append('world_id', String(worldId))
|
||||
}
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Upload failed.')
|
||||
}
|
||||
|
||||
setMeta({
|
||||
width: payload?.width || null,
|
||||
height: payload?.height || null,
|
||||
size: formatBytes(payload?.size_bytes),
|
||||
})
|
||||
onChange?.({ path: payload?.path || '', url: payload?.url || '' })
|
||||
} catch (uploadError) {
|
||||
setError(uploadError?.message || 'Upload failed.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 text-sm text-slate-300">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation()
|
||||
setError('')
|
||||
setMeta(null)
|
||||
|
||||
try {
|
||||
if (value && isTemporaryValue) {
|
||||
setUploading(true)
|
||||
await deleteTemporaryUpload(value)
|
||||
}
|
||||
onChange?.({ path: '', url: '' })
|
||||
} catch (deleteError) {
|
||||
setError(deleteError?.message || 'Could not remove uploaded image.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (uploading) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
if (!uploading) setDragging(true)
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
if (!uploading) setDragging(true)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
void handleFile(event.dataTransfer?.files?.[0])
|
||||
}}
|
||||
className={[
|
||||
'rounded-[24px] border border-dashed px-5 py-5 transition outline-none',
|
||||
uploading
|
||||
? 'cursor-progress border-sky-300/35 bg-sky-400/10'
|
||||
: dragging
|
||||
? 'cursor-pointer border-sky-300/50 bg-sky-400/12'
|
||||
: 'cursor-pointer border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
|
||||
<i className={`fa-solid ${uploading ? 'fa-circle-notch fa-spin' : 'fa-cloud-arrow-up'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{uploading ? 'Uploading image…' : 'Drop image here or browse'}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-400">{helperText}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max {maxFileSizeMb} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-28 w-full overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 lg:w-44">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-500">{emptyLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-400">Stored path: <span className="text-slate-200">{value}</span></div> : null}
|
||||
{meta ? <div className="mt-3 text-xs text-slate-400">Optimized to {meta.width}×{meta.height}{meta.size ? ` • ${meta.size}` : ''}</div> : null}
|
||||
{error ? <div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
onChange={(event) => {
|
||||
void handleFile(event.target.files?.[0])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import WorldPreviewButton from './WorldPreviewButton'
|
||||
|
||||
export default function WorldMiniPreviewPanel({ world, sections, previewUrl }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Live mini preview</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Hero hierarchy, CTA, badges, section order, and attached content update immediately as you edit.</p>
|
||||
</div>
|
||||
<WorldPreviewButton previewUrl={previewUrl} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
|
||||
<div className="relative px-5 py-5" style={{ background: `linear-gradient(135deg, ${world?.accent_color || '#38bdf8'}22, transparent 45%), linear-gradient(180deg, #020617 0%, #0f172a 100%)` }}>
|
||||
{world?.cover_url ? <img src={world.cover_url} alt="" className="absolute inset-0 h-full w-full object-cover opacity-25" /> : null}
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world?.type || 'seasonal'}</span>
|
||||
{world?.badge_label ? <span className="rounded-full border border-white/15 bg-black/25 px-3 py-1">{world.badge_label}</span> : null}
|
||||
{world?.is_featured ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-emerald-100">Homepage feature</span> : null}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{world?.title || 'Untitled world'}</div>
|
||||
{world?.tagline ? <div className="mt-3 text-xs uppercase tracking-[0.22em] text-white/60">{world.tagline}</div> : null}
|
||||
{world?.summary ? <div className="mt-4 max-w-2xl text-sm leading-7 text-slate-200/85">{world.summary}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{world?.cta_label ? <span className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-slate-950">{world.cta_label}<i className="fa-solid fa-arrow-right" /></span> : null}
|
||||
{world?.badge_description ? <span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-4 py-2 text-sm font-semibold text-white">{world.badge_description}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visible section order</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{Array.isArray(sections) && sections.length > 0 ? sections.map((section) => (
|
||||
<div key={section.key} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{section.label}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{section.count} attached items</div>
|
||||
</div>
|
||||
{section.count === 0 ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Empty</span> : null}
|
||||
</div>
|
||||
{Array.isArray(section.items) && section.items.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">{section.items.map((item) => <span key={`${section.key}-${item.id}`} className="rounded-full bg-white/[0.04] px-3 py-1.5">{item.title}</span>)}</div> : null}
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No sections are visible yet. Enable sections and attach content to shape the public world.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
resources/js/components/worlds/editor/WorldPreviewButton.jsx
Normal file
24
resources/js/components/worlds/editor/WorldPreviewButton.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldPreviewButton({ previewUrl, className = '', disabledReason = 'Save the world once to unlock the full preview page.' }) {
|
||||
if (!previewUrl) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-400 ${className}`.trim()}>
|
||||
<div className="font-semibold text-slate-200">Full preview unavailable</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-500">{disabledReason}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15 ${className}`.trim()}
|
||||
>
|
||||
<i className="fa-regular fa-eye" />
|
||||
Open full preview
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldRecurrenceHelper({ enabled, recurrenceKey, editionYear, recurrenceKeyError, editionYearError }) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-6 text-slate-400">
|
||||
Turn on recurrence when this world belongs to a campaign family such as Halloween, Retro Month, or Pixel Week and needs a reusable edition pattern.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const exampleKey = recurrenceKey || 'halloween'
|
||||
const exampleYear = editionYear || new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/10 p-4 text-sm text-slate-200">
|
||||
<div className="font-semibold text-white">Recurring world guidance</div>
|
||||
<div className="mt-2 space-y-2 leading-6 text-slate-300">
|
||||
<p>Use the recurrence key to identify the campaign family. Example: <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
<p>Use the edition year for the specific annual or seasonal instance. Example: <span className="font-semibold text-white">{exampleYear}</span>.</p>
|
||||
<p className="text-sky-100">Example output: {exampleKey === '' ? 'Halloween' : exampleKey.replace(/-/g, ' ')} {exampleYear} is part of the recurring world <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
</div>
|
||||
|
||||
{recurrenceKeyError || editionYearError ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-xs leading-5 text-rose-100">
|
||||
{recurrenceKeyError || editionYearError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
resources/js/components/worlds/editor/WorldRelationCard.jsx
Normal file
51
resources/js/components/worlds/editor/WorldRelationCard.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
|
||||
function SmallBadge({ children, tone = 'default' }) {
|
||||
const styles = {
|
||||
default: 'border-white/10 bg-white/[0.06] text-slate-200',
|
||||
accent: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
feature: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${styles[tone] || styles.default}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function WorldRelationCard({ relation, index, total, sectionLabel, onEdit, onRemove, onMove }) {
|
||||
const preview = relation?.preview || null
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||||
{preview?.image ? <img src={preview.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!preview?.image && preview?.avatar ? <img src={preview.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!preview?.image && !preview?.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
|
||||
{preview?.avatar && preview?.image ? <img src={preview.avatar} alt="" className="absolute bottom-2 left-2 h-8 w-8 rounded-xl border border-white/10 object-cover" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{preview?.entity_label ? <SmallBadge tone="accent">{preview.entity_label}</SmallBadge> : null}
|
||||
{sectionLabel ? <SmallBadge>{sectionLabel}</SmallBadge> : null}
|
||||
{relation?.is_featured ? <SmallBadge tone="feature">Featured</SmallBadge> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-base font-semibold text-white">{preview?.title || 'Choose a relation'}</div>
|
||||
{preview?.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{preview.subtitle}</div> : null}
|
||||
{preview?.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{preview.description}</div> : null}
|
||||
{relation?.context_label ? <div className="mt-2 text-sm font-medium text-sky-100">{relation.context_label}</div> : null}
|
||||
{Array.isArray(preview?.meta) && preview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{preview.meta.map((item) => <span key={item}>{item}</span>)}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button type="button" onClick={() => onMove(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">Up</button>
|
||||
<button type="button" onClick={() => onMove(index, 1)} disabled={index === total - 1} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">Down</button>
|
||||
<button type="button" onClick={onEdit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold text-sky-100">Edit</button>
|
||||
<button type="button" onClick={onRemove} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview?.url ? <a href={preview.url} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 hover:text-white">Open entity <i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '../../ui/Modal'
|
||||
import { Checkbox, NovaSelect } from '../../ui'
|
||||
|
||||
function relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions) {
|
||||
const section = sectionOptions.find((option) => option.value === sectionKey)
|
||||
return section?.relation_types?.[0] || relationTypeOptions?.[0]?.value || 'artwork'
|
||||
}
|
||||
|
||||
function emptyRelation(sectionOptions, relationTypeOptions) {
|
||||
const sectionKey = sectionOptions?.[0]?.value || 'featured_artworks'
|
||||
const relationType = relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions)
|
||||
|
||||
return {
|
||||
section_key: sectionKey,
|
||||
related_type: relationType,
|
||||
related_id: '',
|
||||
context_label: '',
|
||||
sort_order: 0,
|
||||
is_featured: false,
|
||||
preview: null,
|
||||
query: '',
|
||||
}
|
||||
}
|
||||
|
||||
function SearchResultList({ items, loading, selectedId, onSelect }) {
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching campaign entities…</div>
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search by title, slug, creator, or project name to attach curated content.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button key={`${item.entity_type}-${item.id}`} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldRelationPickerModal({ open, onClose, onSave, initialRelation, sectionOptions, relationTypeOptions, searchEntities }) {
|
||||
const [draft, setDraft] = useState(() => emptyRelation(sectionOptions, relationTypeOptions))
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = initialRelation || emptyRelation(sectionOptions, relationTypeOptions)
|
||||
setDraft({
|
||||
...nextDraft,
|
||||
query: nextDraft.query || nextDraft.preview?.title || '',
|
||||
})
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
}, [open, initialRelation, sectionOptions, relationTypeOptions])
|
||||
|
||||
const selectedSection = useMemo(() => sectionOptions.find((option) => option.value === draft.section_key), [sectionOptions, draft.section_key])
|
||||
const availableRelationTypes = useMemo(() => relationTypeOptions.filter((option) => !selectedSection?.relation_types?.length || selectedSection.relation_types.includes(option.value)), [relationTypeOptions, selectedSection])
|
||||
const selectedPreview = useMemo(() => {
|
||||
if (draft.preview) return draft.preview
|
||||
return results.find((item) => String(item.id) === String(draft.related_id)) || null
|
||||
}, [draft.preview, draft.related_id, results])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || availableRelationTypes.length === 0) return
|
||||
if (availableRelationTypes.some((option) => option.value === draft.related_type)) return
|
||||
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
related_type: availableRelationTypes[0].value,
|
||||
related_id: '',
|
||||
preview: null,
|
||||
}))
|
||||
}, [open, availableRelationTypes, draft.related_type])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !draft.related_type) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await searchEntities(draft.related_type, draft.query || '')
|
||||
if (!cancelled) {
|
||||
setResults(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, draft.query ? 220 : 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open, draft.related_type, draft.query, searchEntities])
|
||||
|
||||
const actionLabel = initialRelation?.related_id ? 'Save relation' : 'Attach relation'
|
||||
const canSubmit = Boolean(draft.related_id)
|
||||
const nextRelation = selectedPreview ? { ...draft, preview: selectedPreview } : draft
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => onSave(nextRelation)} disabled={!canSubmit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">{actionLabel}</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Attach curated relation" size="2xl" footer={footer}>
|
||||
<div className="grid gap-5 overflow-x-hidden">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.35fr)] lg:items-end">
|
||||
<NovaSelect label="Section" value={draft.section_key || null} onChange={(nextValue) => setDraft((current) => {
|
||||
const nextSectionKey = String(nextValue || '')
|
||||
return {
|
||||
...current,
|
||||
section_key: nextSectionKey,
|
||||
related_type: relationTypeForSection(nextSectionKey, sectionOptions, relationTypeOptions),
|
||||
related_id: '',
|
||||
preview: null,
|
||||
}
|
||||
})} options={sectionOptions} searchable={false} className="bg-black/20" />
|
||||
<NovaSelect label="Entity type" value={draft.related_type || null} onChange={(nextValue) => setDraft((current) => ({ ...current, related_type: String(nextValue || ''), related_id: '', preview: null }))} options={availableRelationTypes} searchable={false} className="bg-black/20" />
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<div className="flex min-w-0 flex-wrap gap-2 sm:flex-nowrap">
|
||||
<input value={draft.query || ''} onChange={(event) => setDraft((current) => ({ ...current, query: event.target.value }))} placeholder="Search title, slug, group, or creator" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-slate-300">Auto</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SearchResultList items={results} loading={loading} selectedId={draft.related_id} onSelect={(item) => setDraft((current) => ({ ...current, related_id: item.id, preview: item, query: item.title }))} />
|
||||
|
||||
{selectedPreview ? (
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
|
||||
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
|
||||
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
<div className="mt-2 break-words text-xs text-emerald-100/80">Section: {selectedSection?.label || draft.section_key} · {draft.is_featured ? 'Featured relation' : 'Standard relation'}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_10rem] lg:grid-cols-[minmax(0,1fr)_10rem_minmax(0,15rem)] md:items-end">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
|
||||
<input value={draft.context_label || ''} onChange={(event) => setDraft((current) => ({ ...current, context_label: event.target.value }))} placeholder="Featured release, Join this challenge, Meet the creator" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Sort order</span>
|
||||
<input type="number" min="0" value={draft.sort_order} onChange={(event) => setDraft((current) => ({ ...current, sort_order: Number(event.target.value) || 0 }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:col-span-2 lg:col-span-1">
|
||||
<Checkbox checked={Boolean(draft.is_featured)} onChange={(event) => setDraft((current) => ({ ...current, is_featured: event.target.checked }))} label="Featured relation" size={20} variant="accent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import { Checkbox } from '../../ui'
|
||||
|
||||
function Pill({ children, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
accent: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
muted: 'border-white/10 bg-black/20 text-slate-400',
|
||||
warn: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
}
|
||||
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${tones[tone]}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function WorldSectionToggleList({ sectionOptions, order, visibility, relationCounts, onChange }) {
|
||||
const selected = Array.isArray(order) && order.length > 0 ? order : sectionOptions.map((option) => option.value)
|
||||
|
||||
const move = (index, delta) => {
|
||||
const nextIndex = index + delta
|
||||
if (nextIndex < 0 || nextIndex >= selected.length) return
|
||||
const next = [...selected]
|
||||
const [entry] = next.splice(index, 1)
|
||||
next.splice(nextIndex, 0, entry)
|
||||
onChange(next, visibility)
|
||||
}
|
||||
|
||||
const toggle = (key, enabled) => {
|
||||
onChange(selected, { ...visibility, [key]: enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{selected.map((key, index) => {
|
||||
const option = sectionOptions.find((entry) => entry.value === key)
|
||||
if (!option) return null
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Checkbox checked={visibility?.[key] !== false} onChange={(event) => toggle(key, event.target.checked)} label={option.label} size={20} variant="accent" />
|
||||
{option.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{option.description}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Pill tone={visibility?.[key] !== false ? 'accent' : 'muted'}>{visibility?.[key] !== false ? 'Visible on public page' : 'Hidden on public page'}</Pill>
|
||||
<Pill tone={(relationCounts?.[key] || 0) > 0 ? 'default' : 'warn'}>{relationCounts?.[key] || 0} attached items</Pill>
|
||||
</div>
|
||||
{(relationCounts?.[key] || 0) === 0 ? <div className="mt-2 text-xs leading-5 text-slate-500">This section is ready, but it will stay empty until you attach curated items.</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => move(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40">Up</button>
|
||||
<button type="button" onClick={() => move(index, 1)} disabled={index === selected.length - 1} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40">Down</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
resources/js/components/worlds/editor/WorldSummaryCard.jsx
Normal file
115
resources/js/components/worlds/editor/WorldSummaryCard.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return 'Not set'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Not set'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function typeLabel(value) {
|
||||
const labels = {
|
||||
seasonal: 'Seasonal',
|
||||
event: 'Event',
|
||||
campaign: 'Campaign',
|
||||
tribute: 'Tribute',
|
||||
}
|
||||
|
||||
return labels[value] || value || 'Seasonal'
|
||||
}
|
||||
|
||||
function promotionState(world, state) {
|
||||
if (!world?.is_featured) {
|
||||
return {
|
||||
label: 'Public page only',
|
||||
message: 'This world will live at its own URL, but it is not currently marked for homepage or Worlds spotlight placement.',
|
||||
tone: 'slate',
|
||||
}
|
||||
}
|
||||
|
||||
if (state.label === 'Live') {
|
||||
return {
|
||||
label: 'Active seasonal promotion',
|
||||
message: 'Featured promotion is enabled and the world is live, so it is ready for homepage spotlight and promoted Worlds surfaces.',
|
||||
tone: 'emerald',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Homepage spotlight eligible',
|
||||
message: 'Featured promotion is enabled. Once the world is live, it becomes eligible for homepage and Worlds spotlight treatment.',
|
||||
tone: 'sky',
|
||||
}
|
||||
}
|
||||
|
||||
function workflowState(world) {
|
||||
const now = Date.now()
|
||||
const publishedAt = world?.published_at ? new Date(world.published_at).getTime() : null
|
||||
const startsAt = world?.starts_at ? new Date(world.starts_at).getTime() : null
|
||||
const endsAt = world?.ends_at ? new Date(world.ends_at).getTime() : null
|
||||
|
||||
if (world?.status === 'archived') {
|
||||
return { label: 'Archived', message: 'This world has ended and is no longer part of the active campaign cycle.', tone: 'amber' }
|
||||
}
|
||||
|
||||
if (world?.status !== 'published') {
|
||||
return { label: 'Draft', message: 'Editors can keep refining this world before it becomes publicly visible.', tone: 'slate' }
|
||||
}
|
||||
|
||||
if (publishedAt && publishedAt > now) {
|
||||
return { label: 'Scheduled', message: `This world will publish automatically on ${formatDateTime(world.published_at)}.`, tone: 'sky' }
|
||||
}
|
||||
|
||||
if (startsAt && startsAt > now) {
|
||||
return { label: 'Scheduled', message: `This world is published and will go live automatically on ${formatDateTime(world.starts_at)}.`, tone: 'sky' }
|
||||
}
|
||||
|
||||
if (endsAt && endsAt < now) {
|
||||
return { label: 'Ended', message: 'The campaign window has passed. Archive it or create a new edition to continue the lineage.', tone: 'amber' }
|
||||
}
|
||||
|
||||
return { label: 'Live', message: 'This world is currently active on public surfaces.', tone: 'emerald' }
|
||||
}
|
||||
|
||||
const tones = {
|
||||
slate: 'border-white/10 bg-white/[0.04] text-slate-100',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
}
|
||||
|
||||
export default function WorldSummaryCard({ world, themeLabel, relationCount, enabledSectionsCount }) {
|
||||
const state = workflowState(world)
|
||||
const promotion = promotionState(world, state)
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Campaign summary</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">See the world lifecycle, promotion state, and editorial readiness without parsing the whole form.</p>
|
||||
</div>
|
||||
<div className={`rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${tones[state.tone]}`}>{state.label}</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 rounded-[24px] border px-4 py-4 text-sm leading-6 ${tones[state.tone]}`}>
|
||||
{state.message}
|
||||
</div>
|
||||
|
||||
<div className={`mt-3 rounded-[24px] border px-4 py-4 text-sm leading-6 ${tones[promotion.tone]}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">Promotion scope</div>
|
||||
<div className="mt-1 font-semibold">{promotion.label}</div>
|
||||
<div className="mt-1">{promotion.message}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</div><div className="mt-2 text-sm font-semibold text-white">{typeLabel(world?.type)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Theme preset</div><div className="mt-2 text-sm font-semibold text-white">{themeLabel || 'No preset'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign window</div><div className="mt-2 text-sm font-semibold text-white">{world?.starts_at || world?.ends_at ? `${formatDateTime(world?.starts_at)} to ${formatDateTime(world?.ends_at)}` : 'Open ended'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</div><div className="mt-2 text-sm font-semibold text-white">{formatDateTime(world?.published_at)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_recurring ? `${world?.recurrence_key || 'recurring'} ${world?.edition_year || ''}`.trim() : 'One-off world'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editorial setup</div><div className="mt-2 text-sm font-semibold text-white">{relationCount} relations · {enabledSectionsCount} enabled sections</div></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
|
||||
function Pill({ children }) {
|
||||
return <span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{children}</span>
|
||||
}
|
||||
|
||||
function ColorSwatch({ label, value }) {
|
||||
if (!value) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
<span className="h-3 w-3 rounded-full border border-white/15" style={{ backgroundColor: value }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldThemePresetHelper({ theme, onApply }) {
|
||||
if (!theme) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{theme.label} preset</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Preset suggestions fill the campaign basics fast. You can still override every field manually afterwards.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onApply} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">
|
||||
Apply suggestions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<ColorSwatch label={theme.accent_color || 'accent'} value={theme.accent_color} />
|
||||
<ColorSwatch label={theme.accent_color_secondary || 'secondary'} value={theme.accent_color_secondary} />
|
||||
{theme.background_motif ? <Pill>{theme.background_motif}</Pill> : null}
|
||||
{theme.icon_name ? <Pill>{theme.icon_name.replace('fa-solid ', '')}</Pill> : null}
|
||||
{theme.suggested_badge_label ? <Pill>{theme.suggested_badge_label}</Pill> : null}
|
||||
{theme.suggested_cta_label ? <Pill>{theme.suggested_cta_label}</Pill> : null}
|
||||
</div>
|
||||
|
||||
{Array.isArray(theme.related_tags_json) && theme.related_tags_json.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Suggested related tags</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
{theme.related_tags_json.map((tag) => <span key={tag} className="rounded-full bg-white/[0.04] px-3 py-1.5">#{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user