Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
53
resources/js/components/enhance/BeforeAfterSlider.jsx
Normal file
53
resources/js/components/enhance/BeforeAfterSlider.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
resources/js/components/enhance/EnhanceStatusBadge.jsx
Normal file
34
resources/js/components/enhance/EnhanceStatusBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
resources/js/components/enhance/EnhanceStubWarning.jsx
Normal file
14
resources/js/components/enhance/EnhanceStubWarning.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
resources/js/components/upload/UploadDescriptionEditor.jsx
Normal file
239
resources/js/components/upload/UploadDescriptionEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user