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

@@ -389,6 +389,8 @@ export default function StudioContentBrowser({
quickCreate = [],
hideModuleFilter = false,
hideBucketFilter = false,
defaultSort = 'updated_desc',
sortStorageKey = null,
emptyTitle = 'Nothing here yet',
emptyBody = 'Try adjusting filters or create something new.',
}) {
@@ -400,7 +402,7 @@ export default function StudioContentBrowser({
const [pendingFilters, setPendingFilters] = useState({
q: '',
bucket: 'all',
sort: 'updated_desc',
sort: defaultSort,
content_type: 'all',
category: 'all',
tag: '',
@@ -466,12 +468,41 @@ export default function StudioContentBrowser({
setPendingFilters({
q: filters.q || '',
bucket: filters.bucket || 'all',
sort: filters.sort || 'updated_desc',
sort: filters.sort || defaultSort,
content_type: filters.content_type || 'all',
category: filters.category || 'all',
tag: filters.tag || '',
})
}, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag])
}, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag, defaultSort])
useEffect(() => {
if (!sortStorageKey) {
return
}
const params = new URLSearchParams(window.location.search)
if (params.has('sort')) {
return
}
const storedSort = window.localStorage.getItem(sortStorageKey)
const sortOptions = new Set((listing?.sort_options || []).map((option) => option.value))
const activeSort = filters.sort || defaultSort
if (!storedSort || !sortOptions.has(storedSort) || storedSort === activeSort) {
return
}
router.get(window.location.pathname, {
...filters,
sort: storedSort,
page: 1,
}, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}, [sortStorageKey, listing?.sort_options, filters, defaultSort])
const updateQuery = (patch) => {
const next = {
@@ -491,6 +522,10 @@ export default function StudioContentBrowser({
},
})
if (sortStorageKey && typeof next.sort === 'string' && next.sort !== '') {
window.localStorage.setItem(sortStorageKey, next.sort)
}
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
@@ -882,7 +917,7 @@ export default function StudioContentBrowser({
id="studio-filter-sort"
options={selectOptions(listing?.sort_options || [])}
value={pendingFilters.sort}
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? defaultSort)}
placeholder="Recently updated"
searchable={false}
/>

View File

@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
</a>
<div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/enhance" className="block px-4 py-2 text-sm hover:bg-white/5">Enhance</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>

View File

@@ -51,6 +51,15 @@ function ChartIcon() {
)
}
function EnhanceIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l1.9 4.6L18.5 9l-4.6 1.4L12 15l-1.9-4.6L5.5 9l4.6-1.4L12 3Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M18 15l.95 2.05L21 18l-2.05.95L18 21l-.95-2.05L15 18l2.05-.95L18 15Z" />
</svg>
)
}
/* ShareIcon removed — now provided by ArtworkShareButton */
function FlagIcon() {
@@ -215,6 +224,9 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const analyticsUrl = artwork?.management?.analytics_url
|| (artwork?.viewer?.is_owner ? `/studio/artworks/${artwork.id}/analytics` : null)
const enhanceUrl = artwork?.viewer?.is_owner && artwork?.id
? `/enhance/create?artwork=${encodeURIComponent(artwork.id)}`
: null
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
@@ -374,6 +386,16 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
</a>
) : null}
{enhanceUrl ? (
<a
href={enhanceUrl}
className="inline-flex items-center gap-2 rounded-full border border-violet-300/25 bg-violet-400/12 px-5 py-2.5 text-sm font-medium text-violet-50 transition-all duration-200 hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
>
<EnhanceIcon />
Enhance image
</a>
) : null}
{/* Report pill */}
<button
type="button"
@@ -451,6 +473,18 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
</a>
) : null}
{enhanceUrl ? (
<a
href={enhanceUrl}
aria-label="Enhance artwork image"
title="Enhance image"
className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/25 bg-violet-400/12 px-3.5 py-2 text-xs font-medium text-violet-50 transition-all hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
>
<EnhanceIcon />
Enhance
</a>
) : null}
{/* Report */}
<button
type="button"

View File

@@ -0,0 +1,53 @@
import React from 'react'
export default function BeforeAfterSlider({ beforeUrl, afterUrl, beforeAlt = 'Original image', afterAlt = 'Enhanced image' }) {
const [position, setPosition] = React.useState(50)
if (!beforeUrl || !afterUrl) {
return null
}
return (
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Before / after</p>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">Compare the original with the enhanced result</h3>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">{position}%</span>
</div>
<div className="relative mt-5 overflow-hidden rounded-[24px] border border-white/10 bg-black/40">
<img src={beforeUrl} alt={beforeAlt} className="block w-full object-cover" />
<div className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden border-r border-white/80" style={{ width: `${position}%` }}>
<img src={afterUrl} alt={afterAlt} className="block h-full w-full object-cover" />
</div>
<div className="pointer-events-none absolute inset-y-0 left-0" style={{ left: `calc(${position}% - 1px)` }}>
<div className="flex h-full items-center">
<div className="flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border border-white/80 bg-black/60 text-white shadow-[0_0_30px_rgba(15,23,42,0.5)]">
<i className="fa-solid fa-left-right text-xs" />
</div>
</div>
</div>
<div className="pointer-events-none absolute left-3 top-3 rounded-full border border-white/10 bg-[#08111dd8] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100">Original</div>
<div className="pointer-events-none absolute right-3 top-3 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Enhanced</div>
</div>
<label className="mt-5 block">
<span className="sr-only">Adjust before and after comparison slider</span>
<input
type="range"
min="0"
max="100"
step="1"
value={position}
onChange={(event) => setPosition(Number(event.target.value || 50))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/10 accent-sky-300"
aria-label="Adjust before and after comparison slider"
/>
</label>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
const TONES = {
pending: 'border-white/10 bg-white/[0.05] text-slate-200',
queued: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
processing: 'border-violet-300/20 bg-violet-400/12 text-violet-100',
completed: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
failed: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
cancelled: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
expired: 'border-white/10 bg-white/[0.05] text-slate-300',
unknown: 'border-white/10 bg-white/[0.04] text-slate-300',
}
const LABELS = {
pending: 'Pending',
queued: 'Queued',
processing: 'Processing',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
expired: 'Expired',
}
export default function EnhanceStatusBadge({ status, className = '' }) {
const key = String(status || '').toLowerCase()
const tone = TONES[key] || TONES.unknown
const label = LABELS[key] || 'Unknown'
return (
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone} ${className}`.trim()}>
{label}
</span>
)
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
export default function EnhanceStubWarning({ config, moderation = false, className = '' }) {
if (!config?.showStubWarning) {
return null
}
return (
<div className={`rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50 ${className}`.trim()}>
<div>Skinbase Enhance is currently running in preview mode. The generated result is a workflow placeholder until the real upscaling worker is enabled.</div>
{moderation ? <div className="mt-2 text-xs uppercase tracking-[0.14em] text-amber-100/80">Engine: {config.engine}. This is not a real AI upscale result.</div> : null}
</div>
)
}

View File

@@ -0,0 +1,239 @@
import React, { useCallback, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import EmojiPickerButton from '../comments/EmojiPickerButton'
function ToolbarButton({ title, onClick, children, className = '' }) {
return (
<button
type="button"
title={title}
onMouseDown={(event) => {
event.preventDefault()
onClick?.()
}}
className={[
'inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-xs font-semibold text-white/60 transition',
'hover:bg-white/10 hover:text-white',
className,
].join(' ')}
>
{children}
</button>
)
}
export default function UploadDescriptionEditor({ id, value, onChange, placeholder, error, rows = 8 }) {
const [tab, setTab] = useState('write')
const textareaRef = useRef(null)
const focusTextarea = useCallback(() => {
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}, [])
const wrapSelection = useCallback((before, after, placeholderText = 'text') => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const replacement = `${before}${selected || placeholderText}${after}`
const next = current.slice(0, start) + replacement + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
if (selected) {
textarea.selectionStart = start + replacement.length
textarea.selectionEnd = start + replacement.length
} else {
textarea.selectionStart = start + before.length
textarea.selectionEnd = start + before.length + placeholderText.length
}
})
}, [onChange, value])
const prefixLines = useCallback((prefix) => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const fallback = prefix.endsWith('. ') ? `${prefix}item` : `${prefix}item`
const source = selected || fallback
const nextBlock = source.split('\n').map((line) => `${prefix}${line}`).join('\n')
const next = current.slice(0, start) + nextBlock + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.selectionStart = start
textarea.selectionEnd = start + nextBlock.length
})
}, [onChange, value])
const insertLink = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const replacement = selected && /^https?:\/\//i.test(selected)
? `[link](${selected})`
: `[link](https://)`
const next = current.slice(0, start) + replacement + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
if (selected && /^https?:\/\//i.test(selected)) {
textarea.selectionStart = start + 1
textarea.selectionEnd = start + 5
} else {
const urlStart = start + replacement.indexOf('https://')
textarea.selectionStart = urlStart
textarea.selectionEnd = urlStart + 'https://'.length
}
})
}, [onChange, value])
const insertAtCursor = useCallback((text) => {
const textarea = textareaRef.current
if (!textarea) {
onChange?.(`${String(value || '')}${text}`)
return
}
const current = String(value || '')
const start = textarea.selectionStart ?? current.length
const end = textarea.selectionEnd ?? current.length
const next = current.slice(0, start) + text + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.selectionStart = start + text.length
textarea.selectionEnd = start + text.length
})
}, [onChange, value])
const handleKeyDown = useCallback((event) => {
const withModifier = event.ctrlKey || event.metaKey
if (!withModifier) return
switch (event.key.toLowerCase()) {
case 'b':
event.preventDefault()
wrapSelection('**', '**')
break
case 'i':
event.preventDefault()
wrapSelection('*', '*')
break
case 'k':
event.preventDefault()
insertLink()
break
case 'e':
event.preventDefault()
wrapSelection('`', '`')
break
default:
break
}
}, [insertLink, wrapSelection])
const previewValue = String(value || '').trim()
return (
<div className={`overflow-hidden rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('write')}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
>
Write
</button>
<button
type="button"
onClick={() => setTab('preview')}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
>
Preview
</button>
</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-white/40">
Safe formatting only
</div>
</div>
{tab === 'write' && (
<>
<div className="flex flex-wrap items-center gap-1 border-b border-white/10 px-2 py-1">
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>List</ToolbarButton>
<ToolbarButton title="Numbered list" onClick={() => prefixLines('1. ')}>1.</ToolbarButton>
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>Quote</ToolbarButton>
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
<EmojiPickerButton onEmojiSelect={insertAtCursor} className="h-8 w-8 rounded-md text-white/60 hover:bg-white/10 hover:text-white" />
</div>
<textarea
id={id}
ref={textareaRef}
value={value}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={handleKeyDown}
rows={rows}
className="w-full resize-y bg-transparent px-3 py-3 text-sm text-white placeholder-white/45 focus:outline-none"
placeholder={placeholder}
/>
<div className="flex flex-wrap items-center justify-between gap-2 px-3 pb-2 text-[11px] text-white/45">
<span>Supports bold, italic, code, links, lists, quotes, and emoji.</span>
<button type="button" onClick={focusTextarea} className="text-white/50 transition hover:text-white/80">Continue editing</button>
</div>
</>
)}
{tab === 'preview' && (
<div className="min-h-[188px] px-3 py-3">
{previewValue ? (
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
),
}}
>
{previewValue}
</ReactMarkdown>
</div>
) : (
<p className="text-sm italic text-white/35">Nothing to preview yet.</p>
)}
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,8 @@
import React from 'react'
import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox'
import RichTextEditor from '../forum/RichTextEditor'
import SchedulePublishPicker from './SchedulePublishPicker'
import UploadDescriptionEditor from './UploadDescriptionEditor'
export default function UploadSidebar({
title = 'Artwork details',
@@ -53,15 +53,17 @@ export default function UploadSidebar({
<label className="block">
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
<div className="mt-2">
<RichTextEditor
content={metadata.description}
onChange={onChangeDescription}
placeholder="Describe your artwork, tools, inspiration…"
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
minHeight={12}
autofocus={false}
/>
<UploadDescriptionEditor
id="upload-sidebar-description"
value={metadata.description}
onChange={onChangeDescription}
placeholder="Describe your artwork, tools, inspiration..."
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
rows={9}
/>
</div>
<p className="mt-2 text-xs text-white/50">This upload editor only allows safe formatting and emoji. Images, embeds, and raw HTML are blocked.</p>
{errors.description && <p className="mt-1 text-xs text-red-200">{Array.isArray(errors.description) ? errors.description[0] : errors.description}</p>}
</label>
</div>
</section>

View File

@@ -28,6 +28,7 @@ import {
getContentTypeValue,
getProcessingTransparencyLabel,
} from '../../lib/uploadUtils'
import { validateMarkdownLiteContent } from '../../utils/contentValidation'
// ─── Wizard step config ───────────────────────────────────────────────────────
const wizardSteps = [
@@ -335,6 +336,15 @@ export default function UploadWizard({
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
errors.category = 'Subcategory is required for the selected category.'
}
if (!Array.isArray(metadata.tags) || metadata.tags.length === 0) errors.tags = 'Add at least one tag.'
if (!String(metadata.description || '').trim()) {
errors.description = 'Description is required.'
} else {
const descriptionErrors = validateMarkdownLiteContent(metadata.description)
if (descriptionErrors.length > 0) {
errors.description = descriptionErrors[0]
}
}
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
return errors
}, [metadata, requiresSubCategory])
@@ -381,9 +391,11 @@ export default function UploadWizard({
hasCompleteCategory &&
hasTag &&
hasRequiredScreenshot &&
String(metadata.description || '').trim() &&
!metadataErrors.description &&
metadata.rightsAccepted &&
machine.state !== machineStates.publishing
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state])
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.description, metadata.rightsAccepted, metadataErrors.description, machine.state])
const canScheduleSubmit = useMemo(() => {
if (!canPublish) return false