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 (
{toasts.map((t) => (
{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : 'ℹ'} {t.message}
))}
) } // ───────────────────────────────────────────────────────────────────────────── function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) { if (!Array.isArray(items) || items.length === 0) { return
{emptyLabel}
} return (
{items.map((item) => ( ))}
) } function FieldError({ message }) { if (!message) return null return

{message}

} 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 (
{eyebrow ?

{eyebrow}

: null}

{title}

{description ?

{description}

: null}
{actions ?
{actions}
: null}
{children}
) } 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(
{ if (event.target === backdropRef.current) { onClose?.() } }} role="presentation" >

Tag Import

Add {preview.tagsToAdd.length} pasted tag{preview.tagsToAdd.length === 1 ? '' : 's'}?

Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before adding them to the article.

{(preview.skippedDuplicates.length > 0 || preview.skippedInvalid.length > 0) ? (
{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.` : ''}
) : null}

Tags to add

{preview.tagsToAdd.map((tag) => ( {tag.name} ))}
, 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 ( <>
Selected tags
Attach article topics with the same chip-based flow used on artwork tags.
{manageUrl ? Manage tags : null}
{existingTags.length === 0 && pendingTags.length === 0 ? No tags selected : null} {existingTags.map((tag) => ( {tag.name} ))} {pendingTags.map((tag) => ( {tag.name} New ))}
{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.`}
{isOpen ? (
    {suggestions.length > 0 ? suggestions.map((tag, index) => { const active = index === highlightedIndex return (
  • { event.preventDefault() addCandidate(tag.name) }} > {tag.name}
  • ) }) : (
  • No suggestions
  • )}
) : null}
{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.`} {existingTags.length + pendingTags.length} selected
setPastePreview(null)} onConfirm={handleConfirmPaste} /> ) } function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) { const isSourceRelation = String(relation.entity_type || '').trim().toLowerCase() === 'source' return (
Type onChange(index, { ...relation, entity_type: val, entity_id: '', external_url: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
{isSourceRelation ? ( ) : ( )}
{!isSourceRelation && relation.preview ? (
Linked: {relation.preview.title}
{relation.preview.subtitle ?
{relation.preview.subtitle}
: null}
) : null} {isSourceRelation ? (
Source relations store a direct external URL instead of an internal Nova entity ID.
) : (
onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
)}
) } 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, defaultPublishedAt, 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) : (defaultPublishedAt || ''))), 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: '-', })) .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: '

This is sample news content written in HTML.

You can replace it with your own editorial copy.

', 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(
{ if (event.target === backdropRef.current) { onClose?.() } }} role="presentation" >

Structured import

Import or export article JSON

Use this for migrations, AI-assisted drafting, bulk handoff from another editorial system, or copying the current article into reusable JSON.

{importTabs.map((tab) => ( ))}
{activeImportTab === 'input' ? (