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

2606 lines
126 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 }) {
const isSourceRelation = String(relation.entity_type || '').trim().toLowerCase() === 'source'
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: '', external_url: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
</div>
{isSourceRelation ? (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Source URL</span>
<input
value={relation.external_url || ''}
onChange={(event) => onChange(index, { ...relation, external_url: event.target.value, query: event.target.value, entity_id: '' })}
placeholder="https://example.com/original-article"
className="min-w-0 flex-1 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">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>
{!isSourceRelation && 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}
{isSourceRelation ? (
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-100/90">
Source relations store a direct external URL instead of an internal Nova entity ID.
</div>
) : (
<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()
}
function unwrapMarkdownLinkUrl(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const markdownMatch = raw.match(/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i)
if (markdownMatch) {
return String(markdownMatch[1] || '').trim()
}
return raw
}
function isSourceRelationType(entityType) {
return String(entityType || '').trim().toLowerCase() === 'source'
}
const NEWS_NEW_TAG_LIMIT = 30
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 || ''),
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: isSourceRelationType(relation.entity_type) || relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
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 || '')),
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: isSourceRelationType(relation.entity_type) ? '' : (relation.entity_id || ''),
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
context_label: relation.context_label || '',
preview: relation.preview || null,
query: isSourceRelationType(relation.entity_type)
? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '')
: (relation.preview?.title || relation.query || ''),
})) : [],
}
}
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 normalizeImportedDateTime(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const dateTimeMatch = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/)
if (dateTimeMatch) {
return dateTimeMatch[2] ? `${dateTimeMatch[1]}T${dateTimeMatch[2]}` : dateTimeMatch[1]
}
const parsed = new Date(raw)
if (Number.isNaN(parsed.getTime())) {
return raw
}
const pad = (input) => String(input).padStart(2, '0')
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}T${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`
}
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')
if (parsed.published_at != null) {
next.published_at = normalizeImportedDateTime(parsed.published_at)
applied.push('published_at')
}
applyString('meta_title')
applyString('meta_description')
applyString('meta_keywords')
applyString('og_title')
applyString('og_description')
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) => {
const entityType = String(relation?.entity_type || relation?.type || 'group').trim()
const externalUrl = isSourceRelationType(entityType)
? unwrapMarkdownLinkUrl(relation?.external_url || relation?.url || relation?.entity_id || relation?.query || relation?.title || '')
: ''
return {
entity_type: entityType,
entity_id: isSourceRelationType(entityType) || relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
external_url: externalUrl,
context_label: String(relation?.context_label || relation?.label || '').trim(),
preview: null,
query: isSourceRelationType(entityType) ? externalUrl : 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),
}
}
let newsMarkdownTurndown = null
let newsMarkdownTurndownPromise = null
async function loadNewsMarkdownTurndown() {
if (newsMarkdownTurndown) {
return newsMarkdownTurndown
}
if (typeof window === 'undefined') {
return null
}
if (!newsMarkdownTurndownPromise) {
newsMarkdownTurndownPromise = import('turndown')
.then(({ default: TurndownService }) => new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
}))
.then((service) => {
newsMarkdownTurndown = service
return service
})
.catch(() => null)
}
return newsMarkdownTurndownPromise
}
function findNewsOptionById(options, value) {
const normalized = String(value || '').trim()
if (!normalized) return null
return (Array.isArray(options) ? options : []).find((option) => String(option.id ?? option.value ?? '').trim() === normalized) || null
}
function findNewsTagsByIds(options, ids) {
const idSet = new Set((Array.isArray(ids) ? ids : []).map((id) => Number(id)))
return (Array.isArray(options) ? options : [])
.filter((option) => idSet.has(Number(option.id)))
.map((option) => ({
id: Number(option.id),
name: String(option.name || option.label || ''),
slug: String(option.slug || ''),
}))
}
function buildStructuredPlainTextExport(data) {
const lines = []
if (data.title) lines.push(`Title: ${data.title}`)
if (data.excerpt) lines.push(`Excerpt: ${data.excerpt}`)
if (data.date) lines.push(`Date: ${data.date}`)
if (data.category) lines.push(`Category: ${data.category}`)
if (data.body) {
lines.push('')
lines.push('Body:')
lines.push(data.body)
}
return lines.join('\n').trim()
}
function convertNewsHtmlToMarkdown(value) {
const html = String(value || '').trim()
if (!html) return ''
if (!newsMarkdownTurndown) {
return stripHtml(html)
}
return newsMarkdownTurndown.turndown(html).trim()
}
function buildNewsMarkdownExport(data) {
const lines = []
if (data.title) {
lines.push(`# ${data.title}`)
}
if (data.excerpt) {
lines.push(data.excerpt)
}
if (data.date) {
lines.push(`- Date: ${data.date}`)
}
if (data.category) {
lines.push(`- Category: ${data.category}`)
}
const bodyMarkdown = convertNewsHtmlToMarkdown(data.body_html)
if (bodyMarkdown) {
lines.push(bodyMarkdown)
}
return lines.join('\n\n').trim()
}
// ── News image prompt builder ────────────────────────────────────────────────
const NEWS_PROMPT_TYPE_MOODS = {
announcement: 'Futuristic',
release: 'Software Release',
editorial: 'Editorial',
opinion: 'Editorial',
tutorial: 'Clean Instructional',
platform_update: 'Modern Tech',
event: 'Futuristic',
challenge: 'Futuristic',
interview: 'Editorial',
spotlight: 'Editorial',
archive: 'Retro Tech',
industry_news: 'Modern Tech',
review: 'Modern Tech',
roundup: 'Modern Tech',
}
const NEWS_PROMPT_TYPE_ADDONS = {
release: 'Use a glossy software-release poster style with product UI panels, feature highlights, and a polished launch atmosphere.',
announcement: 'Use a clean announcement-poster style with a strong headline, clear hero image, and supporting modules that communicate the main update quickly.',
editorial: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
opinion: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
event: 'Use a conference or event-poster style with keynote energy, glowing screens, stage-like lighting, and a premium event atmosphere.',
tutorial: 'Use a clear structured instructional poster style with organized UI panels, workflow callouts, and helpful visual hierarchy.',
platform_update: 'Use a modern platform-update style with system UI visuals, feature modules, and a polished ecosystem presentation.',
archive: 'Use a retro-tech editorial style inspired by early 2000s computer magazines, with classic hardware, vintage UI influences, and modern polished lighting.',
}
const NEWS_PROMPT_KEYWORD_PATTERNS = [
{
keywords: ['apple', 'wwdc', 'ios', 'macos', 'iphone', 'ipad', 'swift'],
addon: 'Use a sleek developer-conference atmosphere with modern device screens, app ecosystem visuals, and a premium keynote mood.',
},
{
keywords: ['google', 'gemini', 'google i/o', 'android', 'pixel', 'tensorflow'],
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, image and video creation tools, search elements, and generative media visuals.',
},
{
keywords: ['intel', 'amd', 'processor', 'cpu', 'gpu', 'nvidia', 'radeon', 'chip'],
addon: 'Use a retro computing hardware feature style with processor chips, technical callouts, old-school PC references, and magazine-cover energy.',
},
{
keywords: ['skin', 'theme', 'desktop', 'customize', 'customization', 'rainmeter', 'widget'],
addon: 'Use a desktop customization promo style with theme previews, icon panels, widget windows, and a glossy desktop software aesthetic.',
},
{
keywords: ['ai', 'artificial intelligence', 'llm', 'chatgpt', 'openai', 'midjourney', 'stable diffusion', 'generative'],
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, generative media outputs, neural network visuals, and advanced AI tool interfaces.',
},
]
function resolveNewsPromptHeadline(data) {
return String(data.title || data.meta_title || '').trim() || 'Skinbase News'
}
function resolveNewsPromptSubheadline(data) {
const raw = String(data.excerpt || data.meta_description || '').replace(/<[^>]*>/g, '').trim()
if (raw) {
const words = raw.split(/\s+/)
return words.slice(0, 18).join(' ') + (words.length > 18 ? '…' : '')
}
const plain = String(data.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain) {
const sentence = plain.split(/[.!?]/)[0].trim()
if (sentence.length > 10) {
const words = sentence.split(/\s+/)
return words.slice(0, 18).join(' ')
}
}
return 'Latest technology and creative industry update'
}
function resolveNewsPromptTopic(data) {
const parts = []
const cat = String(data.category || '').trim()
if (cat) parts.push(cat)
const tagList = (Array.isArray(data.tag_names) ? data.tag_names : []).slice(0, 5).filter(Boolean)
if (tagList.length) parts.push(tagList.join(', '))
if (!parts.length) {
const words = String(data.title || '').split(/\s+/).filter((w) => w.length > 3).slice(0, 4)
if (words.length) parts.push(words.join(' '))
}
return parts.join(' · ') || 'Technology and digital culture news'
}
function resolveNewsPromptType(data) {
const raw = String(data.type || '').trim()
if (!raw) return 'News'
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function resolveNewsPromptHeroSubject(data) {
const title = String(data.title || '').toLowerCase()
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
const combined = `${title} ${tags}`
const type = String(data.type || '').toLowerCase()
if (/apple|wwdc|ios|macos/.test(combined)) return 'sleek developer conference scene with modern Apple devices, app ecosystem screens, and a keynote stage atmosphere'
if (/google|gemini|google i\/o/.test(combined)) return 'futuristic creative AI workspace with Google AI tools, image and video generation screens, and colorful generative panels'
if (/intel|amd|cpu|processor|gpu|nvidia|radeon/.test(combined)) return 'high-detail processor chip and PC hardware setup with technical callouts and magazine-style editorial framing'
if (/\bai\b|artificial intelligence|llm|chatgpt|openai|midjourney|stable diffusion/.test(combined)) return 'futuristic AI creative studio with generative media outputs, neural network interfaces, and glowing AI panels'
if (/skin|theme|desktop|customiz|rainmeter|widget/.test(combined)) return 'polished desktop customization interface with theme previews, icon panels, and widget windows on a dark desktop'
if (/game|gaming/.test(combined)) return 'immersive gaming setup or game UI with dynamic lighting, modern peripherals, and a premium game atmosphere'
if (/microsoft|windows/.test(combined)) return 'modern Windows interface with system UI panels, taskbar, settings, and a polished OS environment'
if (type === 'tutorial') return 'organized instructional workflow panel with step-by-step UI callouts and visual hierarchy'
if (type === 'event') return 'keynote conference stage with large screens, glowing hall, and event atmosphere'
if (type === 'archive') return 'retro computing hardware from the early 2000s with classic monitors and vintage PC aesthetic'
return 'professional editorial tech workspace with software screens, feature panels, and a polished digital newsroom atmosphere'
}
function resolveNewsPromptSupportingModules(data) {
const type = String(data.type || '').toLowerCase()
const title = String(data.title || '').toLowerCase()
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
const combined = `${title} ${tags}`
if (type === 'release' || /release|launch|version/.test(combined)) return 'version badge, feature highlight cards, changelog strip, UI screenshots, product icon panels'
if (type === 'tutorial') return 'step-by-step panels, UI callouts, workflow arrows, numbered feature blocks'
if (type === 'event') return 'schedule panels, speaker cards, keynote countdown, location badge, feature preview cards'
if (type === 'archive') return 'retro spec badges, vintage hardware panels, timeline strip, era-appropriate UI screenshots'
if (/\bai\b|artificial intelligence|generative/.test(combined)) return 'AI feature cards, generative output previews, glowing interface panels, model capability badges'
if (/hardware|chip|cpu|gpu/.test(combined)) return 'performance charts, spec comparison cards, hardware close-ups, benchmark badges'
return 'feature cards, interface panels, product highlights, mini screenshots, icon blocks'
}
function resolveNewsPromptMood(data) {
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
return NEWS_PROMPT_TYPE_MOODS[type] || 'Modern Tech'
}
function resolveNewsPromptTypeAddon(data) {
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
return NEWS_PROMPT_TYPE_ADDONS[type] || ''
}
function resolveNewsPromptKeywordAddon(data) {
const title = String(data.title || '').toLowerCase()
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
const category = String(data.category || '').toLowerCase()
const combined = `${title} ${tags} ${category}`
const addons = []
for (const pattern of NEWS_PROMPT_KEYWORD_PATTERNS) {
if (pattern.keywords.some((kw) => combined.includes(kw))) {
addons.push(pattern.addon)
}
}
return [...new Set(addons)].join('\n')
}
function buildNewsImagePrompt(data) {
const headline = resolveNewsPromptHeadline(data)
const subheadline = resolveNewsPromptSubheadline(data)
const topic = resolveNewsPromptTopic(data)
const newsType = resolveNewsPromptType(data)
const heroSubject = resolveNewsPromptHeroSubject(data)
const supportingModules = resolveNewsPromptSupportingModules(data)
const mood = resolveNewsPromptMood(data)
const typeAddon = resolveNewsPromptTypeAddon(data)
const keywordAddon = resolveNewsPromptKeywordAddon(data)
const lines = [
'Create a premium Skinbase news cover image in 16:9 aspect ratio.',
'',
'Design it as a professional editorial tech poster for a digital culture, software, hardware, AI, creative tools, desktop customization, or retro computing news article.',
'',
'ARTICLE DETAILS:',
`Headline: "${headline}"`,
`Subheadline: "${subheadline}"`,
`Topic: ${topic}`,
`News type: ${newsType}`,
`Hero subject: ${heroSubject}`,
`Supporting modules: ${supportingModules}`,
`Mood: ${mood}`,
'',
'LAYOUT:',
'Use a structured 16:9 news hero composition with:',
'- Large bold headline in the upper-left or top-center',
'- Smaller subtitle directly below the headline',
'- One strong central hero visual',
'- Supporting side panels, feature cards, icons, UI windows, diagrams, or mini screenshots',
'- A bottom strip with 3 to 6 small highlight blocks or visual details',
'- Clean spacing and a strong visual hierarchy',
'',
'VISUAL STYLE:',
'Use a dark premium background with blue, cyan, violet, neon, or topic-matching accent colors. Add glossy highlights, subtle glow, cinematic depth, crisp lighting, and a polished high-tech editorial look.',
'',
'The image should feel like a professional magazine cover, software release poster, tech conference banner, or retro computing feature graphic. It should be visually rich, but still clean, readable, and organized.',
'',
'TEXT STYLE:',
'Use bold clean sans-serif typography. Keep all visible text short and readable. Avoid long paragraphs inside the image. Use only short labels, feature names, or headline-style phrases.',
'',
'CONTENT DIRECTION:',
'Represent the topic clearly through the central visual. Use relevant objects such as:',
'- software windows',
'- futuristic workstations',
'- creative AI panels',
'- computer chips',
'- retro hardware',
'- desktop customization elements',
'- conference screens',
'- app interface mockups',
'- glowing diagrams',
'- feature cards',
'- product-style panels',
'',
'QUALITY RULES:',
'Make it sharp, premium, polished, high detail, thumbnail-friendly, and suitable as a Skinbase news article cover image.',
'',
'Avoid clutter, random filler objects, unreadable microtext, messy typography, distorted UI, weak composition, watermarks, fake signatures, low-quality stock-photo style, and irrelevant logos.',
]
if (typeAddon) {
lines.push('', typeAddon)
}
if (keywordAddon) {
lines.push('', keywordAddon)
}
return lines.join('\n')
}
// ─────────────────────────────────────────────────────────────────────────────
function buildNewsExportPayloads(data, context = {}) {
const normalized = buildSubmitPayload(data || {})
const category = findNewsOptionById(context.categoryOptions, normalized.category_id)
const existingTags = findNewsTagsByIds(context.tagOptions, normalized.tag_ids)
const author = context.author || null
const full = {
title: normalized.title,
slug: normalized.slug,
excerpt: normalized.excerpt,
content: normalized.content,
cover_image: normalized.cover_image,
type: normalized.type,
category_id: normalized.category_id,
category: category?.name ?? category?.label ?? '',
category_slug: category?.slug ?? '',
author_id: normalized.author_id,
author_name: author?.title ?? author?.name ?? '',
editorial_status: normalized.editorial_status,
published_at: normalized.published_at,
is_featured: normalized.is_featured,
is_pinned: normalized.is_pinned,
comments_enabled: normalized.comments_enabled,
tags: [
...existingTags,
...normalized.new_tag_names.map((name) => ({ name, slug: '' })),
],
tag_names: [
...existingTags.map((tag) => tag.name),
...normalized.new_tag_names,
],
tag_ids: normalized.tag_ids,
new_tag_names: normalized.new_tag_names,
meta_title: normalized.meta_title,
meta_description: normalized.meta_description,
meta_keywords: normalized.meta_keywords,
og_title: normalized.og_title,
og_description: normalized.og_description,
og_image: normalized.og_image,
relations: normalized.relations,
}
const structured = {
title: normalized.title,
excerpt: normalized.excerpt,
date: normalized.published_at,
body: stripHtml(normalized.content),
category: category?.name ?? category?.label ?? '',
}
const markdown = {
title: normalized.title,
excerpt: normalized.excerpt,
date: normalized.published_at,
category: category?.name ?? category?.label ?? '',
body_html: normalized.content,
}
return {
full: JSON.stringify(full, null, 2),
structured: JSON.stringify(structured, null, 2),
structuredPlain: buildStructuredPlainTextExport(structured),
markdown: buildNewsMarkdownExport(markdown),
markdownInput: markdown,
}
}
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, articleData = {}, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
const backdropRef = useRef(null)
const [activeImportTab, setActiveImportTab] = useState('input')
const [copyFeedback, setCopyFeedback] = useState('')
const [exportMode, setExportMode] = useState('full')
const [markdownExportText, setMarkdownExportText] = useState(String(exportPayloads?.markdown || ''))
const [promptText, setPromptText] = useState('')
const [promptIsManual, setPromptIsManual] = useState(false)
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.' },
{ id: 'export', label: 'Export', description: 'Copy the current article out as JSON, text, or Markdown.' },
{ id: 'image_prompt', label: 'Image Prompt', description: 'Auto-generate a cover image prompt from article data.' },
]
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',
og_title: 'Sample News Title',
og_description: 'This is a sample news OG description for the structured import example.',
}
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
- og_title, og_description
- 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, og_title, and og_description 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 activeExportText = exportMode === 'structured'
? String(exportPayloads?.structured || '')
: exportMode === 'markdown'
? markdownExportText
: String(exportPayloads?.full || '')
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])
useEffect(() => {
setMarkdownExportText(String(exportPayloads?.markdown || ''))
}, [exportPayloads])
useEffect(() => {
if (!open || activeImportTab !== 'export' || exportMode !== 'markdown') {
return undefined
}
let cancelled = false
loadNewsMarkdownTurndown().then(() => {
if (cancelled) {
return
}
setMarkdownExportText(buildNewsMarkdownExport(exportPayloads?.markdownInput || {}))
})
return () => {
cancelled = true
}
}, [activeImportTab, exportMode, exportPayloads, open])
// Auto-generate image prompt when the tab opens, or when article data changes
// (unless the editor has manually modified the prompt text).
useEffect(() => {
if (!open || activeImportTab !== 'image_prompt') return
if (promptIsManual) return
setPromptText(buildNewsImagePrompt(articleData))
}, [open, activeImportTab, articleData, promptIsManual])
const handleRegeneratePrompt = useCallback(() => {
setPromptIsManual(false)
setPromptText(buildNewsImagePrompt(articleData))
}, [articleData])
const handleResetPrompt = useCallback(() => {
setPromptIsManual(false)
setPromptText(buildNewsImagePrompt(articleData))
}, [articleData])
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">Import or export article JSON</h3>
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, bulk handoff from another editorial system, or copying the current article into reusable JSON.</p>
</div>
<div className="border-b border-white/[0.06] px-4 py-4">
<div className="grid gap-2 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
{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`, `og_title`, `og_description`</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}
{activeImportTab === 'export' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="grid gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setExportMode('full')}
className={tabButtonClass(exportMode === 'full')}
>
<div className="text-sm font-semibold">Full news JSON</div>
<div className="mt-1 text-xs leading-5 text-current/70">Exports the current article with metadata, tags, and relations.</div>
</button>
<button
type="button"
onClick={() => setExportMode('structured')}
className={tabButtonClass(exportMode === 'structured')}
>
<div className="text-sm font-semibold">Structured JSON</div>
<div className="mt-1 text-xs leading-5 text-current/70">Exports only title, excerpt, date, body, and category.</div>
</button>
<button
type="button"
onClick={() => setExportMode('markdown')}
className={tabButtonClass(exportMode === 'markdown')}
>
<div className="text-sm font-semibold">Markdown</div>
<div className="mt-1 text-xs leading-5 text-current/70">Exports the current article as Markdown with heading, summary, and body.</div>
</button>
</div>
<textarea
readOnly
value={activeExportText}
rows={18}
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"
/>
</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">Export options</div>
<div className="mt-3 space-y-3 leading-6 text-slate-400">
<p><strong className="text-slate-200">Full news JSON</strong> includes the current editable article state: slug, status, tags, metadata, and relations.</p>
<p><strong className="text-slate-200">Structured JSON</strong> keeps the reduced handoff shape: title, excerpt, date, body, and category.</p>
<p><strong className="text-slate-200">Markdown</strong> converts the current article body into Markdown and includes the title plus summary fields for external reuse.</p>
<p>The export uses the live editor state, so unsaved changes are included immediately.</p>
</div>
</div>
</div>
) : null}
{activeImportTab === 'image_prompt' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
Generated cover image prompt
{promptIsManual ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-2 py-0.5 text-[10px] text-amber-100">Manually edited</span> : null}
</span>
<button
type="button"
onClick={handleRegeneratePrompt}
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]"
>
Regenerate
</button>
{promptIsManual ? (
<button
type="button"
onClick={handleResetPrompt}
className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/20"
>
Reset to auto
</button>
) : null}
<button
type="button"
onClick={() => copyText(promptText, 'Image prompt')}
className="rounded-full border border-sky-300/25 bg-sky-400/90 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:brightness-110"
>
Copy prompt
</button>
</div>
<textarea
value={promptText}
onChange={(event) => {
setPromptText(event.target.value)
setPromptIsManual(true)
}}
rows={22}
spellCheck={false}
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/30"
placeholder="Opening the tab will generate a prompt automatically…"
/>
</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">How it works</div>
<div className="mt-3 space-y-3 leading-6 text-slate-400">
<p>The prompt is built automatically from the current article fields: title, excerpt, type, category, and tags.</p>
<p>You can edit the prompt freely. It will be marked as <span className="rounded border border-amber-300/20 bg-amber-400/10 px-1 text-amber-100">Manually edited</span> once you change it.</p>
<p>Click <strong className="text-slate-200">Regenerate</strong> or <strong className="text-slate-200">Reset to auto</strong> to rebuild from the current article state.</p>
<p>Copy the prompt and paste it into an AI image generator such as Midjourney, DALL-E, Stable Diffusion, or Flux.</p>
</div>
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Auto-filled from</div>
<ul className="mt-2 space-y-1 text-xs leading-6 text-slate-400">
<li><span className="text-slate-300">Headline</span> title, meta_title</li>
<li><span className="text-slate-300">Subheadline</span> excerpt, meta_description, content</li>
<li><span className="text-slate-300">Topic</span> category, tags</li>
<li><span className="text-slate-300">Type</span> article type</li>
<li><span className="text-slate-300">Hero / Mood</span> inferred from title, tags, type</li>
<li><span className="text-slate-300">Addons</span> type-based and keyword-based style blocks</li>
</ul>
</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>
{activeImportTab === 'export' ? (
<>
{exportMode === 'structured' ? (
<button
type="button"
onClick={() => copyText(String(exportPayloads?.structuredPlain || ''), 'Structured plain text export')}
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"
>
Copy plain text
</button>
) : null}
<button
type="button"
onClick={() => copyText(activeExportText, exportMode === 'structured' ? 'Structured export' : exportMode === 'markdown' ? 'Markdown export' : 'Full news export')}
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"
>
Copy export
</button>
</>
) : activeImportTab === 'image_prompt' ? null : (
<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
const frontendArticleUrl = String(article.canonical_url || '').trim()
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', '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
const jsonExportPayloads = useMemo(() => buildNewsExportPayloads(form.data, {
categoryOptions: props.categoryOptions,
tagOptions: props.tagOptions,
author: selectedAuthor,
}), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor])
const imagePromptArticleData = useMemo(() => {
const category = findNewsOptionById(props.categoryOptions, form.data.category_id)
const existingTags = findNewsTagsByIds(props.tagOptions, form.data.tag_ids)
return {
title: form.data.title,
excerpt: form.data.excerpt,
content: form.data.content,
type: form.data.type,
category: category?.name ?? category?.label ?? '',
category_slug: category?.slug ?? '',
tag_names: [
...existingTags.map((t) => t.name),
...(Array.isArray(form.data.new_tag_names) ? form.data.new_tag_names : []),
],
meta_title: form.data.meta_title,
meta_description: form.data.meta_description,
}
}, [form.data, props.categoryOptions, props.tagOptions])
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: '',
external_url: '',
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="grid gap-5 border-b border-white/10 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start">
<div className="min-w-0">
<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>
{coverPreviewUrl ? (
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.35)]">
<div className="relative aspect-[16/9] bg-black/30">
<img src={coverPreviewUrl} alt={String(form.data.title || '').trim() || 'News cover preview'} className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-[#020611d9] via-[#02061144] to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Header cover preview</div>
<div className="mt-1 line-clamp-2 text-sm font-semibold text-white">{String(form.data.title || '').trim() || 'Cover image preview'}</div>
</div>
</div>
</div>
) : null}
</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">
{frontendArticleUrl ? <a href={frontendArticleUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2.5 text-sm font-semibold text-cyan-100 transition hover:bg-cyan-400/15">Frontend link</a> : null}
{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="inline-flex items-center gap-2 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">
{hasUnsavedChanges && !form.processing ? <span className="h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_10px_rgba(251,113,133,0.9)] animate-pulse" aria-hidden="true" /> : null}
<span>{form.processing ? 'Saving…' : 'Save article'}</span>
</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}
mediaSupport={{
uploadUrl: props.coverUploadUrl,
deleteUrl: props.coverDeleteUrl,
slot: 'body',
}}
/>
<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>
<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}
exportPayloads={jsonExportPayloads}
articleData={imagePromptArticleData}
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
onChange={(nextValue) => {
setJsonImportValue(nextValue)
if (jsonImportError) {
setJsonImportError('')
}
}}
onClose={() => {
setJsonImportOpen(false)
setJsonImportError('')
}}
onApply={applyJsonImport}
/>
</StudioLayout>
)
}