Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
100
resources/js/components/Studio/StudioContentBrowser.test.jsx
Normal file
100
resources/js/components/Studio/StudioContentBrowser.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
@@ -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 '—'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
18
resources/js/components/auth/RememberMeCheckbox.jsx
Normal file
18
resources/js/components/auth/RememberMeCheckbox.jsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user