Replace native selects with NovaSelect

This commit is contained in:
2026-05-01 07:45:37 +02:00
parent 67be537c86
commit 35011001ba
55 changed files with 3136 additions and 1662 deletions

View File

@@ -1,6 +1,60 @@
import React, { useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor'
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
import NovaSelect from '../../components/ui/NovaSelect'
import { Checkbox } from '../../components/ui'
// ── Minimal toast system ────────────────────────────────────────────────────
let _toastId = 0
function useToast() {
const [toasts, setToasts] = useState([])
const push = useCallback((message, type = 'info') => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 5000)
}, [])
const dismiss = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), [])
return { toasts, push, dismiss }
}
function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null
return (
<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) {
@@ -20,7 +74,7 @@ function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
<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 text-xs text-slate-400 line-clamp-2">{item.description}</div> : null}
{item.description ? <div className="mt-1 line-clamp-2 text-xs text-slate-400">{item.description}</div> : null}
</div>
</button>
))}
@@ -28,16 +82,178 @@ function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
)
}
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 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 TagPicker({ options, selectedIds, newTagNames, tagQuery, onTagQueryChange, onToggle, onCreateTag, onRemoveNewTag, manageUrl }) {
const selectedTags = useMemo(() => options.filter((tag) => selectedIds.includes(tag.id)), [options, selectedIds])
const normalizedQuery = useMemo(() => normalizeNewTagName(tagQuery), [tagQuery])
const matchingExistingTag = useMemo(() => {
if (!normalizedQuery) return null
const lowerQuery = normalizedQuery.toLowerCase()
return options.find((tag) => String(tag.name || '').toLowerCase() === lowerQuery) || null
}, [options, normalizedQuery])
const queryMatchesPending = useMemo(() => {
if (!normalizedQuery) return false
const lowerQuery = normalizedQuery.toLowerCase()
return newTagNames.some((tagName) => tagName.toLowerCase() === lowerQuery)
}, [newTagNames, normalizedQuery])
const availableTags = useMemo(() => {
const query = String(tagQuery || '').trim().toLowerCase()
return options
.filter((tag) => !selectedIds.includes(tag.id))
.filter((tag) => (query === '' ? true : String(tag.name || '').toLowerCase().includes(query)))
.slice(0, 12)
}, [options, selectedIds, tagQuery])
return (
<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 without forcing the editor to scan a wall of checkboxes.</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 flex min-h-[3.5rem] flex-wrap gap-2">
{selectedTags.length > 0 ? selectedTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => onToggle(tag.id)}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-sm text-sky-50 transition hover:bg-sky-400/15"
>
<span>{tag.name}</span>
<span className="text-xs text-sky-100/70">Remove</span>
</button>
)) : null}
{newTagNames.map((tagName) => (
<button
key={tagName}
type="button"
onClick={() => onRemoveNewTag(tagName)}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm text-emerald-50 transition hover:bg-emerald-400/15"
>
<span>{tagName}</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>
))}
{selectedTags.length === 0 && newTagNames.length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">No tags selected yet.</div> : null}
</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={tagQuery}
onChange={(event) => onTagQueryChange(event.target.value)}
onKeyDown={(event) => {
if (!['Enter', ','].includes(event.key)) {
return
}
event.preventDefault()
const nextQuery = normalizeNewTagName(event.currentTarget.value)
if (!nextQuery) {
return
}
if (matchingExistingTag && !selectedIds.includes(matchingExistingTag.id)) {
onToggle(matchingExistingTag.id)
onTagQueryChange('')
return
}
if (!queryMatchesPending) {
onCreateTag(nextQuery)
}
onTagQueryChange('')
}}
placeholder="Search existing tags or type a new one"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
{normalizedQuery && !matchingExistingTag && !queryMatchesPending ? (
<button
type="button"
onClick={() => {
onCreateTag(normalizedQuery)
onTagQueryChange('')
}}
className="mt-3 inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-50"
>
<span>Create tag</span>
<span className="rounded-full border border-emerald-200/30 px-2 py-0.5 text-xs text-emerald-100">{normalizedQuery}</span>
</button>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{availableTags.length > 0 ? availableTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => onToggle(tag.id)}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white transition hover:border-white/20 hover:bg-white/[0.08]"
>
+ {tag.name}
</button>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">No additional tags match the current search.</div>}
</div>
<div className="mt-3 text-xs leading-5 text-slate-500">
Press Enter or comma to queue a new tag. Pending tags are written into the news tag list when the article is saved.
</div>
</div>
</div>
)
}
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">
<label className="grid gap-2 text-sm text-slate-300">
<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>
<select value={relation.entity_type} onChange={(event) => onChange(index, { ...relation, entity_type: event.target.value, entity_id: '', preview: null, query: '' })} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<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">
@@ -67,28 +283,70 @@ function RelationCard({ relation, index, onChange, onRemove, onSearch, results,
)
}
export default function StudioNewsEditor() {
const { props } = usePage()
const article = props.article || {}
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({})
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
const form = useForm({
function selectOptionsFromValues(options, emptyLabel = null) {
const base = Array.isArray(options)
? options.map((option) => ({
value: option.value ?? option.id,
label: option.label ?? option.name,
}))
: []
return emptyLabel ? [{ value: '', label: emptyLabel }, ...base] : base
}
function buildSubmitPayload(data) {
return {
title: String(data.title || '').trim(),
slug: String(data.slug || '').trim(),
excerpt: String(data.excerpt || ''),
content: String(data.content || ''),
cover_image: String(data.cover_image || '').trim(),
type: String(data.type || ''),
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
author_id: data.author_id === '' || data.author_id == null ? null : Number(data.author_id),
editorial_status: String(data.editorial_status || ''),
published_at: data.published_at ? String(data.published_at) : null,
is_featured: Boolean(data.is_featured),
is_pinned: Boolean(data.is_pinned),
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
meta_title: String(data.meta_title || ''),
meta_description: String(data.meta_description || ''),
meta_keywords: String(data.meta_keywords || ''),
canonical_url: String(data.canonical_url || '').trim(),
og_title: String(data.og_title || ''),
og_description: String(data.og_description || ''),
og_image: String(data.og_image || '').trim(),
relations: Array.isArray(data.relations)
? data.relations.map((relation) => ({
entity_type: String(relation.entity_type || '').trim(),
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
context_label: String(relation.context_label || '').trim(),
}))
: [],
}
}
function buildInitialFormData(article, defaultAuthor, typeOptions) {
return {
title: article.title || '',
slug: article.slug || '',
excerpt: article.excerpt || '',
content: article.content || '',
cover_image: article.cover_image || '',
type: article.type || (props.typeOptions?.[0]?.value || 'announcement'),
type: article.type || (typeOptions?.[0]?.value || 'announcement'),
category_id: article.category_id || '',
author_id: article.author_id || props.defaultAuthor?.id || '',
author_id: article.author_id || defaultAuthor?.id || '',
editorial_status: article.editorial_status || 'draft',
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
new_tag_names: [],
meta_title: article.meta_title || '',
meta_description: article.meta_description || '',
meta_keywords: article.meta_keywords || '',
@@ -103,18 +361,50 @@ export default function StudioNewsEditor() {
preview: relation.preview || null,
query: relation.preview?.title || '',
})) : [],
})
}
}
const submit = (event) => {
event.preventDefault()
export default function StudioNewsEditor() {
const { props } = usePage()
const { toasts, push: pushToast, dismiss: dismissToast } = useToast()
const article = props.article || {}
const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.typeOptions), [article, props.defaultAuthor, props.typeOptions])
const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData])
const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
const [relationResults, setRelationResults] = useState({})
const [tagQuery, setTagQuery] = useState('')
const [coverPreviewUrl, setCoverPreviewUrl] = useState(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
const [stagedCoverPath, setStagedCoverPath] = useState('')
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
if (props.updateUrl) {
form.patch(props.updateUrl)
const form = useForm(initialFormData)
useEffect(() => {
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
return
}
form.post(props.storeUrl)
}
lastSyncedArticleKeyRef.current = articleSyncKey
form.setData(initialFormData)
form.clearErrors()
setSelectedAuthor(article.author || props.defaultAuthor || null)
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
setRelationResults({})
setTagQuery('')
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
setStagedCoverPath('')
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
const excerptLength = String(form.data.excerpt || '').trim().length
const bodyWordCount = useMemo(() => {
const plain = stripHtml(form.data.content)
return plain === '' ? 0 : plain.split(/\s+/).length
}, [form.data.content])
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'No category'), [props.categoryOptions])
const searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
@@ -174,95 +464,225 @@ export default function StudioNewsEditor() {
setRelationResults((current) => ({ ...current, [index]: items }))
}
const toggleTag = (tagId) => {
const numericId = Number(tagId)
const next = form.data.tag_ids.includes(numericId)
? form.data.tag_ids.filter((currentId) => currentId !== numericId)
: [...form.data.tag_ids, numericId]
form.setData('tag_ids', next)
if (!form.data.tag_ids.includes(numericId)) {
const matchedTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => tag.id === numericId)
if (matchedTag) {
const lowerName = String(matchedTag.name || '').toLowerCase()
form.setData('new_tag_names', form.data.new_tag_names.filter((tagName) => tagName.toLowerCase() !== lowerName))
}
}
}
const addNewTagName = (rawValue) => {
const nextTagName = normalizeNewTagName(rawValue)
if (!nextTagName) return
const lowerName = nextTagName.toLowerCase()
const matchingExistingTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => String(tag.name || '').toLowerCase() === lowerName)
if (matchingExistingTag) {
if (!form.data.tag_ids.includes(matchingExistingTag.id)) {
form.setData('tag_ids', [...form.data.tag_ids, matchingExistingTag.id])
}
return
}
if (form.data.new_tag_names.some((tagName) => tagName.toLowerCase() === lowerName)) {
return
}
form.setData('new_tag_names', [...form.data.new_tag_names, nextTagName])
}
const removeNewTagName = (tagName) => {
form.setData('new_tag_names', form.data.new_tag_names.filter((currentTagName) => currentTagName !== tagName))
}
const handleManualCoverChange = (nextValue) => {
form.setData('cover_image', nextValue)
if (stagedCoverPath && nextValue !== stagedCoverPath) {
setStagedCoverPath('')
}
if (!nextValue) {
setCoverPreviewUrl('')
return
}
if (String(nextValue).startsWith('http://') || String(nextValue).startsWith('https://')) {
setCoverPreviewUrl(nextValue)
return
}
setCoverPreviewUrl(`${props.coverCdnBaseUrl}/${String(nextValue).replace(/^\/+/, '')}`)
}
const submit = (event) => {
event.preventDefault()
const options = {
preserveScroll: true,
preserveState: false,
onSuccess: () => {
setStagedCoverPath('')
pushToast('Article saved successfully.', 'success')
},
onError: (errors) => {
const errorMessages = Object.values(errors)
const first = errorMessages[0] || 'The article could not be saved.'
const extra = errorMessages.length > 1 ? ` (${errorMessages.length - 1} more field${errorMessages.length > 2 ? 's' : ''})` : ''
pushToast(first + extra, 'error')
},
}
form.transform((data) => buildSubmitPayload(data))
if (props.updateUrl) {
form.patch(props.updateUrl, options)
return
}
form.post(props.storeUrl, options)
}
const deleteArticle = () => {
if (!props.destroyUrl) return
if (!window.confirm('Move this article to trash? This uses soft delete so the record stays in the database.')) return
router.delete(props.destroyUrl, {
preserveScroll: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<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">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<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>
<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">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="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover image URL or path</span>
<input value={form.data.cover_image} onChange={(event) => form.setData('cover_image', event.target.value)} placeholder="https://... or storage path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<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>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</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">Body</span>
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={18} placeholder="Write in Markdown. Existing legacy HTML is still supported on render." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" />
</label>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">Related entities</h2>
<p className="mt-1 text-sm text-slate-400">Attach Groups, artworks, collections, releases, projects, challenges, events, and profiles.</p>
</div>
<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>
<div className="mt-4 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-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No related entities attached yet.</div>}
<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>
</div>
</section>
</SectionCard>
<section className="space-y-6">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Publishing</h2>
<div className="mt-5 grid gap-4">
<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">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select value={form.data.category_id || ''} onChange={(event) => form.setData('category_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No category</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</label>
<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 || ''))} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
</div>
</div>
<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">Workflow status</span>
<select value={form.data.editorial_status} onChange={(event) => form.setData('editorial_status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<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>
<label 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>
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<FieldError message={form.errors.published_at} />
</label>
</div>
@@ -283,51 +703,39 @@ export default function StudioNewsEditor() {
setAuthorQuery(item.title)
form.setData('author_id', item.id)
}} emptyLabel="Search to choose an author profile." />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</span>
<div className="grid gap-2 sm:grid-cols-2">
{(Array.isArray(props.tagOptions) ? props.tagOptions : []).map((tag) => {
const checked = form.data.tag_ids.includes(tag.id)
return (
<label key={tag.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
form.setData('tag_ids', [...form.data.tag_ids, tag.id])
return
}
form.setData('tag_ids', form.data.tag_ids.filter((tagId) => tagId !== tag.id))
}}
/>
<span>{tag.name}</span>
</label>
)
})}
</div>
<FieldError message={form.errors.author_id} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature on newsroom surfaces
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} />
Pin to the top of the newsroom
</label>
<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>
</div>
</SectionCard>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">SEO &amp; social</h2>
<div className="mt-5 grid gap-4">
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
<TagPicker
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
selectedIds={form.data.tag_ids}
newTagNames={form.data.new_tag_names}
tagQuery={tagQuery}
onTagQueryChange={setTagQuery}
onToggle={toggleTag}
onCreateTag={addNewTagName}
onRemoveNewTag={removeNewTagName}
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" />
@@ -359,18 +767,20 @@ export default function StudioNewsEditor() {
<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>
</div>
</SectionCard>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<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">
<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">Save article</button>
{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.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} 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">Archive article</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>
</div>
</section>
</SectionCard>
</div>
</form>
</StudioLayout>
)