Wire admin studio SSR and search infrastructure

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

View File

@@ -3,6 +3,7 @@ import axios from 'axios'
import ShareArtworkModal from './ShareArtworkModal'
import LinkPreviewCard from './LinkPreviewCard'
import TagPeopleModal from './TagPeopleModal'
import DateTimePicker from '../ui/DateTimePicker'
import extractNativeEmoji from '../common/extractNativeEmoji'
import isEventWithinNode from '../common/isEventWithinNode'
@@ -274,13 +275,14 @@ export default function PostComposer({ user, onPosted }) {
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
<div className="flex-1">
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
<input
type="datetime-local"
<div className="block text-[11px] text-slate-400 mb-1">Publish on</div>
<DateTimePicker
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
onChange={setScheduledAt}
minDateTime={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
placeholder="Pick a publish slot"
clearable
className="border-violet-300/20 bg-violet-500/10"
/>
<p className="text-[10px] text-slate-500 mt-1">
{Intl.DateTimeFormat().resolvedOptions().timeZone}

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
import { router } from '@inertiajs/react'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import ConfirmDangerModal from './ConfirmDangerModal'
import NovaSelect from '../ui/NovaSelect'
import Checkbox from '../ui/Checkbox'
function formatDate(value) {
if (!value) return 'Unscheduled'
@@ -79,6 +81,11 @@ function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
|| fallback
}
function stripHtml(value) {
if (typeof value !== 'string') return ''
return value.replace(/<[^>]*>/g, '').trim()
}
function ActionLink({ href, icon, label, onClick }) {
if (!href) return null
@@ -176,7 +183,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
)}
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
{item.description || 'No description yet.'}
{stripHtml(item.description) || 'No description yet.'}
</p>
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
@@ -266,7 +273,7 @@ function ListRow({ item, onExecuteAction, busyKey }) {
</div>
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{stripHtml(item.description) || 'No description yet.'}</p>
<div className="mt-3 flex flex-wrap gap-2">
{readiness && (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
@@ -324,31 +331,48 @@ function materializeFilter(filter, pendingFilters) {
}
}
function selectOptions(options = []) {
return options.map((option) => ({
value: option.value,
label: option.label,
group: option.group,
disabled: option.disabled,
icon: option.icon,
}))
}
function FilterField({ label, children }) {
return (
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</span>
{children}
</div>
)
}
function AdvancedFilterControl({ filter, onChange, value }) {
const controlValue = value ?? filter.value
if (filter.type === 'select') {
const options = selectOptions(filter.options || [])
const searchable = filter.searchable ?? options.length > 8
return (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<select
<FilterField label={filter.label}>
<NovaSelect
id={`studio-filter-${filter.key}`}
options={options}
value={controlValue || 'all'}
onChange={(event) => onChange(filter.key, event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(filter.options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => onChange(filter.key, nextValue ?? 'all')}
placeholder={filter.label}
searchable={searchable}
/>
</FilterField>
)
}
return (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<FilterField label={filter.label}>
<input
type="search"
value={controlValue || ''}
@@ -356,7 +380,7 @@ function AdvancedFilterControl({ filter, onChange, value }) {
placeholder={filter.placeholder || filter.label}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
/>
</label>
</FilterField>
)
}
@@ -817,8 +841,7 @@ export default function StudioContentBrowser({
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className={`grid gap-3 md:grid-cols-2 ${filterGridClass}`}>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<FilterField label="Search">
<input
type="search"
value={pendingFilters.q}
@@ -826,56 +849,44 @@ export default function StudioContentBrowser({
placeholder="Title, description, module"
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
/>
</label>
</FilterField>
{!hideModuleFilter && (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
<select
<FilterField label="Module">
<NovaSelect
id="studio-filter-module"
options={selectOptions(listing?.module_options || [])}
value={filters.module || 'all'}
onChange={(event) => updateQuery({ module: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.module_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => updateQuery({ module: nextValue ?? 'all' })}
placeholder="All content"
searchable={false}
/>
</FilterField>
)}
{!hideBucketFilter && (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
<select
<FilterField label="Status">
<NovaSelect
id="studio-filter-status"
options={selectOptions(listing?.bucket_options || [])}
value={pendingFilters.bucket}
onChange={(event) => setPendingFilter('bucket', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.bucket_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => setPendingFilter('bucket', nextValue ?? 'all')}
placeholder="All"
searchable={false}
/>
</FilterField>
)}
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select
<FilterField label="Sort">
<NovaSelect
id="studio-filter-sort"
options={selectOptions(listing?.sort_options || [])}
value={pendingFilters.sort}
onChange={(event) => setPendingFilter('sort', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.sort_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
placeholder="Recently updated"
searchable={false}
/>
</FilterField>
{advancedFilters.map((filter) => {
const resolvedFilter = materializeFilter(filter, pendingFilters)
@@ -960,15 +971,13 @@ export default function StudioContentBrowser({
{viewMode === 'table' && supportsArtworkBulk && (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
<Checkbox
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
label="Select page"
/>
<span>Select page</span>
</label>
</div>
<span className="text-slate-500">
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
</span>
@@ -1014,11 +1023,9 @@ export default function StudioContentBrowser({
<tr>
{supportsArtworkBulk && (
<th scope="col" className="w-12 px-4 py-3">
<input
type="checkbox"
<Checkbox
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label="Select all artworks on this page"
/>
</th>
@@ -1039,11 +1046,9 @@ export default function StudioContentBrowser({
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
{supportsArtworkBulk && (
<td className="px-4 py-4">
<input
type="checkbox"
<Checkbox
checked={isSelected}
onChange={() => toggleSelected(Number(item.numeric_id))}
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label={`Select ${item.title}`}
/>
</td>

View File

@@ -0,0 +1,100 @@
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import StudioContentBrowser from './StudioContentBrowser'
const routerGet = vi.fn()
const routerReload = vi.fn()
vi.mock('@inertiajs/react', () => ({
router: {
get: routerGet,
reload: routerReload,
},
}))
vi.mock('../../utils/studioEvents', () => ({
studioSurface: () => '/studio/artworks',
trackStudioEvent: vi.fn(),
}))
vi.mock('./ConfirmDangerModal', () => ({
default: () => null,
}))
describe('StudioContentBrowser filters', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders artwork filter dropdowns with NovaSelect instead of native selects', () => {
const { container } = render(
<StudioContentBrowser
hideModuleFilter
listing={{
filters: {
module: 'artworks',
bucket: 'all',
q: '',
sort: 'updated_desc',
content_type: 'all',
category: 'all',
tag: '',
},
items: [],
meta: {
current_page: 1,
last_page: 1,
per_page: 24,
total: 0,
},
bucket_options: [
{ value: 'all', label: 'All' },
{ value: 'published', label: 'Published' },
],
sort_options: [
{ value: 'updated_desc', label: 'Recently updated' },
{ value: 'views_desc', label: 'Most viewed' },
],
advanced_filters: [
{
key: 'content_type',
label: 'Content type',
type: 'select',
value: 'all',
options: [
{ value: 'all', label: 'All content types' },
{ value: '3d', label: '3D' },
],
},
{
key: 'category',
label: 'Category',
type: 'select',
value: 'all',
options: [
{ value: 'all', label: 'All categories' },
{ value: 'abstract', label: 'Abstract', content_type_slug: 'all' },
],
},
{
key: 'tag',
label: 'Tag',
type: 'search',
value: '',
placeholder: 'Filter by tag',
},
],
}}
/>,
)
expect(container.querySelectorAll('select')).toHaveLength(0)
expect(screen.getAllByRole('combobox')).toHaveLength(4)
expect(screen.getByText('Status')).not.toBeNull()
expect(screen.getByText('Sort')).not.toBeNull()
expect(screen.getByText('Content type')).not.toBeNull()
expect(screen.getByText('Category')).not.toBeNull()
})
})

View File

@@ -222,11 +222,27 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
// Count a view on every page load.
useEffect(() => {
if (!artwork?.id) return
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
}).catch(() => {})
const postView = () => {
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
keepalive: true,
}).catch(() => {})
}
if (typeof window === 'undefined') {
postView()
return undefined
}
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(postView, { timeout: 1500 })
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(postView, 1200)
return () => window.clearTimeout(handle)
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const postInteraction = async (url, body) => {
@@ -327,7 +343,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<HeartIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
</button>
<button
@@ -342,7 +358,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<BookmarkIcon filled={bookmarked} />
<span className="tabular-nums">{savedCount}</span>
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
</button>
{/* Share pill */}
@@ -403,7 +419,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<HeartIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
</button>
<button
@@ -418,7 +434,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<BookmarkIcon filled={bookmarked} />
<span className="tabular-nums">{savedCount}</span>
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
</button>
{/* Share */}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
export default function ArtworkAuthor({ artwork, presentSq }) {
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
@@ -34,7 +35,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
{authorName}
</a>
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
<p className="mt-1 text-xs text-soft">{NUMBER_FORMATTER.format(followersCount)} followers</p>
</div>
</div>

View File

@@ -331,7 +331,7 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
{!isAuthenticated && (
<p className="mt-3 text-center text-xs text-soft">
<a href="/login" className="text-accent hover:underline">Sign in</a> to medal this artwork
<a href="/login" className="text-accent underline hover:no-underline">Sign in</a> to medal this artwork
</p>
)}

View File

@@ -20,7 +20,7 @@ function Crumb({ href, children, current = false }) {
if (current) {
return (
<span
className={`${base} text-white/30`}
className={`${base} text-white/55`}
aria-current="page"
>
{children}
@@ -30,7 +30,7 @@ function Crumb({ href, children, current = false }) {
return (
<a
href={href}
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
className={`${base} text-white/55 hover:text-white/80 transition-colors duration-150`}
>
{children}
</a>

View File

@@ -4,10 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
const numberFormatter = new Intl.NumberFormat(undefined, {
const numberFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
function cx(...parts) {
return parts.filter(Boolean).join(' ')
@@ -29,27 +30,26 @@ function formatRelativeTime(value) {
const diffMs = date.getTime() - now.getTime()
const diffSeconds = Math.round(diffMs / 1000)
const absSeconds = Math.abs(diffSeconds)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
if (absSeconds < 60) return relativeTimeFormatter.format(diffSeconds, 'second')
const diffMinutes = Math.round(diffSeconds / 60)
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
if (Math.abs(diffMinutes) < 60) return relativeTimeFormatter.format(diffMinutes, 'minute')
const diffHours = Math.round(diffSeconds / 3600)
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
if (Math.abs(diffHours) < 24) return relativeTimeFormatter.format(diffHours, 'hour')
const diffDays = Math.round(diffSeconds / 86400)
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
if (Math.abs(diffDays) < 7) return relativeTimeFormatter.format(diffDays, 'day')
const diffWeeks = Math.round(diffSeconds / 604800)
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
if (Math.abs(diffWeeks) < 5) return relativeTimeFormatter.format(diffWeeks, 'week')
const diffMonths = Math.round(diffSeconds / 2629800)
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
if (Math.abs(diffMonths) < 12) return relativeTimeFormatter.format(diffMonths, 'month')
const diffYears = Math.round(diffSeconds / 31557600)
return rtf.format(diffYears, 'year')
return relativeTimeFormatter.format(diffYears, 'year')
}
function slugify(value) {

View File

@@ -5,20 +5,44 @@ import ReactionBar from '../comments/ReactionBar'
import LevelBadge from '../xp/LevelBadge'
import { isFlood } from '../../utils/emojiFlood'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
})
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 365) return `${days}d ago`
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
function formatAbsoluteDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_FORMATTER.format(date)
}
function formatAbsoluteDateTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
}
function formatCommentTime(primaryLabel, createdAt) {
return primaryLabel || formatAbsoluteDate(createdAt)
}
/* ── Icons ─────────────────────────────────────────────────────────────────── */
@@ -135,10 +159,10 @@ function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, dept
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={reply.created_at}
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
title={formatAbsoluteDateTime(reply.created_at)}
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
>
{reply.time_ago || timeAgo(reply.created_at)}
{formatCommentTime(reply.time_ago, reply.created_at)}
</time>
</div>
@@ -292,10 +316,10 @@ function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
title={formatAbsoluteDateTime(comment.created_at)}
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
>
{comment.time_ago || timeAgo(comment.created_at)}
{formatCommentTime(comment.time_ago, comment.created_at)}
</time>
</div>

View File

@@ -2,11 +2,33 @@ import React, { useState } from 'react'
const COLLAPSE_AT = 560
function stripTags(value) {
return String(value || '')
.replace(/<\/?(?:html|head|body|title|meta|link|script|style)[^>]*>/gi, '')
.replace(/<[^>]*>/g, '')
.trim()
}
function sanitizeDescriptionHtml(value) {
const html = String(value || '').trim()
if (!html) {
return ''
}
if (/<\/?(?:html|head|body|title|meta|link|script|style)\b/i.test(html)) {
return ''
}
return html
}
export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim()
const contentHtml = (artwork?.description_html || '').trim()
const contentHtml = sanitizeDescriptionHtml(artwork?.description_html || '')
const collapsed = content.length > COLLAPSE_AT && !expanded
const fallbackText = contentHtml ? stripTags(contentHtml) : content
if (content.length === 0) return null
@@ -20,7 +42,8 @@ export default function ArtworkDescription({ artwork }) {
>
<div
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
dangerouslySetInnerHTML={{ __html: contentHtml }}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: contentHtml || escapeHtml(fallbackText) }}
/>
</div>
@@ -36,3 +59,12 @@ export default function ArtworkDescription({ artwork }) {
</div>
)
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}

View File

@@ -2,6 +2,13 @@ import React, { useMemo } from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
import ArtworkFormatBadges from './ArtworkFormatBadges'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
function formatCount(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
@@ -12,7 +19,7 @@ function formatCount(value) {
function formatDate(value) {
if (!value) return '—'
try {
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
return ABSOLUTE_DATE_FORMATTER.format(new Date(value))
} catch {
return '—'
}

View File

@@ -1,24 +1,33 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import ArtworkFormatBadges from './ArtworkFormatBadges'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
function formatCount(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return n.toLocaleString()
return NUMBER_FORMATTER.format(n)
}
function formatDate(value) {
function formatDate(value, useRelative = true) {
if (!value) return '—'
try {
const d = new Date(value)
if (!useRelative) return ABSOLUTE_DATE_FORMATTER.format(d)
const now = Date.now()
const diff = now - d.getTime()
const days = Math.floor(diff / 86_400_000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 30) return `${days} days ago`
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
return ABSOLUTE_DATE_FORMATTER.format(d)
} catch {
return '—'
}
@@ -46,9 +55,14 @@ function InfoRow({ label, value }) {
}
export default function ArtworkDetailsPanel({ artwork, stats }) {
const [hydrated, setHydrated] = useState(false)
const width = artwork?.dimensions?.width || artwork?.width || 0
const height = artwork?.dimensions?.height || artwork?.height || 0
const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null
const resolution = width > 0 && height > 0 ? `${NUMBER_FORMATTER.format(width)} × ${NUMBER_FORMATTER.format(height)}` : null
useEffect(() => {
setHydrated(true)
}, [])
return (
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
@@ -86,7 +100,7 @@ export default function ArtworkDetailsPanel({ artwork, stats }) {
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
) : null}
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at, hydrated)} />
</div>
</section>
)

View File

@@ -84,6 +84,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
loading="eager"
decoding="async"
fetchPriority="high"
onError={(event) => {
event.currentTarget.onerror = null
setShowBackdrop(false)

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
@@ -77,7 +77,7 @@ function RailCard({ item }) {
<img
src={item.thumb || FALLBACK}
srcSet={item.thumbSrcSet || undefined}
sizes="220px"
sizes="(min-width: 1280px) 210px, (min-width: 640px) 220px, 240px"
alt={item.title || 'Artwork'}
className={`h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
loading="lazy"
@@ -339,74 +339,18 @@ function Rail({ title, emoji, items, seeAllHref }) {
/* ── Main export ─────────────────────────────────────────────── */
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
const [similarApiItems, setSimilarApiItems] = useState([])
const [similarLoaded, setSimilarLoaded] = useState(false)
const [trendingItems, setTrendingItems] = useState([])
export default function ArtworkRecommendationsRails({ artwork, related = [], similarApiData = [], trendingData = [] }) {
const relatedCards = useMemo(() => {
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
}, [related])
useEffect(() => {
let isCancelled = false
const similarApiItems = useMemo(() => {
return dedupeByUrl((Array.isArray(similarApiData) ? similarApiData : []).map(normalizeSimilar).filter(Boolean))
}, [similarApiData])
const loadSimilar = async () => {
if (!artwork?.id) {
setSimilarApiItems([])
setSimilarLoaded(true)
return
}
try {
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
if (!response.ok) throw new Error('similar fetch failed')
const payload = await response.json()
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
if (!isCancelled) {
setSimilarApiItems(items)
setSimilarLoaded(true)
}
} catch {
if (!isCancelled) {
setSimilarApiItems([])
setSimilarLoaded(true)
}
}
}
loadSimilar()
return () => {
isCancelled = true
}
}, [artwork?.id])
useEffect(() => {
let isCancelled = false
const loadTrending = async () => {
const categoryId = artwork?.categories?.[0]?.id
if (!categoryId) {
setTrendingItems([])
return
}
try {
const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' })
if (!response.ok) throw new Error('trending fetch failed')
const payload = await response.json()
const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean))
if (!isCancelled) setTrendingItems(items)
} catch {
if (!isCancelled) setTrendingItems([])
}
}
loadTrending()
return () => {
isCancelled = true
}
}, [artwork?.categories])
const trendingItems = useMemo(() => {
return dedupeByUrl((Array.isArray(trendingData) ? trendingData : []).map(normalizeRankItem).filter(Boolean))
}, [trendingData])
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
@@ -415,11 +359,10 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
}, [relatedCards, authorName])
const similarItems = useMemo(() => {
if (!similarLoaded) return []
if (similarApiItems.length > 0) return similarApiItems.slice(0, 12)
if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12)
return trendingItems.slice(0, 12)
}, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems])
}, [similarApiItems, tagBasedFallback, trendingItems])
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
@@ -428,11 +371,9 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
const categoryName = artwork?.categories?.[0]?.name
const trendingLabel = categoryName
? `Trending in ${categoryName}`
: 'Trending'
: 'Trending on Skinbase'
const trendingHref = categoryName
? `/discover/trending`
: '/discover/trending'
const trendingHref = '/discover/trending'
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import ArtworkRecommendationsRails from './ArtworkRecommendationsRails'
describe('ArtworkRecommendationsRails', () => {
beforeEach(() => {
global.fetch = vi.fn((url) => {
if (String(url).includes('/similar-ai')) {
return Promise.resolve({
ok: true,
json: async () => ({ data: [] }),
})
}
if (String(url).includes('/api/rank/category/5?type=trending')) {
return Promise.resolve({
ok: true,
json: async () => ({
data: [
{
id: 11,
title: 'Star map drift',
urls: { direct: '/art/11/star-map-drift' },
author: { name: 'Pilot' },
thumbnail_url: '/thumbs/11.webp',
},
],
}),
})
}
return Promise.resolve({
ok: true,
json: async () => ({ data: [] }),
})
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('loads recommendation rails after mount', async () => {
render(
<ArtworkRecommendationsRails
artwork={{
id: 69827,
user: { name: 'Pilot' },
categories: [{ id: 5, name: 'Sci-Fi' }],
}}
related={[]}
/>,
)
await waitFor(() => {
expect(screen.getByText('Trending in Sci-Fi')).not.toBeNull()
})
expect(global.fetch).toHaveBeenCalledWith('/api/art/69827/similar-ai', { credentials: 'same-origin' })
expect(global.fetch).toHaveBeenCalledWith('/api/rank/category/5?type=trending', { credentials: 'same-origin' })
})
})

View File

@@ -3,6 +3,7 @@ import AuthorBioPopover from './AuthorBioPopover'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
function formatCount(value) {
const n = Number(value || 0)
@@ -91,7 +92,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
) : null}
</div>
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
{NUMBER_FORMATTER.format(followersCount)} Followers
</p>
{/* Profile + Follow buttons */}
@@ -152,7 +153,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
<div className="mt-5 border-t border-white/[0.06] pt-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
<a href={profileUrl} aria-label={isGroupPublisher ? 'View more related works' : `View all from ${authorName}`} className="text-white/30 transition-colors hover:text-white/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>

View File

@@ -0,0 +1,18 @@
import React, { useState } from 'react'
import Checkbox from '../ui/Checkbox'
export default function RememberMeCheckbox({ initialChecked = false, label = 'Remember me', name = 'remember' }) {
const [checked, setChecked] = useState(Boolean(initialChecked))
return (
<Checkbox
name={name}
value="1"
checked={checked}
onChange={(event) => setChecked(event.target.checked)}
label={label}
variant="accent"
size={18}
/>
)
}

View File

@@ -13,6 +13,8 @@ import { common, createLowlight } from 'lowlight';
import tippy from 'tippy.js';
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
import TurnstileField from '../security/TurnstileField';
import DateTimePicker from '../ui/DateTimePicker';
import Modal from '../ui/Modal';
import NovaSelect from '../ui/NovaSelect';
type StoryType = {
@@ -43,7 +45,6 @@ type StoryPayload = {
tags_csv: string;
meta_title: string;
meta_description: string;
canonical_url: string;
og_image: string;
status: string;
scheduled_for: string;
@@ -68,6 +69,8 @@ type Props = {
csrfToken: string;
};
type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null;
const EMPTY_DOC = {
type: 'doc',
content: [{ type: 'paragraph' }],
@@ -90,6 +93,108 @@ const CODE_BLOCK_LANGUAGES = [
{ value: 'markdown', label: 'Markdown' },
];
const INSERT_DIALOG_CONTENT = {
image: {
title: 'Add image from URL',
description: 'Paste a direct image URL to insert a full image block into the story body.',
confirmLabel: 'Insert image',
urlLabel: 'Image URL',
urlPlaceholder: 'https://images.example.com/story-scene.jpg',
urlHint: 'Use a direct image file URL when possible for the most reliable preview.',
},
video: {
title: 'Embed a video',
description: 'Paste a YouTube or Vimeo link. Common watch and share URLs will be converted to embed URLs automatically.',
confirmLabel: 'Embed video',
urlLabel: 'Video URL',
urlPlaceholder: 'https://www.youtube.com/watch?v=example',
urlHint: 'You can paste a normal watch URL, share URL, or a direct embed URL.',
},
download: {
title: 'Add a download link',
description: 'Create a downloadable asset button with a friendly label for readers.',
confirmLabel: 'Add download',
urlLabel: 'File URL',
urlPlaceholder: 'https://cdn.example.com/files/asset.zip',
urlHint: 'Point this at the exact file you want readers to download.',
},
link: {
title: 'Add link to selection',
description: 'Attach a link to the currently selected text in your story.',
confirmLabel: 'Save link',
urlLabel: 'Link URL',
urlPlaceholder: 'https://skinbase.org/help',
urlHint: 'Paste any http or https URL. Leave it empty and use Remove link to clear an existing link.',
},
};
const INSERT_DIALOG_INITIAL_STATE = {
kind: null as InsertDialogKind,
url: '',
title: '',
label: 'Download asset',
error: '',
};
function normalizeHttpUrl(rawValue: string): string | null {
const trimmed = rawValue.trim();
if (trimmed === '') {
return null;
}
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
try {
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function normalizeVideoEmbedUrl(rawValue: string): string | null {
const normalized = normalizeHttpUrl(rawValue);
if (!normalized) {
return null;
}
const parsed = new URL(normalized);
const host = parsed.hostname.replace(/^www\./i, '').toLowerCase();
const path = parsed.pathname;
if (host === 'youtu.be') {
const videoId = path.replace(/^\//, '').split('/')[0];
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
}
if (host === 'youtube.com' || host === 'm.youtube.com') {
if (path === '/watch') {
const videoId = parsed.searchParams.get('v');
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
}
const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i);
if (pathMatch?.[2]) {
return `https://www.youtube.com/embed/${pathMatch[2]}`;
}
}
if (host === 'vimeo.com') {
const videoId = path.replace(/^\//, '').split('/')[0];
return videoId ? `https://player.vimeo.com/video/${videoId}` : normalized;
}
if (host === 'player.vimeo.com') {
return normalized;
}
return normalized;
}
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
@@ -263,6 +368,7 @@ function createSlashCommandExtension(insert: {
code: () => void;
quote: () => void;
divider: () => void;
part: () => void;
gallery: () => void;
video: () => void;
download: () => void;
@@ -282,6 +388,7 @@ function createSlashCommandExtension(insert: {
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Add a new part', key: 'part' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
{ title: 'Video', key: 'video' },
@@ -295,6 +402,7 @@ function createSlashCommandExtension(insert: {
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'part') insert.part();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
if (props.key === 'video') insert.video();
@@ -438,7 +546,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || '');
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
const [status, setStatus] = useState(initialStory.status || 'draft');
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
@@ -449,14 +556,19 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [generalError, setGeneralError] = useState('');
const [insertDialog, setInsertDialog] = useState(INSERT_DIALOG_INITIAL_STATE);
const [wordCount, setWordCount] = useState(0);
const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 });
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const insertSelectionRef = useRef<{ from: number; to: number } | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const excerptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [captchaState, setCaptchaState] = useState({
required: false,
token: '',
@@ -534,17 +646,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
setFieldErrors({});
}, []);
const openLinkPrompt = useCallback((editor: any) => {
const prev = editor.getAttributes('link').href;
const url = window.prompt('Link URL', prev || 'https://');
if (url === null) return;
if (url.trim() === '') {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().setLink({ href: url.trim() }).run();
}, []);
const fetchArtworks = useCallback(async (query: string) => {
const q = encodeURIComponent(query);
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
@@ -612,12 +713,152 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
}, [codeBlockLanguage]);
const closeInsertDialog = useCallback(() => {
insertSelectionRef.current = null;
setInsertDialog(INSERT_DIALOG_INITIAL_STATE);
}, []);
const openInsertDialog = useCallback((kind: Exclude<InsertDialogKind, null>) => {
const currentEditor = editorRef.current;
if (!currentEditor) {
return;
}
const { from, to } = currentEditor.state.selection;
insertSelectionRef.current = { from, to };
setInsertDialog({
kind,
url: '',
title: kind === 'video' ? 'Embedded video' : '',
label: 'Download asset',
error: '',
});
}, []);
const openLinkDialog = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) {
return;
}
const { from, to } = currentEditor.state.selection;
if (from === to) {
return;
}
insertSelectionRef.current = { from, to };
setInsertDialog({
kind: 'link',
url: currentEditor.getAttributes('link').href || '',
title: '',
label: 'Download asset',
error: '',
});
}, []);
const removeSelectedLink = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) {
closeInsertDialog();
return;
}
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection).extendMarkRange('link');
}
chain.unsetLink().run();
closeInsertDialog();
}, [closeInsertDialog]);
const submitInsertDialog = useCallback((event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!insertDialog.kind) {
return;
}
const currentEditor = editorRef.current;
if (!currentEditor) {
closeInsertDialog();
return;
}
if (insertDialog.kind === 'link') {
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection).extendMarkRange('link');
}
const normalizedLink = normalizeHttpUrl(insertDialog.url);
if (!normalizedLink) {
setInsertDialog((previous) => ({
...previous,
error: 'Enter a valid http or https URL for the selected text.',
}));
return;
}
chain.setLink({ href: normalizedLink }).run();
closeInsertDialog();
return;
}
let normalizedUrl = normalizeHttpUrl(insertDialog.url);
if (insertDialog.kind === 'video') {
normalizedUrl = normalizeVideoEmbedUrl(insertDialog.url);
}
if (!normalizedUrl) {
setInsertDialog((previous) => ({
...previous,
error: insertDialog.kind === 'video'
? 'Enter a valid YouTube, Vimeo, or direct embed URL.'
: 'Enter a valid http or https URL.',
}));
return;
}
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection);
}
if (insertDialog.kind === 'image') {
chain.setImage({ src: normalizedUrl }).run();
closeInsertDialog();
return;
}
if (insertDialog.kind === 'video') {
chain.insertContent({
type: 'videoEmbed',
attrs: {
src: normalizedUrl,
title: insertDialog.title.trim() || 'Embedded video',
},
}).run();
closeInsertDialog();
return;
}
chain.insertContent({
type: 'downloadAsset',
attrs: {
url: normalizedUrl,
label: insertDialog.label.trim() || 'Download asset',
},
}).run();
closeInsertDialog();
}, [closeInsertDialog, insertDialog]);
const insertActions = useMemo(() => ({
image: () => {
const currentEditor = editorRef.current;
const url = window.prompt('Image URL', 'https://');
if (!url || !currentEditor) return;
currentEditor.chain().focus().setImage({ src: url }).run();
openInsertDialog('image');
},
uploadImage: () => bodyImageInputRef.current?.click(),
artwork: () => setArtworkModalOpen(true),
@@ -634,6 +875,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
part: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
@@ -642,21 +888,12 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
if (!src) return;
currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
openInsertDialog('video');
},
download: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
const url = window.prompt('Download URL', 'https://');
if (!url) return;
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
currentEditor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
openInsertDialog('download');
},
}), [toggleCodeBlockWithLanguage]);
}), [openInsertDialog, toggleCodeBlockWithLanguage]);
const editor = useEditor({
extensions: [
@@ -692,7 +929,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
content: initialStory.content || EMPTY_DOC,
editorProps: {
attributes: {
class: 'tiptap prose prose-lg prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.85] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
class: 'tiptap prose prose-xl prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.9] prose-li:leading-[1.9] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
@@ -810,39 +1047,62 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
useEffect(() => {
if (!editor) return;
const hidePlusButton = () => {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
};
const updatePlusButton = () => {
const { from, to } = editor.state.selection;
if (from !== to) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
if (from !== to || !editor.isFocused) {
hidePlusButton();
return;
}
const resolvedPos = editor.state.doc.resolve(from);
const parentNode = resolvedPos.parent;
if (parentNode.type.name === 'paragraph' && parentNode.content.size === 0) {
const coords = editor.view.coordsAtPos(from);
const containerRect = editorContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
return;
}
setPlusButtonState({
visible: true,
top: coords.top - 14,
left: containerRect.left - 48,
});
} else {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
const container = editorContainerRef.current;
if (!container) {
hidePlusButton();
return;
}
const domAtPos = editor.view.domAtPos(from);
const anchorNode = domAtPos.node instanceof Element ? domAtPos.node : domAtPos.node.parentElement;
const blockElement = anchorNode?.closest('p, h1, h2, h3, blockquote, pre, li');
if (!blockElement || !container.contains(blockElement)) {
hidePlusButton();
return;
}
const blockRect = blockElement.getBoundingClientRect();
const computedStyle = window.getComputedStyle(blockElement);
const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight);
const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : 32;
setPlusButtonState({
visible: true,
top: blockRect.top + Math.max((lineHeight - 32) / 2, 0),
left: Math.max(16, blockRect.left - 44),
});
};
editor.on('selectionUpdate', updatePlusButton);
editor.on('update', updatePlusButton);
editor.on('focus', updatePlusButton);
editor.on('blur', hidePlusButton);
const frameId = window.requestAnimationFrame(updatePlusButton);
window.addEventListener('scroll', updatePlusButton, true);
window.addEventListener('resize', updatePlusButton);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('scroll', updatePlusButton, true);
window.removeEventListener('resize', updatePlusButton);
editor.off('selectionUpdate', updatePlusButton);
editor.off('update', updatePlusButton);
editor.off('focus', updatePlusButton);
editor.off('blur', hidePlusButton);
};
}, [editor]);
@@ -856,12 +1116,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
meta_title: metaTitle || title,
meta_description: metaDescription || excerpt,
canonical_url: canonicalUrl,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || EMPTY_DOC,
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
@@ -993,6 +1252,84 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const contentError = fieldErrors?.content?.[0] || '';
const excerptError = fieldErrors?.excerpt?.[0] || '';
const tagsError = fieldErrors?.tags_csv?.[0] || '';
const completedChecks = readinessChecks.filter((check) => check.ok).length;
const progressPercent = Math.max(20, Math.round((completedChecks / Math.max(readinessChecks.length, 1)) * 100));
const topActions = [
{
key: 'cover',
label: coverImage ? 'Change cover' : 'Add cover',
detail: coverImage ? 'Refresh the hero image.' : 'Give the story a visual anchor.',
onClick: () => coverImageInputRef.current?.click(),
tone: 'sky',
},
{
key: 'part',
label: 'New part',
detail: 'Drop in the three-dot chapter separator.',
onClick: () => insertActions.part(),
tone: 'violet',
},
{
key: 'settings',
label: 'Story settings',
detail: 'Manage SEO, workflow, and metadata.',
onClick: () => setSettingsOpen(true),
tone: 'slate',
},
];
const desktopInsertActions = [
{ key: 'uploadImage', label: 'Upload photo', detail: 'Drop a full-width image into the body.' },
{ key: 'artwork', label: 'Embed artwork', detail: 'Showcase one of your published pieces.' },
{ key: 'video', label: 'Embed video', detail: 'Paste YouTube or Vimeo and let Nova normalize it.' },
{ key: 'download', label: 'Download link', detail: 'Add a clear file CTA for readers.' },
{ key: 'part', label: 'Add a new part', detail: 'Break long stories into readable chapters.' },
] as Array<{ key: keyof typeof insertActions; label: string; detail: string }>;
const quickLinks = storyId ? [
{ key: 'preview', label: 'Preview story', href: `${endpoints.previewBase}/${storyId}/preview` },
{ key: 'analytics', label: 'Story analytics', href: `${endpoints.analyticsBase}/${storyId}/analytics` },
] : [];
const storySuggestions = [
!coverImage ? {
key: 'cover',
label: 'Add a cover image',
detail: 'A strong visual anchor makes the draft feel finished faster.',
onClick: () => coverImageInputRef.current?.click(),
tone: 'sky',
} : null,
excerpt.trim().length < 40 ? {
key: 'excerpt',
label: 'Sharpen the subtitle',
detail: 'Give readers one sentence that sets the tone before the first paragraph.',
onClick: () => excerptInputRef.current?.focus(),
tone: 'violet',
} : null,
wordCount >= 220 ? {
key: 'part',
label: 'Split the next chapter',
detail: 'This draft is long enough for a visual chapter break.',
onClick: () => insertActions.part(),
tone: 'emerald',
} : null,
tagsCsv.trim().length === '' ? {
key: 'tags',
label: 'Add discovery tags',
detail: 'Open settings and add a few tags so the story is easier to surface later.',
onClick: () => setSettingsOpen(true),
tone: 'amber',
} : null,
].filter(Boolean) as Array<{ key: string; label: string; detail: string; onClick: () => void; tone: string }>;
const topActionToneClasses: Record<string, string> = {
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-400/15',
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100 hover:border-violet-300/35 hover:bg-violet-400/15',
slate: 'border-white/10 bg-white/[0.045] text-white/78 hover:border-white/20 hover:bg-white/[0.08]',
};
const suggestionToneClasses: Record<string, string> = {
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100',
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100',
emerald: 'border-emerald-300/18 bg-emerald-400/10 text-emerald-100',
amber: 'border-amber-300/18 bg-amber-400/10 text-amber-100',
};
const insertArtwork = (item: Artwork) => {
if (!editor) return;
@@ -1009,7 +1346,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
return (
<div className="mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8">
<div className={`min-h-screen px-4 py-4 pb-24 md:px-8 ${focusMode ? 'bg-[linear-gradient(180deg,rgba(6,10,16,0.99),rgba(4,7,12,1))]' : 'bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.09),_transparent_30%),radial-gradient(circle_at_20%_20%,_rgba(14,165,233,0.07),_transparent_24%),linear-gradient(180deg,rgba(7,11,18,0.98),rgba(4,7,12,1))]'}`}>
<div className={`mx-auto ${focusMode ? 'max-w-[1180px]' : 'max-w-[1400px]'}`}>
{/* ── Nova top bar ─────────────────────────────────────────────────── */}
<div className="sticky top-0 z-30 mb-6 flex h-14 items-center justify-between overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.97),rgba(8,12,20,0.97))] px-5 shadow-[0_8px_32px_rgba(3,7,18,0.32)] backdrop-blur-xl">
<div className="flex items-center gap-4">
@@ -1022,6 +1360,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</div>
<div className="flex items-center gap-2">
<span className="hidden text-xs text-white/55 lg:inline">{wordCount > 0 ? `${wordCount.toLocaleString()} words · ${readMinutes} min` : ''}</span>
<button
type="button"
onClick={() => setFocusMode((current) => !current)}
className={`rounded-full border px-3 py-1.5 text-sm transition ${focusMode ? 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white'}`}
>
{focusMode ? 'Exit focus' : 'Focus mode'}
</button>
<button
type="button"
onClick={() => setSettingsOpen(true)}
@@ -1049,8 +1394,75 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</div>
</div>
<div className={`grid gap-6 ${focusMode ? '' : 'xl:grid-cols-[minmax(0,1fr)_300px] xl:items-start'}`}>
<main>
{!focusMode && (
<div className="mb-6 overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,36,0.9),rgba(9,14,24,0.96))] shadow-[0_24px_80px_rgba(2,6,23,0.28)] backdrop-blur-xl">
<div className="flex flex-col gap-5 px-6 py-6 md:px-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/55">Story Studio</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white md:text-[2.35rem]">Shape the narrative before readers ever see the first line.</h1>
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300/82 md:text-[15px]">Use the writing canvas for the draft itself, keep your metadata close, and drop in chapter breaks or rich media without leaving the flow.</p>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[420px] lg:max-w-[460px] lg:flex-1">
{topActions.map((action) => (
<button
key={action.key}
type="button"
onClick={action.onClick}
className={`rounded-[1.35rem] border px-4 py-4 text-left transition ${topActionToneClasses[action.tone]}`}
>
<div className="text-sm font-semibold">{action.label}</div>
<div className="mt-1.5 text-xs leading-5 text-inherit/70">{action.detail}</div>
</button>
))}
</div>
</div>
</div>
)}
<div className="nb-scrollbar-none mb-5 overflow-x-auto overflow-y-hidden rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(11,17,27,0.94),rgba(7,10,17,0.96))] px-4 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.22)] backdrop-blur-xl sm:px-5">
<div className="flex min-w-max items-center gap-2">
{desktopInsertActions.map((action) => (
<button
key={`top-toolbar-${action.key}`}
type="button"
onClick={() => insertActions[action.key]()}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.05] text-[11px] text-sky-200">+</span>
{action.label}
</button>
))}
<span className="mx-1 hidden h-5 w-px bg-white/10 md:block" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Story settings
</button>
<button
type="button"
onClick={() => setFocusMode((current) => !current)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition ${focusMode ? 'border-sky-400/28 bg-sky-400/[0.08] text-sky-100 hover:bg-sky-400/[0.14]' : 'border-white/10 bg-white/[0.04] text-white/78 hover:border-white/20 hover:bg-white/[0.08] hover:text-white'}`}
>
{focusMode ? 'Exit focus' : 'Focus mode'}
</button>
{quickLinks.map((link) => (
<a
key={`top-toolbar-${link.key}`}
href={link.href}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
{link.label}
</a>
))}
</div>
</div>
{/* ── Writing canvas ───────────────────────────────────────────────── */}
<div className="mx-auto max-w-[760px] overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)]">
<div className={`mx-auto overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)] ${focusMode ? 'max-w-[920px]' : 'max-w-[780px]'}`}>
{coverImage ? (
<div className="group relative overflow-hidden rounded-t-2xl">
<img src={coverImage} alt="Story cover" className="h-64 w-full object-cover md:h-80" />
@@ -1110,6 +1522,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* Title */}
<div className="mb-3">
<textarea
ref={titleInputRef}
value={title}
onChange={(event) => {
setTitle(event.target.value);
@@ -1130,6 +1543,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* Excerpt / subtitle */}
<div className="mb-10 border-b border-white/[0.07] pb-8">
<textarea
ref={excerptInputRef}
value={excerpt}
onChange={(event) => {
setExcerpt(event.target.value);
@@ -1183,6 +1597,104 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
)}
</div>
</div>
</div>
</main>
{!focusMode ? (
<aside className="hidden xl:block">
<div className="sticky top-[5.5rem] space-y-4">
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.96),rgba(8,12,20,0.96))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Story pulse</p>
<div className="mt-3 flex items-end justify-between gap-3">
<div>
<p className="text-2xl font-semibold text-white">{completedChecks}/{readinessChecks.length}</p>
<p className="mt-1 text-sm text-slate-300/72">Publishing readiness</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/35">Rhythm</div>
<div className="mt-1 text-sm font-medium text-white/85">{wordCount > 0 ? `${wordCount.toLocaleString()} words` : 'Start writing'}</div>
<div className="mt-1 text-xs text-white/45">{readMinutes} min read</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-[linear-gradient(90deg,rgba(56,189,248,0.9),rgba(59,130,246,0.92))]" style={{ width: `${progressPercent}%` }} />
</div>
</div>
<div className="space-y-2 px-5 py-4">
{readinessChecks.map((check) => (
<div key={check.label} className={`rounded-2xl border px-4 py-3 ${check.ok ? 'border-emerald-400/18 bg-emerald-500/10' : 'border-amber-400/18 bg-amber-500/10'}`}>
<div className="flex items-start gap-3">
<span className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-400/20 text-emerald-200' : 'bg-amber-400/20 text-amber-200'}`}>{check.ok ? '✓' : '!'}</span>
<div>
<p className="text-sm font-medium text-white/88">{check.label}</p>
<p className="mt-1 text-xs leading-5 text-white/48">{check.hint}</p>
</div>
</div>
</div>
))}
</div>
</div>
{storySuggestions.length > 0 ? (
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Suggestions</p>
<p className="mt-2 text-sm leading-6 text-slate-300/78">A few next moves based on the draft you have right now.</p>
</div>
<div className="space-y-2 px-5 py-4">
{storySuggestions.map((suggestion) => (
<button
key={suggestion.key}
type="button"
onClick={suggestion.onClick}
className={`w-full rounded-2xl border px-4 py-3 text-left transition hover:translate-x-0.5 ${suggestionToneClasses[suggestion.tone]}`}
>
<div className="text-sm font-semibold">{suggestion.label}</div>
<div className="mt-1 text-xs leading-5 text-inherit/70">{suggestion.detail}</div>
</button>
))}
</div>
</div>
) : null}
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Desktop shortcuts</p>
<p className="mt-2 text-sm leading-6 text-slate-300/78">Keep the heavy-lift actions nearby while the canvas stays clean.</p>
</div>
<div className="space-y-2 px-5 py-4">
{desktopInsertActions.map((action) => (
<button
key={action.key}
type="button"
onClick={() => insertActions[action.key]()}
className="w-full rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-left transition hover:border-sky-400/30 hover:bg-sky-400/[0.08]"
>
<div className="text-sm font-semibold text-white/88">{action.label}</div>
<div className="mt-1 text-xs leading-5 text-white/48">{action.detail}</div>
</button>
))}
</div>
{quickLinks.length > 0 ? (
<div className="border-t border-white/10 px-5 py-4">
<div className="space-y-2">
{quickLinks.map((link) => (
<a
key={link.key}
href={link.href}
className="block rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-sm font-medium text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
{link.label}
</a>
))}
</div>
</div>
) : null}
</div>
</div>
</aside>
) : null}
</div>
</div>
{/* ── Floating + block insertion button (fixed, always visible when on empty line) ── */}
@@ -1218,6 +1730,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{ label: 'Blockquote', icon: '❝', key: 'quote' },
{ label: 'Code block', icon: '⌨', key: 'code' },
{ label: 'Download link', icon: '↓', key: 'download' },
{ label: 'Add a new part', icon: '⋯', key: 'part' },
{ label: 'Divider', icon: '—', key: 'divider' },
] as Array<{ label: string; icon: string; key: keyof typeof insertActions }>).map((item) => (
<button
@@ -1242,29 +1755,42 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* ── Floating inline formatting toolbar ───────────────────────────── */}
{editor && inlineToolbar.visible && (
<div
className="fixed z-50 flex items-center gap-0.5 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
className="fixed z-50 flex items-center overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
{([
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
{ label: '', title: 'Link', action: () => openLinkPrompt(editor), active: editor.isActive('link'), extra: '' },
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
] as Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>).map((item) => (
<button
key={item.title}
type="button"
title={item.title}
onMouseDown={(e) => e.preventDefault()}
onClick={item.action}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
>
{item.label}
</button>
[
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
],
[
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
],
[
{ label: '⛓', title: 'Link', action: openLinkDialog, active: editor.isActive('link'), extra: '' },
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
],
] as Array<Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>>).map((group, groupIndex) => (
<React.Fragment key={`inline-toolbar-group-${groupIndex}`}>
{groupIndex > 0 ? <span className="mx-1 h-6 w-px bg-white/10" aria-hidden="true" /> : null}
<div className="flex items-center gap-0.5 px-0.5">
{group.map((item) => (
<button
key={item.title}
type="button"
title={item.title}
onMouseDown={(e) => e.preventDefault()}
onClick={item.action}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
>
{item.label}
</button>
))}
</div>
</React.Fragment>
))}
</div>
)}
@@ -1348,7 +1874,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Workflow</p>
<NovaSelect value={status} onChange={(val) => setStatus(val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'pending_review', label: 'Pending Review' }, { value: 'published', label: 'Published' }, { value: 'scheduled', label: 'Scheduled' }, { value: 'archived', label: 'Archived' }]} />
<input type="datetime-local" value={scheduledFor} onChange={(e) => setScheduledFor(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white focus:border-white/20 focus:outline-none" />
<DateTimePicker
value={scheduledFor}
onChange={setScheduledFor}
placeholder="Pick a publish date"
clearable
className="bg-slate-950/60"
/>
</div>
{/* SEO */}
@@ -1357,7 +1889,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
<div className="space-y-2">
<input value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} placeholder="Meta title (defaults to story title)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<textarea value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} rows={3} placeholder="Meta description (defaults to excerpt)" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<input value={canonicalUrl} onChange={(e) => setCanonicalUrl(e.target.value)} placeholder="Canonical URL (optional)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<input value={ogImage} onChange={(e) => setOgImage(e.target.value)} placeholder="OG image URL (defaults to cover)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
</div>
</div>
@@ -1411,6 +1942,97 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</div>
)}
<Modal
open={Boolean(insertDialog.kind)}
onClose={closeInsertDialog}
title={insertDialog.kind ? INSERT_DIALOG_CONTENT[insertDialog.kind].title : ''}
size="md"
footer={insertDialog.kind ? (
<div className="ml-auto flex items-center gap-2">
{insertDialog.kind === 'link' && (
<button
type="button"
onClick={removeSelectedLink}
className="rounded-xl border border-rose-400/20 bg-rose-500/10 px-4 py-2 text-sm text-rose-200 transition hover:bg-rose-500/20"
>
Remove link
</button>
)}
<button
type="button"
onClick={closeInsertDialog}
className="rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white/70 transition hover:bg-white/[0.08] hover:text-white"
>
Cancel
</button>
<button
type="submit"
form="story-insert-dialog-form"
className="rounded-xl bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-[0_6px_20px_rgba(14,165,233,0.35)] transition hover:bg-sky-400"
>
{INSERT_DIALOG_CONTENT[insertDialog.kind].confirmLabel}
</button>
</div>
) : null}
>
{insertDialog.kind ? (
<form id="story-insert-dialog-form" onSubmit={submitInsertDialog} className="space-y-5">
<div className="space-y-2">
<p className="text-sm leading-6 text-slate-200">{INSERT_DIALOG_CONTENT[insertDialog.kind].description}</p>
<p className="text-xs leading-5 text-slate-400">{INSERT_DIALOG_CONTENT[insertDialog.kind].urlHint}</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
{INSERT_DIALOG_CONTENT[insertDialog.kind].urlLabel}
</label>
<input
value={insertDialog.url}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, url: event.target.value, error: '' }))}
placeholder={INSERT_DIALOG_CONTENT[insertDialog.kind].urlPlaceholder}
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
</div>
{insertDialog.kind === 'video' && (
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
Accessible title
</label>
<input
value={insertDialog.title}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, title: event.target.value }))}
placeholder="Embedded video"
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
<p className="text-xs leading-5 text-slate-400">This helps screen readers describe the embedded video block.</p>
</div>
)}
{insertDialog.kind === 'download' && (
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
Button label
</label>
<input
value={insertDialog.label}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, label: event.target.value }))}
placeholder="Download asset"
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
<p className="text-xs leading-5 text-slate-400">Readers will see this label on the download button inside the story.</p>
</div>
)}
{insertDialog.error ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{insertDialog.error}
</div>
) : null}
</form>
) : null}
</Modal>
{/* Hidden file inputs */}
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />

View File

@@ -238,6 +238,8 @@ export default function RichTextEditor({
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
underline: false,
heading: { levels: [2, 3] },
codeBlock: {
HTMLAttributes: { class: 'forum-code-block' },
@@ -262,6 +264,7 @@ export default function RichTextEditor({
suggestion: mentionSuggestion,
}),
],
immediatelyRender: false,
content,
autofocus,
editorProps: {
@@ -291,6 +294,10 @@ export default function RichTextEditor({
useEffect(() => {
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
editor.commands.setContent(content, false)
// Keep the parent form state in sync with what we just rendered.
// setContent with emitUpdate=false silently resets TipTap without
// calling onUpdate, so form.data.content would lag behind the editor.
onChange?.(content)
}
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -38,9 +38,9 @@ export default function GroupProfileSummary({ contributions = [], href = null })
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entry.counts?.artworks || 0).toLocaleString()} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
<span>{Number(entry.counts?.artworks || 0).toLocaleString('en-US')} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString('en-US')} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString('en-US')} projects</span>
</div>
</a>
))}

View File

@@ -1,6 +1,8 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
export default function GroupSummaryPanel({ group, artwork }) {
if (!group) return null
@@ -26,15 +28,15 @@ export default function GroupSummaryPanel({ group, artwork }) {
<div className="mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center">
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.artworks || 0).toLocaleString()}</div>
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.artworks || 0))}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Artworks</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.members || 0).toLocaleString()}</div>
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.members || 0))}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Members</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.followers || 0).toLocaleString()}</div>
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.followers || 0))}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Followers</div>
</div>
</div>

View File

@@ -18,10 +18,13 @@ export default function ProfileCoverEditor({
const [removing, setRemoving] = useState(false)
const [position, setPosition] = useState(coverPosition ?? 50)
const csrfToken = useMemo(
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
[]
)
const csrfToken = useMemo(() => {
if (typeof document === 'undefined') {
return ''
}
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
}, [])
if (!isOpen) {
return null

View File

@@ -1,22 +1,26 @@
import React, { useState } from 'react'
import { usePage } from '@inertiajs/react'
import ProfileCoverEditor from './ProfileCoverEditor'
import LevelBadge from '../xp/LevelBadge'
import XPProgressBar from '../xp/XPProgressBar'
import FollowButton from '../social/FollowButton'
import FollowersPreview from '../social/FollowersPreview'
import MutualFollowersBadge from '../social/MutualFollowersBadge'
import { shinyFlagUrl } from '../../utils/flagUrl'
function formatCompactNumber(value) {
const numeric = Number(value ?? 0)
return numeric.toLocaleString()
return numeric.toLocaleString('en-US')
}
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const { props } = usePage()
const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount)
const [editorOpen, setEditorOpen] = useState(false)
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
@@ -118,9 +122,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
{countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
{profile?.country_code ? (
{flagUrl ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
src={flagUrl}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(event) => { event.target.style.display = 'none' }}

View File

@@ -16,6 +16,8 @@ function typeMeta(type) {
return { icon: 'fa-solid fa-user-plus', label: 'Follow', tone: 'text-emerald-100 bg-emerald-400/12 border-emerald-300/20' }
case 'achievement':
return { icon: 'fa-solid fa-trophy', label: 'Achievement', tone: 'text-yellow-100 bg-yellow-400/12 border-yellow-300/20' }
case 'world_reward':
return { icon: 'fa-solid fa-globe', label: 'World reward', tone: 'text-sky-100 bg-sky-400/12 border-sky-300/20' }
case 'forum_post':
return { icon: 'fa-solid fa-signs-post', label: 'Forum thread', tone: 'text-violet-100 bg-violet-400/12 border-violet-300/20' }
case 'forum_reply':
@@ -46,6 +48,8 @@ function headline(activity) {
return activity?.target_user ? `Started following @${activity.target_user.username || activity.target_user.name}` : 'Started following a creator'
case 'achievement':
return activity?.achievement?.name ? `Unlocked ${activity.achievement.name}` : 'Unlocked a new achievement'
case 'world_reward':
return activity?.world_reward?.badge_label ? `Earned ${activity.world_reward.badge_label}` : 'Earned a new world reward'
case 'forum_post':
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
case 'forum_reply':
@@ -59,6 +63,7 @@ function body(activity) {
if (activity?.comment?.body) return activity.comment.body
if (activity?.forum?.post?.excerpt) return activity.forum.post.excerpt
if (activity?.achievement?.description) return activity.achievement.description
if (activity?.world_reward?.note) return activity.world_reward.note
return ''
}
@@ -68,6 +73,7 @@ function cta(activity) {
if (activity?.forum?.post?.url) return { href: activity.forum.post.url, label: 'Open reply' }
if (activity?.forum?.thread?.url) return { href: activity.forum.thread.url, label: 'Open thread' }
if (activity?.target_user?.profile_url) return { href: activity.target_user.profile_url, label: 'View profile' }
if (activity?.world_reward?.world?.url) return { href: activity.world_reward.world.url, label: 'Open world' }
return null
}
@@ -173,6 +179,14 @@ export default function ActivityCard({ activity }) {
</div>
) : null}
{activity?.world_reward ? (
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">World reward</div>
<div className="mt-1 text-sm font-medium text-white">{activity.world_reward.badge_label}</div>
{activity.world_reward.artwork?.title ? <div className="mt-2 text-sm text-slate-400">Artwork: {activity.world_reward.artwork.title}</div> : null}
</div>
) : null}
{activity?.forum?.thread ? (
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Forum activity</div>

View File

@@ -1,5 +1,7 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import CreatorJourneySection from '../CreatorJourneySection'
import { shinyFlagUrl } from '../../../utils/flagUrl'
const SOCIAL_ICONS = {
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]' },
@@ -226,11 +228,13 @@ function SectionCard({ icon, eyebrow, title, children, className = '' }) {
* TabAbout
* Bio, social links, metadata - replaces old sidebar profile card.
*/
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory, journey }) {
export default function TabAbout({ user, profile, stats, achievements, worldRewards, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory, journey }) {
const { props } = usePage()
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
const website = profile?.website
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
@@ -261,6 +265,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
: []
const followers = recentFollowers ?? []
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
const recentWorldRewards = Array.isArray(worldRewards?.recent) ? worldRewards.recent : []
const stories = Array.isArray(creatorStories) ? creatorStories : []
const comments = Array.isArray(profileComments) ? profileComments : []
const contributionHistory = Array.isArray(groupContributionHistory) ? groupContributionHistory : []
@@ -315,9 +320,9 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
{countryName ? (
<InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2">
{profile?.country_code ? (
{flagUrl ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
src={flagUrl}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
@@ -466,6 +471,31 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
</SectionCard>
) : null}
{recentWorldRewards.length > 0 ? (
<SectionCard icon="fa-solid fa-globe" eyebrow="World recognition" title="Latest world rewards">
<div className="grid gap-3 sm:grid-cols-2">
{recentWorldRewards.slice(0, 4).map((reward) => (
<a
key={reward.id}
href={reward.world?.url || reward.artwork?.url || '#'}
className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 transition hover:border-white/15 hover:bg-white/[0.06]"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{reward.badge_label}</div>
{reward.artwork?.title ? <div className="mt-1 text-sm text-slate-400">{reward.artwork.title}</div> : null}
</div>
<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">
{reward.reward_label}
</span>
</div>
{reward.granted_at ? <div className="mt-3 text-xs text-slate-500">{formatShortDate(reward.granted_at) || 'Rewarded'}</div> : null}
</a>
))}
</div>
</SectionCard>
) : null}
{stories.length > 0 || comments.length > 0 ? (
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
<div className="grid gap-3 lg:grid-cols-2">

View File

@@ -42,6 +42,8 @@ export default function SeoHead({ seo = {}, title = null, description = null, js
{ogUrl ? <meta head-key="og:url" property="og:url" content={ogUrl} /> : null}
{ogImage ? <meta head-key="og:image" property="og:image" content={ogImage} /> : null}
{seo?.og_image_alt ? <meta head-key="og:image:alt" property="og:image:alt" content={seo.og_image_alt} /> : null}
{seo?.og_image_width ? <meta head-key="og:image:width" property="og:image:width" content={String(seo.og_image_width)} /> : null}
{seo?.og_image_height ? <meta head-key="og:image:height" property="og:image:height" content={String(seo.og_image_height)} /> : null}
<meta head-key="twitter:card" name="twitter:card" content={twitterCard} />
<meta head-key="twitter:title" name="twitter:title" content={twitterTitle} />
@@ -56,9 +58,8 @@ export default function SeoHead({ seo = {}, title = null, description = null, js
key={`jsonld-${schemaType}-${index}`}
head-key={`jsonld-${schemaType}-${index}`}
type="application/ld+json"
>
{JSON.stringify(schema)}
</script>
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
)
})}
</Head>

View File

@@ -58,6 +58,34 @@ function mergeDateTime(date, time) {
return `${date}T${time || '00:00'}`
}
function maxDateValue(a, b) {
if (!a) return b || ''
if (!b) return a || ''
return a > b ? a : b
}
function minDateValue(a, b) {
if (!a) return b || ''
if (!b) return a || ''
return a < b ? a : b
}
function clampTimeToBounds(date, time, minDateTime, maxDateTime) {
const nextTime = time || '00:00'
const minParts = splitDateTime(minDateTime)
const maxParts = splitDateTime(maxDateTime)
if (date && minParts.date === date && minParts.time && nextTime < minParts.time) {
return minParts.time
}
if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) {
return maxParts.time
}
return nextTime
}
function formatDisplay(value) {
if (!value) return ''
@@ -147,15 +175,18 @@ export default function DateTimePicker({
value = '',
onChange,
label,
placeholder = 'Pick a date and time',
placeholder,
error,
hint,
required = false,
clearable = false,
id,
disabled = false,
mode = 'datetime',
minDate,
maxDate,
minDateTime,
maxDateTime,
className = '',
}) {
const today = new Date()
@@ -168,6 +199,7 @@ export default function DateTimePicker({
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
const [draftDate, setDraftDate] = useState(initial.date)
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
const triggerRef = useRef(null)
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
@@ -239,16 +271,23 @@ export default function DateTimePicker({
}, [open, panelId])
const applyValue = useCallback((date, time) => {
onChange?.(date ? mergeDateTime(date, time) : '')
}, [onChange])
if (!date) {
onChange?.('')
return
}
onChange?.(mode === 'date' ? date : mergeDateTime(date, time))
}, [mode, onChange])
const handleDateSelect = (nextDate) => {
const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime)
setDraftDate(nextDate)
applyValue(nextDate, draftTime)
setDraftTime(nextTime)
applyValue(nextDate, nextTime)
}
const handleTimeChange = (event) => {
const nextTime = event.target.value
const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime)
setDraftTime(nextTime)
applyValue(draftDate, nextTime)
}
@@ -293,6 +332,12 @@ export default function DateTimePicker({
].join(' ')
const selectedDate = parseDatePart(draftDate)
const minDateTimeParts = splitDateTime(minDateTime)
const maxDateTimeParts = splitDateTime(maxDateTime)
const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date)
const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date)
const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined
const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined
return (
<div className="flex flex-col gap-1.5">
@@ -308,7 +353,7 @@ export default function DateTimePicker({
id={inputId}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label ?? placeholder}
aria-label={label ?? effectivePlaceholder}
className={triggerClass}
onClick={openPicker}
onKeyDown={(event) => {
@@ -328,7 +373,7 @@ export default function DateTimePicker({
</svg>
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
{value ? formatDisplay(value) : placeholder}
{value ? formatDisplay(value) : effectivePlaceholder}
</span>
{clearable && value && (
@@ -386,28 +431,32 @@ export default function DateTimePicker({
month={viewMonth}
selectedDate={selectedDate}
onSelect={handleDateSelect}
minDate={minDate}
maxDate={maxDate}
minDate={effectiveMinDate}
maxDate={effectiveMaxDate}
/>
<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 className={`grid gap-3 ${mode === 'date' ? '' : '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'}
{draftDate ? formatDisplay(draftDate) : '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>
{mode !== 'date' ? (
<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}
min={minTime}
max={maxTime}
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>
) : null}
</div>
<div className="mt-3 flex items-center justify-between">

View File

@@ -26,6 +26,8 @@ import { createPortal } from 'react-dom'
* @prop {boolean} required - asterisk on label
* @prop {boolean} disabled
* @prop {function} renderOption - custom render fn: (option) => ReactNode
* @prop {function} renderValue - custom render fn for single-value trigger: (option) => ReactNode
* @prop {string} searchPlaceholder - placeholder shown in the dropdown search input
*/
export default function NovaSelect({
options = [],
@@ -41,8 +43,10 @@ export default function NovaSelect({
required = false,
disabled = false,
renderOption,
renderValue,
id,
className = '',
searchPlaceholder = 'Search…',
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -211,9 +215,10 @@ export default function NovaSelect({
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
// Build display label(s)
const optionMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o])), [options])
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
const hasValue = selected.length > 0
const selectedOption = !multi && hasValue ? optionMap[String(selected[0])] ?? null : null
// Trigger appearance
const triggerClass = [
@@ -273,7 +278,9 @@ export default function NovaSelect({
))}
{!multi && hasValue && (
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
renderValue && selectedOption
? renderValue(selectedOption)
: <span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
)}
{!hasValue && (
@@ -339,7 +346,7 @@ export default function NovaSelect({
value={search}
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
onKeyDown={handleKeyDown}
placeholder="Search…"
placeholder={searchPlaceholder}
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
autoComplete="off"
/>

View File

@@ -66,6 +66,8 @@ export default function PublishPanel({
allRootCategoryOptions = [],
actionLabel = 'Publish now',
showScheduleControls = true,
publishActionEnabled = true,
publishActionTitle = 'Complete all requirements first',
}) {
const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
@@ -103,6 +105,7 @@ export default function PublishPanel({
const canSchedulePublish =
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
const canTriggerPublish = publishActionEnabled && canSchedulePublish
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
@@ -257,12 +260,12 @@ export default function PublishPanel({
{/* Primary action button */}
<button
type="button"
disabled={!canSchedulePublish || isPublishing}
disabled={!canTriggerPublish || isPublishing}
onClick={() => onPublish?.()}
title={!canPublish ? 'Complete all requirements first' : undefined}
title={!publishActionEnabled ? publishActionTitle : !canPublish ? 'Complete all requirements first' : undefined}
className={[
'w-full rounded-2xl py-3 text-sm font-semibold transition',
canSchedulePublish && !isPublishing
canTriggerPublish && !isPublishing
? publishMode === 'schedule'
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
: 'btn-primary'

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import DateTimePicker from '../ui/DateTimePicker'
/**
* SchedulePublishPicker
@@ -82,14 +83,18 @@ export default function SchedulePublishPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const [dateStr, setDateStr] = useState(initial.date || '')
const [timeStr, setTimeStr] = useState(initial.time || '')
const [localDateTime, setLocalDateTime] = useState(initial.date && initial.time ? `${initial.date}T${initial.time}` : '')
const [error, setError] = useState('')
const minScheduleLocalDateTime = (() => {
const next = toLocalDateTimeString(new Date(Date.now() + MIN_FUTURE_MS).toISOString(), timezone)
return next.date && next.time ? `${next.date}T${next.time}` : ''
})()
const validate = useCallback(
(d, t) => {
if (!d || !t) return 'Date and time are required.'
const iso = localToUtcIso(d, t, timezone)
(value) => {
const [datePart = '', timePart = ''] = String(value || '').split('T')
if (!datePart || !timePart) return 'Date and time are required.'
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
if (!iso) return 'Invalid date or time.'
const target = new Date(iso)
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
@@ -101,31 +106,38 @@ export default function SchedulePublishPicker({
[timezone]
)
useEffect(() => {
const next = toLocalDateTimeString(scheduledAt, timezone)
setLocalDateTime(next.date && next.time ? `${next.date}T${next.time}` : '')
}, [scheduledAt, timezone])
useEffect(() => {
if (mode !== 'schedule') {
setError('')
return
}
if (!dateStr && !timeStr) {
if (!localDateTime) {
setError('')
onScheduleAt?.(null)
return
}
const err = validate(dateStr, timeStr)
const err = validate(localDateTime)
setError(err)
if (!err) {
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
const [datePart = '', timePart = ''] = localDateTime.split('T')
onScheduleAt?.(localToUtcIso(datePart, timePart.slice(0, 5), timezone))
} else {
onScheduleAt?.(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateStr, timeStr, mode])
}, [localDateTime, mode, timezone])
const previewLabel = useMemo(() => {
if (mode !== 'schedule' || error) return null
const iso = localToUtcIso(dateStr, timeStr, timezone)
const [datePart = '', timePart = ''] = localDateTime.split('T')
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
return formatPreviewLabel(iso, timezone)
}, [mode, error, dateStr, timeStr, timezone])
}, [mode, error, localDateTime, timezone])
return (
<div className="space-y-3">
@@ -167,45 +179,18 @@ export default function SchedulePublishPicker({
{mode === 'schedule' && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex-1">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
Date
</label>
<input
id="schedule-date"
type="date"
disabled={disabled}
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
min={new Date().toISOString().slice(0, 10)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
<div className="w-28 shrink-0">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
Time
</label>
<input
id="schedule-time"
type="time"
disabled={disabled}
value={timeStr}
onChange={(e) => setTimeStr(e.target.value)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
</div>
<p className="text-[10px] text-white/35">
Timezone: <span className="text-white/55">{timezone}</span>
</p>
{error && (
<p className="text-xs text-red-400" role="alert">
{error}
</p>
)}
<DateTimePicker
id="schedule-datetime"
label="Release date and time"
value={localDateTime}
onChange={setLocalDateTime}
placeholder="Pick a release slot"
disabled={disabled}
minDateTime={minScheduleLocalDateTime}
clearable
hint={`Timezone: ${timezone}`}
error={error}
/>
{previewLabel && (
<p className="text-xs text-emerald-300/80">

View File

@@ -92,8 +92,8 @@ export default function UploadActions({
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'pointer-events-none fixed inset-x-0 bottom-0 z-[70] px-4 pb-4 pt-3' : ''}`}>
<div className="pointer-events-auto mx-auto w-full max-w-7xl rounded-[24px] border border-white/10 bg-[#08111c]/92 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}

View File

@@ -130,7 +130,6 @@ export default function UploadWizard({
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
const userTimezone = useMemo(() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
}, [])
@@ -393,6 +392,8 @@ export default function UploadWizard({
return true
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
const publishActionEnabled = activeStep === 3 && canScheduleSubmit
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
() => [...primaryErrors, ...screenshotErrors],
@@ -437,13 +438,6 @@ export default function UploadWizard({
clearPolling()
}
}, [abortAllRequests, clearPolling])
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
useEffect(() => {
if (!showMobilePublishPanel) return
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [showMobilePublishPanel])
// ── Metadata helpers ──────────────────────────────────────────────────────
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
@@ -459,7 +453,6 @@ export default function UploadWizard({
setPublishMode('now')
setScheduledAt(null)
setVisibility('public')
setShowMobilePublishPanel(false)
setResolvedArtworkId(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
@@ -705,11 +698,15 @@ export default function UploadWizard({
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
const publishActionTitle = activeStep < 3
? 'Continue to the final publish step to choose Worlds and publish.'
: disableReason
// ─────────────────────────────────────────────────────────────────────────
return (
<section
ref={stepContentRef}
className="space-y-5 pb-32 text-white lg:pb-8"
className="space-y-5 pb-40 text-white lg:pb-40"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
@@ -796,7 +793,7 @@ export default function UploadWizard({
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canScheduleSubmit}
canPublish={publishActionEnabled}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
canCancel={activeStep === 1 && [
@@ -813,7 +810,7 @@ export default function UploadWizard({
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onPublish={() => publishActionEnabled && handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
@@ -841,6 +838,8 @@ export default function UploadWizard({
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
publishActionEnabled={publishActionEnabled}
publishActionTitle={publishActionTitle}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
@@ -864,101 +863,6 @@ export default function UploadWizard({
)}
</div>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
aria-label="Open publish panel"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{reviewSubmissionMode ? 'Review' : 'Publish'}
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[
...(!uploadReady ? [1] : []),
...(hasTitle ? [] : [1]),
...(hasCompleteCategory ? [] : [1]),
...(hasTag ? [] : [1]),
...(hasRequiredScreenshot ? [] : [1]),
...(metadata.rightsAccepted ? [] : [1]),
].length}
</span>
)}
</button>
</div>
)}
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
<AnimatePresence>
{showMobilePublishPanel && (
<>
{/* Backdrop */}
<motion.div
key="mobile-panel-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setShowMobilePublishPanel(false)}
/>
{/* Sheet */}
<motion.div
key="mobile-panel-sheet"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
>
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
}}
onCancel={() => {
setShowMobilePublishPanel(false)
handleCancel()
}}
onGoToStep={(s) => {
setShowMobilePublishPanel(false)
goToStep(s)
}}
allRootCategoryOptions={allRootCategoryOptions}
/>
</motion.div>
</>
)}
</AnimatePresence>
</section>
)
}

View File

@@ -478,12 +478,12 @@ describe('UploadWizard step flow', () => {
expect(studioEditLink.getAttribute('href')).toBe('/studio/artworks/315/edit')
})
it('keeps mobile sticky action bar visible class', async () => {
it('keeps the action bar fixed to the bottom', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })
const bar = screen.getByTestId('wizard-action-bar')
expect((bar.className || '').includes('sticky')).toBe(true)
expect((bar.className || '').includes('fixed')).toBe(true)
expect((bar.className || '').includes('bottom-0')).toBe(true)
})

View File

@@ -229,6 +229,7 @@ export default function Step3Publish({
options={eligibleWorlds}
onToggle={onToggleWorldSubmission}
onNoteChange={onChangeWorldSubmissionNote}
analyticsContext={{ sourceSurface: 'upload_flow', sourceDetail: 'publish_step' }}
/>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">

View File

@@ -10,6 +10,21 @@ import { useNavContext } from '../../lib/useNavContext';
const preloadCache = new Set();
function scheduleIdleTask(callback, delay = 1200) {
if (typeof window === 'undefined') {
callback();
return () => {};
}
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, { timeout: delay });
return () => window.cancelIdleCallback(handle);
}
const handle = window.setTimeout(callback, delay);
return () => window.clearTimeout(handle);
}
function preloadImage(src) {
if (!src || preloadCache.has(src)) return;
preloadCache.add(src);
@@ -44,20 +59,33 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
getNeighbors().then((n) => {
if (cancelled) return;
setNeighbors(n);
[n.prevId, n.nextId].forEach((id) => {
if (!id) return;
});
return () => { cancelled = true; };
}, [artworkId, getNeighbors]);
useEffect(() => {
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
if (ids.length === 0) return undefined;
let cancelled = false;
const cancelIdleTask = scheduleIdleTask(() => {
ids.forEach((id) => {
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
if (cancelled || !data) return;
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
})
.catch(() => {});
});
});
return () => { cancelled = true; };
}, [artworkId, getNeighbors]);
return () => {
cancelled = true;
cancelIdleTask();
};
}, [neighbors.prevId, neighbors.nextId]);
// Stable navigate — reads state via refs, never recreated
const navigate = useCallback(async (targetId, targetUrl) => {