@@ -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 (
-