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

@@ -7,7 +7,16 @@ function MetricCell({ value, suffix = '' }) {
return <span className="font-semibold text-white">{value}{suffix}</span>
}
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, rows = [] }) {
function StatCard({ label, value, suffix = '' }) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
<p className="mt-3 text-3xl font-bold text-white">{value}{suffix}</p>
</div>
)
}
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, summary = null, rows = [] }) {
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin · ${title}`} />
@@ -20,6 +29,19 @@ export default function AcademyAnalyticsContent({ nav = [], range, title, subtit
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
</div>
{summary ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Views" value={Number(summary.views || 0).toLocaleString()} />
<StatCard label="Unique Visitors" value={Number(summary.uniqueVisitors || 0).toLocaleString()} />
<StatCard label="Engaged Views" value={Number(summary.engagedViews || 0).toLocaleString()} />
<StatCard label="Engagement Rate" value={Number(summary.engagementRate || 0).toLocaleString()} suffix="%" />
<StatCard label="Avg Engaged Seconds" value={Number(summary.avgEngagedSeconds || 0).toLocaleString()} suffix="s" />
<StatCard label="Scroll 50%" value={Number(summary.scroll50 || 0).toLocaleString()} />
<StatCard label="Scroll 100%" value={Number(summary.scroll100 || 0).toLocaleString()} />
<StatCard label="Deep Scroll Rate" value={Number(summary.deepScrollRate || 0).toLocaleString()} suffix="%" />
</div>
) : null}
<div className="overflow-hidden rounded-[28px] border border-white/[0.08] bg-white/[0.03]">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">

View File

@@ -12,6 +12,99 @@ function StatCard({ label, value }) {
)
}
function formatDelta(delta) {
if (delta === null || delta === undefined) {
return 'new'
}
if (Number(delta) === 0) {
return '0%'
}
return `${Number(delta) > 0 ? '+' : ''}${Number(delta).toLocaleString()}%`
}
function PromptLibraryTrend({ trend }) {
const current = trend?.current || {}
const deltas = trend?.deltas || {}
const items = [
{ label: 'Views', value: Number(current.views || 0).toLocaleString(), delta: deltas.views },
{ label: 'Unique Visitors', value: Number(current.uniqueVisitors || 0).toLocaleString(), delta: deltas.uniqueVisitors },
{ label: 'Engaged Views', value: Number(current.engagedViews || 0).toLocaleString(), delta: deltas.engagedViews },
{ label: 'Engagement Rate', value: `${Number(current.engagementRate || 0).toLocaleString()}%`, delta: deltas.engagementRate },
]
return (
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Prompt Library Trend</p>
<p className="mt-2 text-sm text-slate-300">{trend?.range?.current?.from} to {trend?.range?.current?.to} compared with {trend?.range?.previous?.from} to {trend?.range?.previous?.to}</p>
</div>
<div className="rounded-full border border-white/[0.08] bg-black/20 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
Popularity {Number(current.popularityScore || 0).toLocaleString()}
</div>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/[0.08] bg-black/20 p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{item.label}</p>
<p className="mt-3 text-2xl font-bold text-white">{item.value}</p>
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-sky-200">{formatDelta(item.delta)} vs previous</p>
</div>
))}
</div>
</div>
)
}
function PopularPromptPeriodUsage({ usage }) {
const periods = usage?.periods || []
return (
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Popular Prompt Period Usage</p>
<p className="mt-2 text-sm text-slate-300">Which ranking window people actually open on the public popular-prompts page.</p>
</div>
<div className="rounded-full border border-white/[0.08] bg-black/20 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
{Number(usage?.totalViews || 0).toLocaleString()} views · {Number(usage?.totalVisitors || 0).toLocaleString()} visitors
</div>
</div>
<div className="mt-5 space-y-3">
{periods.length ? periods.map((period) => (
<div key={period.period} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">{period.label}</p>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{period.period}</p>
</div>
<div className="grid gap-2 text-right sm:grid-cols-3 sm:gap-6">
<div>
<p className="text-sm font-semibold text-sky-100">{Number(period.views || 0).toLocaleString()}</p>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Views</p>
</div>
<div>
<p className="text-sm font-semibold text-sky-100">{Number(period.uniqueVisitors || 0).toLocaleString()}</p>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Visitors</p>
</div>
<div>
<p className="text-sm font-semibold text-sky-100">{Number(period.share || 0).toLocaleString()}%</p>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Share</p>
</div>
</div>
</div>
</div>
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No popular prompt period events have been tracked in this range yet.</p>}
</div>
</div>
)
}
function ContentList({ title, items = [] }) {
return (
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
@@ -36,7 +129,7 @@ function ContentList({ title, items = [] }) {
)
}
export default function AcademyAnalyticsOverview({ nav = [], range, stats, topContent = [], topWeek = [] }) {
export default function AcademyAnalyticsOverview({ nav = [], range, stats, promptLibraryTrend = null, popularPromptPeriodUsage = null, topContent = [], topWeek = [] }) {
return (
<AdminLayout title="Academy Analytics" subtitle="Daily rollup overview for Academy traffic, engagement, and subscription intent.">
<Head title="Admin · Academy Analytics" />
@@ -63,6 +156,9 @@ export default function AcademyAnalyticsOverview({ nav = [], range, stats, topCo
<StatCard label="Upgrade Clicks" value={stats.upgradeClicks} />
</div>
{promptLibraryTrend ? <PromptLibraryTrend trend={promptLibraryTrend} /> : null}
{popularPromptPeriodUsage ? <PopularPromptPeriodUsage usage={popularPromptPeriodUsage} /> : null}
<div className="grid gap-6 xl:grid-cols-2">
<ContentList title="Top Content In Range" items={topContent} />
<ContentList title="Top Content This Week" items={topWeek} />

View File

@@ -51,6 +51,38 @@ function serializeStructuredJson(value) {
}
}
function copyTextToClipboard(text) {
const source = String(text || '')
if (!source) return Promise.reject(new Error('Nothing to copy'))
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return navigator.clipboard.writeText(source)
}
if (typeof document === 'undefined' || !document.body) {
return Promise.reject(new Error('Clipboard unavailable'))
}
const textarea = document.createElement('textarea')
textarea.value = source
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '-1000px'
textarea.style.left = '-1000px'
document.body.appendChild(textarea)
textarea.select()
try {
if (document.execCommand('copy')) {
return Promise.resolve()
}
} finally {
document.body.removeChild(textarea)
}
return Promise.reject(new Error('Clipboard unavailable'))
}
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}

View File

@@ -50,6 +50,7 @@ export default function Dashboard({ stats }) {
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' },
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' },
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
].map((item) => (