Files
SkinbaseNova/resources/js/Pages/Studio/StudioNewsEditor.jsx

1911 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor'
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
import { Checkbox } from '../../components/ui'
// ── Minimal toast system ────────────────────────────────────────────────────
let _toastId = 0
function useToast() {
const [toasts, setToasts] = useState([])
const push = useCallback((message, type = 'info') => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 5000)
}, [])
const dismiss = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), [])
return { toasts, push, dismiss }
}
function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null
return (
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col gap-2" aria-live="polite">
{toasts.map((t) => (
<div
key={t.id}
className={[
'flex items-start gap-3 rounded-2xl border px-4 py-3 text-sm shadow-2xl backdrop-blur-sm',
t.type === 'success'
? 'border-emerald-400/30 bg-emerald-950/90 text-emerald-100'
: t.type === 'error'
? 'border-rose-400/30 bg-rose-950/90 text-rose-100'
: 'border-white/15 bg-slate-900/90 text-slate-100',
].join(' ')}
>
<span className="mt-0.5 text-base leading-none">
{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : ''}
</span>
<span className="flex-1 leading-5">{t.message}</span>
<button
type="button"
onClick={() => onDismiss(t.id)}
className="ml-2 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>×</button>
</div>
))}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-3 text-xs text-slate-500">{emptyLabel}</div>
}
return (
<div className="grid gap-2">
{items.map((item) => (
<button
key={`${item.entity_type}-${item.id}`}
type="button"
onClick={() => onSelect(item)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-3 text-left transition hover:border-white/20"
>
{item.avatar ? <img src={item.avatar} alt={item.title} className="h-10 w-10 rounded-2xl border border-white/10 object-cover" /> : null}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{item.title}</div>
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <div className="mt-1 line-clamp-2 text-xs text-slate-400">{item.description}</div> : null}
</div>
</button>
))}
</div>
)
}
function FieldError({ message }) {
if (!message) return null
return <p className="text-xs text-rose-300">{message}</p>
}
function normalizeNewTagName(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80)
}
function normalizeNewsTagKey(value) {
return normalizeNewTagName(value).toLowerCase()
}
function parseNewsTagList(input) {
return String(input || '')
.split(/[\n,]+/)
.map((item) => normalizeNewTagName(item))
.filter(Boolean)
}
function analyzePastedNewsTags(rawText, selectedKeys) {
const parts = parseNewsTagList(rawText)
const tagsToAdd = []
const skippedDuplicates = []
const skippedInvalid = []
for (const part of parts) {
const normalized = normalizeNewTagName(part)
const key = normalizeNewsTagKey(normalized)
if (!normalized || !key) {
skippedInvalid.push(String(part || '').trim())
continue
}
if (selectedKeys.includes(key) || tagsToAdd.some((item) => item.key === key)) {
skippedDuplicates.push(normalized)
continue
}
tagsToAdd.push({ key, name: normalized })
}
return {
parsedCount: parts.length,
tagsToAdd,
skippedDuplicates,
skippedInvalid,
}
}
function SectionCard({ eyebrow, title, description, actions, children, tone = 'default' }) {
const toneClass = tone === 'feature'
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
: 'bg-white/[0.03]'
return (
<section className={`rounded-[28px] border border-white/10 p-5 ${toneClass}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
<div className="mt-5">{children}</div>
</section>
)
}
function NewsTagInputDialog({ open, preview, onClose, onConfirm }) {
const backdropRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || !preview) return null
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="news-tag-import-title"
className="w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Tag Import</p>
<h3 id="news-tag-import-title" className="mt-2 text-lg font-semibold text-white">
Add {preview.tagsToAdd.length} pasted tag{preview.tagsToAdd.length === 1 ? '' : 's'}?
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before adding them to the article.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{(preview.skippedDuplicates.length > 0 || preview.skippedInvalid.length > 0) ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
{preview.skippedDuplicates.length > 0 ? `${preview.skippedDuplicates.length} duplicate tag${preview.skippedDuplicates.length === 1 ? '' : 's'} ignored.` : ''}
{preview.skippedDuplicates.length > 0 && preview.skippedInvalid.length > 0 ? ' ' : ''}
{preview.skippedInvalid.length > 0 ? `${preview.skippedInvalid.length} invalid tag${preview.skippedInvalid.length === 1 ? '' : 's'} ignored.` : ''}
</div>
) : null}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
<div className="max-h-56 overflow-auto rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-wrap gap-2">
{preview.tagsToAdd.map((tag) => (
<span
key={tag.key}
className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-100"
>
{tag.name}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
type="button"
onClick={() => onClose?.()}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm?.()}
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
>
Add tags
</button>
</div>
</div>
</div>,
document.body,
)
}
function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange, onNewTagNamesChange, manageUrl, maxNewTags = NEWS_NEW_TAG_LIMIT }) {
const [query, setQuery] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [error, setError] = useState('')
const [pastePreview, setPastePreview] = useState(null)
const selectedIdSet = useMemo(() => new Set((selectedIds || []).map((id) => Number(id))), [selectedIds])
const existingTags = useMemo(() => (Array.isArray(options) ? options : []).filter((tag) => selectedIdSet.has(Number(tag.id))), [options, selectedIdSet])
const pendingTags = useMemo(() => (Array.isArray(newTagNames) ? newTagNames : []).map((name) => ({ key: normalizeNewsTagKey(name), name: normalizeNewTagName(name) })).filter((tag) => tag.key), [newTagNames])
const combinedNames = useMemo(() => [...existingTags.map((tag) => String(tag.name || '')), ...pendingTags.map((tag) => tag.name)], [existingTags, pendingTags])
const combinedKeys = useMemo(() => combinedNames.map((name) => normalizeNewsTagKey(name)).filter(Boolean), [combinedNames])
const newTagLimit = Math.max(0, Number(maxNewTags || NEWS_NEW_TAG_LIMIT))
const remainingNewTagSlots = Math.max(0, newTagLimit - pendingTags.length)
const normalizedQuery = useMemo(() => normalizeNewTagName(query), [query])
const syncNames = useCallback((names) => {
const seen = new Set()
const nextIds = []
const nextNewNames = []
names.forEach((rawName) => {
const nextName = normalizeNewTagName(rawName)
const key = normalizeNewsTagKey(nextName)
if (!key || seen.has(key)) {
return
}
seen.add(key)
const existing = (Array.isArray(options) ? options : []).find((tag) => normalizeNewsTagKey(tag.name) === key)
if (existing) {
nextIds.push(Number(existing.id))
return
}
nextNewNames.push(nextName)
})
onSelectedIdsChange(nextIds)
onNewTagNamesChange(nextNewNames)
}, [onNewTagNamesChange, onSelectedIdsChange, options])
const exactMatch = useMemo(() => {
if (!normalizedQuery) return null
return (Array.isArray(options) ? options : []).find((tag) => normalizeNewsTagKey(tag.name) === normalizeNewsTagKey(normalizedQuery)) || null
}, [normalizedQuery, options])
const suggestions = useMemo(() => {
const source = Array.isArray(options) ? options : []
const lowerQuery = normalizeNewsTagKey(query)
return source
.filter((tag) => !selectedIdSet.has(Number(tag.id)))
.filter((tag) => !pendingTags.some((pendingTag) => pendingTag.key === normalizeNewsTagKey(tag.name)))
.filter((tag) => (lowerQuery === '' ? true : normalizeNewsTagKey(tag.name).includes(lowerQuery)))
.slice(0, 8)
}, [options, pendingTags, query, selectedIdSet])
useEffect(() => {
setHighlightedIndex(suggestions.length > 0 ? 0 : -1)
}, [suggestions])
const addCandidate = useCallback((rawName) => {
const nextName = normalizeNewTagName(rawName)
if (!nextName) {
return
}
const key = normalizeNewsTagKey(nextName)
if (combinedKeys.includes(key)) {
setError('Duplicate tag')
return
}
if (pendingTags.length >= newTagLimit) {
setError(`You can add up to ${newTagLimit} new tags per article.`)
return
}
setError('')
syncNames([...combinedNames, nextName])
setQuery('')
setIsOpen(false)
}, [combinedKeys, combinedNames, newTagLimit, pendingTags.length, syncNames])
const removeExisting = useCallback((tagId) => {
onSelectedIdsChange((selectedIds || []).filter((id) => Number(id) !== Number(tagId)))
}, [onSelectedIdsChange, selectedIds])
const removePending = useCallback((tagName) => {
onNewTagNamesChange((newTagNames || []).filter((name) => normalizeNewsTagKey(name) !== normalizeNewsTagKey(tagName)))
}, [newTagNames, onNewTagNamesChange])
const handlePaste = useCallback((event) => {
const raw = event.clipboardData?.getData('text')
if (!raw) return
const parts = parseNewsTagList(raw)
if (parts.length <= 1) return
event.preventDefault()
const preview = analyzePastedNewsTags(raw, combinedKeys)
if (preview.tagsToAdd.length === 0) {
setError('No new tags found in pasted text')
return
}
if (preview.tagsToAdd.length > remainingNewTagSlots) {
setError(`You can only add ${remainingNewTagSlots} more new tag${remainingNewTagSlots === 1 ? '' : 's'} to this article.`)
return
}
setError('')
setPastePreview(preview)
}, [combinedKeys, remainingNewTagSlots])
const handleConfirmPaste = useCallback(() => {
if (!pastePreview) return
syncNames([...combinedNames, ...pastePreview.tagsToAdd.map((tag) => tag.name)])
setPastePreview(null)
setQuery('')
setIsOpen(false)
}, [combinedNames, pastePreview, syncNames])
const handleKeyDown = useCallback((event) => {
if (event.key === 'Escape') {
setIsOpen(false)
return
}
if (event.key === 'Backspace' && query.length === 0 && combinedNames.length > 0) {
const lastPending = pendingTags[pendingTags.length - 1]
if (lastPending) {
removePending(lastPending.name)
return
}
const lastExisting = existingTags[existingTags.length - 1]
if (lastExisting) {
removeExisting(lastExisting.id)
}
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (suggestions.length === 0) return
setIsOpen(true)
setHighlightedIndex((current) => Math.min(current + 1, suggestions.length - 1))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (suggestions.length === 0) return
setIsOpen(true)
setHighlightedIndex((current) => Math.max(current - 1, 0))
return
}
if (!['Enter', ',', 'Tab'].includes(event.key)) {
return
}
if (event.key === 'Tab' && !isOpen && normalizedQuery === '') {
return
}
event.preventDefault()
if ((event.key === 'Enter' || event.key === 'Tab') && isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
addCandidate(suggestions[highlightedIndex].name)
return
}
if (!normalizedQuery) {
return
}
if (exactMatch) {
addCandidate(exactMatch.name)
return
}
addCandidate(normalizedQuery)
}, [addCandidate, combinedNames.length, exactMatch, existingTags, highlightedIndex, isOpen, normalizedQuery, pendingTags, query.length, removeExisting, removePending, suggestions])
return (
<>
<div className="grid gap-4">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected tags</div>
<div className="mt-1 text-sm text-slate-400">Attach article topics with the same chip-based flow used on artwork tags.</div>
</div>
{manageUrl ? <a href={manageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">Manage tags</a> : null}
</div>
<div className="mt-4 min-h-[3rem] rounded-xl border border-white/10 bg-white/5 p-2">
<div className="flex flex-wrap gap-2">
{existingTags.length === 0 && pendingTags.length === 0 ? <span className="px-2 py-1 text-xs text-white/50">No tags selected</span> : null}
{existingTags.map((tag) => (
<span key={tag.id} className="group inline-flex max-w-full items-center gap-2 rounded-full border border-white/20 bg-slate-900/80 px-3 py-1.5 text-xs text-slate-100">
<span className="truncate">{tag.name}</span>
<button type="button" onClick={() => removeExisting(tag.id)} className="rounded-full p-0.5 text-slate-300 transition hover:bg-white/10 hover:text-white" aria-label={`Remove tag ${tag.name}`}>
</button>
</span>
))}
{pendingTags.map((tag) => (
<span key={tag.key} className="group inline-flex max-w-full items-center gap-2 rounded-full border border-emerald-300/25 bg-emerald-400/10 px-3 py-1.5 text-xs text-emerald-50">
<span className="truncate">{tag.name}</span>
<span className="rounded-full border border-emerald-200/30 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100/80">New</span>
<button type="button" onClick={() => removePending(tag.name)} className="rounded-full p-0.5 text-emerald-100/75 transition hover:bg-white/10 hover:text-white" aria-label={`Remove new tag ${tag.name}`}>
</button>
</span>
))}
</div>
</div>
<div className={`mt-3 rounded-2xl border px-3 py-2 text-xs ${remainingNewTagSlots === 0 ? 'border-amber-300/25 bg-amber-400/10 text-amber-100' : remainingNewTagSlots <= 3 ? 'border-amber-300/20 bg-amber-400/8 text-amber-100' : 'border-white/10 bg-white/[0.03] text-slate-400'}`}>
{remainingNewTagSlots === 0
? `New tag limit reached: up to ${newTagLimit} new tags can be staged for one article.`
: `${pendingTags.length}/${newTagLimit} new tags staged. ${remainingNewTagSlots} slot${remainingNewTagSlots === 1 ? '' : 's'} left.`}
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Find tags</span>
<input
value={query}
onChange={(event) => {
setQuery(event.target.value)
setError('')
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Search existing tags or type a new one"
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-white/45 focus:border-sky-400 focus:outline-none"
aria-label="Search or add news tags"
/>
</label>
{isOpen ? (
<div className="mt-3 overflow-hidden rounded-xl bg-slate-950/98 shadow-xl shadow-black/50 ring-1 ring-white/10">
<ul role="listbox" className="max-h-56 overflow-auto py-1">
{suggestions.length > 0 ? suggestions.map((tag, index) => {
const active = index === highlightedIndex
return (
<li
key={tag.id}
role="option"
aria-selected={active}
className={`flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm transition ${active ? 'bg-sky-500/20 text-white' : 'text-white/85 hover:bg-white/10'}`}
onMouseDown={(event) => {
event.preventDefault()
addCandidate(tag.name)
}}
>
<span className="truncate">{tag.name}</span>
</li>
)
}) : (
<li className="px-3 py-2 text-xs text-white/50">No suggestions</li>
)}
</ul>
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-3 text-xs">
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
{error || `Type and press Enter, comma, or Tab to add. Paste a comma-separated list to review multiple tags. Up to ${newTagLimit} new tags can be staged.`}
</span>
<span className="text-white/50">{existingTags.length + pendingTags.length} selected</span>
</div>
</div>
</div>
<NewsTagInputDialog
open={Boolean(pastePreview)}
preview={pastePreview}
onClose={() => setPastePreview(null)}
onConfirm={handleConfirmPaste}
/>
</>
)
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
<div className="flex gap-2">
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => onSearch(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
</label>
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
</div>
{relation.preview ? (
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Linked: {relation.preview.title}</div>
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
</div>
) : null}
<div className="mt-4">
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
</div>
<label className="mt-4 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={relation.context_label || ''} onChange={(event) => onChange(index, { ...relation, context_label: event.target.value })} placeholder="Featured release, Meet the creator, Join this challenge…" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
)
}
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
const NEWS_NEW_TAG_LIMIT = 12
function slugifyNewsTitle(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 180)
}
function selectOptionsFromValues(options, emptyLabel = null) {
const base = Array.isArray(options)
? options.map((option) => ({
value: String(option.value ?? option.id),
label: option.label ?? option.name,
}))
: []
return emptyLabel ? [{ value: '', label: emptyLabel }, ...base] : base
}
function buildSubmitPayload(data) {
return {
title: String(data.title || '').trim(),
slug: String(data.slug || '').trim(),
excerpt: String(data.excerpt || ''),
content: String(data.content || ''),
cover_image: String(data.cover_image || '').trim(),
type: String(data.type || ''),
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
author_id: data.author_id === '' || data.author_id == null ? null : Number(data.author_id),
editorial_status: String(data.editorial_status || ''),
published_at: data.published_at ? String(data.published_at) : null,
is_featured: Boolean(data.is_featured),
is_pinned: Boolean(data.is_pinned),
comments_enabled: Boolean(data.comments_enabled),
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
meta_title: String(data.meta_title || ''),
meta_description: String(data.meta_description || ''),
meta_keywords: String(data.meta_keywords || ''),
canonical_url: String(data.canonical_url || '').trim(),
og_title: String(data.og_title || ''),
og_description: String(data.og_description || ''),
og_image: String(data.og_image || '').trim(),
relations: Array.isArray(data.relations)
? data.relations.map((relation) => ({
entity_type: String(relation.entity_type || '').trim(),
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
context_label: String(relation.context_label || '').trim(),
}))
: [],
}
}
function hasRequiredCategory(categoryId) {
if (categoryId === '' || categoryId == null) {
return false
}
return Number(categoryId) > 0
}
function getDraftValue(source, key, fallback = '') {
if (source && Object.prototype.hasOwnProperty.call(source, key)) {
const val = source[key]
return val != null ? val : fallback
}
return fallback
}
function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}) {
return {
title: String(getDraftValue(oldInput, 'title', article.title || '')),
slug: String(getDraftValue(oldInput, 'slug', article.slug || '')),
excerpt: String(getDraftValue(oldInput, 'excerpt', article.excerpt || '')),
content: String(getDraftValue(oldInput, 'content', article.content || '')),
cover_image: String(getDraftValue(oldInput, 'cover_image', article.cover_image || '')),
type: String(getDraftValue(oldInput, 'type', article.type || (typeOptions?.[0]?.value || 'announcement'))),
category_id: String(getDraftValue(oldInput, 'category_id', article.category_id ? String(article.category_id) : '')),
author_id: getDraftValue(oldInput, 'author_id', article.author_id || defaultAuthor?.id || ''),
editorial_status: String(getDraftValue(oldInput, 'editorial_status', article.editorial_status || 'draft')),
published_at: String(getDraftValue(oldInput, 'published_at', article.published_at ? String(article.published_at).slice(0, 16) : '')),
is_featured: parseBooleanish(getDraftValue(oldInput, 'is_featured', Boolean(article.is_featured))),
is_pinned: parseBooleanish(getDraftValue(oldInput, 'is_pinned', Boolean(article.is_pinned))),
comments_enabled: parseBooleanish(getDraftValue(oldInput, 'comments_enabled', article.id ? Boolean(article.comments_enabled) : true)),
tag_ids: Array.isArray(getDraftValue(oldInput, 'tag_ids', article.tag_ids)) ? getDraftValue(oldInput, 'tag_ids', article.tag_ids) : [],
new_tag_names: Array.isArray(getDraftValue(oldInput, 'new_tag_names', [])) ? getDraftValue(oldInput, 'new_tag_names', []) : [],
meta_title: String(getDraftValue(oldInput, 'meta_title', article.meta_title || '')),
meta_description: String(getDraftValue(oldInput, 'meta_description', article.meta_description || '')),
meta_keywords: String(getDraftValue(oldInput, 'meta_keywords', article.meta_keywords || '')),
canonical_url: String(getDraftValue(oldInput, 'canonical_url', article.canonical_url || '')),
og_title: String(getDraftValue(oldInput, 'og_title', article.og_title || '')),
og_description: String(getDraftValue(oldInput, 'og_description', article.og_description || '')),
og_image: String(getDraftValue(oldInput, 'og_image', article.og_image || '')),
relations: Array.isArray(getDraftValue(oldInput, 'relations', article.relations)) ? getDraftValue(oldInput, 'relations', article.relations).map((relation) => ({
entity_type: relation.entity_type || 'group',
entity_id: relation.entity_id || '',
context_label: relation.context_label || '',
preview: relation.preview || null,
query: relation.preview?.title || '',
})) : [],
}
}
const NEWS_EDITOR_TABS = [
{
id: 'content',
label: 'Main content',
description: 'Headline, excerpt, cover, and article body.',
},
{
id: 'publishing',
label: 'Publishing',
description: 'Category, author, scheduling, and visibility.',
},
{
id: 'discoverability',
label: 'Social + SEO',
description: 'Tags, metadata, and social preview fields.',
},
{
id: 'connections',
label: 'Connections',
description: 'Related entities and structured import.',
},
]
function parseBooleanish(value) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
const normalized = String(value || '').trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
return Boolean(value)
}
function normalizeImportedStringArray(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean)
}
return String(value || '')
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function normalizeImportedTagList(value) {
if (!Array.isArray(value)) {
return normalizeImportedStringArray(value)
}
return value
.map((item) => {
if (typeof item === 'string' || typeof item === 'number') {
return normalizeNewTagName(item)
}
if (item && typeof item === 'object') {
return normalizeNewTagName(item.name ?? item.title ?? item.label ?? item.slug ?? '')
}
return normalizeNewTagName(item)
})
.filter(Boolean)
}
function parseStructuredNewsImport(rawValue, context) {
const parsed = JSON.parse(String(rawValue || '').trim())
const categoryOptions = Array.isArray(context.categoryOptions) ? context.categoryOptions : []
const tagOptions = Array.isArray(context.tagOptions) ? context.tagOptions : []
const typeOptions = Array.isArray(context.typeOptions) ? context.typeOptions : []
const statusOptions = Array.isArray(context.statusOptions) ? context.statusOptions : []
const next = {}
const applied = []
const applyString = (inputKey, formKey = inputKey) => {
if (parsed[inputKey] == null) return
next[formKey] = String(parsed[inputKey])
applied.push(formKey)
}
const applyBoolean = (inputKey, formKey = inputKey) => {
if (parsed[inputKey] == null) return
next[formKey] = parseBooleanish(parsed[inputKey])
applied.push(formKey)
}
applyString('title')
applyString('slug')
applyString('excerpt')
applyString('content')
applyString('cover_image')
applyString('published_at')
applyString('meta_title')
applyString('meta_description')
applyString('meta_keywords')
applyString('canonical_url')
applyString('og_title')
applyString('og_description')
applyString('og_image')
if (parsed.type != null) {
const requested = String(parsed.type).trim().toLowerCase()
const match = typeOptions.find((option) => String(option.value ?? option.id ?? '').trim().toLowerCase() === requested || String(option.label ?? option.name ?? '').trim().toLowerCase() === requested)
next.type = match ? String(match.value ?? match.id ?? '') : String(parsed.type)
applied.push('type')
}
if (parsed.editorial_status != null) {
const requested = String(parsed.editorial_status).trim().toLowerCase()
const match = statusOptions.find((option) => String(option.value ?? option.id ?? '').trim().toLowerCase() === requested || String(option.label ?? option.name ?? '').trim().toLowerCase() === requested)
next.editorial_status = match ? String(match.value ?? match.id ?? '') : String(parsed.editorial_status)
applied.push('editorial_status')
}
if (parsed.category_id != null || parsed.category != null || parsed.category_slug != null) {
const requested = String(parsed.category_id ?? parsed.category_slug ?? parsed.category).trim().toLowerCase()
const match = categoryOptions.find((option) => [option.id, option.value, option.slug, option.name, option.label]
.map((candidate) => String(candidate ?? '').trim().toLowerCase())
.includes(requested))
if (match) {
next.category_id = String(match.id ?? match.value ?? '')
applied.push('category_id')
}
}
if (parsed.author_id != null) {
next.author_id = String(parsed.author_id)
applied.push('author_id')
}
applyBoolean('is_featured')
applyBoolean('is_pinned')
applyBoolean('comments_enabled')
const tagNames = normalizeImportedTagList(parsed.tags ?? parsed.tag_names)
const tagIds = Array.isArray(parsed.tag_ids) ? parsed.tag_ids.map((item) => Number(item)).filter(Boolean) : []
if (tagNames.length > 0 || tagIds.length > 0) {
const existingIds = new Set(tagIds)
const newTagNames = []
tagNames.forEach((tagName) => {
const normalized = normalizeNewsTagKey(tagName)
const match = tagOptions.find((option) => normalizeNewsTagKey(option.name) === normalized)
if (match) {
existingIds.add(Number(match.id))
} else {
newTagNames.push(normalizeNewTagName(tagName))
}
})
next.tag_ids = Array.from(existingIds)
next.new_tag_names = newTagNames
applied.push('tag_ids', 'new_tag_names')
}
if (Array.isArray(parsed.relations)) {
next.relations = parsed.relations
.map((relation) => ({
entity_type: String(relation?.entity_type || relation?.type || 'group').trim(),
entity_id: relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
context_label: String(relation?.context_label || relation?.label || '').trim(),
preview: null,
query: String(relation?.query || relation?.title || '').trim(),
}))
.filter((relation) => relation.entity_type)
applied.push('relations')
}
return {
next,
applied,
authorQuery: parsed.author_query != null ? String(parsed.author_query) : (parsed.author_name != null ? String(parsed.author_name) : null),
}
}
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
const backdropRef = useRef(null)
const [activeImportTab, setActiveImportTab] = useState('input')
const [copyFeedback, setCopyFeedback] = useState('')
const importTabs = [
{ id: 'input', label: 'Input', description: 'Paste JSON and apply it to the editor.' },
{ id: 'structure', label: 'Structure example', description: 'A working example of the expected payload.' },
{ id: 'docs', label: 'Documentation', description: 'Field notes and mapping rules.' },
{ id: 'prompts', label: 'AI prompts', description: 'Prompt examples for generating structured news.' },
]
const structureExample = {
title: 'Sample News Title',
slug: 'sample-news-title',
excerpt: 'This is a sample news excerpt that demonstrates the structured import format.',
content: '<p>This is sample news content written in HTML.</p><p>You can replace it with your own editorial copy.</p>',
cover_image: 'sample-news-cover.webp',
type: 'Announcement',
category_id: 1,
category: 'General',
category_slug: 'general',
editorial_status: 'draft',
published_at: '2026-05-03 09:00:00',
author_id: 1,
author_name: 'Sample Author',
is_featured: false,
is_pinned: false,
comments_enabled: true,
tags: [
{ name: 'Sample Tag', slug: 'sample-tag' },
{ name: 'News Import', slug: 'news-import' },
],
tag_names: ['Sample Tag', 'News Import'],
tag_ids: [],
relations: {
related_articles: [],
related_artworks: [],
related_users: [],
source_urls: ['https://example.com/sample-news-source'],
},
meta_title: 'Sample News Title - Skinbase Example',
meta_description: 'This is a sample news meta description for the structured import example.',
meta_keywords: 'sample news, structured import, editorial example',
canonical_url: 'https://skinbase.org/news/sample-news-title',
og_title: 'Sample News Title',
og_description: 'This is a sample news OG description for the structured import example.',
og_image: 'sample-news-cover.webp',
}
const newsJsonSchemaSummary = `You are generating a Skinbase news article JSON object.
Return only valid JSON. No markdown, no commentary, no code fences.
Required fields:
- title: string
- content: HTML string
Recommended fields:
- slug: SEO slug
- excerpt: short summary
- cover_image: image path or URL
- type: one of the editor's news types
- category_id: preferred category id
- editorial_status: draft|review|scheduled|published|archived
- published_at: YYYY-MM-DD HH:MM:SS
- author_id or author_name
- comments_enabled: boolean
- is_featured: boolean
- is_pinned: boolean
- meta_title, meta_description, meta_keywords
- canonical_url
- og_title, og_description, og_image
- tags: array of strings or objects with name/title/label/slug
- tag_names: array of strings
- tag_ids: array of ids if you already know them
- relations: array of objects with entity_type, entity_id, context_label
Rules:
- Use HTML paragraphs in content.
- Keep excerpt concise.
- Prefer category_id when you know it; otherwise include category/category_slug for matching.
- If a tag is an object, keep the name field readable.
- If source URLs are available, include them in a relations-related field or source notes field.
`
const aiPromptExamples = [
{
title: 'Blog-to-news generator',
prompt: `${newsJsonSchemaSummary}
Transform the following article into a news payload for the editor.
- Preserve the factual meaning and the editorial tone.
- Choose a concise title and an SEO-friendly slug.
- Write content as HTML paragraphs.
- Include 8 to 14 highly relevant tags.
- Include category_id when possible, otherwise use category_slug or category to help matching.
- Fill meta_title, meta_description, canonical_url, og_title, og_description, and og_image when available.
- Make comments_enabled true unless the source clearly says otherwise.
Input article text:
{{ARTICLE_TEXT}}`,
},
{
title: 'Release announcement prompt',
prompt: `${newsJsonSchemaSummary}
Create a structured release announcement JSON object.
- Use a direct product/news style headline.
- Keep the excerpt short and easy to scan.
- Write the content as 3 to 6 HTML paragraphs.
- Include a realistic published_at timestamp in local time.
- Set editorial_status to published if the article is already live, otherwise draft.
- Set comments_enabled to true unless the release is sensitive or comments should be disabled.
- Add 8 to 12 tags.
Release notes:
{{RELEASE_NOTES}}`,
},
{
title: 'Migration import prompt',
prompt: `${newsJsonSchemaSummary}
Convert the source article into Skinbase news JSON.
- Preserve the factual content and keep the article structure readable.
- Return only JSON.
- Normalize tags into an array of objects with name and slug.
- If the source contains article links, place them in a source_urls field inside the relations object.
- If the source provides an author, category, or publish date, map those into the matching editor fields.
- Use sensible defaults for any missing metadata.
Source article:
{{SOURCE_ARTICLE}}`,
},
]
function tabButtonClass(active) {
return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200'}`
}
const copyText = async (text, label) => {
try {
await navigator.clipboard.writeText(String(text))
setCopyFeedback(`${label} copied`)
window.setTimeout(() => setCopyFeedback(''), 1800)
} catch (copyError) {
setCopyFeedback('Copy failed')
window.setTimeout(() => setCopyFeedback(''), 1800)
}
}
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open) return null
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="news-json-import-title"
className="flex h-[min(90vh,780px)] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured import</p>
<h3 id="news-json-import-title" className="mt-2 text-lg font-semibold text-white">Paste article JSON</h3>
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, or bulk handoff from another editorial system. Matching fields are applied directly to the editor.</p>
</div>
<div className="border-b border-white/[0.06] px-4 py-4">
<div className="grid gap-2 md:grid-cols-4">
{importTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveImportTab(tab.id)}
className={tabButtonClass(activeImportTab === tab.id)}
>
<div className="text-sm font-semibold">{tab.label}</div>
<div className="mt-1 text-xs leading-5 text-current/70">{tab.description}</div>
</button>
))}
</div>
</div>
<div className="nova-scrollbar flex-1 min-h-0 overflow-y-auto px-6 py-5">
{activeImportTab === 'input' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="grid gap-3">
<textarea
value={value}
onChange={(event) => onChange?.(event.target.value)}
rows={18}
placeholder={'{\n "title": "My news title",\n "slug": "my-news-title",\n "excerpt": "Short summary",\n "tags": ["release", "community"]\n}'}
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none placeholder:text-white/30"
/>
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recognized keys</div>
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>`title`, `slug`, `excerpt`, `content`, `cover_image`</p>
<p>`type`, `category_id`, `category`, `category_slug`</p>
<p>`editorial_status`, `published_at`, `author_id`, `author_name`</p>
<p>`is_featured`, `is_pinned`, `comments_enabled`</p>
<p>`tags`, `tag_names`, `tag_ids`, `relations`</p>
<p>`new_tag_names` is capped at {newTagLimit} items per article.</p>
<p>`meta_title`, `meta_description`, `meta_keywords`, `canonical_url`, `og_title`, `og_description`, `og_image`</p>
</div>
</div>
</div>
) : null}
{activeImportTab === 'structure' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Structure example</div>
<button
type="button"
onClick={() => copyText(JSON.stringify(structureExample, null, 2), 'Structure example')}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]"
>
Copy example
</button>
</div>
<pre className="nova-scrollbar max-h-[52vh] overflow-auto rounded-[20px] border border-white/10 bg-slate-950/80 p-4 text-xs leading-6 text-slate-200">{JSON.stringify(structureExample, null, 2)}</pre>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Notes</div>
<div className="mt-3 space-y-3 leading-6 text-slate-400">
<p>Tags can be an array of strings or objects with `name`, `title`, `label`, or `slug`.</p>
<p>`content` accepts HTML. Keep paragraph tags and inline formatting in the JSON string.</p>
<p>`relations` may contain structured links and source URLs, but only direct entity fields are currently applied automatically.</p>
</div>
</div>
</div>
) : null}
{activeImportTab === 'docs' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Field guide</div>
<div className="mt-3 space-y-3 text-slate-400">
<p><strong className="text-slate-200">title</strong> - article headline. If `slug` is omitted the editor can derive it from the title.</p>
<p><strong className="text-slate-200">excerpt</strong> - short summary shown in listings and metadata.</p>
<p><strong className="text-slate-200">content</strong> - HTML body, usually paragraph tags plus basic formatting.</p>
<p><strong className="text-slate-200">category_id</strong> - preferred category selector value. `category_slug` and `category` are accepted for matching, but `category_id` is the reliable one.</p>
<p><strong className="text-slate-200">tags</strong> - array of strings or objects. Existing tags are matched by name; new ones are staged automatically.</p>
<p><strong className="text-slate-200">new_tag_names</strong> - capped at {newTagLimit} items per article. Use existing tags where possible to stay within the limit.</p>
<p><strong className="text-slate-200">meta_keywords</strong> - max 255 characters. Keep it concise or the save will fail validation.</p>
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Import rules</div>
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>Boolean fields accept `true`/`false`, `1`/`0`, `yes`/`no`, and `on`/`off`.</p>
<p>Date values should be in `YYYY-MM-DD HH:MM:SS` format for scheduled stories.</p>
<p>Keep `new_tag_names` at or below {newTagLimit} items, which matches the editor validation.</p>
<p>Unknown keys are ignored, so you can paste a broader object safely.</p>
</div>
</div>
</div>
) : null}
{activeImportTab === 'prompts' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="grid gap-4">
{aiPromptExamples.map((example) => (
<div key={example.title} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70">{example.title}</div>
<button
type="button"
onClick={() => copyText(example.prompt, example.title)}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]"
>
Copy prompt
</button>
</div>
<pre className="nova-scrollbar mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200">{example.prompt}</pre>
</div>
))}
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Prompt tips</div>
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>Tell the model to return JSON only, with no explanation text.</p>
<p>Ask for `tags` as an array of objects when you want the most compatible import shape.</p>
<p>Include `source_urls` or reference links in the source instruction if you want them copied into the story notes.</p>
</div>
</div>
</div>
) : null}
</div>
{copyFeedback ? (
<div className="px-6 pb-2 text-right text-xs font-medium text-sky-200/80">{copyFeedback}</div>
) : null}
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
type="button"
onClick={() => onClose?.()}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={() => onApply?.()}
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
>
Apply JSON
</button>
</div>
</div>
</div>,
document.body,
)
}
export default function StudioNewsEditor() {
const { props } = usePage()
const { toasts, push: pushToast, dismiss: dismissToast } = useToast()
const article = props.article || {}
const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.typeOptions, props.oldInput || {}), [article, props.defaultAuthor, props.oldInput, props.typeOptions])
const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData])
const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
const [relationResults, setRelationResults] = useState({})
const [coverPreviewUrl, setCoverPreviewUrl] = useState(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
const [stagedCoverPath, setStagedCoverPath] = useState('')
const [activeTab, setActiveTab] = useState('content')
const [jsonImportOpen, setJsonImportOpen] = useState(false)
const [jsonImportValue, setJsonImportValue] = useState('')
const [jsonImportError, setJsonImportError] = useState('')
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
const slugTouchedRef = useRef(Boolean(String(article.slug || '').trim()))
const form = useForm(initialFormData)
const normalizedInitialPayload = useMemo(() => JSON.stringify(buildSubmitPayload(initialFormData)), [initialFormData])
const normalizedCurrentPayload = useMemo(() => JSON.stringify(buildSubmitPayload(form.data)), [form.data])
const hasUnsavedChanges = normalizedCurrentPayload !== normalizedInitialPayload
useEffect(() => {
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
return
}
lastSyncedArticleKeyRef.current = articleSyncKey
form.setData(initialFormData)
form.clearErrors()
setSelectedAuthor(article.author || props.defaultAuthor || null)
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
setRelationResults({})
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
setStagedCoverPath('')
slugTouchedRef.current = Boolean(String((article.slug || initialFormData.slug || '')).trim())
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
useEffect(() => {
if (slugTouchedRef.current) {
return
}
const nextSlug = slugifyNewsTitle(form.data.title)
if (nextSlug === String(form.data.slug || '')) {
return
}
form.setData('slug', nextSlug)
}, [form, form.data.slug, form.data.title])
useEffect(() => {
if (!hasUnsavedChanges || form.processing) {
return undefined
}
const message = 'You have unsaved article changes. Leave without saving?'
const handleBeforeUnload = (event) => {
event.preventDefault()
event.returnValue = message
return message
}
const handleDocumentClick = (event) => {
if (event.defaultPrevented) {
return
}
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null
if (!anchor) {
return
}
const href = anchor.getAttribute('href')
if (!href || href.startsWith('#') || anchor.getAttribute('download') != null) {
return
}
if (anchor.target && anchor.target.toLowerCase() === '_blank') {
return
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return
}
const destination = new URL(anchor.href, window.location.href)
const current = new URL(window.location.href)
if (destination.href === current.href) {
return
}
if (!window.confirm(message)) {
event.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('click', handleDocumentClick, true)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
document.removeEventListener('click', handleDocumentClick, true)
}
}, [form.processing, hasUnsavedChanges])
const excerptLength = String(form.data.excerpt || '').trim().length
const bodyWordCount = useMemo(() => {
const plain = stripHtml(form.data.content)
return plain === '' ? 0 : plain.split(/\s+/).length
}, [form.data.content])
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'Select category'), [props.categoryOptions])
const currentTabIndex = Math.max(0, NEWS_EDITOR_TABS.findIndex((tab) => tab.id === activeTab))
const currentTab = NEWS_EDITOR_TABS[currentTabIndex] || NEWS_EDITOR_TABS[0]
const tabErrorCounts = useMemo(() => ({
content: ['title', 'slug', 'excerpt', 'content', 'cover_image'].filter((key) => Boolean(form.errors[key])).length,
publishing: ['type', 'category_id', 'author_id', 'editorial_status', 'published_at', 'comments_enabled'].filter((key) => Boolean(form.errors[key])).length,
discoverability: ['tag_ids', 'new_tag_names', 'meta_title', 'meta_description', 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image'].filter((key) => Boolean(form.errors[key])).length,
connections: ['relations'].filter((key) => Boolean(form.errors[key])).length,
}), [form.errors])
const overviewItems = useMemo(() => ([
{ label: 'Headline', done: String(form.data.title || '').trim().length > 0 },
{ label: 'Category', done: hasRequiredCategory(form.data.category_id) },
{ label: 'Excerpt', done: String(form.data.excerpt || '').trim().length > 0 },
{ label: 'Body', done: bodyWordCount > 0 },
{ label: 'Cover image', done: String(form.data.cover_image || '').trim().length > 0 },
{ label: 'Author', done: Boolean(form.data.author_id) },
]), [bodyWordCount, form.data.author_id, form.data.category_id, form.data.cover_image, form.data.excerpt, form.data.title])
const completedCount = overviewItems.filter((item) => item.done).length
useEffect(() => {
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
if (firstErrorTab) {
setActiveTab(firstErrorTab.id)
}
}, [tabErrorCounts])
const searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
url.searchParams.set('type', type)
url.searchParams.set('q', query)
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
})
if (!response.ok) {
return []
}
const payload = await response.json()
return Array.isArray(payload.items) ? payload.items : []
}
const runAuthorSearch = async () => {
const items = await searchEntities('user', authorQuery)
setAuthorResults(items)
}
const addRelation = () => {
form.setData('relations', [
...form.data.relations,
{
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
entity_id: '',
context_label: '',
preview: null,
query: '',
},
])
}
const updateRelation = (index, nextRelation) => {
form.setData('relations', form.data.relations.map((relation, relationIndex) => (relationIndex === index ? nextRelation : relation)))
}
const removeRelation = (index) => {
form.setData('relations', form.data.relations.filter((_, relationIndex) => relationIndex !== index))
setRelationResults((current) => {
const next = { ...current }
delete next[index]
return next
})
}
const runRelationSearch = async (index) => {
const relation = form.data.relations[index]
if (!relation) return
const items = await searchEntities(relation.entity_type, relation.query || '')
setRelationResults((current) => ({ ...current, [index]: items }))
}
const handleManualCoverChange = (nextValue) => {
form.setData('cover_image', nextValue)
if (stagedCoverPath && nextValue !== stagedCoverPath) {
setStagedCoverPath('')
}
if (!nextValue) {
setCoverPreviewUrl('')
return
}
if (String(nextValue).startsWith('http://') || String(nextValue).startsWith('https://')) {
setCoverPreviewUrl(nextValue)
return
}
setCoverPreviewUrl(`${props.coverCdnBaseUrl}/${String(nextValue).replace(/^\/+/, '')}`)
}
const submit = (event) => {
event.preventDefault()
if (!hasRequiredCategory(form.data.category_id)) {
form.setError('category_id', 'Choose a category before saving the article.')
pushToast('Choose a category before saving the article.', 'error')
return
}
const options = {
preserveScroll: true,
preserveState: 'errors',
onSuccess: () => {
setStagedCoverPath('')
pushToast('Article saved successfully.', 'success')
},
onError: (errors) => {
const errorMessages = Object.values(errors)
const first = errorMessages[0] || 'The article could not be saved.'
const extra = errorMessages.length > 1 ? ` (${errorMessages.length - 1} more field${errorMessages.length > 2 ? 's' : ''})` : ''
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
if (firstErrorTab) {
setActiveTab(firstErrorTab.id)
}
pushToast(first + extra, 'error')
},
}
form.transform((data) => buildSubmitPayload(data))
if (props.updateUrl) {
form.patch(props.updateUrl, options)
return
}
form.post(props.storeUrl, options)
}
const deleteArticle = () => {
if (!props.destroyUrl) return
if (!window.confirm('Move this article to trash? This uses soft delete so the record stays in the database.')) return
router.delete(props.destroyUrl, {
preserveScroll: true,
})
}
const goToNextTab = (direction) => {
const next = NEWS_EDITOR_TABS[currentTabIndex + direction]
if (!next) return
setActiveTab(next.id)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const applyJsonImport = () => {
try {
const parsed = parseStructuredNewsImport(jsonImportValue, {
categoryOptions: props.categoryOptions,
tagOptions: props.tagOptions,
typeOptions: props.typeOptions,
statusOptions: props.statusOptions,
})
Object.entries(parsed.next).forEach(([key, value]) => {
form.setData(key, value)
})
if (parsed.authorQuery) {
setAuthorQuery(parsed.authorQuery)
}
if (parsed.next.cover_image != null) {
handleManualCoverChange(parsed.next.cover_image)
}
if (parsed.next.slug != null) {
slugTouchedRef.current = true
}
setJsonImportError('')
setJsonImportOpen(false)
pushToast(`Applied ${parsed.applied.length} field${parsed.applied.length === 1 ? '' : 's'} from JSON.`, 'success')
} catch (error) {
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<ToastStack toasts={toasts} onDismiss={dismissToast} />
<div className="space-y-6 pb-24">
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{props.indexUrl ? <a href={props.indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to news list</a> : null}
<span>{article.id ? `Article #${article.id}` : 'New article'}</span>
<span className="rounded-full border border-white/10 px-3 py-1.5 text-sky-100/80">{currentTab.label}</span>
</div>
<h1 className="mt-3 truncate text-2xl font-semibold tracking-[-0.03em] text-white">{String(form.data.title || '').trim() || (article.id ? 'Untitled article' : 'Create article')}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Keep the draft flow simple: write the story in one place, handle publishing in one place, and keep promotion metadata nearby instead of buried below the fold.</p>
</div>
</div>
<div className="sticky top-16 z-30 border-y border-white/10 bg-[linear-gradient(180deg,rgba(9,14,24,0.98),rgba(6,10,18,0.98))] px-4 py-3 backdrop-blur">
<div className="flex justify-end gap-2 overflow-x-auto">
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-2.5 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15">Preview</a> : null}
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Import JSON</button>
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">{form.processing ? 'Saving…' : 'Save article'}</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2.5 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15">Publish now</button> : null}
</div>
</div>
<div className="flex gap-2 overflow-x-auto px-4 py-3">
{NEWS_EDITOR_TABS.map((tab) => {
const active = tab.id === activeTab
const errorCount = tabErrorCounts[tab.id] || 0
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex min-w-[170px] flex-col rounded-[22px] border px-4 py-3 text-left transition ${active ? 'border-sky-300/30 bg-sky-400/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.06]'}`}
>
<span className="flex items-center justify-between gap-3 text-sm font-semibold">
<span>{tab.label}</span>
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.16em] text-rose-100">{errorCount}</span> : null}
</span>
<span className="mt-1 text-xs leading-5 text-slate-400">{tab.description}</span>
</button>
)
})}
</div>
</section>
<form id="studio-news-editor-form" onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
<div className="space-y-6">
{activeTab === 'content' ? (
<>
<SectionCard
eyebrow="Story workspace"
title={article.id ? 'Shape the full newsroom story before it goes live.' : 'Create a newsroom story that reads like an editorial feature, not a raw database form.'}
description="The cover, excerpt, body, and structure below are the core writing flow. Everything else is support, not a distraction."
tone="feature"
>
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Headline that can carry the article alone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Aim for a clear editorial headline that still makes sense in cards, notifications, and social previews.</span>
<FieldError message={form.errors.title} />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
<div className="flex gap-2">
<input
value={form.data.slug}
onChange={(event) => {
const nextValue = event.target.value
slugTouchedRef.current = String(nextValue).trim() !== ''
form.setData('slug', nextValue)
}}
placeholder="optional-manual-slug"
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
<button
type="button"
onClick={() => {
slugTouchedRef.current = false
form.setData('slug', slugifyNewsTitle(form.data.title))
}}
className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
Sync
</button>
</div>
<span className="text-xs leading-5 text-slate-500">The slug now follows the title automatically until you edit it manually. Use Sync to regenerate it from the current headline.</span>
<FieldError message={form.errors.slug} />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<span>Excerpt</span>
<span className="text-slate-500">{excerptLength}/800</span>
</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={6} placeholder="Write the concise summary used in listing cards, metadata, and archive previews." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Lead with the update, why it matters, and the audience hook. Two to four punchy sentences usually land better than one dense paragraph.</span>
<FieldError message={form.errors.excerpt} />
</label>
<div className="grid gap-4">
<WorldMediaUploadField
label="Cover image"
slot="cover"
value={form.data.cover_image}
previewUrl={coverPreviewUrl}
emptyLabel="Drop a cover image"
helperText="Upload the hero image directly to object storage. A wide landscape image works best for cards, preview surfaces, and social sharing."
uploadUrl={props.coverUploadUrl}
deleteUrl={props.coverDeleteUrl}
onChange={({ path, url }) => {
setStagedCoverPath(path || '')
form.setData('cover_image', path || '')
setCoverPreviewUrl(url || '')
}}
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
/>
<FieldError message={form.errors.cover_image} />
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported legacy stories, or when you already have the exact asset URL you want to use.</span>
</label>
</div>
</div>
</SectionCard>
<SectionCard eyebrow="Full message" title="Body editor" description="Write the main article in a richer editing surface so the content reads like a polished story, not pasted plain text." actions={<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">{bodyWordCount.toLocaleString()} words</div>}>
<div className="grid gap-3 text-sm text-slate-300">
<RichTextEditor
content={form.data.content}
onChange={(nextValue) => form.setData('content', nextValue)}
placeholder="Open with the update, add context, use links, pull quotes, headings, and imagery where the story needs structure."
error={form.errors.content}
minHeight={24}
autofocus={false}
advancedNews
searchEntities={searchEntities}
/>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
Story workflow suggestion: lead with the change, explain why it matters, add supporting detail, then end with a clear call to action or next step.
</div>
</div>
</SectionCard>
</>
) : null}
{activeTab === 'publishing' ? (
<SectionCard eyebrow="Editorial controls" title="Publishing" description="Set ownership, placement, timing, and surface behavior before the article leaves draft.">
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Type" value={form.data.type || null} onChange={(nextValue) => form.setData('type', String(nextValue || ''))} options={typeOptions} searchable={false} className="bg-black/20" error={form.errors.type} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Category" value={form.data.category_id || ''} onChange={(nextValue) => {
form.setData('category_id', String(nextValue || ''))
if (nextValue) {
form.clearErrors('category_id')
}
}} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue)} placeholder="Pick a publish slot" clearable className="bg-black/20" />
<FieldError message={form.errors.published_at} />
</div>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Author</div>
<div className="flex gap-2">
<input value={authorQuery} onChange={(event) => setAuthorQuery(event.target.value)} placeholder="Search for an author" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={runAuthorSearch} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
{selectedAuthor ? (
<div className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Selected author: {selectedAuthor.title}</div>
{selectedAuthor.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedAuthor.subtitle}</div> : null}
</div>
) : null}
<SearchResultList items={authorResults} onSelect={(item) => {
setSelectedAuthor(item)
setAuthorQuery(item.title)
form.setData('author_id', item.id)
}} emptyLabel="Search to choose an author profile." />
<FieldError message={form.errors.author_id} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on newsroom surfaces" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.comments_enabled} onChange={(event) => form.setData('comments_enabled', event.target.checked)} label="Allow comments on the article page" size={20} variant="accent" />
<FieldError message={form.errors.comments_enabled} />
</div>
</div>
</SectionCard>
) : null}
{activeTab === 'discoverability' ? (
<>
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
<NewsTagInput
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
selectedIds={form.data.tag_ids}
newTagNames={form.data.new_tag_names}
onSelectedIdsChange={(nextIds) => form.setData('tag_ids', nextIds)}
onNewTagNamesChange={(nextNames) => form.setData('new_tag_names', nextNames)}
manageUrl={props.tagsUrl}
maxNewTags={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
/>
<div className="mt-3">
<FieldError message={form.errors.tag_ids || form.errors.new_tag_names} />
</div>
</SectionCard>
<SectionCard eyebrow="Metadata" title="SEO and social" description="Keep search and sharing fields aligned with the main editorial package.">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} 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">Meta description</span>
<textarea value={form.data.meta_description} onChange={(event) => form.setData('meta_description', event.target.value)} rows={3} className="rounded-[24px] 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">Meta keywords</span>
<input
value={form.data.meta_keywords}
onChange={(event) => form.setData('meta_keywords', event.target.value)}
placeholder="creator-story, release, tutorial"
maxLength={255}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
<span className="text-xs leading-5 text-slate-500">Maximum 255 characters. The field now stops at the limit so it fails less often on save.</span>
<FieldError message={form.errors.meta_keywords} />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Canonical URL</span>
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG title</span>
<input value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} 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">OG image</span>
<input value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG description</span>
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</SectionCard>
</>
) : null}
{activeTab === 'connections' ? (
<>
<SectionCard eyebrow="Context links" title="Related entities" description="Attach groups, artworks, collections, releases, projects, challenges, events, and profiles so the article becomes part of the rest of Nova instead of a dead-end page." actions={<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>}>
<div className="grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<RelationCard
key={`${relation.entity_type}-${index}`}
relation={relation}
index={index}
onChange={updateRelation}
onRemove={removeRelation}
onSearch={runRelationSearch}
results={relationResults[index] || []}
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
/>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No related entities attached yet.</div>}
</div>
</SectionCard>
<SectionCard eyebrow="Structured workflows" title="Paste article JSON" description="Import known fields from structured data, then refine the result inside the normal editor flow." actions={<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Open import</button>}>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-400">
This is useful when another tool already generated article structure for you. Paste JSON, map it into the form, then review copy, metadata, and publish controls before saving.
</div>
</SectionCard>
</>
) : null}
</div>
<div className="space-y-6 xl:sticky xl:top-[9.75rem]">
<SectionCard eyebrow="Overview" title="Editing progress" description="A compact status rail so you can see what still needs attention without leaving the current tab.">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
{hasUnsavedChanges ? <div className="mb-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">You have unsaved changes.</div> : <div className="mb-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">All changes saved.</div>}
<div className="flex items-end justify-between gap-3">
<div>
<div className="text-3xl font-semibold tracking-[-0.04em] text-white">{completedCount}/{overviewItems.length}</div>
<div className="mt-1 text-sm text-slate-400">Core article pieces ready</div>
</div>
<div className="rounded-full border border-white/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{bodyWordCount.toLocaleString()} words</div>
</div>
<div className="mt-4 space-y-2">
{overviewItems.map((item) => (
<div key={item.label} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2.5 text-sm">
<span className="text-slate-200">{item.label}</span>
<span className={`rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${item.done ? 'border border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border border-white/10 bg-white/[0.04] text-slate-400'}`}>{item.done ? 'Ready' : 'Missing'}</span>
</div>
))}
</div>
</div>
</SectionCard>
<SectionCard eyebrow="Actions" title="Always-visible controls" description="Primary article actions stay pinned here as a second access point while you write.">
<div className="grid gap-3">
{Object.keys(form.errors || {}).length > 0 ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">The article was not saved. Fix the highlighted fields and try again.</div> : null}
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">{form.processing ? 'Saving article…' : 'Save article'}</button>
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="w-full rounded-full border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-center text-sm font-semibold text-indigo-100">Preview article</a> : null}
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Archive article</button> : null}
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
{props.destroyUrl ? <button type="button" onClick={deleteArticle} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Move to trash</button> : null}
</div>
</SectionCard>
<SectionCard eyebrow="Utilities" title="Editor shortcuts" description="Keep taxonomy management and structured import nearby without leaving the writing flow.">
<div className="grid gap-3">
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Paste structured JSON</button>
{props.categoriesUrl ? <a href={props.categoriesUrl} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/[0.06]">Manage categories</a> : null}
{props.tagsUrl ? <a href={props.tagsUrl} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/[0.06]">Manage tags</a> : null}
</div>
</SectionCard>
</div>
</form>
<nav className="sticky bottom-0 z-20 border-t border-white/10 bg-[rgba(2,6,23,0.92)] px-4 py-3 backdrop-blur xl:hidden">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3">
<button type="button" onClick={() => goToNextTab(-1)} disabled={currentTabIndex === 0} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-50">Back</button>
<div className="text-center">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Step {currentTabIndex + 1} / {NEWS_EDITOR_TABS.length}</div>
<div className="mt-0.5 text-sm font-semibold text-white">{currentTab.label}</div>
</div>
<button type="button" onClick={() => goToNextTab(1)} disabled={currentTabIndex >= NEWS_EDITOR_TABS.length - 1} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-50">Next</button>
</div>
</nav>
</div>
<JsonImportDialog
open={jsonImportOpen}
value={jsonImportValue}
error={jsonImportError}
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
onChange={(nextValue) => {
setJsonImportValue(nextValue)
if (jsonImportError) {
setJsonImportError('')
}
}}
onClose={() => {
setJsonImportOpen(false)
setJsonImportError('')
}}
onApply={applyJsonImport}
/>
</StudioLayout>
)
}