News: normalize category select values; fix Studio news editor category persistence; add CSV→SQL generator and news_dates.sql
This commit is contained in:
@@ -352,7 +352,7 @@ final class StudioNewsController extends Controller
|
|||||||
'content' => ['required', 'string', 'max:500000'],
|
'content' => ['required', 'string', 'max:500000'],
|
||||||
'cover_image' => ['nullable', 'string', 'max:2048'],
|
'cover_image' => ['nullable', 'string', 'max:2048'],
|
||||||
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
|
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
|
||||||
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'],
|
'category_id' => ['required', 'integer', 'exists:news_categories,id'],
|
||||||
'author_id' => ['nullable', 'integer', 'exists:users,id'],
|
'author_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))],
|
'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))],
|
||||||
'published_at' => ['nullable', 'date'],
|
'published_at' => ['nullable', 'date'],
|
||||||
|
|||||||
1410
news_dates.sql
Normal file
1410
news_dates.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { router, useForm, usePage } from '@inertiajs/react'
|
import { router, useForm, usePage } from '@inertiajs/react'
|
||||||
import StudioLayout from '../../Layouts/StudioLayout'
|
import StudioLayout from '../../Layouts/StudioLayout'
|
||||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||||
@@ -92,6 +93,48 @@ function normalizeNewTagName(value) {
|
|||||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80)
|
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' }) {
|
function SectionCard({ eyebrow, title, description, actions, children, tone = 'default' }) {
|
||||||
const toneClass = tone === 'feature'
|
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-[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)]'
|
||||||
@@ -112,138 +155,373 @@ function SectionCard({ eyebrow, title, description, actions, children, tone = 'd
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagPicker({ options, selectedIds, newTagNames, tagQuery, onTagQueryChange, onToggle, onCreateTag, onRemoveNewTag, manageUrl }) {
|
function NewsTagInputDialog({ open, preview, onClose, onConfirm }) {
|
||||||
const selectedTags = useMemo(() => options.filter((tag) => selectedIds.includes(tag.id)), [options, selectedIds])
|
const backdropRef = useRef(null)
|
||||||
const normalizedQuery = useMemo(() => normalizeNewTagName(tagQuery), [tagQuery])
|
|
||||||
const matchingExistingTag = useMemo(() => {
|
|
||||||
if (!normalizedQuery) return null
|
|
||||||
|
|
||||||
const lowerQuery = normalizedQuery.toLowerCase()
|
useEffect(() => {
|
||||||
|
if (!open) return undefined
|
||||||
|
|
||||||
return options.find((tag) => String(tag.name || '').toLowerCase() === lowerQuery) || null
|
const handleKeyDown = (event) => {
|
||||||
}, [options, normalizedQuery])
|
if (event.key === 'Escape') {
|
||||||
const queryMatchesPending = useMemo(() => {
|
onClose?.()
|
||||||
if (!normalizedQuery) return false
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lowerQuery = normalizedQuery.toLowerCase()
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose, open])
|
||||||
|
|
||||||
return newTagNames.some((tagName) => tagName.toLowerCase() === lowerQuery)
|
if (!open || !preview) return null
|
||||||
}, [newTagNames, normalizedQuery])
|
|
||||||
|
|
||||||
const availableTags = useMemo(() => {
|
return createPortal(
|
||||||
const query = String(tagQuery || '').trim().toLowerCase()
|
<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>
|
||||||
|
|
||||||
return options
|
<div className="space-y-4 px-6 py-5">
|
||||||
.filter((tag) => !selectedIds.includes(tag.id))
|
{(preview.skippedDuplicates.length > 0 || preview.skippedInvalid.length > 0) ? (
|
||||||
.filter((tag) => (query === '' ? true : String(tag.name || '').toLowerCase().includes(query)))
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
|
||||||
.slice(0, 12)
|
{preview.skippedDuplicates.length > 0 ? `${preview.skippedDuplicates.length} duplicate tag${preview.skippedDuplicates.length === 1 ? '' : 's'} ignored.` : ''}
|
||||||
}, [options, selectedIds, tagQuery])
|
{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}
|
||||||
|
|
||||||
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>
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected tags</div>
|
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
|
||||||
<div className="mt-1 text-sm text-slate-400">Attach article topics without forcing the editor to scan a wall of checkboxes.</div>
|
<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>
|
||||||
{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>
|
||||||
|
|
||||||
<div className="mt-4 flex min-h-[3.5rem] flex-wrap gap-2">
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||||
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onClose?.()}
|
||||||
onCreateTag(normalizedQuery)
|
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"
|
||||||
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>
|
Cancel
|
||||||
<span className="rounded-full border border-emerald-200/30 px-2 py-0.5 text-xs text-emerald-100">{normalizedQuery}</span>
|
</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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange, onNewTagNamesChange, manageUrl }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [pastePreview, setPastePreview] = useState(null)
|
||||||
|
|
||||||
|
const selectedIdSet = useMemo(() => new Set((selectedIds || []).map((id) => Number(id))), [selectedIds])
|
||||||
|
const existingTags = useMemo(() => (Array.isArray(options) ? options : []).filter((tag) => selectedIdSet.has(Number(tag.id))), [options, selectedIdSet])
|
||||||
|
const pendingTags = useMemo(() => (Array.isArray(newTagNames) ? newTagNames : []).map((name) => ({ key: normalizeNewsTagKey(name), name: normalizeNewTagName(name) })).filter((tag) => tag.key), [newTagNames])
|
||||||
|
const combinedNames = useMemo(() => [...existingTags.map((tag) => String(tag.name || '')), ...pendingTags.map((tag) => tag.name)], [existingTags, pendingTags])
|
||||||
|
const combinedKeys = useMemo(() => combinedNames.map((name) => normalizeNewsTagKey(name)).filter(Boolean), [combinedNames])
|
||||||
|
const normalizedQuery = useMemo(() => normalizeNewTagName(query), [query])
|
||||||
|
|
||||||
|
const syncNames = useCallback((names) => {
|
||||||
|
const seen = new Set()
|
||||||
|
const nextIds = []
|
||||||
|
const nextNewNames = []
|
||||||
|
|
||||||
|
names.forEach((rawName) => {
|
||||||
|
const nextName = normalizeNewTagName(rawName)
|
||||||
|
const key = normalizeNewsTagKey(nextName)
|
||||||
|
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
const existing = (Array.isArray(options) ? options : []).find((tag) => normalizeNewsTagKey(tag.name) === key)
|
||||||
|
if (existing) {
|
||||||
|
nextIds.push(Number(existing.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextNewNames.push(nextName)
|
||||||
|
})
|
||||||
|
|
||||||
|
onSelectedIdsChange(nextIds)
|
||||||
|
onNewTagNamesChange(nextNewNames)
|
||||||
|
}, [onNewTagNamesChange, onSelectedIdsChange, options])
|
||||||
|
|
||||||
|
const exactMatch = useMemo(() => {
|
||||||
|
if (!normalizedQuery) return null
|
||||||
|
return (Array.isArray(options) ? options : []).find((tag) => normalizeNewsTagKey(tag.name) === normalizeNewsTagKey(normalizedQuery)) || null
|
||||||
|
}, [normalizedQuery, options])
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const source = Array.isArray(options) ? options : []
|
||||||
|
const lowerQuery = normalizeNewsTagKey(query)
|
||||||
|
|
||||||
|
return source
|
||||||
|
.filter((tag) => !selectedIdSet.has(Number(tag.id)))
|
||||||
|
.filter((tag) => !pendingTags.some((pendingTag) => pendingTag.key === normalizeNewsTagKey(tag.name)))
|
||||||
|
.filter((tag) => (lowerQuery === '' ? true : normalizeNewsTagKey(tag.name).includes(lowerQuery)))
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [options, pendingTags, query, selectedIdSet])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(suggestions.length > 0 ? 0 : -1)
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
|
const addCandidate = useCallback((rawName) => {
|
||||||
|
const nextName = normalizeNewTagName(rawName)
|
||||||
|
if (!nextName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeNewsTagKey(nextName)
|
||||||
|
if (combinedKeys.includes(key)) {
|
||||||
|
setError('Duplicate tag')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
syncNames([...combinedNames, nextName])
|
||||||
|
setQuery('')
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [combinedKeys, combinedNames, syncNames])
|
||||||
|
|
||||||
|
const removeExisting = useCallback((tagId) => {
|
||||||
|
onSelectedIdsChange((selectedIds || []).filter((id) => Number(id) !== Number(tagId)))
|
||||||
|
}, [onSelectedIdsChange, selectedIds])
|
||||||
|
|
||||||
|
const removePending = useCallback((tagName) => {
|
||||||
|
onNewTagNamesChange((newTagNames || []).filter((name) => normalizeNewsTagKey(name) !== normalizeNewsTagKey(tagName)))
|
||||||
|
}, [newTagNames, onNewTagNamesChange])
|
||||||
|
|
||||||
|
const handlePaste = useCallback((event) => {
|
||||||
|
const raw = event.clipboardData?.getData('text')
|
||||||
|
if (!raw) return
|
||||||
|
|
||||||
|
const parts = parseNewsTagList(raw)
|
||||||
|
if (parts.length <= 1) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const preview = analyzePastedNewsTags(raw, combinedKeys)
|
||||||
|
if (preview.tagsToAdd.length === 0) {
|
||||||
|
setError('No new tags found in pasted text')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setPastePreview(preview)
|
||||||
|
}, [combinedKeys])
|
||||||
|
|
||||||
|
const handleConfirmPaste = useCallback(() => {
|
||||||
|
if (!pastePreview) return
|
||||||
|
syncNames([...combinedNames, ...pastePreview.tagsToAdd.map((tag) => tag.name)])
|
||||||
|
setPastePreview(null)
|
||||||
|
setQuery('')
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [combinedNames, pastePreview, syncNames])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' && query.length === 0 && combinedNames.length > 0) {
|
||||||
|
const lastPending = pendingTags[pendingTags.length - 1]
|
||||||
|
if (lastPending) {
|
||||||
|
removePending(lastPending.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastExisting = existingTags[existingTags.length - 1]
|
||||||
|
if (lastExisting) {
|
||||||
|
removeExisting(lastExisting.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (suggestions.length === 0) return
|
||||||
|
setIsOpen(true)
|
||||||
|
setHighlightedIndex((current) => Math.min(current + 1, suggestions.length - 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (suggestions.length === 0) return
|
||||||
|
setIsOpen(true)
|
||||||
|
setHighlightedIndex((current) => Math.max(current - 1, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['Enter', ',', 'Tab'].includes(event.key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Tab' && !isOpen && normalizedQuery === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if ((event.key === 'Enter' || event.key === 'Tab') && isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
|
||||||
|
addCandidate(suggestions[highlightedIndex].name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
addCandidate(exactMatch.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate(normalizedQuery)
|
||||||
|
}, [addCandidate, combinedNames.length, exactMatch, existingTags, highlightedIndex, isOpen, normalizedQuery, pendingTags, query.length, removeExisting, removePending, suggestions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected tags</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-400">Attach article topics with the same chip-based flow used on artwork tags.</div>
|
||||||
|
</div>
|
||||||
|
{manageUrl ? <a href={manageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">Manage tags</a> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 min-h-[3rem] rounded-xl border border-white/10 bg-white/5 p-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{existingTags.length === 0 && pendingTags.length === 0 ? <span className="px-2 py-1 text-xs text-white/50">No tags selected</span> : null}
|
||||||
|
{existingTags.map((tag) => (
|
||||||
|
<span key={tag.id} className="group inline-flex max-w-full items-center gap-2 rounded-full border border-white/20 bg-slate-900/80 px-3 py-1.5 text-xs text-slate-100">
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
<button type="button" onClick={() => removeExisting(tag.id)} className="rounded-full p-0.5 text-slate-300 transition hover:bg-white/10 hover:text-white" aria-label={`Remove tag ${tag.name}`}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{pendingTags.map((tag) => (
|
||||||
|
<span key={tag.key} className="group inline-flex max-w-full items-center gap-2 rounded-full border border-emerald-300/25 bg-emerald-400/10 px-3 py-1.5 text-xs text-emerald-50">
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
<span className="rounded-full border border-emerald-200/30 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100/80">New</span>
|
||||||
|
<button type="button" onClick={() => removePending(tag.name)} className="rounded-full p-0.5 text-emerald-100/75 transition hover:bg-white/10 hover:text-white" aria-label={`Remove new tag ${tag.name}`}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Find tags</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value)
|
||||||
|
setError('')
|
||||||
|
setIsOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder="Search existing tags or type a new one"
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-white/45 focus:border-sky-400 focus:outline-none"
|
||||||
|
aria-label="Search or add news tags"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="mt-3 overflow-hidden rounded-xl bg-slate-950/98 shadow-xl shadow-black/50 ring-1 ring-white/10">
|
||||||
|
<ul role="listbox" className="max-h-56 overflow-auto py-1">
|
||||||
|
{suggestions.length > 0 ? suggestions.map((tag, index) => {
|
||||||
|
const active = index === highlightedIndex
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={tag.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
className={`flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm transition ${active ? 'bg-sky-500/20 text-white' : 'text-white/85 hover:bg-white/10'}`}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
addCandidate(tag.name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<li className="px-3 py-2 text-xs text-white/50">No suggestions</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
|
||||||
|
{error || 'Type and press Enter, comma, or Tab to add. Paste a comma-separated list to review multiple tags.'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/50">{existingTags.length + pendingTags.length} selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewsTagInputDialog
|
||||||
|
open={Boolean(pastePreview)}
|
||||||
|
preview={pastePreview}
|
||||||
|
onClose={() => setPastePreview(null)}
|
||||||
|
onConfirm={handleConfirmPaste}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +569,7 @@ function stripHtml(value) {
|
|||||||
function selectOptionsFromValues(options, emptyLabel = null) {
|
function selectOptionsFromValues(options, emptyLabel = null) {
|
||||||
const base = Array.isArray(options)
|
const base = Array.isArray(options)
|
||||||
? options.map((option) => ({
|
? options.map((option) => ({
|
||||||
value: option.value ?? option.id,
|
value: String(option.value ?? option.id),
|
||||||
label: option.label ?? option.name,
|
label: option.label ?? option.name,
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
@@ -333,6 +611,14 @@ function buildSubmitPayload(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRequiredCategory(categoryId) {
|
||||||
|
if (categoryId === '' || categoryId == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(categoryId) > 0
|
||||||
|
}
|
||||||
|
|
||||||
function buildInitialFormData(article, defaultAuthor, typeOptions) {
|
function buildInitialFormData(article, defaultAuthor, typeOptions) {
|
||||||
return {
|
return {
|
||||||
title: article.title || '',
|
title: article.title || '',
|
||||||
@@ -341,7 +627,7 @@ function buildInitialFormData(article, defaultAuthor, typeOptions) {
|
|||||||
content: article.content || '',
|
content: article.content || '',
|
||||||
cover_image: article.cover_image || '',
|
cover_image: article.cover_image || '',
|
||||||
type: article.type || (typeOptions?.[0]?.value || 'announcement'),
|
type: article.type || (typeOptions?.[0]?.value || 'announcement'),
|
||||||
category_id: article.category_id || '',
|
category_id: article.category_id ? String(article.category_id) : '',
|
||||||
author_id: article.author_id || defaultAuthor?.id || '',
|
author_id: article.author_id || defaultAuthor?.id || '',
|
||||||
editorial_status: article.editorial_status || 'draft',
|
editorial_status: article.editorial_status || 'draft',
|
||||||
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
|
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
|
||||||
@@ -377,7 +663,6 @@ export default function StudioNewsEditor() {
|
|||||||
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||||||
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
|
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
|
||||||
const [relationResults, setRelationResults] = useState({})
|
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 [coverPreviewUrl, setCoverPreviewUrl] = useState(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
||||||
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
||||||
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
|
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
|
||||||
@@ -395,7 +680,6 @@ export default function StudioNewsEditor() {
|
|||||||
setSelectedAuthor(article.author || props.defaultAuthor || null)
|
setSelectedAuthor(article.author || props.defaultAuthor || null)
|
||||||
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||||||
setRelationResults({})
|
setRelationResults({})
|
||||||
setTagQuery('')
|
|
||||||
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
||||||
setStagedCoverPath('')
|
setStagedCoverPath('')
|
||||||
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
|
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
|
||||||
@@ -407,7 +691,7 @@ export default function StudioNewsEditor() {
|
|||||||
}, [form.data.content])
|
}, [form.data.content])
|
||||||
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
|
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
|
||||||
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
|
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
|
||||||
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'No category'), [props.categoryOptions])
|
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'Select category'), [props.categoryOptions])
|
||||||
|
|
||||||
const searchEntities = async (type, query) => {
|
const searchEntities = async (type, query) => {
|
||||||
const url = new URL(props.entitySearchUrl, window.location.origin)
|
const url = new URL(props.entitySearchUrl, window.location.origin)
|
||||||
@@ -467,48 +751,6 @@ export default function StudioNewsEditor() {
|
|||||||
setRelationResults((current) => ({ ...current, [index]: items }))
|
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) => {
|
const handleManualCoverChange = (nextValue) => {
|
||||||
form.setData('cover_image', nextValue)
|
form.setData('cover_image', nextValue)
|
||||||
|
|
||||||
@@ -532,6 +774,12 @@ export default function StudioNewsEditor() {
|
|||||||
const submit = (event) => {
|
const submit = (event) => {
|
||||||
event.preventDefault()
|
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 = {
|
const options = {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
preserveState: false,
|
preserveState: false,
|
||||||
@@ -674,7 +922,12 @@ export default function StudioNewsEditor() {
|
|||||||
<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} />
|
<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>
|
||||||
<div className="grid gap-2 text-sm text-slate-300">
|
<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} />
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -726,15 +979,12 @@ export default function StudioNewsEditor() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
|
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
|
||||||
<TagPicker
|
<NewsTagInput
|
||||||
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
|
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
|
||||||
selectedIds={form.data.tag_ids}
|
selectedIds={form.data.tag_ids}
|
||||||
newTagNames={form.data.new_tag_names}
|
newTagNames={form.data.new_tag_names}
|
||||||
tagQuery={tagQuery}
|
onSelectedIdsChange={(nextIds) => form.setData('tag_ids', nextIds)}
|
||||||
onTagQueryChange={setTagQuery}
|
onNewTagNamesChange={(nextNames) => form.setData('new_tag_names', nextNames)}
|
||||||
onToggle={toggleTag}
|
|
||||||
onCreateTag={addNewTagName}
|
|
||||||
onRemoveNewTag={removeNewTagName}
|
|
||||||
manageUrl={props.tagsUrl}
|
manageUrl={props.tagsUrl}
|
||||||
/>
|
/>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
|||||||
30
scripts/csv_to_sql.py
Normal file
30
scripts/csv_to_sql.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CSV_PATH = ROOT / 'news_dates.csv'
|
||||||
|
OUT_PATH = ROOT / 'news_dates.sql'
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with CSV_PATH.open(newline='', encoding='utf-8') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
with OUT_PATH.open('w', encoding='utf-8', newline='\n') as out:
|
||||||
|
out.write('-- Generated from news_dates.csv\n')
|
||||||
|
out.write('-- Sets published_at, created_at, updated_at to CSV date\n')
|
||||||
|
out.write('BEGIN;\n')
|
||||||
|
for row in rows:
|
||||||
|
news_id = (row.get('news_id') or '').strip().strip('"')
|
||||||
|
date = (row.get('update_date') or '').strip().strip('"')
|
||||||
|
if not news_id or not date:
|
||||||
|
continue
|
||||||
|
date = date.replace("'", "''")
|
||||||
|
out.write(
|
||||||
|
f"UPDATE news_articles SET published_at = '{date}', created_at = '{date}', updated_at = '{date}' WHERE news_id = {news_id};\n"
|
||||||
|
)
|
||||||
|
out.write('COMMIT;\n')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user