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

1045 lines
52 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 }) {
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 (
<>
<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>
<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.'}
</span>
<span className="text-white/50">{existingTags.length + pendingTags.length} selected</span>
</div>
</div>
</div>
<NewsTagInputDialog
open={Boolean(pastePreview)}
preview={pastePreview}
onClose={() => setPastePreview(null)}
onConfirm={handleConfirmPaste}
/>
</>
)
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
<div className="flex gap-2">
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => onSearch(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
</label>
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
</div>
{relation.preview ? (
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Linked: {relation.preview.title}</div>
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
</div>
) : null}
<div className="mt-4">
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
</div>
<label className="mt-4 grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
<input value={relation.context_label || ''} onChange={(event) => onChange(index, { ...relation, context_label: event.target.value })} placeholder="Featured release, Meet the creator, Join this challenge…" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
)
}
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
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 (
<StudioLayout title={props.title} subtitle={props.description}>
<ToastStack toasts={toasts} onDismiss={dismissToast} />
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(340px,0.85fr)]">
<div className="space-y-6">
<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, tags, and related entities are all tuned for homepage spotlight, archive browsing, and article detail pages."
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>
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" 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">Leave blank to generate from the title, or set a durable URL manually when the story needs a stable public address.</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}
/>
<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>
<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>
</div>
<div className="space-y-6">
<SectionCard eyebrow="Editorial controls" title="Publishing" description="Set ownership, placement, timing, and surface behavior before the article leaves draft.">
<div className="grid gap-4">
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15"><i className="fa-regular fa-eye" />Preview article</a> : null}
<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>
<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}
/>
<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" 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">Canonical URL</span>
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG title</span>
<input value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG image</span>
<input value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG description</span>
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</SectionCard>
<SectionCard eyebrow="Actions" title="Save and publish" description="Use the primary action for create or update, then promote, archive, or trash the article from the same control rail.">
<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" 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.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>
</div>
</form>
</StudioLayout>
)
}