Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -544,33 +544,53 @@ function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange,
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
const isSourceRelation = String(relation.entity_type || '').trim().toLowerCase() === 'source'
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} />
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', external_url: '', 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>
{isSourceRelation ? (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Source URL</span>
<input
value={relation.external_url || ''}
onChange={(event) => onChange(index, { ...relation, external_url: event.target.value, query: event.target.value, entity_id: '' })}
placeholder="https://example.com/original-article"
className="min-w-0 flex-1 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">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 ? (
{!isSourceRelation && 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>
{isSourceRelation ? (
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-100/90">
Source relations store a direct external URL instead of an internal Nova entity ID.
</div>
) : (
<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>
@@ -584,6 +604,22 @@ function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
function unwrapMarkdownLinkUrl(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const markdownMatch = raw.match(/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i)
if (markdownMatch) {
return String(markdownMatch[1] || '').trim()
}
return raw
}
function isSourceRelationType(entityType) {
return String(entityType || '').trim().toLowerCase() === 'source'
}
const NEWS_NEW_TAG_LIMIT = 30
function slugifyNewsTitle(value) {
@@ -633,7 +669,8 @@ function buildSubmitPayload(data) {
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),
entity_id: isSourceRelationType(relation.entity_type) || relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
context_label: String(relation.context_label || '').trim(),
}))
: [],
@@ -682,10 +719,13 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}
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 || '',
entity_id: isSourceRelationType(relation.entity_type) ? '' : (relation.entity_id || ''),
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
context_label: relation.context_label || '',
preview: relation.preview || null,
query: relation.preview?.title || '',
query: isSourceRelationType(relation.entity_type)
? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '')
: (relation.preview?.title || relation.query || ''),
})) : [],
}
}
@@ -808,7 +848,6 @@ function parseStructuredNewsImport(rawValue, context) {
applyString('meta_keywords')
applyString('og_title')
applyString('og_description')
applyString('og_image')
if (parsed.type != null) {
const requested = String(parsed.type).trim().toLowerCase()
@@ -869,13 +908,21 @@ function parseStructuredNewsImport(rawValue, context) {
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(),
}))
.map((relation) => {
const entityType = String(relation?.entity_type || relation?.type || 'group').trim()
const externalUrl = isSourceRelationType(entityType)
? unwrapMarkdownLinkUrl(relation?.external_url || relation?.url || relation?.entity_id || relation?.query || relation?.title || '')
: ''
return {
entity_type: entityType,
entity_id: isSourceRelationType(entityType) || relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
external_url: externalUrl,
context_label: String(relation?.context_label || relation?.label || '').trim(),
preview: null,
query: isSourceRelationType(entityType) ? externalUrl : String(relation?.query || relation?.title || '').trim(),
}
})
.filter((relation) => relation.entity_type)
applied.push('relations')
}
@@ -991,6 +1038,231 @@ function buildNewsMarkdownExport(data) {
return lines.join('\n\n').trim()
}
// ── News image prompt builder ────────────────────────────────────────────────
const NEWS_PROMPT_TYPE_MOODS = {
announcement: 'Futuristic',
release: 'Software Release',
editorial: 'Editorial',
opinion: 'Editorial',
tutorial: 'Clean Instructional',
platform_update: 'Modern Tech',
event: 'Futuristic',
challenge: 'Futuristic',
interview: 'Editorial',
spotlight: 'Editorial',
archive: 'Retro Tech',
industry_news: 'Modern Tech',
review: 'Modern Tech',
roundup: 'Modern Tech',
}
const NEWS_PROMPT_TYPE_ADDONS = {
release: 'Use a glossy software-release poster style with product UI panels, feature highlights, and a polished launch atmosphere.',
announcement: 'Use a clean announcement-poster style with a strong headline, clear hero image, and supporting modules that communicate the main update quickly.',
editorial: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
opinion: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
event: 'Use a conference or event-poster style with keynote energy, glowing screens, stage-like lighting, and a premium event atmosphere.',
tutorial: 'Use a clear structured instructional poster style with organized UI panels, workflow callouts, and helpful visual hierarchy.',
platform_update: 'Use a modern platform-update style with system UI visuals, feature modules, and a polished ecosystem presentation.',
archive: 'Use a retro-tech editorial style inspired by early 2000s computer magazines, with classic hardware, vintage UI influences, and modern polished lighting.',
}
const NEWS_PROMPT_KEYWORD_PATTERNS = [
{
keywords: ['apple', 'wwdc', 'ios', 'macos', 'iphone', 'ipad', 'swift'],
addon: 'Use a sleek developer-conference atmosphere with modern device screens, app ecosystem visuals, and a premium keynote mood.',
},
{
keywords: ['google', 'gemini', 'google i/o', 'android', 'pixel', 'tensorflow'],
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, image and video creation tools, search elements, and generative media visuals.',
},
{
keywords: ['intel', 'amd', 'processor', 'cpu', 'gpu', 'nvidia', 'radeon', 'chip'],
addon: 'Use a retro computing hardware feature style with processor chips, technical callouts, old-school PC references, and magazine-cover energy.',
},
{
keywords: ['skin', 'theme', 'desktop', 'customize', 'customization', 'rainmeter', 'widget'],
addon: 'Use a desktop customization promo style with theme previews, icon panels, widget windows, and a glossy desktop software aesthetic.',
},
{
keywords: ['ai', 'artificial intelligence', 'llm', 'chatgpt', 'openai', 'midjourney', 'stable diffusion', 'generative'],
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, generative media outputs, neural network visuals, and advanced AI tool interfaces.',
},
]
function resolveNewsPromptHeadline(data) {
return String(data.title || data.meta_title || '').trim() || 'Skinbase News'
}
function resolveNewsPromptSubheadline(data) {
const raw = String(data.excerpt || data.meta_description || '').replace(/<[^>]*>/g, '').trim()
if (raw) {
const words = raw.split(/\s+/)
return words.slice(0, 18).join(' ') + (words.length > 18 ? '…' : '')
}
const plain = String(data.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain) {
const sentence = plain.split(/[.!?]/)[0].trim()
if (sentence.length > 10) {
const words = sentence.split(/\s+/)
return words.slice(0, 18).join(' ')
}
}
return 'Latest technology and creative industry update'
}
function resolveNewsPromptTopic(data) {
const parts = []
const cat = String(data.category || '').trim()
if (cat) parts.push(cat)
const tagList = (Array.isArray(data.tag_names) ? data.tag_names : []).slice(0, 5).filter(Boolean)
if (tagList.length) parts.push(tagList.join(', '))
if (!parts.length) {
const words = String(data.title || '').split(/\s+/).filter((w) => w.length > 3).slice(0, 4)
if (words.length) parts.push(words.join(' '))
}
return parts.join(' · ') || 'Technology and digital culture news'
}
function resolveNewsPromptType(data) {
const raw = String(data.type || '').trim()
if (!raw) return 'News'
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function resolveNewsPromptHeroSubject(data) {
const title = String(data.title || '').toLowerCase()
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
const combined = `${title} ${tags}`
const type = String(data.type || '').toLowerCase()
if (/apple|wwdc|ios|macos/.test(combined)) return 'sleek developer conference scene with modern Apple devices, app ecosystem screens, and a keynote stage atmosphere'
if (/google|gemini|google i\/o/.test(combined)) return 'futuristic creative AI workspace with Google AI tools, image and video generation screens, and colorful generative panels'
if (/intel|amd|cpu|processor|gpu|nvidia|radeon/.test(combined)) return 'high-detail processor chip and PC hardware setup with technical callouts and magazine-style editorial framing'
if (/\bai\b|artificial intelligence|llm|chatgpt|openai|midjourney|stable diffusion/.test(combined)) return 'futuristic AI creative studio with generative media outputs, neural network interfaces, and glowing AI panels'
if (/skin|theme|desktop|customiz|rainmeter|widget/.test(combined)) return 'polished desktop customization interface with theme previews, icon panels, and widget windows on a dark desktop'
if (/game|gaming/.test(combined)) return 'immersive gaming setup or game UI with dynamic lighting, modern peripherals, and a premium game atmosphere'
if (/microsoft|windows/.test(combined)) return 'modern Windows interface with system UI panels, taskbar, settings, and a polished OS environment'
if (type === 'tutorial') return 'organized instructional workflow panel with step-by-step UI callouts and visual hierarchy'
if (type === 'event') return 'keynote conference stage with large screens, glowing hall, and event atmosphere'
if (type === 'archive') return 'retro computing hardware from the early 2000s with classic monitors and vintage PC aesthetic'
return 'professional editorial tech workspace with software screens, feature panels, and a polished digital newsroom atmosphere'
}
function resolveNewsPromptSupportingModules(data) {
const type = String(data.type || '').toLowerCase()
const title = String(data.title || '').toLowerCase()
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
const combined = `${title} ${tags}`
if (type === 'release' || /release|launch|version/.test(combined)) return 'version badge, feature highlight cards, changelog strip, UI screenshots, product icon panels'
if (type === 'tutorial') return 'step-by-step panels, UI callouts, workflow arrows, numbered feature blocks'
if (type === 'event') return 'schedule panels, speaker cards, keynote countdown, location badge, feature preview cards'
if (type === 'archive') return 'retro spec badges, vintage hardware panels, timeline strip, era-appropriate UI screenshots'
if (/\bai\b|artificial intelligence|generative/.test(combined)) return 'AI feature cards, generative output previews, glowing interface panels, model capability badges'
if (/hardware|chip|cpu|gpu/.test(combined)) return 'performance charts, spec comparison cards, hardware close-ups, benchmark badges'
return 'feature cards, interface panels, product highlights, mini screenshots, icon blocks'
}
function resolveNewsPromptMood(data) {
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
return NEWS_PROMPT_TYPE_MOODS[type] || 'Modern Tech'
}
function resolveNewsPromptTypeAddon(data) {
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
return NEWS_PROMPT_TYPE_ADDONS[type] || ''
}
function resolveNewsPromptKeywordAddon(data) {
const title = String(data.title || '').toLowerCase()
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
const category = String(data.category || '').toLowerCase()
const combined = `${title} ${tags} ${category}`
const addons = []
for (const pattern of NEWS_PROMPT_KEYWORD_PATTERNS) {
if (pattern.keywords.some((kw) => combined.includes(kw))) {
addons.push(pattern.addon)
}
}
return [...new Set(addons)].join('\n')
}
function buildNewsImagePrompt(data) {
const headline = resolveNewsPromptHeadline(data)
const subheadline = resolveNewsPromptSubheadline(data)
const topic = resolveNewsPromptTopic(data)
const newsType = resolveNewsPromptType(data)
const heroSubject = resolveNewsPromptHeroSubject(data)
const supportingModules = resolveNewsPromptSupportingModules(data)
const mood = resolveNewsPromptMood(data)
const typeAddon = resolveNewsPromptTypeAddon(data)
const keywordAddon = resolveNewsPromptKeywordAddon(data)
const lines = [
'Create a premium Skinbase news cover image in 16:9 aspect ratio.',
'',
'Design it as a professional editorial tech poster for a digital culture, software, hardware, AI, creative tools, desktop customization, or retro computing news article.',
'',
'ARTICLE DETAILS:',
`Headline: "${headline}"`,
`Subheadline: "${subheadline}"`,
`Topic: ${topic}`,
`News type: ${newsType}`,
`Hero subject: ${heroSubject}`,
`Supporting modules: ${supportingModules}`,
`Mood: ${mood}`,
'',
'LAYOUT:',
'Use a structured 16:9 news hero composition with:',
'- Large bold headline in the upper-left or top-center',
'- Smaller subtitle directly below the headline',
'- One strong central hero visual',
'- Supporting side panels, feature cards, icons, UI windows, diagrams, or mini screenshots',
'- A bottom strip with 3 to 6 small highlight blocks or visual details',
'- Clean spacing and a strong visual hierarchy',
'',
'VISUAL STYLE:',
'Use a dark premium background with blue, cyan, violet, neon, or topic-matching accent colors. Add glossy highlights, subtle glow, cinematic depth, crisp lighting, and a polished high-tech editorial look.',
'',
'The image should feel like a professional magazine cover, software release poster, tech conference banner, or retro computing feature graphic. It should be visually rich, but still clean, readable, and organized.',
'',
'TEXT STYLE:',
'Use bold clean sans-serif typography. Keep all visible text short and readable. Avoid long paragraphs inside the image. Use only short labels, feature names, or headline-style phrases.',
'',
'CONTENT DIRECTION:',
'Represent the topic clearly through the central visual. Use relevant objects such as:',
'- software windows',
'- futuristic workstations',
'- creative AI panels',
'- computer chips',
'- retro hardware',
'- desktop customization elements',
'- conference screens',
'- app interface mockups',
'- glowing diagrams',
'- feature cards',
'- product-style panels',
'',
'QUALITY RULES:',
'Make it sharp, premium, polished, high detail, thumbnail-friendly, and suitable as a Skinbase news article cover image.',
'',
'Avoid clutter, random filler objects, unreadable microtext, messy typography, distorted UI, weak composition, watermarks, fake signatures, low-quality stock-photo style, and irrelevant logos.',
]
if (typeAddon) {
lines.push('', typeAddon)
}
if (keywordAddon) {
lines.push('', keywordAddon)
}
return lines.join('\n')
}
// ─────────────────────────────────────────────────────────────────────────────
function buildNewsExportPayloads(data, context = {}) {
const normalized = buildSubmitPayload(data || {})
const category = findNewsOptionById(context.categoryOptions, normalized.category_id)
@@ -1058,12 +1330,14 @@ function buildNewsExportPayloads(data, context = {}) {
}
}
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, articleData = {}, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
const backdropRef = useRef(null)
const [activeImportTab, setActiveImportTab] = useState('input')
const [copyFeedback, setCopyFeedback] = useState('')
const [exportMode, setExportMode] = useState('full')
const [markdownExportText, setMarkdownExportText] = useState(String(exportPayloads?.markdown || ''))
const [promptText, setPromptText] = useState('')
const [promptIsManual, setPromptIsManual] = useState(false)
const importTabs = [
{ id: 'input', label: 'Input', description: 'Paste JSON and apply it to the editor.' },
@@ -1071,6 +1345,7 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, expo
{ id: 'docs', label: 'Documentation', description: 'Field notes and mapping rules.' },
{ id: 'prompts', label: 'AI prompts', description: 'Prompt examples for generating structured news.' },
{ id: 'export', label: 'Export', description: 'Copy the current article out as JSON, text, or Markdown.' },
{ id: 'image_prompt', label: 'Image Prompt', description: 'Auto-generate a cover image prompt from article data.' },
]
const structureExample = {
@@ -1107,7 +1382,6 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, expo
meta_keywords: 'sample news, structured import, editorial example',
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.
@@ -1131,7 +1405,7 @@ Recommended fields:
- is_featured: boolean
- is_pinned: boolean
- meta_title, meta_description, meta_keywords
- og_title, og_description, og_image
- og_title, og_description
- 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
@@ -1156,7 +1430,7 @@ Transform the following article into a news payload for the editor.
- 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, og_title, og_description, and og_image when available.
- Fill meta_title, meta_description, og_title, and og_description when available.
- Make comments_enabled true unless the source clearly says otherwise.
Input article text:
@@ -1253,6 +1527,24 @@ Source article:
}
}, [activeImportTab, exportMode, exportPayloads, open])
// Auto-generate image prompt when the tab opens, or when article data changes
// (unless the editor has manually modified the prompt text).
useEffect(() => {
if (!open || activeImportTab !== 'image_prompt') return
if (promptIsManual) return
setPromptText(buildNewsImagePrompt(articleData))
}, [open, activeImportTab, articleData, promptIsManual])
const handleRegeneratePrompt = useCallback(() => {
setPromptIsManual(false)
setPromptText(buildNewsImagePrompt(articleData))
}, [articleData])
const handleResetPrompt = useCallback(() => {
setPromptIsManual(false)
setPromptText(buildNewsImagePrompt(articleData))
}, [articleData])
if (!open) return null
return createPortal(
@@ -1279,7 +1571,7 @@ Source article:
</div>
<div className="border-b border-white/[0.06] px-4 py-4">
<div className="grid gap-2 md:grid-cols-5">
<div className="grid gap-2 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
{importTabs.map((tab) => (
<button
key={tab.id}
@@ -1317,7 +1609,7 @@ Source article:
<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`, `og_title`, `og_description`, `og_image`</p>
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`</p>
</div>
</div>
</div>
@@ -1457,6 +1749,72 @@ Source article:
</div>
</div>
) : null}
{activeImportTab === 'image_prompt' ? (
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
Generated cover image prompt
{promptIsManual ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-2 py-0.5 text-[10px] text-amber-100">Manually edited</span> : null}
</span>
<button
type="button"
onClick={handleRegeneratePrompt}
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]"
>
Regenerate
</button>
{promptIsManual ? (
<button
type="button"
onClick={handleResetPrompt}
className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/20"
>
Reset to auto
</button>
) : null}
<button
type="button"
onClick={() => copyText(promptText, 'Image prompt')}
className="rounded-full border border-sky-300/25 bg-sky-400/90 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:brightness-110"
>
Copy prompt
</button>
</div>
<textarea
value={promptText}
onChange={(event) => {
setPromptText(event.target.value)
setPromptIsManual(true)
}}
rows={22}
spellCheck={false}
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/30"
placeholder="Opening the tab will generate a prompt automatically…"
/>
</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">How it works</div>
<div className="mt-3 space-y-3 leading-6 text-slate-400">
<p>The prompt is built automatically from the current article fields: title, excerpt, type, category, and tags.</p>
<p>You can edit the prompt freely. It will be marked as <span className="rounded border border-amber-300/20 bg-amber-400/10 px-1 text-amber-100">Manually edited</span> once you change it.</p>
<p>Click <strong className="text-slate-200">Regenerate</strong> or <strong className="text-slate-200">Reset to auto</strong> to rebuild from the current article state.</p>
<p>Copy the prompt and paste it into an AI image generator such as Midjourney, DALL-E, Stable Diffusion, or Flux.</p>
</div>
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Auto-filled from</div>
<ul className="mt-2 space-y-1 text-xs leading-6 text-slate-400">
<li><span className="text-slate-300">Headline</span> title, meta_title</li>
<li><span className="text-slate-300">Subheadline</span> excerpt, meta_description, content</li>
<li><span className="text-slate-300">Topic</span> category, tags</li>
<li><span className="text-slate-300">Type</span> article type</li>
<li><span className="text-slate-300">Hero / Mood</span> inferred from title, tags, type</li>
<li><span className="text-slate-300">Addons</span> type-based and keyword-based style blocks</li>
</ul>
</div>
</div>
) : null}
</div>
{copyFeedback ? (
@@ -1490,7 +1848,7 @@ Source article:
Copy export
</button>
</>
) : (
) : activeImportTab === 'image_prompt' ? null : (
<button
type="button"
onClick={() => onApply?.()}
@@ -1529,6 +1887,7 @@ export default function StudioNewsEditor() {
const normalizedInitialPayload = useMemo(() => JSON.stringify(buildSubmitPayload(initialFormData)), [initialFormData])
const normalizedCurrentPayload = useMemo(() => JSON.stringify(buildSubmitPayload(form.data)), [form.data])
const hasUnsavedChanges = normalizedCurrentPayload !== normalizedInitialPayload
const frontendArticleUrl = String(article.canonical_url || '').trim()
useEffect(() => {
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
@@ -1646,6 +2005,25 @@ export default function StudioNewsEditor() {
author: selectedAuthor,
}), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor])
const imagePromptArticleData = useMemo(() => {
const category = findNewsOptionById(props.categoryOptions, form.data.category_id)
const existingTags = findNewsTagsByIds(props.tagOptions, form.data.tag_ids)
return {
title: form.data.title,
excerpt: form.data.excerpt,
content: form.data.content,
type: form.data.type,
category: category?.name ?? category?.label ?? '',
category_slug: category?.slug ?? '',
tag_names: [
...existingTags.map((t) => t.name),
...(Array.isArray(form.data.new_tag_names) ? form.data.new_tag_names : []),
],
meta_title: form.data.meta_title,
meta_description: form.data.meta_description,
}
}, [form.data, props.categoryOptions, props.tagOptions])
useEffect(() => {
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
if (firstErrorTab) {
@@ -1684,6 +2062,7 @@ export default function StudioNewsEditor() {
{
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
entity_id: '',
external_url: '',
context_label: '',
preview: null,
query: '',
@@ -1825,8 +2204,8 @@ export default function StudioNewsEditor() {
<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="grid gap-5 border-b border-white/10 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start">
<div className="min-w-0">
<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>
@@ -1836,13 +2215,29 @@ export default function StudioNewsEditor() {
<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>
{coverPreviewUrl ? (
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.35)]">
<div className="relative aspect-[16/9] bg-black/30">
<img src={coverPreviewUrl} alt={String(form.data.title || '').trim() || 'News cover preview'} className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-[#020611d9] via-[#02061144] to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Header cover preview</div>
<div className="mt-1 line-clamp-2 text-sm font-semibold text-white">{String(form.data.title || '').trim() || 'Cover image preview'}</div>
</div>
</div>
</div>
) : null}
</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">
{frontendArticleUrl ? <a href={frontendArticleUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2.5 text-sm font-semibold text-cyan-100 transition hover:bg-cyan-400/15">Frontend link</a> : null}
{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>
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="inline-flex items-center gap-2 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">
{hasUnsavedChanges && !form.processing ? <span className="h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_10px_rgba(251,113,133,0.9)] animate-pulse" aria-hidden="true" /> : null}
<span>{form.processing ? 'Saving…' : 'Save article'}</span>
</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>
@@ -1965,6 +2360,11 @@ export default function StudioNewsEditor() {
autofocus={false}
advancedNews
searchEntities={searchEntities}
mediaSupport={{
uploadUrl: props.coverUploadUrl,
deleteUrl: props.coverDeleteUrl,
slot: 'body',
}}
/>
<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.
@@ -2187,6 +2587,7 @@ export default function StudioNewsEditor() {
value={jsonImportValue}
error={jsonImportError}
exportPayloads={jsonExportPayloads}
articleData={imagePromptArticleData}
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
onChange={(nextValue) => {
setJsonImportValue(nextValue)