Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user