import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 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 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 TagPicker({ options, selectedIds, newTagNames, tagQuery, onTagQueryChange, onToggle, onCreateTag, onRemoveNewTag, manageUrl }) { const selectedTags = useMemo(() => options.filter((tag) => selectedIds.includes(tag.id)), [options, selectedIds]) const normalizedQuery = useMemo(() => normalizeNewTagName(tagQuery), [tagQuery]) const matchingExistingTag = useMemo(() => { if (!normalizedQuery) return null const lowerQuery = normalizedQuery.toLowerCase() return options.find((tag) => String(tag.name || '').toLowerCase() === lowerQuery) || null }, [options, normalizedQuery]) const queryMatchesPending = useMemo(() => { if (!normalizedQuery) return false const lowerQuery = normalizedQuery.toLowerCase() return newTagNames.some((tagName) => tagName.toLowerCase() === lowerQuery) }, [newTagNames, normalizedQuery]) const availableTags = useMemo(() => { const query = String(tagQuery || '').trim().toLowerCase() return options .filter((tag) => !selectedIds.includes(tag.id)) .filter((tag) => (query === '' ? true : String(tag.name || '').toLowerCase().includes(query))) .slice(0, 12) }, [options, selectedIds, tagQuery]) return (
Selected tags
Attach article topics without forcing the editor to scan a wall of checkboxes.
{manageUrl ? Manage tags : null}
{selectedTags.length > 0 ? selectedTags.map((tag) => ( )) : null} {newTagNames.map((tagName) => ( ))} {selectedTags.length === 0 && newTagNames.length === 0 ?
No tags selected yet.
: null}
{normalizedQuery && !matchingExistingTag && !queryMatchesPending ? ( ) : null}
{availableTags.length > 0 ? availableTags.map((tag) => ( )) :
No additional tags match the current search.
}
Press Enter or comma to queue a new tag. Pending tags are written into the news tag list when the article is saved.
) } function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) { return (
Type onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
{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." />
) } 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: 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), 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 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 || '', 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), 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 [tagQuery, setTagQuery] = 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({}) setTagQuery('') 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 || [], 'No 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 toggleTag = (tagId) => { const numericId = Number(tagId) const next = form.data.tag_ids.includes(numericId) ? form.data.tag_ids.filter((currentId) => currentId !== numericId) : [...form.data.tag_ids, numericId] form.setData('tag_ids', next) if (!form.data.tag_ids.includes(numericId)) { const matchedTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => tag.id === numericId) if (matchedTag) { const lowerName = String(matchedTag.name || '').toLowerCase() form.setData('new_tag_names', form.data.new_tag_names.filter((tagName) => tagName.toLowerCase() !== lowerName)) } } } const addNewTagName = (rawValue) => { const nextTagName = normalizeNewTagName(rawValue) if (!nextTagName) return const lowerName = nextTagName.toLowerCase() const matchingExistingTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => String(tag.name || '').toLowerCase() === lowerName) if (matchingExistingTag) { if (!form.data.tag_ids.includes(matchingExistingTag.id)) { form.setData('tag_ids', [...form.data.tag_ids, matchingExistingTag.id]) } return } if (form.data.new_tag_names.some((tagName) => tagName.toLowerCase() === lowerName)) { return } form.setData('new_tag_names', [...form.data.new_tag_names, nextTagName]) } const removeNewTagName = (tagName) => { form.setData('new_tag_names', form.data.new_tag_names.filter((currentTagName) => currentTagName !== tagName)) } 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() 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 (