Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -584,7 +584,7 @@ function stripHtml(value) {
|
||||
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const NEWS_NEW_TAG_LIMIT = 12
|
||||
const NEWS_NEW_TAG_LIMIT = 30
|
||||
|
||||
function slugifyNewsTitle(value) {
|
||||
return String(value || '')
|
||||
@@ -627,7 +627,6 @@ function buildSubmitPayload(data) {
|
||||
meta_title: String(data.meta_title || ''),
|
||||
meta_description: String(data.meta_description || ''),
|
||||
meta_keywords: String(data.meta_keywords || ''),
|
||||
canonical_url: String(data.canonical_url || '').trim(),
|
||||
og_title: String(data.og_title || ''),
|
||||
og_description: String(data.og_description || ''),
|
||||
og_image: String(data.og_image || '').trim(),
|
||||
@@ -678,7 +677,6 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}
|
||||
meta_title: String(getDraftValue(oldInput, 'meta_title', article.meta_title || '')),
|
||||
meta_description: String(getDraftValue(oldInput, 'meta_description', article.meta_description || '')),
|
||||
meta_keywords: String(getDraftValue(oldInput, 'meta_keywords', article.meta_keywords || '')),
|
||||
canonical_url: String(getDraftValue(oldInput, 'canonical_url', article.canonical_url || '')),
|
||||
og_title: String(getDraftValue(oldInput, 'og_title', article.og_title || '')),
|
||||
og_description: String(getDraftValue(oldInput, 'og_description', article.og_description || '')),
|
||||
og_image: String(getDraftValue(oldInput, 'og_image', article.og_image || '')),
|
||||
@@ -756,6 +754,25 @@ function normalizeImportedTagList(value) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeImportedDateTime(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const dateTimeMatch = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/)
|
||||
if (dateTimeMatch) {
|
||||
return dateTimeMatch[2] ? `${dateTimeMatch[1]}T${dateTimeMatch[2]}` : dateTimeMatch[1]
|
||||
}
|
||||
|
||||
const parsed = new Date(raw)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const pad = (input) => String(input).padStart(2, '0')
|
||||
|
||||
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}T${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`
|
||||
}
|
||||
|
||||
function parseStructuredNewsImport(rawValue, context) {
|
||||
const parsed = JSON.parse(String(rawValue || '').trim())
|
||||
const categoryOptions = Array.isArray(context.categoryOptions) ? context.categoryOptions : []
|
||||
@@ -782,11 +799,13 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
applyString('excerpt')
|
||||
applyString('content')
|
||||
applyString('cover_image')
|
||||
applyString('published_at')
|
||||
if (parsed.published_at != null) {
|
||||
next.published_at = normalizeImportedDateTime(parsed.published_at)
|
||||
applied.push('published_at')
|
||||
}
|
||||
applyString('meta_title')
|
||||
applyString('meta_description')
|
||||
applyString('meta_keywords')
|
||||
applyString('canonical_url')
|
||||
applyString('og_title')
|
||||
applyString('og_description')
|
||||
applyString('og_image')
|
||||
@@ -868,16 +887,190 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
let newsMarkdownTurndown = null
|
||||
let newsMarkdownTurndownPromise = null
|
||||
|
||||
async function loadNewsMarkdownTurndown() {
|
||||
if (newsMarkdownTurndown) {
|
||||
return newsMarkdownTurndown
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!newsMarkdownTurndownPromise) {
|
||||
newsMarkdownTurndownPromise = import('turndown')
|
||||
.then(({ default: TurndownService }) => new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
emDelimiter: '*',
|
||||
}))
|
||||
.then((service) => {
|
||||
newsMarkdownTurndown = service
|
||||
return service
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
return newsMarkdownTurndownPromise
|
||||
}
|
||||
|
||||
function findNewsOptionById(options, value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return null
|
||||
|
||||
return (Array.isArray(options) ? options : []).find((option) => String(option.id ?? option.value ?? '').trim() === normalized) || null
|
||||
}
|
||||
|
||||
function findNewsTagsByIds(options, ids) {
|
||||
const idSet = new Set((Array.isArray(ids) ? ids : []).map((id) => Number(id)))
|
||||
|
||||
return (Array.isArray(options) ? options : [])
|
||||
.filter((option) => idSet.has(Number(option.id)))
|
||||
.map((option) => ({
|
||||
id: Number(option.id),
|
||||
name: String(option.name || option.label || ''),
|
||||
slug: String(option.slug || ''),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildStructuredPlainTextExport(data) {
|
||||
const lines = []
|
||||
|
||||
if (data.title) lines.push(`Title: ${data.title}`)
|
||||
if (data.excerpt) lines.push(`Excerpt: ${data.excerpt}`)
|
||||
if (data.date) lines.push(`Date: ${data.date}`)
|
||||
if (data.category) lines.push(`Category: ${data.category}`)
|
||||
|
||||
if (data.body) {
|
||||
lines.push('')
|
||||
lines.push('Body:')
|
||||
lines.push(data.body)
|
||||
}
|
||||
|
||||
return lines.join('\n').trim()
|
||||
}
|
||||
|
||||
function convertNewsHtmlToMarkdown(value) {
|
||||
const html = String(value || '').trim()
|
||||
if (!html) return ''
|
||||
|
||||
if (!newsMarkdownTurndown) {
|
||||
return stripHtml(html)
|
||||
}
|
||||
|
||||
return newsMarkdownTurndown.turndown(html).trim()
|
||||
}
|
||||
|
||||
function buildNewsMarkdownExport(data) {
|
||||
const lines = []
|
||||
|
||||
if (data.title) {
|
||||
lines.push(`# ${data.title}`)
|
||||
}
|
||||
|
||||
if (data.excerpt) {
|
||||
lines.push(data.excerpt)
|
||||
}
|
||||
|
||||
if (data.date) {
|
||||
lines.push(`- Date: ${data.date}`)
|
||||
}
|
||||
|
||||
if (data.category) {
|
||||
lines.push(`- Category: ${data.category}`)
|
||||
}
|
||||
|
||||
const bodyMarkdown = convertNewsHtmlToMarkdown(data.body_html)
|
||||
if (bodyMarkdown) {
|
||||
lines.push(bodyMarkdown)
|
||||
}
|
||||
|
||||
return lines.join('\n\n').trim()
|
||||
}
|
||||
|
||||
function buildNewsExportPayloads(data, context = {}) {
|
||||
const normalized = buildSubmitPayload(data || {})
|
||||
const category = findNewsOptionById(context.categoryOptions, normalized.category_id)
|
||||
const existingTags = findNewsTagsByIds(context.tagOptions, normalized.tag_ids)
|
||||
const author = context.author || null
|
||||
|
||||
const full = {
|
||||
title: normalized.title,
|
||||
slug: normalized.slug,
|
||||
excerpt: normalized.excerpt,
|
||||
content: normalized.content,
|
||||
cover_image: normalized.cover_image,
|
||||
type: normalized.type,
|
||||
category_id: normalized.category_id,
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
category_slug: category?.slug ?? '',
|
||||
author_id: normalized.author_id,
|
||||
author_name: author?.title ?? author?.name ?? '',
|
||||
editorial_status: normalized.editorial_status,
|
||||
published_at: normalized.published_at,
|
||||
is_featured: normalized.is_featured,
|
||||
is_pinned: normalized.is_pinned,
|
||||
comments_enabled: normalized.comments_enabled,
|
||||
tags: [
|
||||
...existingTags,
|
||||
...normalized.new_tag_names.map((name) => ({ name, slug: '' })),
|
||||
],
|
||||
tag_names: [
|
||||
...existingTags.map((tag) => tag.name),
|
||||
...normalized.new_tag_names,
|
||||
],
|
||||
tag_ids: normalized.tag_ids,
|
||||
new_tag_names: normalized.new_tag_names,
|
||||
meta_title: normalized.meta_title,
|
||||
meta_description: normalized.meta_description,
|
||||
meta_keywords: normalized.meta_keywords,
|
||||
og_title: normalized.og_title,
|
||||
og_description: normalized.og_description,
|
||||
og_image: normalized.og_image,
|
||||
relations: normalized.relations,
|
||||
}
|
||||
|
||||
const structured = {
|
||||
title: normalized.title,
|
||||
excerpt: normalized.excerpt,
|
||||
date: normalized.published_at,
|
||||
body: stripHtml(normalized.content),
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
}
|
||||
|
||||
const markdown = {
|
||||
title: normalized.title,
|
||||
excerpt: normalized.excerpt,
|
||||
date: normalized.published_at,
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
body_html: normalized.content,
|
||||
}
|
||||
|
||||
return {
|
||||
full: JSON.stringify(full, null, 2),
|
||||
structured: JSON.stringify(structured, null, 2),
|
||||
structuredPlain: buildStructuredPlainTextExport(structured),
|
||||
markdown: buildNewsMarkdownExport(markdown),
|
||||
markdownInput: markdown,
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, 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 importTabs = [
|
||||
{ id: 'input', label: 'Input', description: 'Paste JSON and apply it to the editor.' },
|
||||
{ id: 'structure', label: 'Structure example', description: 'A working example of the expected payload.' },
|
||||
{ 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.' },
|
||||
]
|
||||
|
||||
const structureExample = {
|
||||
@@ -912,7 +1105,6 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newT
|
||||
meta_title: 'Sample News Title - Skinbase Example',
|
||||
meta_description: 'This is a sample news meta description for the structured import example.',
|
||||
meta_keywords: 'sample news, structured import, editorial example',
|
||||
canonical_url: 'https://skinbase.org/news/sample-news-title',
|
||||
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',
|
||||
@@ -939,7 +1131,6 @@ Recommended fields:
|
||||
- is_featured: boolean
|
||||
- is_pinned: boolean
|
||||
- meta_title, meta_description, meta_keywords
|
||||
- canonical_url
|
||||
- og_title, og_description, og_image
|
||||
- tags: array of strings or objects with name/title/label/slug
|
||||
- tag_names: array of strings
|
||||
@@ -965,7 +1156,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, canonical_url, og_title, og_description, and og_image when available.
|
||||
- Fill meta_title, meta_description, og_title, og_description, and og_image when available.
|
||||
- Make comments_enabled true unless the source clearly says otherwise.
|
||||
|
||||
Input article text:
|
||||
@@ -1008,6 +1199,12 @@ Source article:
|
||||
return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200'}`
|
||||
}
|
||||
|
||||
const activeExportText = exportMode === 'structured'
|
||||
? String(exportPayloads?.structured || '')
|
||||
: exportMode === 'markdown'
|
||||
? markdownExportText
|
||||
: String(exportPayloads?.full || '')
|
||||
|
||||
const copyText = async (text, label) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(text))
|
||||
@@ -1032,6 +1229,30 @@ Source article:
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownExportText(String(exportPayloads?.markdown || ''))
|
||||
}, [exportPayloads])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeImportTab !== 'export' || exportMode !== 'markdown') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
loadNewsMarkdownTurndown().then(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setMarkdownExportText(buildNewsMarkdownExport(exportPayloads?.markdownInput || {}))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeImportTab, exportMode, exportPayloads, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -1053,12 +1274,12 @@ Source article:
|
||||
>
|
||||
<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">Structured import</p>
|
||||
<h3 id="news-json-import-title" className="mt-2 text-lg font-semibold text-white">Paste article JSON</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, or bulk handoff from another editorial system. Matching fields are applied directly to the editor.</p>
|
||||
<h3 id="news-json-import-title" className="mt-2 text-lg font-semibold text-white">Import or export article JSON</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, bulk handoff from another editorial system, or copying the current article into reusable JSON.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06] px-4 py-4">
|
||||
<div className="grid gap-2 md:grid-cols-4">
|
||||
<div className="grid gap-2 md:grid-cols-5">
|
||||
{importTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -1096,7 +1317,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`, `canonical_url`, `og_title`, `og_description`, `og_image`</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`, `og_image`</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1186,6 +1407,56 @@ Source article:
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeImportTab === 'export' ? (
|
||||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMode('full')}
|
||||
className={tabButtonClass(exportMode === 'full')}
|
||||
>
|
||||
<div className="text-sm font-semibold">Full news JSON</div>
|
||||
<div className="mt-1 text-xs leading-5 text-current/70">Exports the current article with metadata, tags, and relations.</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMode('structured')}
|
||||
className={tabButtonClass(exportMode === 'structured')}
|
||||
>
|
||||
<div className="text-sm font-semibold">Structured JSON</div>
|
||||
<div className="mt-1 text-xs leading-5 text-current/70">Exports only title, excerpt, date, body, and category.</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMode('markdown')}
|
||||
className={tabButtonClass(exportMode === 'markdown')}
|
||||
>
|
||||
<div className="text-sm font-semibold">Markdown</div>
|
||||
<div className="mt-1 text-xs leading-5 text-current/70">Exports the current article as Markdown with heading, summary, and body.</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={activeExportText}
|
||||
rows={18}
|
||||
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none"
|
||||
/>
|
||||
</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">Export options</div>
|
||||
<div className="mt-3 space-y-3 leading-6 text-slate-400">
|
||||
<p><strong className="text-slate-200">Full news JSON</strong> includes the current editable article state: slug, status, tags, metadata, and relations.</p>
|
||||
<p><strong className="text-slate-200">Structured JSON</strong> keeps the reduced handoff shape: title, excerpt, date, body, and category.</p>
|
||||
<p><strong className="text-slate-200">Markdown</strong> converts the current article body into Markdown and includes the title plus summary fields for external reuse.</p>
|
||||
<p>The export uses the live editor state, so unsaved changes are included immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{copyFeedback ? (
|
||||
@@ -1200,13 +1471,34 @@ Source article:
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply?.()}
|
||||
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"
|
||||
>
|
||||
Apply JSON
|
||||
</button>
|
||||
{activeImportTab === 'export' ? (
|
||||
<>
|
||||
{exportMode === 'structured' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(String(exportPayloads?.structuredPlain || ''), 'Structured plain text export')}
|
||||
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"
|
||||
>
|
||||
Copy plain text
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(activeExportText, exportMode === 'structured' ? 'Structured export' : exportMode === 'markdown' ? 'Markdown export' : 'Full news export')}
|
||||
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"
|
||||
>
|
||||
Copy export
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply?.()}
|
||||
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"
|
||||
>
|
||||
Apply JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -1336,7 +1628,7 @@ export default function StudioNewsEditor() {
|
||||
const tabErrorCounts = useMemo(() => ({
|
||||
content: ['title', 'slug', 'excerpt', 'content', 'cover_image'].filter((key) => Boolean(form.errors[key])).length,
|
||||
publishing: ['type', 'category_id', 'author_id', 'editorial_status', 'published_at', 'comments_enabled'].filter((key) => Boolean(form.errors[key])).length,
|
||||
discoverability: ['tag_ids', 'new_tag_names', 'meta_title', 'meta_description', 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image'].filter((key) => Boolean(form.errors[key])).length,
|
||||
discoverability: ['tag_ids', 'new_tag_names', 'meta_title', 'meta_description', 'meta_keywords', 'og_title', 'og_description', 'og_image'].filter((key) => Boolean(form.errors[key])).length,
|
||||
connections: ['relations'].filter((key) => Boolean(form.errors[key])).length,
|
||||
}), [form.errors])
|
||||
const overviewItems = useMemo(() => ([
|
||||
@@ -1348,6 +1640,11 @@ export default function StudioNewsEditor() {
|
||||
{ label: 'Author', done: Boolean(form.data.author_id) },
|
||||
]), [bodyWordCount, form.data.author_id, form.data.category_id, form.data.cover_image, form.data.excerpt, form.data.title])
|
||||
const completedCount = overviewItems.filter((item) => item.done).length
|
||||
const jsonExportPayloads = useMemo(() => buildNewsExportPayloads(form.data, {
|
||||
categoryOptions: props.categoryOptions,
|
||||
tagOptions: props.tagOptions,
|
||||
author: selectedAuthor,
|
||||
}), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor])
|
||||
|
||||
useEffect(() => {
|
||||
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
|
||||
@@ -1781,10 +2078,6 @@ export default function StudioNewsEditor() {
|
||||
<span className="text-xs leading-5 text-slate-500">Maximum 255 characters. The field now stops at the limit so it fails less often on save.</span>
|
||||
<FieldError message={form.errors.meta_keywords} />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Canonical URL</span>
|
||||
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG title</span>
|
||||
@@ -1893,6 +2186,7 @@ export default function StudioNewsEditor() {
|
||||
open={jsonImportOpen}
|
||||
value={jsonImportValue}
|
||||
error={jsonImportError}
|
||||
exportPayloads={jsonExportPayloads}
|
||||
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
|
||||
onChange={(nextValue) => {
|
||||
setJsonImportValue(nextValue)
|
||||
|
||||
Reference in New Issue
Block a user