1911 lines
94 KiB
JavaScript
1911 lines
94 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { router, useForm, usePage } from '@inertiajs/react'
|
||
import StudioLayout from '../../Layouts/StudioLayout'
|
||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
|
||
import DateTimePicker from '../../components/ui/DateTimePicker'
|
||
import NovaSelect from '../../components/ui/NovaSelect'
|
||
import { Checkbox } from '../../components/ui'
|
||
|
||
// ── Minimal toast system ────────────────────────────────────────────────────
|
||
let _toastId = 0
|
||
|
||
function useToast() {
|
||
const [toasts, setToasts] = useState([])
|
||
|
||
const push = useCallback((message, type = 'info') => {
|
||
const id = ++_toastId
|
||
setToasts((prev) => [...prev, { id, message, type }])
|
||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 5000)
|
||
}, [])
|
||
|
||
const dismiss = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), [])
|
||
|
||
return { toasts, push, dismiss }
|
||
}
|
||
|
||
function ToastStack({ toasts, onDismiss }) {
|
||
if (!toasts.length) return null
|
||
return (
|
||
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col gap-2" aria-live="polite">
|
||
{toasts.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
className={[
|
||
'flex items-start gap-3 rounded-2xl border px-4 py-3 text-sm shadow-2xl backdrop-blur-sm',
|
||
t.type === 'success'
|
||
? 'border-emerald-400/30 bg-emerald-950/90 text-emerald-100'
|
||
: t.type === 'error'
|
||
? 'border-rose-400/30 bg-rose-950/90 text-rose-100'
|
||
: 'border-white/15 bg-slate-900/90 text-slate-100',
|
||
].join(' ')}
|
||
>
|
||
<span className="mt-0.5 text-base leading-none">
|
||
{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : 'ℹ'}
|
||
</span>
|
||
<span className="flex-1 leading-5">{t.message}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDismiss(t.id)}
|
||
className="ml-2 opacity-60 hover:opacity-100"
|
||
aria-label="Dismiss"
|
||
>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-3 text-xs text-slate-500">{emptyLabel}</div>
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-2">
|
||
{items.map((item) => (
|
||
<button
|
||
key={`${item.entity_type}-${item.id}`}
|
||
type="button"
|
||
onClick={() => onSelect(item)}
|
||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-3 text-left transition hover:border-white/20"
|
||
>
|
||
{item.avatar ? <img src={item.avatar} alt={item.title} className="h-10 w-10 rounded-2xl border border-white/10 object-cover" /> : null}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold text-white">{item.title}</div>
|
||
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||
{item.description ? <div className="mt-1 line-clamp-2 text-xs text-slate-400">{item.description}</div> : null}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FieldError({ message }) {
|
||
if (!message) return null
|
||
return <p className="text-xs text-rose-300">{message}</p>
|
||
}
|
||
|
||
function normalizeNewTagName(value) {
|
||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80)
|
||
}
|
||
|
||
function normalizeNewsTagKey(value) {
|
||
return normalizeNewTagName(value).toLowerCase()
|
||
}
|
||
|
||
function parseNewsTagList(input) {
|
||
return String(input || '')
|
||
.split(/[\n,]+/)
|
||
.map((item) => normalizeNewTagName(item))
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function analyzePastedNewsTags(rawText, selectedKeys) {
|
||
const parts = parseNewsTagList(rawText)
|
||
const tagsToAdd = []
|
||
const skippedDuplicates = []
|
||
const skippedInvalid = []
|
||
|
||
for (const part of parts) {
|
||
const normalized = normalizeNewTagName(part)
|
||
const key = normalizeNewsTagKey(normalized)
|
||
|
||
if (!normalized || !key) {
|
||
skippedInvalid.push(String(part || '').trim())
|
||
continue
|
||
}
|
||
|
||
if (selectedKeys.includes(key) || tagsToAdd.some((item) => item.key === key)) {
|
||
skippedDuplicates.push(normalized)
|
||
continue
|
||
}
|
||
|
||
tagsToAdd.push({ key, name: normalized })
|
||
}
|
||
|
||
return {
|
||
parsedCount: parts.length,
|
||
tagsToAdd,
|
||
skippedDuplicates,
|
||
skippedInvalid,
|
||
}
|
||
}
|
||
|
||
function SectionCard({ eyebrow, title, description, actions, children, tone = 'default' }) {
|
||
const toneClass = tone === 'feature'
|
||
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
||
: 'bg-white/[0.03]'
|
||
|
||
return (
|
||
<section className={`rounded-[28px] border border-white/10 p-5 ${toneClass}`}>
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div className="max-w-3xl">
|
||
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
|
||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
||
</div>
|
||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||
</div>
|
||
<div className="mt-5">{children}</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function NewsTagInputDialog({ open, preview, onClose, onConfirm }) {
|
||
const backdropRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined
|
||
|
||
const handleKeyDown = (event) => {
|
||
if (event.key === 'Escape') {
|
||
onClose?.()
|
||
}
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||
}, [onClose, open])
|
||
|
||
if (!open || !preview) return null
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={backdropRef}
|
||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||
onClick={(event) => {
|
||
if (event.target === backdropRef.current) {
|
||
onClose?.()
|
||
}
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="news-tag-import-title"
|
||
className="w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
|
||
>
|
||
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Tag Import</p>
|
||
<h3 id="news-tag-import-title" className="mt-2 text-lg font-semibold text-white">
|
||
Add {preview.tagsToAdd.length} pasted tag{preview.tagsToAdd.length === 1 ? '' : 's'}?
|
||
</h3>
|
||
<p className="mt-2 text-sm leading-6 text-white/65">
|
||
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before adding them to the article.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-4 px-6 py-5">
|
||
{(preview.skippedDuplicates.length > 0 || preview.skippedInvalid.length > 0) ? (
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
|
||
{preview.skippedDuplicates.length > 0 ? `${preview.skippedDuplicates.length} duplicate tag${preview.skippedDuplicates.length === 1 ? '' : 's'} ignored.` : ''}
|
||
{preview.skippedDuplicates.length > 0 && preview.skippedInvalid.length > 0 ? ' ' : ''}
|
||
{preview.skippedInvalid.length > 0 ? `${preview.skippedInvalid.length} invalid tag${preview.skippedInvalid.length === 1 ? '' : 's'} ignored.` : ''}
|
||
</div>
|
||
) : null}
|
||
|
||
<div>
|
||
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
|
||
<div className="max-h-56 overflow-auto rounded-2xl border border-white/10 bg-white/[0.03] p-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{preview.tagsToAdd.map((tag) => (
|
||
<span
|
||
key={tag.key}
|
||
className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-100"
|
||
>
|
||
{tag.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => onClose?.()}
|
||
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onConfirm?.()}
|
||
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
|
||
>
|
||
Add tags
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange, onNewTagNamesChange, manageUrl, maxNewTags = NEWS_NEW_TAG_LIMIT }) {
|
||
const [query, setQuery] = useState('')
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||
const [error, setError] = useState('')
|
||
const [pastePreview, setPastePreview] = useState(null)
|
||
|
||
const selectedIdSet = useMemo(() => new Set((selectedIds || []).map((id) => Number(id))), [selectedIds])
|
||
const existingTags = useMemo(() => (Array.isArray(options) ? options : []).filter((tag) => selectedIdSet.has(Number(tag.id))), [options, selectedIdSet])
|
||
const pendingTags = useMemo(() => (Array.isArray(newTagNames) ? newTagNames : []).map((name) => ({ key: normalizeNewsTagKey(name), name: normalizeNewTagName(name) })).filter((tag) => tag.key), [newTagNames])
|
||
const combinedNames = useMemo(() => [...existingTags.map((tag) => String(tag.name || '')), ...pendingTags.map((tag) => tag.name)], [existingTags, pendingTags])
|
||
const combinedKeys = useMemo(() => combinedNames.map((name) => normalizeNewsTagKey(name)).filter(Boolean), [combinedNames])
|
||
const newTagLimit = Math.max(0, Number(maxNewTags || NEWS_NEW_TAG_LIMIT))
|
||
const remainingNewTagSlots = Math.max(0, newTagLimit - pendingTags.length)
|
||
const normalizedQuery = useMemo(() => normalizeNewTagName(query), [query])
|
||
|
||
const syncNames = useCallback((names) => {
|
||
const seen = new Set()
|
||
const nextIds = []
|
||
const nextNewNames = []
|
||
|
||
names.forEach((rawName) => {
|
||
const nextName = normalizeNewTagName(rawName)
|
||
const key = normalizeNewsTagKey(nextName)
|
||
|
||
if (!key || seen.has(key)) {
|
||
return
|
||
}
|
||
|
||
seen.add(key)
|
||
|
||
const existing = (Array.isArray(options) ? options : []).find((tag) => normalizeNewsTagKey(tag.name) === key)
|
||
if (existing) {
|
||
nextIds.push(Number(existing.id))
|
||
return
|
||
}
|
||
|
||
nextNewNames.push(nextName)
|
||
})
|
||
|
||
onSelectedIdsChange(nextIds)
|
||
onNewTagNamesChange(nextNewNames)
|
||
}, [onNewTagNamesChange, onSelectedIdsChange, options])
|
||
|
||
const exactMatch = useMemo(() => {
|
||
if (!normalizedQuery) return null
|
||
return (Array.isArray(options) ? options : []).find((tag) => normalizeNewsTagKey(tag.name) === normalizeNewsTagKey(normalizedQuery)) || null
|
||
}, [normalizedQuery, options])
|
||
|
||
const suggestions = useMemo(() => {
|
||
const source = Array.isArray(options) ? options : []
|
||
const lowerQuery = normalizeNewsTagKey(query)
|
||
|
||
return source
|
||
.filter((tag) => !selectedIdSet.has(Number(tag.id)))
|
||
.filter((tag) => !pendingTags.some((pendingTag) => pendingTag.key === normalizeNewsTagKey(tag.name)))
|
||
.filter((tag) => (lowerQuery === '' ? true : normalizeNewsTagKey(tag.name).includes(lowerQuery)))
|
||
.slice(0, 8)
|
||
}, [options, pendingTags, query, selectedIdSet])
|
||
|
||
useEffect(() => {
|
||
setHighlightedIndex(suggestions.length > 0 ? 0 : -1)
|
||
}, [suggestions])
|
||
|
||
const addCandidate = useCallback((rawName) => {
|
||
const nextName = normalizeNewTagName(rawName)
|
||
if (!nextName) {
|
||
return
|
||
}
|
||
|
||
const key = normalizeNewsTagKey(nextName)
|
||
if (combinedKeys.includes(key)) {
|
||
setError('Duplicate tag')
|
||
return
|
||
}
|
||
|
||
if (pendingTags.length >= newTagLimit) {
|
||
setError(`You can add up to ${newTagLimit} new tags per article.`)
|
||
return
|
||
}
|
||
|
||
setError('')
|
||
syncNames([...combinedNames, nextName])
|
||
setQuery('')
|
||
setIsOpen(false)
|
||
}, [combinedKeys, combinedNames, newTagLimit, pendingTags.length, syncNames])
|
||
|
||
const removeExisting = useCallback((tagId) => {
|
||
onSelectedIdsChange((selectedIds || []).filter((id) => Number(id) !== Number(tagId)))
|
||
}, [onSelectedIdsChange, selectedIds])
|
||
|
||
const removePending = useCallback((tagName) => {
|
||
onNewTagNamesChange((newTagNames || []).filter((name) => normalizeNewsTagKey(name) !== normalizeNewsTagKey(tagName)))
|
||
}, [newTagNames, onNewTagNamesChange])
|
||
|
||
const handlePaste = useCallback((event) => {
|
||
const raw = event.clipboardData?.getData('text')
|
||
if (!raw) return
|
||
|
||
const parts = parseNewsTagList(raw)
|
||
if (parts.length <= 1) return
|
||
|
||
event.preventDefault()
|
||
const preview = analyzePastedNewsTags(raw, combinedKeys)
|
||
if (preview.tagsToAdd.length === 0) {
|
||
setError('No new tags found in pasted text')
|
||
return
|
||
}
|
||
|
||
if (preview.tagsToAdd.length > remainingNewTagSlots) {
|
||
setError(`You can only add ${remainingNewTagSlots} more new tag${remainingNewTagSlots === 1 ? '' : 's'} to this article.`)
|
||
return
|
||
}
|
||
|
||
setError('')
|
||
setPastePreview(preview)
|
||
}, [combinedKeys, remainingNewTagSlots])
|
||
|
||
const handleConfirmPaste = useCallback(() => {
|
||
if (!pastePreview) return
|
||
syncNames([...combinedNames, ...pastePreview.tagsToAdd.map((tag) => tag.name)])
|
||
setPastePreview(null)
|
||
setQuery('')
|
||
setIsOpen(false)
|
||
}, [combinedNames, pastePreview, syncNames])
|
||
|
||
const handleKeyDown = useCallback((event) => {
|
||
if (event.key === 'Escape') {
|
||
setIsOpen(false)
|
||
return
|
||
}
|
||
|
||
if (event.key === 'Backspace' && query.length === 0 && combinedNames.length > 0) {
|
||
const lastPending = pendingTags[pendingTags.length - 1]
|
||
if (lastPending) {
|
||
removePending(lastPending.name)
|
||
return
|
||
}
|
||
|
||
const lastExisting = existingTags[existingTags.length - 1]
|
||
if (lastExisting) {
|
||
removeExisting(lastExisting.id)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault()
|
||
if (suggestions.length === 0) return
|
||
setIsOpen(true)
|
||
setHighlightedIndex((current) => Math.min(current + 1, suggestions.length - 1))
|
||
return
|
||
}
|
||
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault()
|
||
if (suggestions.length === 0) return
|
||
setIsOpen(true)
|
||
setHighlightedIndex((current) => Math.max(current - 1, 0))
|
||
return
|
||
}
|
||
|
||
if (!['Enter', ',', 'Tab'].includes(event.key)) {
|
||
return
|
||
}
|
||
|
||
if (event.key === 'Tab' && !isOpen && normalizedQuery === '') {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
|
||
if ((event.key === 'Enter' || event.key === 'Tab') && isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
|
||
addCandidate(suggestions[highlightedIndex].name)
|
||
return
|
||
}
|
||
|
||
if (!normalizedQuery) {
|
||
return
|
||
}
|
||
|
||
if (exactMatch) {
|
||
addCandidate(exactMatch.name)
|
||
return
|
||
}
|
||
|
||
addCandidate(normalizedQuery)
|
||
}, [addCandidate, combinedNames.length, exactMatch, existingTags, highlightedIndex, isOpen, normalizedQuery, pendingTags, query.length, removeExisting, removePending, suggestions])
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-4">
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected tags</div>
|
||
<div className="mt-1 text-sm text-slate-400">Attach article topics with the same chip-based flow used on artwork tags.</div>
|
||
</div>
|
||
{manageUrl ? <a href={manageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">Manage tags</a> : null}
|
||
</div>
|
||
|
||
<div className="mt-4 min-h-[3rem] rounded-xl border border-white/10 bg-white/5 p-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
{existingTags.length === 0 && pendingTags.length === 0 ? <span className="px-2 py-1 text-xs text-white/50">No tags selected</span> : null}
|
||
{existingTags.map((tag) => (
|
||
<span key={tag.id} className="group inline-flex max-w-full items-center gap-2 rounded-full border border-white/20 bg-slate-900/80 px-3 py-1.5 text-xs text-slate-100">
|
||
<span className="truncate">{tag.name}</span>
|
||
<button type="button" onClick={() => removeExisting(tag.id)} className="rounded-full p-0.5 text-slate-300 transition hover:bg-white/10 hover:text-white" aria-label={`Remove tag ${tag.name}`}>
|
||
✕
|
||
</button>
|
||
</span>
|
||
))}
|
||
{pendingTags.map((tag) => (
|
||
<span key={tag.key} className="group inline-flex max-w-full items-center gap-2 rounded-full border border-emerald-300/25 bg-emerald-400/10 px-3 py-1.5 text-xs text-emerald-50">
|
||
<span className="truncate">{tag.name}</span>
|
||
<span className="rounded-full border border-emerald-200/30 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100/80">New</span>
|
||
<button type="button" onClick={() => removePending(tag.name)} className="rounded-full p-0.5 text-emerald-100/75 transition hover:bg-white/10 hover:text-white" aria-label={`Remove new tag ${tag.name}`}>
|
||
✕
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`mt-3 rounded-2xl border px-3 py-2 text-xs ${remainingNewTagSlots === 0 ? 'border-amber-300/25 bg-amber-400/10 text-amber-100' : remainingNewTagSlots <= 3 ? 'border-amber-300/20 bg-amber-400/8 text-amber-100' : 'border-white/10 bg-white/[0.03] text-slate-400'}`}>
|
||
{remainingNewTagSlots === 0
|
||
? `New tag limit reached: up to ${newTagLimit} new tags can be staged for one article.`
|
||
: `${pendingTags.length}/${newTagLimit} new tags staged. ${remainingNewTagSlots} slot${remainingNewTagSlots === 1 ? '' : 's'} left.`}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Find tags</span>
|
||
<input
|
||
value={query}
|
||
onChange={(event) => {
|
||
setQuery(event.target.value)
|
||
setError('')
|
||
setIsOpen(true)
|
||
}}
|
||
onFocus={() => setIsOpen(true)}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder="Search existing tags or type a new one"
|
||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-white/45 focus:border-sky-400 focus:outline-none"
|
||
aria-label="Search or add news tags"
|
||
/>
|
||
</label>
|
||
|
||
{isOpen ? (
|
||
<div className="mt-3 overflow-hidden rounded-xl bg-slate-950/98 shadow-xl shadow-black/50 ring-1 ring-white/10">
|
||
<ul role="listbox" className="max-h-56 overflow-auto py-1">
|
||
{suggestions.length > 0 ? suggestions.map((tag, index) => {
|
||
const active = index === highlightedIndex
|
||
return (
|
||
<li
|
||
key={tag.id}
|
||
role="option"
|
||
aria-selected={active}
|
||
className={`flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm transition ${active ? 'bg-sky-500/20 text-white' : 'text-white/85 hover:bg-white/10'}`}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault()
|
||
addCandidate(tag.name)
|
||
}}
|
||
>
|
||
<span className="truncate">{tag.name}</span>
|
||
</li>
|
||
)
|
||
}) : (
|
||
<li className="px-3 py-2 text-xs text-white/50">No suggestions</li>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-3 flex items-center justify-between gap-3 text-xs">
|
||
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
|
||
{error || `Type and press Enter, comma, or Tab to add. Paste a comma-separated list to review multiple tags. Up to ${newTagLimit} new tags can be staged.`}
|
||
</span>
|
||
<span className="text-white/50">{existingTags.length + pendingTags.length} selected</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<NewsTagInputDialog
|
||
open={Boolean(pastePreview)}
|
||
preview={pastePreview}
|
||
onClose={() => setPastePreview(null)}
|
||
onConfirm={handleConfirmPaste}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
|
||
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()
|
||
}
|
||
|
||
const NEWS_NEW_TAG_LIMIT = 12
|
||
|
||
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 || ''),
|
||
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 getDraftValue(source, key, fallback = '') {
|
||
if (source && Object.prototype.hasOwnProperty.call(source, key)) {
|
||
const val = source[key]
|
||
return val != null ? val : fallback
|
||
}
|
||
|
||
return fallback
|
||
}
|
||
|
||
function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}) {
|
||
return {
|
||
title: String(getDraftValue(oldInput, 'title', article.title || '')),
|
||
slug: String(getDraftValue(oldInput, 'slug', article.slug || '')),
|
||
excerpt: String(getDraftValue(oldInput, 'excerpt', article.excerpt || '')),
|
||
content: String(getDraftValue(oldInput, 'content', article.content || '')),
|
||
cover_image: String(getDraftValue(oldInput, 'cover_image', article.cover_image || '')),
|
||
type: String(getDraftValue(oldInput, 'type', article.type || (typeOptions?.[0]?.value || 'announcement'))),
|
||
category_id: String(getDraftValue(oldInput, 'category_id', article.category_id ? String(article.category_id) : '')),
|
||
author_id: getDraftValue(oldInput, 'author_id', article.author_id || defaultAuthor?.id || ''),
|
||
editorial_status: String(getDraftValue(oldInput, 'editorial_status', article.editorial_status || 'draft')),
|
||
published_at: String(getDraftValue(oldInput, 'published_at', article.published_at ? String(article.published_at).slice(0, 16) : '')),
|
||
is_featured: parseBooleanish(getDraftValue(oldInput, 'is_featured', Boolean(article.is_featured))),
|
||
is_pinned: parseBooleanish(getDraftValue(oldInput, 'is_pinned', Boolean(article.is_pinned))),
|
||
comments_enabled: parseBooleanish(getDraftValue(oldInput, 'comments_enabled', article.id ? Boolean(article.comments_enabled) : true)),
|
||
tag_ids: Array.isArray(getDraftValue(oldInput, 'tag_ids', article.tag_ids)) ? getDraftValue(oldInput, 'tag_ids', article.tag_ids) : [],
|
||
new_tag_names: Array.isArray(getDraftValue(oldInput, 'new_tag_names', [])) ? getDraftValue(oldInput, 'new_tag_names', []) : [],
|
||
meta_title: String(getDraftValue(oldInput, 'meta_title', article.meta_title || '')),
|
||
meta_description: String(getDraftValue(oldInput, 'meta_description', article.meta_description || '')),
|
||
meta_keywords: String(getDraftValue(oldInput, 'meta_keywords', article.meta_keywords || '')),
|
||
canonical_url: String(getDraftValue(oldInput, 'canonical_url', article.canonical_url || '')),
|
||
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: relation.entity_id || '',
|
||
context_label: relation.context_label || '',
|
||
preview: relation.preview || null,
|
||
query: relation.preview?.title || '',
|
||
})) : [],
|
||
}
|
||
}
|
||
|
||
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 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')
|
||
applyString('published_at')
|
||
applyString('meta_title')
|
||
applyString('meta_description')
|
||
applyString('meta_keywords')
|
||
applyString('canonical_url')
|
||
applyString('og_title')
|
||
applyString('og_description')
|
||
applyString('og_image')
|
||
|
||
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) => ({
|
||
entity_type: String(relation?.entity_type || relation?.type || 'group').trim(),
|
||
entity_id: relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
|
||
context_label: String(relation?.context_label || relation?.label || '').trim(),
|
||
preview: null,
|
||
query: 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),
|
||
}
|
||
}
|
||
|
||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||
const backdropRef = useRef(null)
|
||
const [activeImportTab, setActiveImportTab] = useState('input')
|
||
const [copyFeedback, setCopyFeedback] = useState('')
|
||
|
||
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.' },
|
||
]
|
||
|
||
const structureExample = {
|
||
title: 'Sample News Title',
|
||
slug: 'sample-news-title',
|
||
excerpt: 'This is a sample news excerpt that demonstrates the structured import format.',
|
||
content: '<p>This is sample news content written in HTML.</p><p>You can replace it with your own editorial copy.</p>',
|
||
cover_image: 'sample-news-cover.webp',
|
||
type: 'Announcement',
|
||
category_id: 1,
|
||
category: 'General',
|
||
category_slug: 'general',
|
||
editorial_status: 'draft',
|
||
published_at: '2026-05-03 09:00:00',
|
||
author_id: 1,
|
||
author_name: 'Sample Author',
|
||
is_featured: false,
|
||
is_pinned: false,
|
||
comments_enabled: true,
|
||
tags: [
|
||
{ name: 'Sample Tag', slug: 'sample-tag' },
|
||
{ name: 'News Import', slug: 'news-import' },
|
||
],
|
||
tag_names: ['Sample Tag', 'News Import'],
|
||
tag_ids: [],
|
||
relations: {
|
||
related_articles: [],
|
||
related_artworks: [],
|
||
related_users: [],
|
||
source_urls: ['https://example.com/sample-news-source'],
|
||
},
|
||
meta_title: 'Sample News Title - Skinbase Example',
|
||
meta_description: 'This is a sample news meta description for the structured import example.',
|
||
meta_keywords: 'sample news, structured import, editorial example',
|
||
canonical_url: 'https://skinbase.org/news/sample-news-title',
|
||
og_title: 'Sample News Title',
|
||
og_description: 'This is a sample news OG description for the structured import example.',
|
||
og_image: 'sample-news-cover.webp',
|
||
}
|
||
|
||
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
|
||
- canonical_url
|
||
- og_title, og_description, og_image
|
||
- 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, canonical_url, og_title, og_description, and og_image 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 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])
|
||
|
||
if (!open) return null
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={backdropRef}
|
||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||
onClick={(event) => {
|
||
if (event.target === backdropRef.current) {
|
||
onClose?.()
|
||
}
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="news-json-import-title"
|
||
className="flex h-[min(90vh,780px)] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
|
||
>
|
||
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured import</p>
|
||
<h3 id="news-json-import-title" className="mt-2 text-lg font-semibold text-white">Paste article JSON</h3>
|
||
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, or bulk handoff from another editorial system. Matching fields are applied directly to the editor.</p>
|
||
</div>
|
||
|
||
<div className="border-b border-white/[0.06] px-4 py-4">
|
||
<div className="grid gap-2 md:grid-cols-4">
|
||
{importTabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveImportTab(tab.id)}
|
||
className={tabButtonClass(activeImportTab === tab.id)}
|
||
>
|
||
<div className="text-sm font-semibold">{tab.label}</div>
|
||
<div className="mt-1 text-xs leading-5 text-current/70">{tab.description}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="nova-scrollbar flex-1 min-h-0 overflow-y-auto px-6 py-5">
|
||
{activeImportTab === 'input' ? (
|
||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="grid gap-3">
|
||
<textarea
|
||
value={value}
|
||
onChange={(event) => onChange?.(event.target.value)}
|
||
rows={18}
|
||
placeholder={'{\n "title": "My news title",\n "slug": "my-news-title",\n "excerpt": "Short summary",\n "tags": ["release", "community"]\n}'}
|
||
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none placeholder:text-white/30"
|
||
/>
|
||
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recognized keys</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>`title`, `slug`, `excerpt`, `content`, `cover_image`</p>
|
||
<p>`type`, `category_id`, `category`, `category_slug`</p>
|
||
<p>`editorial_status`, `published_at`, `author_id`, `author_name`</p>
|
||
<p>`is_featured`, `is_pinned`, `comments_enabled`</p>
|
||
<p>`tags`, `tag_names`, `tag_ids`, `relations`</p>
|
||
<p>`new_tag_names` is capped at {newTagLimit} items per article.</p>
|
||
<p>`meta_title`, `meta_description`, `meta_keywords`, `canonical_url`, `og_title`, `og_description`, `og_image`</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeImportTab === 'structure' ? (
|
||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Structure example</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyText(JSON.stringify(structureExample, null, 2), 'Structure example')}
|
||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]"
|
||
>
|
||
Copy example
|
||
</button>
|
||
</div>
|
||
<pre className="nova-scrollbar max-h-[52vh] overflow-auto rounded-[20px] border border-white/10 bg-slate-950/80 p-4 text-xs leading-6 text-slate-200">{JSON.stringify(structureExample, null, 2)}</pre>
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Notes</div>
|
||
<div className="mt-3 space-y-3 leading-6 text-slate-400">
|
||
<p>Tags can be an array of strings or objects with `name`, `title`, `label`, or `slug`.</p>
|
||
<p>`content` accepts HTML. Keep paragraph tags and inline formatting in the JSON string.</p>
|
||
<p>`relations` may contain structured links and source URLs, but only direct entity fields are currently applied automatically.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeImportTab === 'docs' ? (
|
||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Field guide</div>
|
||
<div className="mt-3 space-y-3 text-slate-400">
|
||
<p><strong className="text-slate-200">title</strong> - article headline. If `slug` is omitted the editor can derive it from the title.</p>
|
||
<p><strong className="text-slate-200">excerpt</strong> - short summary shown in listings and metadata.</p>
|
||
<p><strong className="text-slate-200">content</strong> - HTML body, usually paragraph tags plus basic formatting.</p>
|
||
<p><strong className="text-slate-200">category_id</strong> - preferred category selector value. `category_slug` and `category` are accepted for matching, but `category_id` is the reliable one.</p>
|
||
<p><strong className="text-slate-200">tags</strong> - array of strings or objects. Existing tags are matched by name; new ones are staged automatically.</p>
|
||
<p><strong className="text-slate-200">new_tag_names</strong> - capped at {newTagLimit} items per article. Use existing tags where possible to stay within the limit.</p>
|
||
<p><strong className="text-slate-200">meta_keywords</strong> - max 255 characters. Keep it concise or the save will fail validation.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Import rules</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>Boolean fields accept `true`/`false`, `1`/`0`, `yes`/`no`, and `on`/`off`.</p>
|
||
<p>Date values should be in `YYYY-MM-DD HH:MM:SS` format for scheduled stories.</p>
|
||
<p>Keep `new_tag_names` at or below {newTagLimit} items, which matches the editor validation.</p>
|
||
<p>Unknown keys are ignored, so you can paste a broader object safely.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeImportTab === 'prompts' ? (
|
||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="grid gap-4">
|
||
{aiPromptExamples.map((example) => (
|
||
<div key={example.title} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70">{example.title}</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyText(example.prompt, example.title)}
|
||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]"
|
||
>
|
||
Copy prompt
|
||
</button>
|
||
</div>
|
||
<pre className="nova-scrollbar mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200">{example.prompt}</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Prompt tips</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>Tell the model to return JSON only, with no explanation text.</p>
|
||
<p>Ask for `tags` as an array of objects when you want the most compatible import shape.</p>
|
||
<p>Include `source_urls` or reference links in the source instruction if you want them copied into the story notes.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{copyFeedback ? (
|
||
<div className="px-6 pb-2 text-right text-xs font-medium text-sky-200/80">{copyFeedback}</div>
|
||
) : null}
|
||
|
||
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => onClose?.()}
|
||
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onApply?.()}
|
||
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
|
||
>
|
||
Apply JSON
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
export default function StudioNewsEditor() {
|
||
const { props } = usePage()
|
||
const { toasts, push: pushToast, dismiss: dismissToast } = useToast()
|
||
const article = props.article || {}
|
||
const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.typeOptions, props.oldInput || {}), [article, props.defaultAuthor, props.oldInput, props.typeOptions])
|
||
const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData])
|
||
const [authorResults, setAuthorResults] = useState([])
|
||
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
|
||
const [relationResults, setRelationResults] = useState({})
|
||
const [coverPreviewUrl, setCoverPreviewUrl] = useState(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
||
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
||
const [activeTab, setActiveTab] = useState('content')
|
||
const [jsonImportOpen, setJsonImportOpen] = useState(false)
|
||
const [jsonImportValue, setJsonImportValue] = useState('')
|
||
const [jsonImportError, setJsonImportError] = useState('')
|
||
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
|
||
const slugTouchedRef = useRef(Boolean(String(article.slug || '').trim()))
|
||
|
||
const form = useForm(initialFormData)
|
||
const normalizedInitialPayload = useMemo(() => JSON.stringify(buildSubmitPayload(initialFormData)), [initialFormData])
|
||
const normalizedCurrentPayload = useMemo(() => JSON.stringify(buildSubmitPayload(form.data)), [form.data])
|
||
const hasUnsavedChanges = normalizedCurrentPayload !== normalizedInitialPayload
|
||
|
||
useEffect(() => {
|
||
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
|
||
return
|
||
}
|
||
|
||
lastSyncedArticleKeyRef.current = articleSyncKey
|
||
form.setData(initialFormData)
|
||
form.clearErrors()
|
||
setSelectedAuthor(article.author || props.defaultAuthor || null)
|
||
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||
setRelationResults({})
|
||
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
||
setStagedCoverPath('')
|
||
slugTouchedRef.current = Boolean(String((article.slug || initialFormData.slug || '')).trim())
|
||
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
|
||
|
||
useEffect(() => {
|
||
if (slugTouchedRef.current) {
|
||
return
|
||
}
|
||
|
||
const nextSlug = slugifyNewsTitle(form.data.title)
|
||
if (nextSlug === String(form.data.slug || '')) {
|
||
return
|
||
}
|
||
|
||
form.setData('slug', nextSlug)
|
||
}, [form, form.data.slug, form.data.title])
|
||
|
||
useEffect(() => {
|
||
if (!hasUnsavedChanges || form.processing) {
|
||
return undefined
|
||
}
|
||
|
||
const message = 'You have unsaved article changes. Leave without saving?'
|
||
|
||
const handleBeforeUnload = (event) => {
|
||
event.preventDefault()
|
||
event.returnValue = message
|
||
return message
|
||
}
|
||
|
||
const handleDocumentClick = (event) => {
|
||
if (event.defaultPrevented) {
|
||
return
|
||
}
|
||
|
||
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null
|
||
if (!anchor) {
|
||
return
|
||
}
|
||
|
||
const href = anchor.getAttribute('href')
|
||
if (!href || href.startsWith('#') || anchor.getAttribute('download') != null) {
|
||
return
|
||
}
|
||
|
||
if (anchor.target && anchor.target.toLowerCase() === '_blank') {
|
||
return
|
||
}
|
||
|
||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||
return
|
||
}
|
||
|
||
const destination = new URL(anchor.href, window.location.href)
|
||
const current = new URL(window.location.href)
|
||
if (destination.href === current.href) {
|
||
return
|
||
}
|
||
|
||
if (!window.confirm(message)) {
|
||
event.preventDefault()
|
||
}
|
||
}
|
||
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
document.addEventListener('click', handleDocumentClick, true)
|
||
|
||
return () => {
|
||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
document.removeEventListener('click', handleDocumentClick, true)
|
||
}
|
||
}, [form.processing, hasUnsavedChanges])
|
||
|
||
const excerptLength = String(form.data.excerpt || '').trim().length
|
||
const bodyWordCount = useMemo(() => {
|
||
const plain = stripHtml(form.data.content)
|
||
return plain === '' ? 0 : plain.split(/\s+/).length
|
||
}, [form.data.content])
|
||
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
|
||
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
|
||
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'Select category'), [props.categoryOptions])
|
||
const currentTabIndex = Math.max(0, NEWS_EDITOR_TABS.findIndex((tab) => tab.id === activeTab))
|
||
const currentTab = NEWS_EDITOR_TABS[currentTabIndex] || NEWS_EDITOR_TABS[0]
|
||
const tabErrorCounts = useMemo(() => ({
|
||
content: ['title', 'slug', 'excerpt', 'content', 'cover_image'].filter((key) => Boolean(form.errors[key])).length,
|
||
publishing: ['type', 'category_id', 'author_id', 'editorial_status', 'published_at', 'comments_enabled'].filter((key) => Boolean(form.errors[key])).length,
|
||
discoverability: ['tag_ids', 'new_tag_names', 'meta_title', 'meta_description', 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image'].filter((key) => Boolean(form.errors[key])).length,
|
||
connections: ['relations'].filter((key) => Boolean(form.errors[key])).length,
|
||
}), [form.errors])
|
||
const overviewItems = useMemo(() => ([
|
||
{ label: 'Headline', done: String(form.data.title || '').trim().length > 0 },
|
||
{ label: 'Category', done: hasRequiredCategory(form.data.category_id) },
|
||
{ label: 'Excerpt', done: String(form.data.excerpt || '').trim().length > 0 },
|
||
{ label: 'Body', done: bodyWordCount > 0 },
|
||
{ label: 'Cover image', done: String(form.data.cover_image || '').trim().length > 0 },
|
||
{ label: 'Author', done: Boolean(form.data.author_id) },
|
||
]), [bodyWordCount, form.data.author_id, form.data.category_id, form.data.cover_image, form.data.excerpt, form.data.title])
|
||
const completedCount = overviewItems.filter((item) => item.done).length
|
||
|
||
useEffect(() => {
|
||
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
|
||
if (firstErrorTab) {
|
||
setActiveTab(firstErrorTab.id)
|
||
}
|
||
}, [tabErrorCounts])
|
||
|
||
const searchEntities = async (type, query) => {
|
||
const url = new URL(props.entitySearchUrl, window.location.origin)
|
||
url.searchParams.set('type', type)
|
||
url.searchParams.set('q', query)
|
||
|
||
const response = await fetch(url.toString(), {
|
||
headers: {
|
||
Accept: 'application/json',
|
||
},
|
||
credentials: 'same-origin',
|
||
})
|
||
|
||
if (!response.ok) {
|
||
return []
|
||
}
|
||
|
||
const payload = await response.json()
|
||
return Array.isArray(payload.items) ? payload.items : []
|
||
}
|
||
|
||
const runAuthorSearch = async () => {
|
||
const items = await searchEntities('user', authorQuery)
|
||
setAuthorResults(items)
|
||
}
|
||
|
||
const addRelation = () => {
|
||
form.setData('relations', [
|
||
...form.data.relations,
|
||
{
|
||
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
|
||
entity_id: '',
|
||
context_label: '',
|
||
preview: null,
|
||
query: '',
|
||
},
|
||
])
|
||
}
|
||
|
||
const updateRelation = (index, nextRelation) => {
|
||
form.setData('relations', form.data.relations.map((relation, relationIndex) => (relationIndex === index ? nextRelation : relation)))
|
||
}
|
||
|
||
const removeRelation = (index) => {
|
||
form.setData('relations', form.data.relations.filter((_, relationIndex) => relationIndex !== index))
|
||
setRelationResults((current) => {
|
||
const next = { ...current }
|
||
delete next[index]
|
||
return next
|
||
})
|
||
}
|
||
|
||
const runRelationSearch = async (index) => {
|
||
const relation = form.data.relations[index]
|
||
if (!relation) return
|
||
const items = await searchEntities(relation.entity_type, relation.query || '')
|
||
setRelationResults((current) => ({ ...current, [index]: items }))
|
||
}
|
||
|
||
const handleManualCoverChange = (nextValue) => {
|
||
form.setData('cover_image', nextValue)
|
||
|
||
if (stagedCoverPath && nextValue !== stagedCoverPath) {
|
||
setStagedCoverPath('')
|
||
}
|
||
|
||
if (!nextValue) {
|
||
setCoverPreviewUrl('')
|
||
return
|
||
}
|
||
|
||
if (String(nextValue).startsWith('http://') || String(nextValue).startsWith('https://')) {
|
||
setCoverPreviewUrl(nextValue)
|
||
return
|
||
}
|
||
|
||
setCoverPreviewUrl(`${props.coverCdnBaseUrl}/${String(nextValue).replace(/^\/+/, '')}`)
|
||
}
|
||
|
||
const submit = (event) => {
|
||
event.preventDefault()
|
||
|
||
if (!hasRequiredCategory(form.data.category_id)) {
|
||
form.setError('category_id', 'Choose a category before saving the article.')
|
||
pushToast('Choose a category before saving the article.', 'error')
|
||
return
|
||
}
|
||
|
||
const options = {
|
||
preserveScroll: true,
|
||
preserveState: 'errors',
|
||
onSuccess: () => {
|
||
setStagedCoverPath('')
|
||
pushToast('Article saved successfully.', 'success')
|
||
},
|
||
onError: (errors) => {
|
||
const errorMessages = Object.values(errors)
|
||
const first = errorMessages[0] || 'The article could not be saved.'
|
||
const extra = errorMessages.length > 1 ? ` (${errorMessages.length - 1} more field${errorMessages.length > 2 ? 's' : ''})` : ''
|
||
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
|
||
|
||
if (firstErrorTab) {
|
||
setActiveTab(firstErrorTab.id)
|
||
}
|
||
|
||
pushToast(first + extra, 'error')
|
||
},
|
||
}
|
||
|
||
form.transform((data) => buildSubmitPayload(data))
|
||
|
||
if (props.updateUrl) {
|
||
form.patch(props.updateUrl, options)
|
||
return
|
||
}
|
||
|
||
form.post(props.storeUrl, options)
|
||
}
|
||
|
||
const deleteArticle = () => {
|
||
if (!props.destroyUrl) return
|
||
if (!window.confirm('Move this article to trash? This uses soft delete so the record stays in the database.')) return
|
||
|
||
router.delete(props.destroyUrl, {
|
||
preserveScroll: true,
|
||
})
|
||
}
|
||
|
||
const goToNextTab = (direction) => {
|
||
const next = NEWS_EDITOR_TABS[currentTabIndex + direction]
|
||
if (!next) return
|
||
setActiveTab(next.id)
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
|
||
const applyJsonImport = () => {
|
||
try {
|
||
const parsed = parseStructuredNewsImport(jsonImportValue, {
|
||
categoryOptions: props.categoryOptions,
|
||
tagOptions: props.tagOptions,
|
||
typeOptions: props.typeOptions,
|
||
statusOptions: props.statusOptions,
|
||
})
|
||
|
||
Object.entries(parsed.next).forEach(([key, value]) => {
|
||
form.setData(key, value)
|
||
})
|
||
|
||
if (parsed.authorQuery) {
|
||
setAuthorQuery(parsed.authorQuery)
|
||
}
|
||
|
||
if (parsed.next.cover_image != null) {
|
||
handleManualCoverChange(parsed.next.cover_image)
|
||
}
|
||
|
||
if (parsed.next.slug != null) {
|
||
slugTouchedRef.current = true
|
||
}
|
||
|
||
setJsonImportError('')
|
||
setJsonImportOpen(false)
|
||
pushToast(`Applied ${parsed.applied.length} field${parsed.applied.length === 1 ? '' : 's'} from JSON.`, 'success')
|
||
} catch (error) {
|
||
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<StudioLayout title={props.title} subtitle={props.description}>
|
||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||
<div className="space-y-6 pb-24">
|
||
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||
{props.indexUrl ? <a href={props.indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to news list</a> : null}
|
||
<span>{article.id ? `Article #${article.id}` : 'New article'}</span>
|
||
<span className="rounded-full border border-white/10 px-3 py-1.5 text-sky-100/80">{currentTab.label}</span>
|
||
</div>
|
||
<h1 className="mt-3 truncate text-2xl font-semibold tracking-[-0.03em] text-white">{String(form.data.title || '').trim() || (article.id ? 'Untitled article' : 'Create article')}</h1>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Keep the draft flow simple: write the story in one place, handle publishing in one place, and keep promotion metadata nearby instead of buried below the fold.</p>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div className="sticky top-16 z-30 border-y border-white/10 bg-[linear-gradient(180deg,rgba(9,14,24,0.98),rgba(6,10,18,0.98))] px-4 py-3 backdrop-blur">
|
||
<div className="flex justify-end gap-2 overflow-x-auto">
|
||
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-2.5 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15">Preview</a> : null}
|
||
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Import JSON</button>
|
||
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">{form.processing ? 'Saving…' : 'Save article'}</button>
|
||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2.5 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15">Publish now</button> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 overflow-x-auto px-4 py-3">
|
||
{NEWS_EDITOR_TABS.map((tab) => {
|
||
const active = tab.id === activeTab
|
||
const errorCount = tabErrorCounts[tab.id] || 0
|
||
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`flex min-w-[170px] flex-col rounded-[22px] border px-4 py-3 text-left transition ${active ? 'border-sky-300/30 bg-sky-400/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.06]'}`}
|
||
>
|
||
<span className="flex items-center justify-between gap-3 text-sm font-semibold">
|
||
<span>{tab.label}</span>
|
||
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.16em] text-rose-100">{errorCount}</span> : null}
|
||
</span>
|
||
<span className="mt-1 text-xs leading-5 text-slate-400">{tab.description}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<form id="studio-news-editor-form" onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||
<div className="space-y-6">
|
||
{activeTab === 'content' ? (
|
||
<>
|
||
<SectionCard
|
||
eyebrow="Story workspace"
|
||
title={article.id ? 'Shape the full newsroom story before it goes live.' : 'Create a newsroom story that reads like an editorial feature, not a raw database form.'}
|
||
description="The cover, excerpt, body, and structure below are the core writing flow. Everything else is support, not a distraction."
|
||
tone="feature"
|
||
>
|
||
<div className="grid gap-5">
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
|
||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Headline that can carry the article alone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
<span className="text-xs leading-5 text-slate-500">Aim for a clear editorial headline that still makes sense in cards, notifications, and social previews.</span>
|
||
<FieldError message={form.errors.title} />
|
||
</label>
|
||
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={form.data.slug}
|
||
onChange={(event) => {
|
||
const nextValue = event.target.value
|
||
slugTouchedRef.current = String(nextValue).trim() !== ''
|
||
form.setData('slug', nextValue)
|
||
}}
|
||
placeholder="optional-manual-slug"
|
||
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
slugTouchedRef.current = false
|
||
form.setData('slug', slugifyNewsTitle(form.data.title))
|
||
}}
|
||
className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||
>
|
||
Sync
|
||
</button>
|
||
</div>
|
||
<span className="text-xs leading-5 text-slate-500">The slug now follows the title automatically until you edit it manually. Use Sync to regenerate it from the current headline.</span>
|
||
<FieldError message={form.errors.slug} />
|
||
</label>
|
||
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||
<span>Excerpt</span>
|
||
<span className="text-slate-500">{excerptLength}/800</span>
|
||
</span>
|
||
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={6} placeholder="Write the concise summary used in listing cards, metadata, and archive previews." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
<span className="text-xs leading-5 text-slate-500">Lead with the update, why it matters, and the audience hook. Two to four punchy sentences usually land better than one dense paragraph.</span>
|
||
<FieldError message={form.errors.excerpt} />
|
||
</label>
|
||
|
||
<div className="grid gap-4">
|
||
<WorldMediaUploadField
|
||
label="Cover image"
|
||
slot="cover"
|
||
value={form.data.cover_image}
|
||
previewUrl={coverPreviewUrl}
|
||
emptyLabel="Drop a cover image"
|
||
helperText="Upload the hero image directly to object storage. A wide landscape image works best for cards, preview surfaces, and social sharing."
|
||
uploadUrl={props.coverUploadUrl}
|
||
deleteUrl={props.coverDeleteUrl}
|
||
onChange={({ path, url }) => {
|
||
setStagedCoverPath(path || '')
|
||
form.setData('cover_image', path || '')
|
||
setCoverPreviewUrl(url || '')
|
||
}}
|
||
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
||
/>
|
||
<FieldError message={form.errors.cover_image} />
|
||
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
|
||
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported legacy stories, or when you already have the exact asset URL you want to use.</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard eyebrow="Full message" title="Body editor" description="Write the main article in a richer editing surface so the content reads like a polished story, not pasted plain text." actions={<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">{bodyWordCount.toLocaleString()} words</div>}>
|
||
<div className="grid gap-3 text-sm text-slate-300">
|
||
<RichTextEditor
|
||
content={form.data.content}
|
||
onChange={(nextValue) => form.setData('content', nextValue)}
|
||
placeholder="Open with the update, add context, use links, pull quotes, headings, and imagery where the story needs structure."
|
||
error={form.errors.content}
|
||
minHeight={24}
|
||
autofocus={false}
|
||
advancedNews
|
||
searchEntities={searchEntities}
|
||
/>
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
||
Story workflow suggestion: lead with the change, explain why it matters, add supporting detail, then end with a clear call to action or next step.
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
</>
|
||
) : null}
|
||
|
||
{activeTab === 'publishing' ? (
|
||
<SectionCard eyebrow="Editorial controls" title="Publishing" description="Set ownership, placement, timing, and surface behavior before the article leaves draft.">
|
||
<div className="grid gap-4">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2 text-sm text-slate-300">
|
||
<NovaSelect label="Type" value={form.data.type || null} onChange={(nextValue) => form.setData('type', String(nextValue || ''))} options={typeOptions} searchable={false} className="bg-black/20" error={form.errors.type} />
|
||
</div>
|
||
<div className="grid gap-2 text-sm text-slate-300">
|
||
<NovaSelect label="Category" value={form.data.category_id || ''} onChange={(nextValue) => {
|
||
form.setData('category_id', String(nextValue || ''))
|
||
if (nextValue) {
|
||
form.clearErrors('category_id')
|
||
}
|
||
}} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2 text-sm text-slate-300">
|
||
<NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} />
|
||
</div>
|
||
<div className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
||
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue)} placeholder="Pick a publish slot" clearable className="bg-black/20" />
|
||
<FieldError message={form.errors.published_at} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Author</div>
|
||
<div className="flex gap-2">
|
||
<input value={authorQuery} onChange={(event) => setAuthorQuery(event.target.value)} placeholder="Search for an author" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
<button type="button" onClick={runAuthorSearch} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
|
||
</div>
|
||
{selectedAuthor ? (
|
||
<div className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||
<div className="font-semibold">Selected author: {selectedAuthor.title}</div>
|
||
{selectedAuthor.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedAuthor.subtitle}</div> : null}
|
||
</div>
|
||
) : null}
|
||
<SearchResultList items={authorResults} onSelect={(item) => {
|
||
setSelectedAuthor(item)
|
||
setAuthorQuery(item.title)
|
||
form.setData('author_id', item.id)
|
||
}} emptyLabel="Search to choose an author profile." />
|
||
<FieldError message={form.errors.author_id} />
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on newsroom surfaces" size={20} variant="accent" />
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.comments_enabled} onChange={(event) => form.setData('comments_enabled', event.target.checked)} label="Allow comments on the article page" size={20} variant="accent" />
|
||
<FieldError message={form.errors.comments_enabled} />
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
) : null}
|
||
|
||
{activeTab === 'discoverability' ? (
|
||
<>
|
||
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
|
||
<NewsTagInput
|
||
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
|
||
selectedIds={form.data.tag_ids}
|
||
newTagNames={form.data.new_tag_names}
|
||
onSelectedIdsChange={(nextIds) => form.setData('tag_ids', nextIds)}
|
||
onNewTagNamesChange={(nextNames) => form.setData('new_tag_names', nextNames)}
|
||
manageUrl={props.tagsUrl}
|
||
maxNewTags={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
|
||
/>
|
||
<div className="mt-3">
|
||
<FieldError message={form.errors.tag_ids || form.errors.new_tag_names} />
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard eyebrow="Metadata" title="SEO and social" description="Keep search and sharing fields aligned with the main editorial package.">
|
||
<div className="grid gap-4">
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
|
||
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta description</span>
|
||
<textarea value={form.data.meta_description} onChange={(event) => form.setData('meta_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta keywords</span>
|
||
<input
|
||
value={form.data.meta_keywords}
|
||
onChange={(event) => form.setData('meta_keywords', event.target.value)}
|
||
placeholder="creator-story, release, tutorial"
|
||
maxLength={255}
|
||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||
/>
|
||
<span className="text-xs leading-5 text-slate-500">Maximum 255 characters. The field now stops at the limit so it fails less often on save.</span>
|
||
<FieldError message={form.errors.meta_keywords} />
|
||
</label>
|
||
<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>
|
||
</>
|
||
) : null}
|
||
|
||
{activeTab === 'connections' ? (
|
||
<>
|
||
<SectionCard eyebrow="Context links" title="Related entities" description="Attach groups, artworks, collections, releases, projects, challenges, events, and profiles so the article becomes part of the rest of Nova instead of a dead-end page." actions={<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>}>
|
||
<div className="grid gap-4">
|
||
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
|
||
<RelationCard
|
||
key={`${relation.entity_type}-${index}`}
|
||
relation={relation}
|
||
index={index}
|
||
onChange={updateRelation}
|
||
onRemove={removeRelation}
|
||
onSearch={runRelationSearch}
|
||
results={relationResults[index] || []}
|
||
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
|
||
/>
|
||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No related entities attached yet.</div>}
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard eyebrow="Structured workflows" title="Paste article JSON" description="Import known fields from structured data, then refine the result inside the normal editor flow." actions={<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Open import</button>}>
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-400">
|
||
This is useful when another tool already generated article structure for you. Paste JSON, map it into the form, then review copy, metadata, and publish controls before saving.
|
||
</div>
|
||
</SectionCard>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="space-y-6 xl:sticky xl:top-[9.75rem]">
|
||
<SectionCard eyebrow="Overview" title="Editing progress" description="A compact status rail so you can see what still needs attention without leaving the current tab.">
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
{hasUnsavedChanges ? <div className="mb-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">You have unsaved changes.</div> : <div className="mb-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">All changes saved.</div>}
|
||
<div className="flex items-end justify-between gap-3">
|
||
<div>
|
||
<div className="text-3xl font-semibold tracking-[-0.04em] text-white">{completedCount}/{overviewItems.length}</div>
|
||
<div className="mt-1 text-sm text-slate-400">Core article pieces ready</div>
|
||
</div>
|
||
<div className="rounded-full border border-white/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{bodyWordCount.toLocaleString()} words</div>
|
||
</div>
|
||
<div className="mt-4 space-y-2">
|
||
{overviewItems.map((item) => (
|
||
<div key={item.label} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2.5 text-sm">
|
||
<span className="text-slate-200">{item.label}</span>
|
||
<span className={`rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${item.done ? 'border border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border border-white/10 bg-white/[0.04] text-slate-400'}`}>{item.done ? 'Ready' : 'Missing'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard eyebrow="Actions" title="Always-visible controls" description="Primary article actions stay pinned here as a second access point while you write.">
|
||
<div className="grid gap-3">
|
||
{Object.keys(form.errors || {}).length > 0 ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">The article was not saved. Fix the highlighted fields and try again.</div> : null}
|
||
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">{form.processing ? 'Saving article…' : 'Save article'}</button>
|
||
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="w-full rounded-full border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-center text-sm font-semibold text-indigo-100">Preview article</a> : null}
|
||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
|
||
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Archive article</button> : null}
|
||
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
|
||
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
|
||
{props.destroyUrl ? <button type="button" onClick={deleteArticle} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Move to trash</button> : null}
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard eyebrow="Utilities" title="Editor shortcuts" description="Keep taxonomy management and structured import nearby without leaving the writing flow.">
|
||
<div className="grid gap-3">
|
||
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Paste structured JSON</button>
|
||
{props.categoriesUrl ? <a href={props.categoriesUrl} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/[0.06]">Manage categories</a> : null}
|
||
{props.tagsUrl ? <a href={props.tagsUrl} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/[0.06]">Manage tags</a> : null}
|
||
</div>
|
||
</SectionCard>
|
||
</div>
|
||
</form>
|
||
|
||
<nav className="sticky bottom-0 z-20 border-t border-white/10 bg-[rgba(2,6,23,0.92)] px-4 py-3 backdrop-blur xl:hidden">
|
||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3">
|
||
<button type="button" onClick={() => goToNextTab(-1)} disabled={currentTabIndex === 0} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-50">Back</button>
|
||
<div className="text-center">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Step {currentTabIndex + 1} / {NEWS_EDITOR_TABS.length}</div>
|
||
<div className="mt-0.5 text-sm font-semibold text-white">{currentTab.label}</div>
|
||
</div>
|
||
<button type="button" onClick={() => goToNextTab(1)} disabled={currentTabIndex >= NEWS_EDITOR_TABS.length - 1} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-50">Next</button>
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
|
||
<JsonImportDialog
|
||
open={jsonImportOpen}
|
||
value={jsonImportValue}
|
||
error={jsonImportError}
|
||
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
|
||
onChange={(nextValue) => {
|
||
setJsonImportValue(nextValue)
|
||
if (jsonImportError) {
|
||
setJsonImportError('')
|
||
}
|
||
}}
|
||
onClose={() => {
|
||
setJsonImportOpen(false)
|
||
setJsonImportError('')
|
||
}}
|
||
onApply={applyJsonImport}
|
||
/>
|
||
</StudioLayout>
|
||
)
|
||
} |