+
+
{props.indexUrl ?
Back to news list : null}
{article.id ? `Article #${article.id}` : 'New article'}
@@ -1836,13 +2215,29 @@ export default function StudioNewsEditor() {
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.
+ {coverPreviewUrl ? (
+
+
+

+
+
+
Header cover preview
+
{String(form.data.title || '').trim() || 'Cover image preview'}
+
+
+
+ ) : null}
+ {frontendArticleUrl ?
Frontend link : null}
{props.previewUrl ?
Preview : null}
-
+
{props.publishUrl ?
: null}
@@ -1965,6 +2360,11 @@ export default function StudioNewsEditor() {
autofocus={false}
advancedNews
searchEntities={searchEntities}
+ mediaSupport={{
+ uploadUrl: props.coverUploadUrl,
+ deleteUrl: props.coverDeleteUrl,
+ slot: 'body',
+ }}
/>
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)
diff --git a/resources/js/Pages/Studio/StudioStories.jsx b/resources/js/Pages/Studio/StudioStories.jsx
index 7caaa47e..9bfbaddb 100644
--- a/resources/js/Pages/Studio/StudioStories.jsx
+++ b/resources/js/Pages/Studio/StudioStories.jsx
@@ -30,7 +30,12 @@ export default function StudioStories() {
-
+
)
}
\ No newline at end of file
diff --git a/resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js b/resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js
new file mode 100644
index 00000000..a97212cf
--- /dev/null
+++ b/resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js
@@ -0,0 +1,312 @@
+// Vitest uses globals (configured in vite.config.mjs: test.globals = true).
+// Pure logic tests for the news image prompt builder helpers.
+// These helpers live in StudioNewsEditor.jsx — this file duplicates them
+// so they can be tested in isolation without pulling in the full editor bundle.
+
+// ── Minimal copies of the prompt-builder helpers ──────────────────────────
+
+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 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 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.',
+ '',
+ `Headline: "${headline}"`,
+ `Subheadline: "${subheadline}"`,
+ `Topic: ${topic}`,
+ `News type: ${newsType}`,
+ `Mood: ${mood}`,
+ ]
+ if (typeAddon) lines.push('', typeAddon)
+ if (keywordAddon) lines.push('', keywordAddon)
+ return lines.join('\n')
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('resolveNewsPromptHeadline', () => {
+ it('returns title when present', () => {
+ expect(resolveNewsPromptHeadline({ title: 'My Title' })).toBe('My Title')
+ })
+
+ it('falls back to meta_title', () => {
+ expect(resolveNewsPromptHeadline({ title: '', meta_title: 'Meta Title' })).toBe('Meta Title')
+ })
+
+ it('returns fallback when both are empty', () => {
+ expect(resolveNewsPromptHeadline({})).toBe('Skinbase News')
+ })
+})
+
+describe('resolveNewsPromptSubheadline', () => {
+ it('returns excerpt truncated to 18 words', () => {
+ const long = 'word '.repeat(25).trim()
+ const result = resolveNewsPromptSubheadline({ excerpt: long })
+ expect(result.endsWith('…')).toBe(true)
+ expect(result.split(/\s+/).filter((w) => !w.startsWith('…') && w !== '…').length).toBeLessThanOrEqual(18)
+ })
+
+ it('returns excerpt as-is when short', () => {
+ expect(resolveNewsPromptSubheadline({ excerpt: 'Short excerpt.' })).toBe('Short excerpt.')
+ })
+
+ it('strips HTML tags from excerpt', () => {
+ expect(resolveNewsPromptSubheadline({ excerpt: '
Clean text
' })).toBe('Clean text')
+ })
+
+ it('returns fallback when all fields are empty', () => {
+ expect(resolveNewsPromptSubheadline({})).toBe('Latest technology and creative industry update')
+ })
+
+ it('falls back to meta_description when excerpt is missing', () => {
+ expect(resolveNewsPromptSubheadline({ meta_description: 'Meta desc' })).toBe('Meta desc')
+ })
+})
+
+describe('resolveNewsPromptTopic', () => {
+ it('includes category and tags', () => {
+ const result = resolveNewsPromptTopic({ category: 'Tech News', tag_names: ['AI', 'Google'] })
+ expect(result).toContain('Tech News')
+ expect(result).toContain('AI')
+ expect(result).toContain('Google')
+ })
+
+ it('falls back to title keywords when no category or tags', () => {
+ const result = resolveNewsPromptTopic({ title: 'Some Long Title With Words' })
+ expect(result.length).toBeGreaterThan(0)
+ })
+
+ it('returns generic fallback when all are empty', () => {
+ expect(resolveNewsPromptTopic({})).toBe('Technology and digital culture news')
+ })
+
+ it('caps tags at 5', () => {
+ const data = { tag_names: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }
+ const result = resolveNewsPromptTopic(data)
+ expect(result.split(',').length).toBeLessThanOrEqual(5)
+ })
+})
+
+describe('resolveNewsPromptType', () => {
+ it('capitalizes and humanizes underscored types', () => {
+ expect(resolveNewsPromptType({ type: 'platform_update' })).toBe('Platform Update')
+ })
+
+ it('returns "News" when type is missing', () => {
+ expect(resolveNewsPromptType({})).toBe('News')
+ })
+
+ it('handles simple types correctly', () => {
+ expect(resolveNewsPromptType({ type: 'editorial' })).toBe('Editorial')
+ expect(resolveNewsPromptType({ type: 'release' })).toBe('Release')
+ })
+})
+
+describe('resolveNewsPromptMood', () => {
+ it('returns correct mood for known types', () => {
+ expect(resolveNewsPromptMood({ type: 'archive' })).toBe('Retro Tech')
+ expect(resolveNewsPromptMood({ type: 'tutorial' })).toBe('Clean Instructional')
+ expect(resolveNewsPromptMood({ type: 'announcement' })).toBe('Futuristic')
+ expect(resolveNewsPromptMood({ type: 'release' })).toBe('Software Release')
+ })
+
+ it('returns Modern Tech fallback for unknown type', () => {
+ expect(resolveNewsPromptMood({ type: 'unknown_type' })).toBe('Modern Tech')
+ expect(resolveNewsPromptMood({})).toBe('Modern Tech')
+ })
+})
+
+describe('resolveNewsPromptTypeAddon', () => {
+ it('returns addon text for known types', () => {
+ expect(resolveNewsPromptTypeAddon({ type: 'release' })).toContain('software-release poster')
+ expect(resolveNewsPromptTypeAddon({ type: 'event' })).toContain('conference or event-poster')
+ expect(resolveNewsPromptTypeAddon({ type: 'archive' })).toContain('retro-tech editorial')
+ })
+
+ it('returns empty string for unknown type', () => {
+ expect(resolveNewsPromptTypeAddon({ type: 'unknown' })).toBe('')
+ expect(resolveNewsPromptTypeAddon({})).toBe('')
+ })
+})
+
+describe('resolveNewsPromptKeywordAddon', () => {
+ it('detects apple/WWDC keyword in title', () => {
+ const result = resolveNewsPromptKeywordAddon({ title: 'Apple WWDC 2026 Keynote', tag_names: [] })
+ expect(result).toContain('developer-conference atmosphere')
+ })
+
+ it('detects AI keyword in tags', () => {
+ const result = resolveNewsPromptKeywordAddon({ title: 'New Update', tag_names: ['AI', 'Gemini'] })
+ expect(result).toContain('creative AI studio')
+ })
+
+ it('detects desktop customization keyword', () => {
+ const result = resolveNewsPromptKeywordAddon({ title: 'Best desktop customization tools', tag_names: [] })
+ expect(result).toContain('desktop customization promo')
+ })
+
+ it('returns empty string when no keywords match', () => {
+ const result = resolveNewsPromptKeywordAddon({ title: 'Random Article', tag_names: ['general'] })
+ expect(result).toBe('')
+ })
+
+ it('deduplicates addons when multiple patterns match the same text', () => {
+ // "AI" matches both ai pattern; ensure no duplicates
+ const result = resolveNewsPromptKeywordAddon({ title: 'ai ai ai', tag_names: ['ai', 'artificial intelligence'] })
+ const lines = result.split('\n').filter(Boolean)
+ expect(lines.length).toBe(new Set(lines).size)
+ })
+})
+
+describe('buildNewsImagePrompt', () => {
+ it('includes the article headline in the prompt', () => {
+ const prompt = buildNewsImagePrompt({ title: 'Google I/O 2026 Keynote', type: 'announcement' })
+ expect(prompt).toContain('Google I/O 2026 Keynote')
+ })
+
+ it('includes the type addon for release articles', () => {
+ const prompt = buildNewsImagePrompt({ title: 'App Launch', type: 'release' })
+ expect(prompt).toContain('software-release poster')
+ })
+
+ it('includes the keyword addon when tags match', () => {
+ const prompt = buildNewsImagePrompt({ title: 'New skin', tag_names: ['desktop', 'theme'], type: 'release' })
+ expect(prompt).toContain('desktop customization promo')
+ })
+
+ it('uses fallback headline when title is missing', () => {
+ const prompt = buildNewsImagePrompt({})
+ expect(prompt).toContain('Skinbase News')
+ })
+
+ it('contains the 16:9 aspect ratio instruction', () => {
+ const prompt = buildNewsImagePrompt({ title: 'Test' })
+ expect(prompt).toContain('16:9')
+ })
+
+ it('contains the mood line', () => {
+ const prompt = buildNewsImagePrompt({ title: 'Tutorial Article', type: 'tutorial' })
+ expect(prompt).toContain('Clean Instructional')
+ })
+})
diff --git a/resources/js/Pages/Upload/Index.jsx b/resources/js/Pages/Upload/Index.jsx
index f580cbbe..58c087f4 100644
--- a/resources/js/Pages/Upload/Index.jsx
+++ b/resources/js/Pages/Upload/Index.jsx
@@ -3,8 +3,10 @@ import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import TagInput from '../../components/tags/TagInput'
import UploadWizard from '../../components/upload/UploadWizard'
+import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor'
import Checkbox from '../../Components/ui/Checkbox'
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
+import { validateMarkdownLiteContent } from '../../utils/contentValidation'
const phases = {
idle: 'idle',
@@ -177,7 +179,7 @@ function getTypeKey(ct) {
return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
}
-function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs, userId }) {
+function useUploadMachine({ draftId = null, filesCdnUrl = '', chunkSize, chunkRequestTimeoutMs, userId = null } = {}) {
const [state, dispatch] = useReducer(reducer, { ...initialState, draftId })
const pollRef = useRef(null)
const adaptiveChunkSizeRef = useRef(Math.max(1, Number(chunkSize || 0)))
@@ -548,6 +550,14 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
return
}
+ const descriptionErrors = validateMarkdownLiteContent(state.metadata.description)
+ if (descriptionErrors.length > 0) {
+ const message = descriptionErrors[0]
+ dispatch({ type: 'UPLOAD_ERROR', error: message })
+ pushNotice('error', message)
+ return
+ }
+
if (!state.metadata.licenseAccepted) {
const message = 'You must confirm ownership of the artwork.'
dispatch({ type: 'UPLOAD_ERROR', error: message })
@@ -619,7 +629,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
}
}
-export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) {
+export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize, chunkRequestTimeoutMs } = {}) {
const { props } = usePage()
const pageTitle = 'Upload Artwork — Creator Studio'
const pageDescription = 'Submit a new artwork, complete the required metadata, and publish it from Skinbase Creator Studio.'
@@ -745,6 +755,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
)
const categoryOptions = useMemo(() => selectedType?.categories || [], [selectedType])
const hasAtLeastOneTag = useMemo(() => parseUiTags(state.metadata.tags).length > 0, [state.metadata.tags])
+ const descriptionErrors = useMemo(() => validateMarkdownLiteContent(state.metadata.description), [state.metadata.description])
useEffect(() => {
// Prefer server-provided props, else try fetching from API endpoints
@@ -1047,13 +1058,17 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
@@ -1099,9 +1114,10 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
!state.metadata.category ||
!hasAtLeastOneTag ||
!state.metadata.description.trim() ||
+ descriptionErrors.length > 0 ||
!state.metadata.licenseAccepted
}
- className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
+ className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || descriptionErrors.length > 0 || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
>
Start upload
diff --git a/resources/js/collections.jsx b/resources/js/collections.jsx
index 2a50e3ae..d3adf3e5 100644
--- a/resources/js/collections.jsx
+++ b/resources/js/collections.jsx
@@ -32,6 +32,11 @@ const pages = {
'!./Pages/Academy/**/__tests__/**',
'!./Pages/Academy/**/*.test.jsx',
]),
+ ...import.meta.glob([
+ './Pages/Enhance/**/*.jsx',
+ '!./Pages/Enhance/**/__tests__/**',
+ '!./Pages/Enhance/**/*.test.jsx',
+ ]),
}
function resolvePage(name) {
diff --git a/resources/js/components/Studio/StudioContentBrowser.jsx b/resources/js/components/Studio/StudioContentBrowser.jsx
index 86411e13..56705e17 100644
--- a/resources/js/components/Studio/StudioContentBrowser.jsx
+++ b/resources/js/components/Studio/StudioContentBrowser.jsx
@@ -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}
/>
diff --git a/resources/js/components/Topbar.jsx b/resources/js/components/Topbar.jsx
index 97568bbe..147b3813 100644
--- a/resources/js/components/Topbar.jsx
+++ b/resources/js/components/Topbar.jsx
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
Upload
+
Enhance
Studio
{user.moderationUrl ?
Moderation : null}
Dashboard
diff --git a/resources/js/components/artwork/ArtworkActionBar.jsx b/resources/js/components/artwork/ArtworkActionBar.jsx
index f10ddad1..9f00bb53 100644
--- a/resources/js/components/artwork/ArtworkActionBar.jsx
+++ b/resources/js/components/artwork/ArtworkActionBar.jsx
@@ -51,6 +51,15 @@ function ChartIcon() {
)
}
+function EnhanceIcon() {
+ return (
+
+ )
+}
+
/* 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
) : null}
+ {enhanceUrl ? (
+
+
+ Enhance image
+
+ ) : null}
+
{/* Report pill */}
+ )
+}
\ No newline at end of file
diff --git a/resources/js/components/enhance/EnhanceStatusBadge.jsx b/resources/js/components/enhance/EnhanceStatusBadge.jsx
new file mode 100644
index 00000000..51335b88
--- /dev/null
+++ b/resources/js/components/enhance/EnhanceStatusBadge.jsx
@@ -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 (
+
+ {label}
+
+ )
+}
\ No newline at end of file
diff --git a/resources/js/components/enhance/EnhanceStubWarning.jsx b/resources/js/components/enhance/EnhanceStubWarning.jsx
new file mode 100644
index 00000000..403703c4
--- /dev/null
+++ b/resources/js/components/enhance/EnhanceStubWarning.jsx
@@ -0,0 +1,14 @@
+import React from 'react'
+
+export default function EnhanceStubWarning({ config, moderation = false, className = '' }) {
+ if (!config?.showStubWarning) {
+ return null
+ }
+
+ return (
+
+
Skinbase Enhance is currently running in preview mode. The generated result is a workflow placeholder until the real upscaling worker is enabled.
+ {moderation ?
Engine: {config.engine}. This is not a real AI upscale result.
: null}
+
+ )
+}
\ No newline at end of file
diff --git a/resources/js/components/upload/UploadDescriptionEditor.jsx b/resources/js/components/upload/UploadDescriptionEditor.jsx
new file mode 100644
index 00000000..6e69799a
--- /dev/null
+++ b/resources/js/components/upload/UploadDescriptionEditor.jsx
@@ -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 (
+
{
+ 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}
+
+ )
+}
+
+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 (
+
+
+
+ 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
+
+ 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
+
+
+
+ Safe formatting only
+
+
+
+ {tab === 'write' && (
+ <>
+
+ wrapSelection('**', '**')}>B
+ wrapSelection('*', '*')}>I
+ wrapSelection('`', '`')}>{'>'}
+ Link
+
+ prefixLines('- ')}>List
+ prefixLines('1. ')}>1.
+ prefixLines('> ')}>Quote
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/resources/js/components/upload/UploadSidebar.jsx b/resources/js/components/upload/UploadSidebar.jsx
index bb7ab414..305de726 100644
--- a/resources/js/components/upload/UploadSidebar.jsx
+++ b/resources/js/components/upload/UploadSidebar.jsx
@@ -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({
diff --git a/resources/js/components/upload/UploadWizard.jsx b/resources/js/components/upload/UploadWizard.jsx
index 93d93bbc..486ca201 100644
--- a/resources/js/components/upload/UploadWizard.jsx
+++ b/resources/js/components/upload/UploadWizard.jsx
@@ -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
diff --git a/resources/js/lib/academyAnalytics.js b/resources/js/lib/academyAnalytics.js
index 34101e3a..3af54ddf 100644
--- a/resources/js/lib/academyAnalytics.js
+++ b/resources/js/lib/academyAnalytics.js
@@ -199,6 +199,30 @@ function contentViewEventType(contentType) {
return 'academy_content_view'
}
+function analyticsMetadata(analytics, extra = {}) {
+ const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
+
+ return {
+ page_name: analytics?.pageName,
+ ...metadata,
+ ...extra,
+ }
+}
+
+function analyticsTrackingKey(analytics) {
+ if (analytics?.trackingKey) {
+ return String(analytics.trackingKey)
+ }
+
+ const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
+ const pairs = Object.entries(metadata)
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, value]) => `${key}:${String(value)}`)
+
+ return pairs.join('|')
+}
+
export function trackUpgradeClick(analytics, metadata = {}) {
if (!analytics?.eventUrl) {
return
@@ -217,20 +241,17 @@ export function useAcademyPageAnalytics(analytics) {
return undefined
}
- const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}`
+ const trackingKey = analyticsTrackingKey(analytics)
+ const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}:${trackingKey || 'default'}`
- void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, {
- page_name: analytics.pageName,
- }, {
+ void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:page-view`,
})
if (analytics.contentType || analytics.contentId) {
- void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, {
- page_name: analytics.pageName,
- }, {
+ void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:content-view`,
@@ -238,9 +259,7 @@ export function useAcademyPageAnalytics(analytics) {
}
if (analytics.isPremium && analytics.isLocked) {
- void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, {
- page_name: analytics.pageName,
- }, {
+ void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:premium-preview`,
@@ -248,10 +267,9 @@ export function useAcademyPageAnalytics(analytics) {
}
const engagedTimer = window.setTimeout(() => {
- void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, {
- page_name: analytics.pageName,
+ void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
engaged_seconds: 15,
- }, {
+ }), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:engaged`,
@@ -275,10 +293,9 @@ export function useAcademyPageAnalytics(analytics) {
}
sentMilestones.add(milestone.threshold)
- void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, {
- page_name: analytics.pageName,
+ void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
scroll_percent: milestone.threshold,
- }, {
+ }), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:scroll-${milestone.threshold}`,
@@ -292,5 +309,5 @@ export function useAcademyPageAnalytics(analytics) {
window.clearTimeout(engagedTimer)
window.removeEventListener('scroll', onScroll)
}
- }, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName])
+ }, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName, analytics?.trackingKey, JSON.stringify(analytics?.metadata || {})])
}
\ No newline at end of file
diff --git a/resources/js/lib/academyAnalytics.test.js b/resources/js/lib/academyAnalytics.test.js
index aa9fb458..b119c8b2 100644
--- a/resources/js/lib/academyAnalytics.test.js
+++ b/resources/js/lib/academyAnalytics.test.js
@@ -1,11 +1,18 @@
+import React from 'react'
+import { cleanup, render, waitFor } from '@testing-library/react'
+
function prepareEnvironment() {
document.head.innerHTML = '
'
- vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('visitor-123')
- vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
+ const storage = new Map([['academy.analytics.visitor-id', 'visitor-123']])
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => storage.get(String(key)) ?? null)
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
+ storage.set(String(key), String(value))
+ })
globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, headers: { get: () => 'application/json' }, json: () => Promise.resolve({ ok: true }) }))
}
function cleanupEnvironment() {
+ cleanup()
vi.restoreAllMocks()
document.head.innerHTML = ''
}
@@ -66,5 +73,73 @@ test('academy search click attribution falls back to keepalive fetch when sendBe
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
expect(globalThis.fetch.mock.calls[0][1].keepalive).toBe(true)
+ cleanupEnvironment()
+})
+
+test('academy page analytics includes custom metadata and varies page-view once keys by tracking context', async () => {
+ prepareEnvironment()
+
+ const { useAcademyPageAnalytics } = await import('./academyAnalytics.js')
+
+ Object.defineProperty(navigator, 'sendBeacon', {
+ configurable: true,
+ value: undefined,
+ })
+
+ function TestPage({ analytics }) {
+ useAcademyPageAnalytics(analytics)
+ return React.createElement('div', null, 'Academy page')
+ }
+
+ const { rerender } = render(
+ React.createElement(TestPage, {
+ analytics: {
+ enabled: true,
+ eventUrl: '/academy/analytics/events',
+ pageName: 'academy_prompts_popular',
+ contentType: 'academy_prompt_popular',
+ contentId: null,
+ trackingKey: 'period:30d',
+ metadata: { period: '30d', period_days: 30 },
+ },
+ }),
+ )
+
+ await waitFor(() => {
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2)
+ })
+
+ const firstPageView = globalThis.fetch.mock.calls
+ .map((call) => JSON.parse(call[1].body))
+ .find((payload) => payload.event_type === 'academy_page_view')
+
+ expect(firstPageView.metadata.period).toBe('30d')
+ expect(firstPageView.metadata.period_days).toBe(30)
+
+ rerender(
+ React.createElement(TestPage, {
+ analytics: {
+ enabled: true,
+ eventUrl: '/academy/analytics/events',
+ pageName: 'academy_prompts_popular',
+ contentType: 'academy_prompt_popular',
+ contentId: null,
+ trackingKey: 'period:7d',
+ metadata: { period: '7d', period_days: 7 },
+ },
+ }),
+ )
+
+ await waitFor(() => {
+ expect(globalThis.fetch).toHaveBeenCalledTimes(4)
+ })
+
+ const pageViews = globalThis.fetch.mock.calls
+ .map((call) => JSON.parse(call[1].body))
+ .filter((payload) => payload.event_type === 'academy_page_view')
+
+ expect(pageViews).toHaveLength(2)
+ expect(pageViews[1].metadata.period).toBe('7d')
+
cleanupEnvironment()
})
\ No newline at end of file
diff --git a/resources/js/lib/uploadNotices.js b/resources/js/lib/uploadNotices.js
index ebe77b54..efbb1390 100644
--- a/resources/js/lib/uploadNotices.js
+++ b/resources/js/lib/uploadNotices.js
@@ -23,6 +23,17 @@ function normalizeType(value, fallback = 'error') {
return fallback
}
+function firstValidationError(errors) {
+ if (!errors || typeof errors !== 'object') return ''
+
+ for (const value of Object.values(errors)) {
+ if (Array.isArray(value) && value[0]) return String(value[0]).trim()
+ if (typeof value === 'string' && value.trim()) return value.trim()
+ }
+
+ return ''
+}
+
export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
const status = Number(error?.response?.status || 0)
const payload = error?.response?.data || {}
@@ -30,6 +41,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
const mapped = REASON_MAP[reason]
const errorCode = String(error?.code || '').toUpperCase()
const rawMessage = typeof error?.message === 'string' ? error.message.trim() : ''
+ const validationMessage = firstValidationError(payload?.errors)
const timedOut = errorCode === 'ECONNABORTED' || /timeout/i.test(rawMessage)
const requestTooLarge = status === 413
@@ -41,6 +53,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
(requestTooLarge ? 'Server rejected this upload chunk as too large. Retrying with smaller chunks may help, or increase Nginx/PHP upload limits.' : '') ||
(timedOut ? 'Upload request timed out before the server responded. Check Nginx/PHP-FPM body handling and try again.' : '') ||
mapped?.message ||
+ validationMessage ||
(typeof payload?.message === 'string' && payload.message.trim()) ||
rawMessage ||
fallback
diff --git a/resources/js/upload.jsx b/resources/js/upload.jsx
index 74a878d6..6922e74f 100644
--- a/resources/js/upload.jsx
+++ b/resources/js/upload.jsx
@@ -3,12 +3,31 @@ import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
import UploadPage from './Pages/Upload/Index'
-const pages = {
+const staticPages = {
'Upload/Index': UploadPage,
}
+const dynamicPages = Object.fromEntries(
+ Object.entries(import.meta.glob('./Pages/Enhance/**/*.jsx')).map(([path, resolver]) => [
+ path.replace('./Pages/', '').replace('.jsx', ''),
+ resolver,
+ ])
+)
+
createInertiaApp({
- resolve: (name) => pages[name],
+ resolve: (name) => {
+ if (staticPages[name]) {
+ return staticPages[name]
+ }
+
+ const page = dynamicPages[name]
+
+ if (!page) {
+ throw new Error(`Unknown upload page: ${name}`)
+ }
+
+ return page().then((module) => module.default)
+ },
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
diff --git a/resources/js/utils/contentValidation.js b/resources/js/utils/contentValidation.js
new file mode 100644
index 00000000..16538cfa
--- /dev/null
+++ b/resources/js/utils/contentValidation.js
@@ -0,0 +1,93 @@
+import { countEmoji, FLOOD_DENSITY_THRESHOLD, FLOOD_COUNT_THRESHOLD } from './emojiFlood'
+
+const HTML_TAG_RE = /<[a-z][^>]*>/i
+const MAX_CONTENT_LENGTH = 10000
+
+function decodeHtmlEntities(value) {
+ const decoded = String(value || '')
+
+ if (typeof document === 'undefined') {
+ return decoded
+ .replace(/ /gi, ' ')
+ .replace(/&/gi, '&')
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/"/gi, '"')
+ .replace(/'/gi, "'")
+ }
+
+ const textarea = document.createElement('textarea')
+ textarea.innerHTML = decoded
+ return textarea.value
+}
+
+function stripResidualTags(value) {
+ return String(value || '').replace(/<[^>]+>/g, '')
+}
+
+export function normalizeMarkdownLiteContent(value) {
+ const raw = String(value || '')
+ const trimmed = raw.trim()
+
+ if (!trimmed || !HTML_TAG_RE.test(trimmed)) {
+ return raw
+ }
+
+ const normalized = raw
+ .replace(/<\s*a[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\s*\/a\s*>/gi, (_, __, href, label) => {
+ const text = stripResidualTags(label).trim() || href
+ return `[${text}](${href})`
+ })
+ .replace(/<\s*(strong|b)(?:\s+[^>]*)?>([\s\S]*?)<\s*\/\s*\1\s*>/gi, (_, __, text) => `**${stripResidualTags(text)}**`)
+ .replace(/<\s*(em|i)(?:\s+[^>]*)?>([\s\S]*?)<\s*\/\s*\1\s*>/gi, (_, __, text) => `*${stripResidualTags(text)}*`)
+ .replace(/<\s*code(?:\s+[^>]*)?>([\s\S]*?)<\s*\/code\s*>/gi, (_, text) => `\`${stripResidualTags(text)}\``)
+ .replace(/<\s*br\s*\/?>/gi, '\n')
+ .replace(/<\s*\/p\s*>/gi, '\n\n')
+ .replace(/<\s*p(?:\s+[^>]*)?>/gi, '')
+ .replace(/<\s*li(?:\s+[^>]*)?>([\s\S]*?)<\s*\/li\s*>/gi, (_, text) => `- ${stripResidualTags(text).trim()}\n`)
+ .replace(/<\s*\/ul\s*>|<\s*\/ol\s*>/gi, '\n')
+ .replace(/<\s*(ul|ol)(?:\s+[^>]*)?>/gi, '')
+ .replace(/<\s*blockquote(?:\s+[^>]*)?>([\s\S]*?)<\s*\/blockquote\s*>/gi, (_, text) => {
+ const lines = stripResidualTags(text)
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => `> ${line}`)
+ return `${lines.join('\n')}\n\n`
+ })
+ .replace(/<[^>]+>/g, '')
+
+ return decodeHtmlEntities(normalized)
+ .replace(/\r\n?/g, '\n')
+ .replace(/[\t ]+\n/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
+export function validateMarkdownLiteContent(value) {
+ const raw = String(value || '')
+ const trimmed = raw.trim()
+
+ if (!trimmed) return []
+
+ const errors = []
+
+ if (trimmed.length > MAX_CONTENT_LENGTH) {
+ errors.push('Content exceeds maximum length of 10,000 characters.')
+ }
+
+ if (HTML_TAG_RE.test(trimmed)) {
+ errors.push('HTML tags are not allowed. Use Markdown formatting instead.')
+ }
+
+ const emojiCount = countEmoji(trimmed)
+ if (emojiCount > FLOOD_COUNT_THRESHOLD) {
+ errors.push('Too many emoji. Please limit emoji usage.')
+ }
+
+ if (emojiCount > 5 && trimmed.length > 0 && (emojiCount / trimmed.length) > FLOOD_DENSITY_THRESHOLD) {
+ errors.push('Content is mostly emoji. Please add some text.')
+ }
+
+ return errors
+}
\ No newline at end of file
diff --git a/resources/js/utils/enhanceFormatting.js b/resources/js/utils/enhanceFormatting.js
new file mode 100644
index 00000000..b33e8721
--- /dev/null
+++ b/resources/js/utils/enhanceFormatting.js
@@ -0,0 +1,33 @@
+const dateFormatter = new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ timeZone: 'UTC',
+})
+
+const numberFormatter = new Intl.NumberFormat('en-US')
+
+export function formatEnhanceDate(value) {
+ if (!value) return '—'
+
+ const parsed = new Date(value)
+
+ if (Number.isNaN(parsed.getTime())) {
+ return '—'
+ }
+
+ return `${dateFormatter.format(parsed)} UTC`
+}
+
+export function formatEnhanceInteger(value) {
+ const parsed = Number(value)
+
+ if (!Number.isFinite(parsed)) {
+ return '0'
+ }
+
+ return numberFormatter.format(parsed)
+}
\ No newline at end of file
diff --git a/resources/views/_legacy/home/news.blade.php b/resources/views/_legacy/home/news.blade.php
index 4cc6718a..7f4a6c8d 100644
--- a/resources/views/_legacy/home/news.blade.php
+++ b/resources/views/_legacy/home/news.blade.php
@@ -12,7 +12,7 @@
Written by {{ $item->uname }} on {{ Carbon::parse($item->post_date)->format('j F Y \@ H:i') }}
- {!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
+ {{ Str::limit(strip_tags($item->preview ?? ''), 240, '...') }}
More
diff --git a/resources/views/artworks/edit.blade.php b/resources/views/artworks/edit.blade.php
index 99ba7078..a039ed3f 100644
--- a/resources/views/artworks/edit.blade.php
+++ b/resources/views/artworks/edit.blade.php
@@ -43,6 +43,7 @@
@endsection
-
-@push('scripts')
-
-@endpush
diff --git a/resources/views/news/_article_card.blade.php b/resources/views/news/_article_card.blade.php
index c3aa4c1d..065cafbc 100644
--- a/resources/views/news/_article_card.blade.php
+++ b/resources/views/news/_article_card.blade.php
@@ -2,7 +2,7 @@