Save workspace changes
This commit is contained in:
52
resources/js/components/worlds/WorldCard.jsx
Normal file
52
resources/js/components/worlds/WorldCard.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
|
||||
function themeStyle(theme) {
|
||||
return {
|
||||
'--world-accent': theme?.accent_color || '#38bdf8',
|
||||
'--world-accent-secondary': theme?.accent_color_secondary || '#0f172a',
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldCard({ world, compact = false }) {
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={world.public_url}
|
||||
className={`group relative block w-full overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/70 transition duration-300 hover:-translate-y-1 hover:border-white/20 ${compact ? 'p-5' : 'p-6'}`}
|
||||
style={themeStyle(world.theme)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_42%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_94%,black),_rgba(2,6,23,0.94))] opacity-95" />
|
||||
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/80 to-slate-950/10" />
|
||||
|
||||
<div className="relative flex h-full min-h-[16rem] flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-white/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.phase || world.status}</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
|
||||
</div>
|
||||
<h3 className={`mt-4 max-w-xl font-semibold tracking-[-0.03em] text-white ${compact ? 'text-2xl' : 'text-3xl'}`}>{world.title}</h3>
|
||||
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200/85">{world.summary}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1 text-sm text-slate-200/80">
|
||||
{world.timeframe_label ? <div>{world.timeframe_label}</div> : null}
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-white/55">
|
||||
<i className={world.icon_name || 'fa-solid fa-globe'} />
|
||||
<span>{world.theme?.label || world.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition group-hover:bg-white/15">
|
||||
{world.cta_label || 'Open world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
|
||||
function statusTone(item) {
|
||||
return item?.status_label === 'Featured'
|
||||
? 'border-amber-300/30 bg-amber-400/12 text-amber-100'
|
||||
: 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100'
|
||||
}
|
||||
|
||||
export default function WorldCommunitySubmissionsSection({ section }) {
|
||||
if (!section || !Array.isArray(section.items) || section.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{section.items.length} artworks</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.items.map((item) => (
|
||||
<a key={item.id} href={item.url} className="group overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden border-b border-white/10 bg-slate-950/80">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
|
||||
) : (
|
||||
<div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-4">
|
||||
{item.context_label ? <span className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80">{item.context_label}</span> : null}
|
||||
{item.status_label ? <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>{item.status_label}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.meta.map((entry) => (
|
||||
<span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
91
resources/js/components/worlds/WorldHero.jsx
Normal file
91
resources/js/components/worlds/WorldHero.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
|
||||
function styleForWorld(world) {
|
||||
return {
|
||||
'--world-accent': world?.theme?.accent_color || '#38bdf8',
|
||||
'--world-accent-secondary': world?.theme?.accent_color_secondary || '#0f172a',
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedIconName(world) {
|
||||
const icon = String(world?.icon_name || '').trim()
|
||||
|
||||
if (icon) {
|
||||
return icon
|
||||
}
|
||||
|
||||
const themeIcon = String(world?.theme?.icon_name || '').trim()
|
||||
|
||||
return themeIcon || 'fa-solid fa-globe'
|
||||
}
|
||||
|
||||
export default function WorldHero({ world, previewMode = false }) {
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[36px] border border-white/10" style={styleForWorld(world)}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_34%),radial-gradient(circle_at_82%_18%,_color-mix(in_srgb,var(--world-accent-secondary)_68%,transparent),_transparent_42%),linear-gradient(135deg,_rgba(2,6,23,0.92),_rgba(15,23,42,0.82)_45%,_rgba(2,6,23,0.95))]" />
|
||||
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/80 to-slate-950/20" />
|
||||
|
||||
<div className="relative grid gap-10 px-6 py-8 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_20rem] lg:px-10 lg:py-10">
|
||||
<div>
|
||||
{previewMode ? <div className="inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-amber-100">Preview Mode</div> : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.type}</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
|
||||
{world.timeframe_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.timeframe_label}</span> : null}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">{world.title}</h1>
|
||||
{world.tagline ? <p className="mt-4 text-sm uppercase tracking-[0.24em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-6 max-w-3xl text-base leading-7 text-slate-200/86 sm:text-lg">{world.summary}</p> : null}
|
||||
{world.description ? (
|
||||
<div
|
||||
className="prose prose-invert prose-sm mt-5 max-w-3xl prose-p:text-slate-300/88 prose-p:leading-7 prose-headings:text-white prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-500/40 prose-blockquote:text-slate-300 prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:border prose-pre:border-white/[0.06] prose-pre:bg-white/[0.04] prose-hr:border-white/10 prose-ul:text-slate-300/88 prose-ol:text-slate-300/88"
|
||||
dangerouslySetInnerHTML={{ __html: world.description }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{world.cta_url ? <a href={world.cta_url} className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">{world.cta_label || 'Explore'}<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
{world.public_url ? <a href={world.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/12">Canonical page<i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
</div>
|
||||
|
||||
{Array.isArray(world.related_tags) && world.related_tags.length > 0 ? (
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{world.related_tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/12 bg-black/25 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-slate-200/80">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="grid gap-4 self-end">
|
||||
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white shadow-2xl shadow-slate-950/30 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/12 bg-white/10 text-lg text-white">
|
||||
<i className={resolvedIconName(world)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Theme</div>
|
||||
<div className="mt-1 text-lg font-semibold">{world.theme?.label || world.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{world.badge_description ? <p className="mt-4 text-sm leading-6 text-slate-300">{world.badge_description}</p> : null}
|
||||
|
||||
<div className="mt-5 grid gap-3 text-sm text-slate-200/90">
|
||||
{world.timeframe_label ? <div className="flex items-center gap-2"><i className="fa-regular fa-calendar" /><span>{world.timeframe_label}</span></div> : null}
|
||||
{world.edition_year ? <div className="flex items-center gap-2"><i className="fa-solid fa-repeat" /><span>Edition {world.edition_year}</span></div> : null}
|
||||
{world.is_recurring ? <div className="flex items-center gap-2"><i className="fa-solid fa-clock-rotate-left" /><span>Recurring world</span></div> : null}
|
||||
</div>
|
||||
|
||||
{world.badge_url ? <a href={world.badge_url} className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-100 hover:text-white">View badge<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
51
resources/js/components/worlds/WorldSection.jsx
Normal file
51
resources/js/components/worlds/WorldSection.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
|
||||
function EntityCard({ item }) {
|
||||
return (
|
||||
<a href={item.url} className="group rounded-[26px] border border-white/10 bg-white/[0.03] p-4 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden rounded-[22px] border border-white/10 bg-slate-950/70">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
|
||||
) : (
|
||||
<div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />
|
||||
)}
|
||||
{item.avatar ? <img src={item.avatar} alt="" className="absolute bottom-3 left-3 h-12 w-12 rounded-2xl border border-white/15 object-cover shadow-lg shadow-black/40" /> : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{item.context_label ? <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.context_label}</div> : null}
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.meta.map((entry) => (
|
||||
<span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldSection({ section }) {
|
||||
if (!section || !Array.isArray(section.items) || section.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{section.items.length} items</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.items.map((item) => <EntityCard key={`${item.entity_type}-${item.id}`} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
164
resources/js/components/worlds/WorldSubmissionSelector.jsx
Normal file
164
resources/js/components/worlds/WorldSubmissionSelector.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
|
||||
function statusTone(item) {
|
||||
if (item?.is_featured) {
|
||||
return 'border-amber-300/30 bg-amber-400/10 text-amber-100'
|
||||
}
|
||||
|
||||
switch (item?.status) {
|
||||
case 'live':
|
||||
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'removed':
|
||||
return 'border-orange-300/30 bg-orange-400/10 text-orange-100'
|
||||
case 'blocked':
|
||||
return 'border-rose-300/30 bg-rose-400/10 text-rose-100'
|
||||
case 'pending':
|
||||
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
function modeTone(mode) {
|
||||
switch (mode) {
|
||||
case 'manual_approval':
|
||||
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
|
||||
case 'auto_add':
|
||||
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
function dateBadgeLabel(item) {
|
||||
const timeframe = String(item?.timeframe_label || '').trim()
|
||||
const submissionWindow = String(item?.submission_window_label || '').trim()
|
||||
|
||||
if (timeframe && submissionWindow) {
|
||||
return timeframe === submissionWindow ? timeframe : `${submissionWindow} • ${timeframe}`
|
||||
}
|
||||
|
||||
return submissionWindow || timeframe || ''
|
||||
}
|
||||
|
||||
export default function WorldSubmissionSelector({
|
||||
title = 'Add to Worlds',
|
||||
description = 'Attach this artwork to active worlds while keeping community participation separate from curated editorial relations.',
|
||||
options = [],
|
||||
emptyMessage = 'No worlds are currently open for creator participation.',
|
||||
onToggle,
|
||||
onNoteChange,
|
||||
className = '',
|
||||
}) {
|
||||
const items = Array.isArray(options) ? options : []
|
||||
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 bg-white/[0.03] p-5 ${className}`.trim()}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-400">{description}</p>
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{items.filter((item) => item.selected).length} selected</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-5 rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const checked = Boolean(item.selected)
|
||||
const locked = Boolean(item.selection_locked)
|
||||
const combinedDateLabel = dateBadgeLabel(item)
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`overflow-hidden rounded-[24px] border ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !locked && onToggle?.(item.id)}
|
||||
disabled={locked}
|
||||
className="w-full p-4 text-left disabled:cursor-not-allowed disabled:opacity-100"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
{item.status_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
) : null}
|
||||
{item.participation_mode_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>
|
||||
{item.participation_mode_label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{item.tagline ? <p className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.tagline}</p> : null}
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs md:mt-0.5 ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
|
||||
{item.summary ? <p className="text-sm leading-6 text-slate-300 md:col-span-3">{item.summary}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-300 md:col-span-3">
|
||||
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
|
||||
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-white/10 px-4 py-4">
|
||||
{item.submission_guidelines ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
|
||||
<div className="mt-2">{item.submission_guidelines}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.selection_locked_reason ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
|
||||
) : null}
|
||||
|
||||
{item.reviewer_note ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
|
||||
<div className="mt-2 leading-6">{item.reviewer_note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checked && item.submission_note_enabled ? (
|
||||
<label className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={item.note || ''}
|
||||
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
|
||||
disabled={locked}
|
||||
placeholder="Optional note for world moderators: fit, context, challenge angle, or why this artwork belongs here."
|
||||
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition }) {
|
||||
if (!duplicateUrl && !newEditionUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Reuse this world</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Duplicate the current campaign structure or roll it forward into the next edition without rebuilding the curated setup.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{duplicateUrl ? <button type="button" onClick={onDuplicate} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">Duplicate world</button> : null}
|
||||
{newEditionUrl ? <button type="button" onClick={onCreateEdition} disabled={!canCreateEdition} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-100 disabled:cursor-not-allowed disabled:opacity-50">Create next edition</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCreateEdition ? <div className="mt-3 text-xs leading-5 text-slate-500">Next-edition creation unlocks once this world has recurrence data.</div> : null}
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">Template creation is prepared through duplication. A dedicated preset/template browser can be layered on top later without changing the editor data model.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
231
resources/js/components/worlds/editor/WorldMediaUploadField.jsx
Normal file
231
resources/js/components/worlds/editor/WorldMediaUploadField.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return null
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function WorldMediaUploadField({
|
||||
label,
|
||||
slot,
|
||||
value,
|
||||
previewUrl,
|
||||
emptyLabel,
|
||||
helperText,
|
||||
uploadUrl,
|
||||
deleteUrl,
|
||||
worldId = null,
|
||||
onChange,
|
||||
isTemporaryValue = false,
|
||||
accept = 'image/jpeg,image/png,image/webp',
|
||||
maxFileSizeMb = 6,
|
||||
}) {
|
||||
const inputRef = useRef(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [meta, setMeta] = useState(null)
|
||||
|
||||
const csrfToken = useMemo(
|
||||
() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteTemporaryUpload = async (path) => {
|
||||
if (!deleteUrl || !path) return
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
world_id: worldId || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Could not remove uploaded image.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (!file || uploading) return
|
||||
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/webp']
|
||||
if (!allowed.includes(String(file.type || '').toLowerCase())) {
|
||||
setError('Use a JPG, PNG, or WEBP image.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > maxFileSizeMb * 1024 * 1024) {
|
||||
setError(`Image is too large. Maximum allowed size is ${maxFileSizeMb} MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
if (value && isTemporaryValue) {
|
||||
await deleteTemporaryUpload(value)
|
||||
}
|
||||
|
||||
const body = new FormData()
|
||||
body.append('slot', slot)
|
||||
body.append('image', file)
|
||||
if (worldId) {
|
||||
body.append('world_id', String(worldId))
|
||||
}
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Upload failed.')
|
||||
}
|
||||
|
||||
setMeta({
|
||||
width: payload?.width || null,
|
||||
height: payload?.height || null,
|
||||
size: formatBytes(payload?.size_bytes),
|
||||
})
|
||||
onChange?.({ path: payload?.path || '', url: payload?.url || '' })
|
||||
} catch (uploadError) {
|
||||
setError(uploadError?.message || 'Upload failed.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 text-sm text-slate-300">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation()
|
||||
setError('')
|
||||
setMeta(null)
|
||||
|
||||
try {
|
||||
if (value && isTemporaryValue) {
|
||||
setUploading(true)
|
||||
await deleteTemporaryUpload(value)
|
||||
}
|
||||
onChange?.({ path: '', url: '' })
|
||||
} catch (deleteError) {
|
||||
setError(deleteError?.message || 'Could not remove uploaded image.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (uploading) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
if (!uploading) setDragging(true)
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
if (!uploading) setDragging(true)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
void handleFile(event.dataTransfer?.files?.[0])
|
||||
}}
|
||||
className={[
|
||||
'rounded-[24px] border border-dashed px-5 py-5 transition outline-none',
|
||||
uploading
|
||||
? 'cursor-progress border-sky-300/35 bg-sky-400/10'
|
||||
: dragging
|
||||
? 'cursor-pointer border-sky-300/50 bg-sky-400/12'
|
||||
: 'cursor-pointer border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
|
||||
<i className={`fa-solid ${uploading ? 'fa-circle-notch fa-spin' : 'fa-cloud-arrow-up'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{uploading ? 'Uploading image…' : 'Drop image here or browse'}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-400">{helperText}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max {maxFileSizeMb} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-28 w-full overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 lg:w-44">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-500">{emptyLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-400">Stored path: <span className="text-slate-200">{value}</span></div> : null}
|
||||
{meta ? <div className="mt-3 text-xs text-slate-400">Optimized to {meta.width}×{meta.height}{meta.size ? ` • ${meta.size}` : ''}</div> : null}
|
||||
{error ? <div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
onChange={(event) => {
|
||||
void handleFile(event.target.files?.[0])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import WorldPreviewButton from './WorldPreviewButton'
|
||||
|
||||
export default function WorldMiniPreviewPanel({ world, sections, previewUrl }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Live mini preview</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Hero hierarchy, CTA, badges, section order, and attached content update immediately as you edit.</p>
|
||||
</div>
|
||||
<WorldPreviewButton previewUrl={previewUrl} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
|
||||
<div className="relative px-5 py-5" style={{ background: `linear-gradient(135deg, ${world?.accent_color || '#38bdf8'}22, transparent 45%), linear-gradient(180deg, #020617 0%, #0f172a 100%)` }}>
|
||||
{world?.cover_url ? <img src={world.cover_url} alt="" className="absolute inset-0 h-full w-full object-cover opacity-25" /> : null}
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world?.type || 'seasonal'}</span>
|
||||
{world?.badge_label ? <span className="rounded-full border border-white/15 bg-black/25 px-3 py-1">{world.badge_label}</span> : null}
|
||||
{world?.is_featured ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-emerald-100">Homepage feature</span> : null}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{world?.title || 'Untitled world'}</div>
|
||||
{world?.tagline ? <div className="mt-3 text-xs uppercase tracking-[0.22em] text-white/60">{world.tagline}</div> : null}
|
||||
{world?.summary ? <div className="mt-4 max-w-2xl text-sm leading-7 text-slate-200/85">{world.summary}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{world?.cta_label ? <span className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-slate-950">{world.cta_label}<i className="fa-solid fa-arrow-right" /></span> : null}
|
||||
{world?.badge_description ? <span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-4 py-2 text-sm font-semibold text-white">{world.badge_description}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visible section order</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{Array.isArray(sections) && sections.length > 0 ? sections.map((section) => (
|
||||
<div key={section.key} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{section.label}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{section.count} attached items</div>
|
||||
</div>
|
||||
{section.count === 0 ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Empty</span> : null}
|
||||
</div>
|
||||
{Array.isArray(section.items) && section.items.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">{section.items.map((item) => <span key={`${section.key}-${item.id}`} className="rounded-full bg-white/[0.04] px-3 py-1.5">{item.title}</span>)}</div> : null}
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No sections are visible yet. Enable sections and attach content to shape the public world.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
resources/js/components/worlds/editor/WorldPreviewButton.jsx
Normal file
24
resources/js/components/worlds/editor/WorldPreviewButton.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldPreviewButton({ previewUrl, className = '', disabledReason = 'Save the world once to unlock the full preview page.' }) {
|
||||
if (!previewUrl) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-400 ${className}`.trim()}>
|
||||
<div className="font-semibold text-slate-200">Full preview unavailable</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-500">{disabledReason}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15 ${className}`.trim()}
|
||||
>
|
||||
<i className="fa-regular fa-eye" />
|
||||
Open full preview
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldRecurrenceHelper({ enabled, recurrenceKey, editionYear, recurrenceKeyError, editionYearError }) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-6 text-slate-400">
|
||||
Turn on recurrence when this world belongs to a campaign family such as Halloween, Retro Month, or Pixel Week and needs a reusable edition pattern.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const exampleKey = recurrenceKey || 'halloween'
|
||||
const exampleYear = editionYear || new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/10 p-4 text-sm text-slate-200">
|
||||
<div className="font-semibold text-white">Recurring world guidance</div>
|
||||
<div className="mt-2 space-y-2 leading-6 text-slate-300">
|
||||
<p>Use the recurrence key to identify the campaign family. Example: <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
<p>Use the edition year for the specific annual or seasonal instance. Example: <span className="font-semibold text-white">{exampleYear}</span>.</p>
|
||||
<p className="text-sky-100">Example output: {exampleKey === '' ? 'Halloween' : exampleKey.replace(/-/g, ' ')} {exampleYear} is part of the recurring world <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
</div>
|
||||
|
||||
{recurrenceKeyError || editionYearError ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-xs leading-5 text-rose-100">
|
||||
{recurrenceKeyError || editionYearError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
resources/js/components/worlds/editor/WorldRelationCard.jsx
Normal file
51
resources/js/components/worlds/editor/WorldRelationCard.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
|
||||
function SmallBadge({ children, tone = 'default' }) {
|
||||
const styles = {
|
||||
default: 'border-white/10 bg-white/[0.06] text-slate-200',
|
||||
accent: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
feature: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${styles[tone] || styles.default}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function WorldRelationCard({ relation, index, total, sectionLabel, onEdit, onRemove, onMove }) {
|
||||
const preview = relation?.preview || null
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||||
{preview?.image ? <img src={preview.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!preview?.image && preview?.avatar ? <img src={preview.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!preview?.image && !preview?.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
|
||||
{preview?.avatar && preview?.image ? <img src={preview.avatar} alt="" className="absolute bottom-2 left-2 h-8 w-8 rounded-xl border border-white/10 object-cover" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{preview?.entity_label ? <SmallBadge tone="accent">{preview.entity_label}</SmallBadge> : null}
|
||||
{sectionLabel ? <SmallBadge>{sectionLabel}</SmallBadge> : null}
|
||||
{relation?.is_featured ? <SmallBadge tone="feature">Featured</SmallBadge> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-base font-semibold text-white">{preview?.title || 'Choose a relation'}</div>
|
||||
{preview?.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{preview.subtitle}</div> : null}
|
||||
{preview?.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{preview.description}</div> : null}
|
||||
{relation?.context_label ? <div className="mt-2 text-sm font-medium text-sky-100">{relation.context_label}</div> : null}
|
||||
{Array.isArray(preview?.meta) && preview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{preview.meta.map((item) => <span key={item}>{item}</span>)}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button type="button" onClick={() => onMove(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">Up</button>
|
||||
<button type="button" onClick={() => onMove(index, 1)} disabled={index === total - 1} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">Down</button>
|
||||
<button type="button" onClick={onEdit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold text-sky-100">Edit</button>
|
||||
<button type="button" onClick={onRemove} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview?.url ? <a href={preview.url} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 hover:text-white">Open entity <i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '../../ui/Modal'
|
||||
import { Checkbox, NovaSelect } from '../../ui'
|
||||
|
||||
function relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions) {
|
||||
const section = sectionOptions.find((option) => option.value === sectionKey)
|
||||
return section?.relation_types?.[0] || relationTypeOptions?.[0]?.value || 'artwork'
|
||||
}
|
||||
|
||||
function emptyRelation(sectionOptions, relationTypeOptions) {
|
||||
const sectionKey = sectionOptions?.[0]?.value || 'featured_artworks'
|
||||
const relationType = relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions)
|
||||
|
||||
return {
|
||||
section_key: sectionKey,
|
||||
related_type: relationType,
|
||||
related_id: '',
|
||||
context_label: '',
|
||||
sort_order: 0,
|
||||
is_featured: false,
|
||||
preview: null,
|
||||
query: '',
|
||||
}
|
||||
}
|
||||
|
||||
function SearchResultList({ items, loading, selectedId, onSelect }) {
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching campaign entities…</div>
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search by title, slug, creator, or project name to attach curated content.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button key={`${item.entity_type}-${item.id}`} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldRelationPickerModal({ open, onClose, onSave, initialRelation, sectionOptions, relationTypeOptions, searchEntities }) {
|
||||
const [draft, setDraft] = useState(() => emptyRelation(sectionOptions, relationTypeOptions))
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = initialRelation || emptyRelation(sectionOptions, relationTypeOptions)
|
||||
setDraft({
|
||||
...nextDraft,
|
||||
query: nextDraft.query || nextDraft.preview?.title || '',
|
||||
})
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
}, [open, initialRelation, sectionOptions, relationTypeOptions])
|
||||
|
||||
const selectedSection = useMemo(() => sectionOptions.find((option) => option.value === draft.section_key), [sectionOptions, draft.section_key])
|
||||
const availableRelationTypes = useMemo(() => relationTypeOptions.filter((option) => !selectedSection?.relation_types?.length || selectedSection.relation_types.includes(option.value)), [relationTypeOptions, selectedSection])
|
||||
const selectedPreview = useMemo(() => {
|
||||
if (draft.preview) return draft.preview
|
||||
return results.find((item) => String(item.id) === String(draft.related_id)) || null
|
||||
}, [draft.preview, draft.related_id, results])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || availableRelationTypes.length === 0) return
|
||||
if (availableRelationTypes.some((option) => option.value === draft.related_type)) return
|
||||
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
related_type: availableRelationTypes[0].value,
|
||||
related_id: '',
|
||||
preview: null,
|
||||
}))
|
||||
}, [open, availableRelationTypes, draft.related_type])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !draft.related_type) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await searchEntities(draft.related_type, draft.query || '')
|
||||
if (!cancelled) {
|
||||
setResults(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, draft.query ? 220 : 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open, draft.related_type, draft.query, searchEntities])
|
||||
|
||||
const actionLabel = initialRelation?.related_id ? 'Save relation' : 'Attach relation'
|
||||
const canSubmit = Boolean(draft.related_id)
|
||||
const nextRelation = selectedPreview ? { ...draft, preview: selectedPreview } : draft
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => onSave(nextRelation)} disabled={!canSubmit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">{actionLabel}</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Attach curated relation" size="2xl" footer={footer}>
|
||||
<div className="grid gap-5 overflow-x-hidden">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.35fr)] lg:items-end">
|
||||
<NovaSelect label="Section" value={draft.section_key || null} onChange={(nextValue) => setDraft((current) => {
|
||||
const nextSectionKey = String(nextValue || '')
|
||||
return {
|
||||
...current,
|
||||
section_key: nextSectionKey,
|
||||
related_type: relationTypeForSection(nextSectionKey, sectionOptions, relationTypeOptions),
|
||||
related_id: '',
|
||||
preview: null,
|
||||
}
|
||||
})} options={sectionOptions} searchable={false} className="bg-black/20" />
|
||||
<NovaSelect label="Entity type" value={draft.related_type || null} onChange={(nextValue) => setDraft((current) => ({ ...current, related_type: String(nextValue || ''), related_id: '', preview: null }))} options={availableRelationTypes} searchable={false} className="bg-black/20" />
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<div className="flex min-w-0 flex-wrap gap-2 sm:flex-nowrap">
|
||||
<input value={draft.query || ''} onChange={(event) => setDraft((current) => ({ ...current, query: event.target.value }))} placeholder="Search title, slug, group, or creator" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-slate-300">Auto</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SearchResultList items={results} loading={loading} selectedId={draft.related_id} onSelect={(item) => setDraft((current) => ({ ...current, related_id: item.id, preview: item, query: item.title }))} />
|
||||
|
||||
{selectedPreview ? (
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
|
||||
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
|
||||
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
<div className="mt-2 break-words text-xs text-emerald-100/80">Section: {selectedSection?.label || draft.section_key} · {draft.is_featured ? 'Featured relation' : 'Standard relation'}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_10rem] lg:grid-cols-[minmax(0,1fr)_10rem_minmax(0,15rem)] md:items-end">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
|
||||
<input value={draft.context_label || ''} onChange={(event) => setDraft((current) => ({ ...current, context_label: event.target.value }))} placeholder="Featured release, Join this challenge, Meet the creator" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Sort order</span>
|
||||
<input type="number" min="0" value={draft.sort_order} onChange={(event) => setDraft((current) => ({ ...current, sort_order: Number(event.target.value) || 0 }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:col-span-2 lg:col-span-1">
|
||||
<Checkbox checked={Boolean(draft.is_featured)} onChange={(event) => setDraft((current) => ({ ...current, is_featured: event.target.checked }))} label="Featured relation" size={20} variant="accent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import { Checkbox } from '../../ui'
|
||||
|
||||
function Pill({ children, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
accent: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
muted: 'border-white/10 bg-black/20 text-slate-400',
|
||||
warn: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
}
|
||||
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${tones[tone]}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function WorldSectionToggleList({ sectionOptions, order, visibility, relationCounts, onChange }) {
|
||||
const selected = Array.isArray(order) && order.length > 0 ? order : sectionOptions.map((option) => option.value)
|
||||
|
||||
const move = (index, delta) => {
|
||||
const nextIndex = index + delta
|
||||
if (nextIndex < 0 || nextIndex >= selected.length) return
|
||||
const next = [...selected]
|
||||
const [entry] = next.splice(index, 1)
|
||||
next.splice(nextIndex, 0, entry)
|
||||
onChange(next, visibility)
|
||||
}
|
||||
|
||||
const toggle = (key, enabled) => {
|
||||
onChange(selected, { ...visibility, [key]: enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{selected.map((key, index) => {
|
||||
const option = sectionOptions.find((entry) => entry.value === key)
|
||||
if (!option) return null
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Checkbox checked={visibility?.[key] !== false} onChange={(event) => toggle(key, event.target.checked)} label={option.label} size={20} variant="accent" />
|
||||
{option.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{option.description}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Pill tone={visibility?.[key] !== false ? 'accent' : 'muted'}>{visibility?.[key] !== false ? 'Visible on public page' : 'Hidden on public page'}</Pill>
|
||||
<Pill tone={(relationCounts?.[key] || 0) > 0 ? 'default' : 'warn'}>{relationCounts?.[key] || 0} attached items</Pill>
|
||||
</div>
|
||||
{(relationCounts?.[key] || 0) === 0 ? <div className="mt-2 text-xs leading-5 text-slate-500">This section is ready, but it will stay empty until you attach curated items.</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => move(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40">Up</button>
|
||||
<button type="button" onClick={() => move(index, 1)} disabled={index === selected.length - 1} className="rounded-full border border-white/10 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40">Down</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
resources/js/components/worlds/editor/WorldSummaryCard.jsx
Normal file
115
resources/js/components/worlds/editor/WorldSummaryCard.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return 'Not set'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Not set'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function typeLabel(value) {
|
||||
const labels = {
|
||||
seasonal: 'Seasonal',
|
||||
event: 'Event',
|
||||
campaign: 'Campaign',
|
||||
tribute: 'Tribute',
|
||||
}
|
||||
|
||||
return labels[value] || value || 'Seasonal'
|
||||
}
|
||||
|
||||
function promotionState(world, state) {
|
||||
if (!world?.is_featured) {
|
||||
return {
|
||||
label: 'Public page only',
|
||||
message: 'This world will live at its own URL, but it is not currently marked for homepage or Worlds spotlight placement.',
|
||||
tone: 'slate',
|
||||
}
|
||||
}
|
||||
|
||||
if (state.label === 'Live') {
|
||||
return {
|
||||
label: 'Active seasonal promotion',
|
||||
message: 'Featured promotion is enabled and the world is live, so it is ready for homepage spotlight and promoted Worlds surfaces.',
|
||||
tone: 'emerald',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Homepage spotlight eligible',
|
||||
message: 'Featured promotion is enabled. Once the world is live, it becomes eligible for homepage and Worlds spotlight treatment.',
|
||||
tone: 'sky',
|
||||
}
|
||||
}
|
||||
|
||||
function workflowState(world) {
|
||||
const now = Date.now()
|
||||
const publishedAt = world?.published_at ? new Date(world.published_at).getTime() : null
|
||||
const startsAt = world?.starts_at ? new Date(world.starts_at).getTime() : null
|
||||
const endsAt = world?.ends_at ? new Date(world.ends_at).getTime() : null
|
||||
|
||||
if (world?.status === 'archived') {
|
||||
return { label: 'Archived', message: 'This world has ended and is no longer part of the active campaign cycle.', tone: 'amber' }
|
||||
}
|
||||
|
||||
if (world?.status !== 'published') {
|
||||
return { label: 'Draft', message: 'Editors can keep refining this world before it becomes publicly visible.', tone: 'slate' }
|
||||
}
|
||||
|
||||
if (publishedAt && publishedAt > now) {
|
||||
return { label: 'Scheduled', message: `This world will publish automatically on ${formatDateTime(world.published_at)}.`, tone: 'sky' }
|
||||
}
|
||||
|
||||
if (startsAt && startsAt > now) {
|
||||
return { label: 'Scheduled', message: `This world is published and will go live automatically on ${formatDateTime(world.starts_at)}.`, tone: 'sky' }
|
||||
}
|
||||
|
||||
if (endsAt && endsAt < now) {
|
||||
return { label: 'Ended', message: 'The campaign window has passed. Archive it or create a new edition to continue the lineage.', tone: 'amber' }
|
||||
}
|
||||
|
||||
return { label: 'Live', message: 'This world is currently active on public surfaces.', tone: 'emerald' }
|
||||
}
|
||||
|
||||
const tones = {
|
||||
slate: 'border-white/10 bg-white/[0.04] text-slate-100',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
}
|
||||
|
||||
export default function WorldSummaryCard({ world, themeLabel, relationCount, enabledSectionsCount }) {
|
||||
const state = workflowState(world)
|
||||
const promotion = promotionState(world, state)
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Campaign summary</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">See the world lifecycle, promotion state, and editorial readiness without parsing the whole form.</p>
|
||||
</div>
|
||||
<div className={`rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${tones[state.tone]}`}>{state.label}</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 rounded-[24px] border px-4 py-4 text-sm leading-6 ${tones[state.tone]}`}>
|
||||
{state.message}
|
||||
</div>
|
||||
|
||||
<div className={`mt-3 rounded-[24px] border px-4 py-4 text-sm leading-6 ${tones[promotion.tone]}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">Promotion scope</div>
|
||||
<div className="mt-1 font-semibold">{promotion.label}</div>
|
||||
<div className="mt-1">{promotion.message}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</div><div className="mt-2 text-sm font-semibold text-white">{typeLabel(world?.type)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Theme preset</div><div className="mt-2 text-sm font-semibold text-white">{themeLabel || 'No preset'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign window</div><div className="mt-2 text-sm font-semibold text-white">{world?.starts_at || world?.ends_at ? `${formatDateTime(world?.starts_at)} to ${formatDateTime(world?.ends_at)}` : 'Open ended'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</div><div className="mt-2 text-sm font-semibold text-white">{formatDateTime(world?.published_at)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_recurring ? `${world?.recurrence_key || 'recurring'} ${world?.edition_year || ''}`.trim() : 'One-off world'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editorial setup</div><div className="mt-2 text-sm font-semibold text-white">{relationCount} relations · {enabledSectionsCount} enabled sections</div></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
|
||||
function Pill({ children }) {
|
||||
return <span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{children}</span>
|
||||
}
|
||||
|
||||
function ColorSwatch({ label, value }) {
|
||||
if (!value) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
<span className="h-3 w-3 rounded-full border border-white/15" style={{ backgroundColor: value }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldThemePresetHelper({ theme, onApply }) {
|
||||
if (!theme) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{theme.label} preset</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Preset suggestions fill the campaign basics fast. You can still override every field manually afterwards.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onApply} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">
|
||||
Apply suggestions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<ColorSwatch label={theme.accent_color || 'accent'} value={theme.accent_color} />
|
||||
<ColorSwatch label={theme.accent_color_secondary || 'secondary'} value={theme.accent_color_secondary} />
|
||||
{theme.background_motif ? <Pill>{theme.background_motif}</Pill> : null}
|
||||
{theme.icon_name ? <Pill>{theme.icon_name.replace('fa-solid ', '')}</Pill> : null}
|
||||
{theme.suggested_badge_label ? <Pill>{theme.suggested_badge_label}</Pill> : null}
|
||||
{theme.suggested_cta_label ? <Pill>{theme.suggested_cta_label}</Pill> : null}
|
||||
</div>
|
||||
|
||||
{Array.isArray(theme.related_tags_json) && theme.related_tags_json.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Suggested related tags</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
{theme.related_tags_json.map((tag) => <span key={tag} className="rounded-full bg-white/[0.04] px-3 py-1.5">#{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user