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}
onDismiss(t.id)}
className="ml-2 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>×
))}
)
}
// ─────────────────────────────────────────────────────────────────────────────
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
if (!Array.isArray(items) || items.length === 0) {
return {emptyLabel}
}
return (
{items.map((item) => (
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 ? : null}
{item.title}
{item.subtitle ?
{item.subtitle}
: null}
{item.description ?
{item.description}
: null}
))}
)
}
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}
))}
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
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
,
document.body,
)
}
function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange, onNewTagNamesChange, manageUrl }) {
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 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
}
setError('')
syncNames([...combinedNames, nextName])
setQuery('')
setIsOpen(false)
}, [combinedKeys, combinedNames, 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
}
setError('')
setPastePreview(preview)
}, [combinedKeys])
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}
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}`}>
✕
))}
{pendingTags.map((tag) => (
{tag.name}
New
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}`}>
✕
))}
Find tags
{
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"
/>
{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.'}
{existingTags.length + pendingTags.length} selected
setPastePreview(null)}
onConfirm={handleConfirmPaste}
/>
>
)
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return (
{relation.preview ? (
Linked: {relation.preview.title}
{relation.preview.subtitle ?
{relation.preview.subtitle}
: null}
) : null}
onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
Context label
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" />
)
}
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
function selectOptionsFromValues(options, emptyLabel = null) {
const base = Array.isArray(options)
? options.map((option) => ({
value: String(option.value ?? option.id),
label: option.label ?? option.name,
}))
: []
return emptyLabel ? [{ value: '', label: emptyLabel }, ...base] : base
}
function buildSubmitPayload(data) {
return {
title: String(data.title || '').trim(),
slug: String(data.slug || '').trim(),
excerpt: String(data.excerpt || ''),
content: String(data.content || ''),
cover_image: String(data.cover_image || '').trim(),
type: String(data.type || ''),
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
author_id: data.author_id === '' || data.author_id == null ? null : Number(data.author_id),
editorial_status: String(data.editorial_status || ''),
published_at: data.published_at ? String(data.published_at) : null,
is_featured: Boolean(data.is_featured),
is_pinned: Boolean(data.is_pinned),
comments_enabled: Boolean(data.comments_enabled),
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
meta_title: String(data.meta_title || ''),
meta_description: String(data.meta_description || ''),
meta_keywords: String(data.meta_keywords || ''),
canonical_url: String(data.canonical_url || '').trim(),
og_title: String(data.og_title || ''),
og_description: String(data.og_description || ''),
og_image: String(data.og_image || '').trim(),
relations: Array.isArray(data.relations)
? data.relations.map((relation) => ({
entity_type: String(relation.entity_type || '').trim(),
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
context_label: String(relation.context_label || '').trim(),
}))
: [],
}
}
function hasRequiredCategory(categoryId) {
if (categoryId === '' || categoryId == null) {
return false
}
return Number(categoryId) > 0
}
function buildInitialFormData(article, defaultAuthor, typeOptions) {
return {
title: article.title || '',
slug: article.slug || '',
excerpt: article.excerpt || '',
content: article.content || '',
cover_image: article.cover_image || '',
type: article.type || (typeOptions?.[0]?.value || 'announcement'),
category_id: article.category_id ? String(article.category_id) : '',
author_id: article.author_id || defaultAuthor?.id || '',
editorial_status: article.editorial_status || 'draft',
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned),
comments_enabled: Boolean(article.comments_enabled),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
new_tag_names: [],
meta_title: article.meta_title || '',
meta_description: article.meta_description || '',
meta_keywords: article.meta_keywords || '',
canonical_url: article.canonical_url || '',
og_title: article.og_title || '',
og_description: article.og_description || '',
og_image: article.og_image || '',
relations: Array.isArray(article.relations) ? article.relations.map((relation) => ({
entity_type: relation.entity_type || 'group',
entity_id: relation.entity_id || '',
context_label: relation.context_label || '',
preview: relation.preview || null,
query: relation.preview?.title || '',
})) : [],
}
}
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), [article, props.defaultAuthor, 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 lastSyncedArticleKeyRef = useRef(articleSyncKey)
const form = useForm(initialFormData)
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('')
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
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 searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
url.searchParams.set('type', type)
url.searchParams.set('q', query)
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
})
if (!response.ok) {
return []
}
const payload = await response.json()
return Array.isArray(payload.items) ? payload.items : []
}
const runAuthorSearch = async () => {
const items = await searchEntities('user', authorQuery)
setAuthorResults(items)
}
const addRelation = () => {
form.setData('relations', [
...form.data.relations,
{
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
entity_id: '',
context_label: '',
preview: null,
query: '',
},
])
}
const updateRelation = (index, nextRelation) => {
form.setData('relations', form.data.relations.map((relation, relationIndex) => (relationIndex === index ? nextRelation : relation)))
}
const removeRelation = (index) => {
form.setData('relations', form.data.relations.filter((_, relationIndex) => relationIndex !== index))
setRelationResults((current) => {
const next = { ...current }
delete next[index]
return next
})
}
const runRelationSearch = async (index) => {
const relation = form.data.relations[index]
if (!relation) return
const items = await searchEntities(relation.entity_type, relation.query || '')
setRelationResults((current) => ({ ...current, [index]: items }))
}
const handleManualCoverChange = (nextValue) => {
form.setData('cover_image', nextValue)
if (stagedCoverPath && nextValue !== stagedCoverPath) {
setStagedCoverPath('')
}
if (!nextValue) {
setCoverPreviewUrl('')
return
}
if (String(nextValue).startsWith('http://') || String(nextValue).startsWith('https://')) {
setCoverPreviewUrl(nextValue)
return
}
setCoverPreviewUrl(`${props.coverCdnBaseUrl}/${String(nextValue).replace(/^\/+/, '')}`)
}
const submit = (event) => {
event.preventDefault()
if (!hasRequiredCategory(form.data.category_id)) {
form.setError('category_id', 'Choose a category before saving the article.')
pushToast('Choose a category before saving the article.', 'error')
return
}
const options = {
preserveScroll: true,
preserveState: false,
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' : ''})` : ''
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,
})
}
return (
)
}