Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
@@ -1,7 +1,53 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function slugifyHeading(value, fallback = 'section') {
|
||||
const normalized = String(value || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
function formatLessonDate(value) {
|
||||
if (!value) return 'Recently updated'
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) return 'Recently updated'
|
||||
|
||||
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
function formatLessonMinutes(minutes) {
|
||||
const value = Number(minutes || 0)
|
||||
|
||||
return value > 0 ? `${value} min read` : 'Quick read'
|
||||
}
|
||||
|
||||
function StatPill({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-slate-400">{label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonInfoRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{label}</span>
|
||||
<span className="text-sm font-semibold text-white">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LockedPanel({ pricingUrl, label }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
|
||||
@@ -13,10 +59,206 @@ function LockedPanel({ pricingUrl, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyShow({ pageType, item, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
||||
function copyTextToClipboard(text) {
|
||||
const source = String(text || '')
|
||||
if (!source) return Promise.reject(new Error('Nothing to copy'))
|
||||
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(source)
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = source
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-1000px'
|
||||
textarea.style.left = '-1000px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
if (document.execCommand('copy')) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
function PromptCopyButton({ prompt }) {
|
||||
const [status, setStatus] = useState('idle')
|
||||
const resetTimerRef = useRef(0)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copyTextToClipboard(prompt)
|
||||
.then(() => setStatus('copied'))
|
||||
.catch(() => setStatus('failed'))
|
||||
.finally(() => {
|
||||
window.clearTimeout(resetTimerRef.current)
|
||||
resetTimerRef.current = window.setTimeout(() => setStatus('idle'), 1800)
|
||||
})
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[#ffb9ab]/20 bg-[#ffb9ab]/10 px-4 py-2 text-sm font-semibold text-[#ffe2dc] transition hover:border-[#ffb9ab]/35 hover:bg-[#ffb9ab]/16"
|
||||
aria-label="Copy prompt"
|
||||
>
|
||||
<i className={`fa-solid ${status === 'copied' ? 'fa-check' : status === 'failed' ? 'fa-triangle-exclamation' : 'fa-copy'}`} />
|
||||
<span>{status === 'copied' ? 'Copied' : status === 'failed' ? 'Copy failed' : 'Copy prompt'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AiComparisonSection({ block }) {
|
||||
const payload = block?.payload || {}
|
||||
const criteria = Array.isArray(payload.criteria) ? payload.criteria.filter(Boolean) : []
|
||||
const results = Array.isArray(block?.comparison_results) ? block.comparison_results.filter((result) => result?.active !== false) : []
|
||||
const hasPrompt = Boolean(payload.prompt)
|
||||
const hasNegativePrompt = Boolean(payload.negative_prompt)
|
||||
const hasUsefulData = Boolean(block?.title || payload.title || payload.intro || hasPrompt || hasNegativePrompt || payload.aspect_ratio || criteria.length || results.length)
|
||||
|
||||
if (!hasUsefulData) return null
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,151,132,0.14),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffb8aa]">AI Model Comparison</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">{payload.title || block.title || 'Same Prompt, Different AI Models'}</h2>
|
||||
{payload.intro ? <p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">{payload.intro}</p> : null}
|
||||
</div>
|
||||
{payload.aspect_ratio ? <div className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200">Aspect ratio {payload.aspect_ratio}</div> : null}
|
||||
</div>
|
||||
|
||||
{hasPrompt ? (
|
||||
<div className="mt-6 rounded-[26px] border border-[#ffb8aa]/15 bg-black/25 p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#ffd0c6]">Prompt used</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">Shared source prompt across all compared models</p>
|
||||
</div>
|
||||
<PromptCopyButton prompt={payload.prompt} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{payload.prompt}</pre>
|
||||
{hasNegativePrompt ? (
|
||||
<div className="mt-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300">{payload.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{criteria.length ? (
|
||||
<div className="mt-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">What we compare</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{criteria.map((criterion) => (
|
||||
<span key={criterion} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-medium text-slate-100">{criterion}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length ? (
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 2xl:grid-cols-4">
|
||||
{results.map((result) => {
|
||||
const imageUrl = result.thumb_url || result.image_url || result.thumb_path || result.image_path || ''
|
||||
const score = Number(result.score || 0)
|
||||
const hasScore = Number.isFinite(score) && score > 0
|
||||
const altText = `${result.model_name || 'AI model'} by ${result.provider || 'unknown provider'} result for ${payload.prompt || 'comparison prompt'}`
|
||||
|
||||
return (
|
||||
<article key={result.id || `${result.provider}-${result.model_name}-${result.sort_order || 0}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_16px_40px_rgba(2,6,23,0.18)]">
|
||||
<div className="aspect-video overflow-hidden bg-slate-950/80">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={altText} loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-slate-500">No comparison image provided.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold tracking-[-0.03em] text-white">{result.model_name || result.provider || 'AI model'}</h3>
|
||||
{result.provider ? <p className="mt-1 text-sm text-slate-400">{result.provider}</p> : null}
|
||||
</div>
|
||||
{hasScore ? <div className="rounded-full border border-[#ffb8aa]/20 bg-[#ffb8aa]/10 px-3 py-1 text-sm font-semibold text-[#ffe3dd]">{`Skinbase score ${score}/10`}</div> : null}
|
||||
</div>
|
||||
|
||||
{result.settings ? (
|
||||
<div className="rounded-[20px] border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Settings</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300">{result.settings}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.strengths ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/75">Strengths</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{result.strengths}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.weaknesses ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-200/75">Weaknesses</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300">{result.weaknesses}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.best_for ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75">Best for</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{result.best_for}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyShow({ pageType, item, relatedLessons = [], seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const [completed, setCompleted] = useState(Boolean(initialCompleted))
|
||||
const [saved, setSaved] = useState(Boolean(initialSaved))
|
||||
const [tableOfContents, setTableOfContents] = useState([])
|
||||
const [activeHeadingId, setActiveHeadingId] = useState('')
|
||||
const articleContentRef = useRef(null)
|
||||
const lessonCover = item?.cover_image_url || item?.cover_image || ''
|
||||
const lessonCategory = item?.category?.name || 'Academy'
|
||||
const lessonDifficulty = item?.difficulty || 'Intermediate'
|
||||
const lessonMinutes = formatLessonMinutes(item?.reading_minutes)
|
||||
const lessonUpdated = formatLessonDate(item?.published_at)
|
||||
const lessonBlocks = Array.isArray(item?.blocks) ? item.blocks : []
|
||||
const relatedLessonList = Array.isArray(relatedLessons) ? relatedLessons : []
|
||||
const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.'
|
||||
const fontScaleStorageKey = 'academy.lesson.font-scale'
|
||||
const fontScaleMin = 0.95
|
||||
const fontScaleMax = 1.12
|
||||
const fontScaleStep = 0.04
|
||||
const [lessonFontScale, setLessonFontScale] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1.04
|
||||
}
|
||||
|
||||
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
||||
|
||||
if (Number.isFinite(storedValue)) {
|
||||
return Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue))
|
||||
}
|
||||
|
||||
return 1.04
|
||||
})
|
||||
|
||||
const markComplete = () => {
|
||||
if (!completeUrl || completed) return
|
||||
@@ -35,84 +277,414 @@ export default function AcademyShow({ pageType, item, seo, pricingUrl, completeU
|
||||
})
|
||||
}
|
||||
|
||||
const decreaseFontSize = () => {
|
||||
setLessonFontScale((current) => Math.max(fontScaleMin, Number((current - fontScaleStep).toFixed(2))))
|
||||
}
|
||||
|
||||
const increaseFontSize = () => {
|
||||
setLessonFontScale((current) => Math.min(fontScaleMax, Number((current + fontScaleStep).toFixed(2))))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
|
||||
setTableOfContents([])
|
||||
setActiveHeadingId('')
|
||||
return
|
||||
}
|
||||
|
||||
const headings = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
|
||||
const seenIds = new Map()
|
||||
const nextTableOfContents = headings.map((heading, index) => {
|
||||
const baseId = slugifyHeading(heading.textContent, `section-${index + 1}`)
|
||||
const seenCount = seenIds.get(baseId) ?? 0
|
||||
const nextId = seenCount > 0 ? `${baseId}-${seenCount + 1}` : baseId
|
||||
|
||||
seenIds.set(baseId, seenCount + 1)
|
||||
heading.id = nextId
|
||||
|
||||
return {
|
||||
id: nextId,
|
||||
title: heading.textContent?.trim() || `Section ${index + 1}`,
|
||||
level: heading.tagName.toLowerCase(),
|
||||
}
|
||||
})
|
||||
|
||||
setTableOfContents(nextTableOfContents)
|
||||
}, [item?.content, pageType])
|
||||
|
||||
useEffect(() => {
|
||||
if (pageType !== 'lesson' || tableOfContents.length === 0 || !articleContentRef.current) {
|
||||
setActiveHeadingId('')
|
||||
return
|
||||
}
|
||||
|
||||
const headingElements = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
|
||||
|
||||
if (!headingElements.length) {
|
||||
setActiveHeadingId('')
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const visibleEntries = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top)
|
||||
|
||||
if (visibleEntries.length) {
|
||||
setActiveHeadingId((current) => visibleEntries[0].target.id || current)
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '-18% 0px -68% 0px',
|
||||
threshold: [0, 1],
|
||||
})
|
||||
|
||||
headingElements.forEach((heading) => observer.observe(heading))
|
||||
|
||||
const firstVisibleHeading = headingElements.find((heading) => heading.getBoundingClientRect().top >= 0) || headingElements[0]
|
||||
if (firstVisibleHeading?.id) {
|
||||
setActiveHeadingId(firstVisibleHeading.id)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [pageType, tableOfContents, lessonFontScale])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.setItem(fontScaleStorageKey, String(lessonFontScale))
|
||||
}, [lessonFontScale])
|
||||
|
||||
useEffect(() => {
|
||||
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const codeBlocks = Array.from(articleContentRef.current.querySelectorAll('pre code'))
|
||||
|
||||
if (!codeBlocks.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackCopyText = (text) => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-1000px'
|
||||
textarea.style.left = '-1000px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
return document.execCommand('copy')
|
||||
} catch (_error) {
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = (text) => {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
return fallbackCopyText(text)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
codeBlocks.forEach((block) => {
|
||||
const pre = block.parentElement
|
||||
|
||||
if (!pre || pre.dataset.academyCopyButtonMounted === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
const button = document.createElement('button')
|
||||
const icon = document.createElement('span')
|
||||
const label = document.createElement('span')
|
||||
|
||||
button.type = 'button'
|
||||
button.className = 'story-code-copy-button academy-code-copy-button'
|
||||
icon.className = 'story-code-copy-icon'
|
||||
icon.setAttribute('aria-hidden', 'true')
|
||||
icon.textContent = '⧉'
|
||||
label.className = 'story-code-copy-label'
|
||||
label.textContent = 'Copy'
|
||||
button.appendChild(icon)
|
||||
button.appendChild(label)
|
||||
button.dataset.copied = 'idle'
|
||||
button.setAttribute('aria-label', 'Copy code block')
|
||||
|
||||
let resetTimer = 0
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const source = block.innerText || block.textContent || ''
|
||||
|
||||
copyText(source)
|
||||
.then(() => {
|
||||
icon.textContent = '✓'
|
||||
label.textContent = 'Copied'
|
||||
button.dataset.copied = 'true'
|
||||
})
|
||||
.catch(() => {
|
||||
icon.textContent = '!'
|
||||
label.textContent = 'Failed'
|
||||
button.dataset.copied = 'false'
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(resetTimer)
|
||||
resetTimer = window.setTimeout(() => {
|
||||
icon.textContent = '⧉'
|
||||
label.textContent = 'Copy'
|
||||
button.dataset.copied = 'idle'
|
||||
}, 1800)
|
||||
})
|
||||
})
|
||||
|
||||
pre.appendChild(button)
|
||||
pre.dataset.academyCopyButtonMounted = 'true'
|
||||
})
|
||||
}, [item?.content, lessonFontScale, pageType])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1200px] space-y-6">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{item.title}</h1>
|
||||
<p className="mt-4 text-base leading-8 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
||||
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Submit artwork</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-[1320px] space-y-6">
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
{item.locked ? <LockedPanel pricingUrl={pricingUrl} label={pageType} /> : null}
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
{pageType === 'lesson' ? <div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content || item.content_preview}</div> : null}
|
||||
{pageType === 'prompt' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
||||
</div>
|
||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'pack' ? (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(item.prompts || []).map((prompt) => (
|
||||
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'challenge' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.submissions || []).length ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{item.submissions.map((submission) => (
|
||||
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
|
||||
</div>
|
||||
))}
|
||||
{pageType === 'lesson' ? (
|
||||
<div className="space-y-8">
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="relative overflow-hidden p-8 md:p-10 lg:p-12">
|
||||
{lessonCover ? <img src={lessonCover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-15" /> : null}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_34%),linear-gradient(135deg,_rgba(2,6,23,0.96),_rgba(15,23,42,0.78))]" />
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonCategory}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonDifficulty}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{item.title}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
||||
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-sky-300/25 hover:bg-sky-300/12 hover:text-sky-100">Submit artwork</Link> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatPill label="Category" value={lessonCategory} />
|
||||
<StatPill label="Reading" value={lessonMinutes} />
|
||||
<StatPill label="Updated" value={lessonUpdated} />
|
||||
<StatPill label="Access" value={item.access_level || 'free'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<aside className="border-t border-white/10 bg-white/[0.03] p-6 lg:border-l lg:border-t-0 lg:p-8">
|
||||
<div className="space-y-5 lg:sticky lg:top-6">
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
|
||||
{lessonCover ? <img src={lessonCover} alt={item.title} className="h-52 w-full object-cover" /> : <div className="flex h-52 items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.18),_rgba(17,24,39,0.94))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300">Lesson cover</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<LessonInfoRow label="Series" value={lessonCategory} />
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
<LessonInfoRow label="Reading time" value={lessonMinutes} />
|
||||
<LessonInfoRow label="Published" value={lessonUpdated} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : 'Full lesson content is available below.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<article className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200 md:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Article</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Lesson content</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{lessonMinutes}</span>
|
||||
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-black/20 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decreaseFontSize}
|
||||
disabled={lessonFontScale <= fontScaleMin}
|
||||
aria-label="Decrease article text size"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-sm font-semibold text-slate-200 transition hover:border-sky-300/30 hover:bg-sky-300/12 hover:text-sky-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="min-w-12 px-1 text-center text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{Math.round(lessonFontScale * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increaseFontSize}
|
||||
disabled={lessonFontScale >= fontScaleMax}
|
||||
aria-label="Increase article text size"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-sm font-semibold text-slate-200 transition hover:border-sky-300/30 hover:bg-sky-300/12 hover:text-sky-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{item.content ? (
|
||||
<div className="space-y-8">
|
||||
<div
|
||||
ref={articleContentRef}
|
||||
className="story-prose academy-lesson-prose prose prose-invert max-w-none"
|
||||
style={{ '--academy-lesson-font-scale': lessonFontScale }}
|
||||
dangerouslySetInnerHTML={{ __html: item.content }}
|
||||
/>
|
||||
{lessonBlocks.map((block) => <AiComparisonSection key={block.id || `${block.type}-${block.sort_order || 0}`} block={block} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content_preview}</div>
|
||||
{lessonBlocks.map((block) => <AiComparisonSection key={block.id || `${block.type}-${block.sort_order || 0}`} block={block} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||
{tableOfContents.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">On this page</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Table of contents</h3>
|
||||
|
||||
<nav aria-label="Lesson table of contents" className="mt-5 space-y-1.5">
|
||||
{tableOfContents.map((entry) => (
|
||||
<a
|
||||
key={entry.id}
|
||||
href={`#${entry.id}`}
|
||||
aria-current={activeHeadingId === entry.id ? 'location' : undefined}
|
||||
className={`academy-lesson-toc-link ${entry.level === 'h3' ? 'academy-lesson-toc-link-subtle' : ''} ${activeHeadingId === entry.id ? 'academy-lesson-toc-link-active' : ''}`}
|
||||
>
|
||||
<span className="academy-lesson-toc-link-indicator" aria-hidden="true" />
|
||||
<span>{entry.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Series info</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{lessonCategory}</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
<LessonInfoRow label="Category" value={lessonCategory} />
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
<LessonInfoRow label="Reading" value={lessonMinutes} />
|
||||
<LessonInfoRow label="Updated" value={lessonUpdated} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedLessonList.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Continue learning</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">More in {lessonCategory}</h3>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{relatedLessonList.map((relatedLesson, index) => (
|
||||
<Link
|
||||
key={relatedLesson.id}
|
||||
href={`/academy/lessons/${relatedLesson.slug}`}
|
||||
className="group flex gap-4 rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-300/10 text-sm font-semibold text-sky-100">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
|
||||
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{formatLessonMinutes(relatedLesson.reading_minutes)}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-400">{relatedLesson.excerpt || relatedLesson.content_preview || 'Continue the series with the next lesson.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
{pageType === 'prompt' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
||||
</div>
|
||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'pack' ? (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(item.prompts || []).map((prompt) => (
|
||||
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'challenge' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.submissions || []).length ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{item.submissions.map((submission) => (
|
||||
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
||||
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||
import LessonEditor from './LessonEditor'
|
||||
|
||||
function normalizePayload(fields, data) {
|
||||
const payload = { ...data }
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.type === 'csv') {
|
||||
payload[field.name] = String(payload[field.name] || '').split(',').map((item) => item.trim()).filter(Boolean)
|
||||
payload[field.name] = String(payload[field.name] || '')
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
@@ -23,6 +28,57 @@ function normalizePayload(fields, data) {
|
||||
return payload
|
||||
}
|
||||
|
||||
function getField(fields, name) {
|
||||
return fields.find((field) => field.name === name) || null
|
||||
}
|
||||
|
||||
function SectionCard({ eyebrow, title, description, children, className = '' }) {
|
||||
return (
|
||||
<section className={`w-full min-w-0 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_20px_80px_rgba(15,23,42,0.18)] ${className}`.trim()}>
|
||||
<div className="mb-5">
|
||||
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">{eyebrow}</p> : null}
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 text-sm leading-7 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
<div className="grid gap-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, error, ...rest }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{label}</span>
|
||||
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" {...rest} />
|
||||
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextAreaField({ label, value, onChange, error, rows = 6, hint }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{label}</span>
|
||||
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" />
|
||||
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
||||
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleField({ label, checked, onChange, help, error }) {
|
||||
return (
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="mt-1" />
|
||||
<span>
|
||||
<span className="block font-semibold text-white">{label}</span>
|
||||
{help ? <span className="mt-1 block text-xs leading-5 text-slate-400">{help}</span> : null}
|
||||
{error ? <span className="mt-2 block text-xs text-rose-300">{error}</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ field, form }) {
|
||||
const value = form.data[field.name]
|
||||
|
||||
@@ -35,18 +91,44 @@ function Field({ field, form }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'datetime-local') {
|
||||
return (
|
||||
<DateTimePicker
|
||||
label={field.label}
|
||||
value={value || ''}
|
||||
onChange={(nextValue) => form.setData(field.name, nextValue || '')}
|
||||
error={form.errors[field.name]}
|
||||
clearable
|
||||
className="bg-black/20"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return <textarea value={value || ''} onChange={(event) => form.setData(field.name, event.target.value)} rows={6} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{field.label}</span>
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(event) => form.setData(field.name, event.target.value)}
|
||||
rows={field.rows || 6}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none"
|
||||
/>
|
||||
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<NovaSelect
|
||||
label={field.label}
|
||||
value={value ?? ''}
|
||||
onChange={(nextValue) => form.setData(field.name, nextValue ?? '')}
|
||||
options={field.options || []}
|
||||
searchable={false}
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
className="rounded-2xl bg-black/20"
|
||||
error={form.errors[field.name]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -55,30 +137,329 @@ function Field({ field, form }) {
|
||||
return (
|
||||
<NovaSelect
|
||||
multi
|
||||
label={field.label}
|
||||
value={value || []}
|
||||
onChange={(nextValue) => form.setData(field.name, Array.isArray(nextValue) ? nextValue : [])}
|
||||
options={field.options || []}
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
className="rounded-2xl bg-black/20"
|
||||
error={form.errors[field.name]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <input type={field.type || 'text'} value={value ?? ''} onChange={(event) => form.setData(field.name, event.target.value)} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => form.setData(field.name, event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCrudForm({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
function PromptPreviewDropzone({ form, previewUrl }) {
|
||||
const inputRef = useRef(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState('')
|
||||
const [selectedFileName, setSelectedFileName] = useState('')
|
||||
|
||||
const previewSrc = localPreviewUrl || previewUrl || form.data.preview_image || ''
|
||||
|
||||
useEffect(() => () => {
|
||||
if (localPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreviewUrl)
|
||||
}
|
||||
}, [localPreviewUrl])
|
||||
|
||||
const setSelectedFile = (file) => {
|
||||
if (localPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreviewUrl)
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
setLocalPreviewUrl('')
|
||||
setSelectedFileName('')
|
||||
form.setData('preview_image_file', null)
|
||||
form.clearErrors('preview_image_file')
|
||||
return
|
||||
}
|
||||
|
||||
const nextPreviewUrl = URL.createObjectURL(file)
|
||||
setLocalPreviewUrl(nextPreviewUrl)
|
||||
setSelectedFileName(file.name)
|
||||
form.setData('preview_image_file', file)
|
||||
form.clearErrors('preview_image_file')
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
if (localPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreviewUrl)
|
||||
}
|
||||
|
||||
setLocalPreviewUrl('')
|
||||
setSelectedFileName('')
|
||||
form.setData('preview_image_file', null)
|
||||
form.clearErrors('preview_image_file')
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
eyebrow="Visual preview"
|
||||
title="Preview image"
|
||||
description="Drag an image here or paste a URL. Uploaded files are converted to WebP and stored on Contabo automatically."
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
setSelectedFile(event.dataTransfer?.files?.[0] || null)
|
||||
}}
|
||||
className={[
|
||||
'w-full min-w-0 rounded-[28px] border border-dashed p-5 outline-none transition',
|
||||
dragging ? 'border-sky-300/50 bg-sky-400/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">Drop a preview image or browse</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-400">JPG, PNG, or WEBP. The server re-encodes the final asset to WebP before uploading it to the CDN.</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max 5 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-full gap-3">
|
||||
<div className="overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt="Prompt preview" className="h-40 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center px-4 text-center text-sm text-slate-500">No preview image selected</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => inputRef.current?.click()} className="flex-1 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Browse</button>
|
||||
{selectedFileName || localPreviewUrl ? <button type="button" onClick={clearSelection} className="rounded-full border border-white/10 bg-transparent px-4 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/[0.04]">Clear</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
setSelectedFile(event.target.files?.[0] || null)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid min-w-0 gap-3 md:grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,220px)]">
|
||||
<TextField
|
||||
label="Preview image URL fallback"
|
||||
value={form.data.preview_image || ''}
|
||||
onChange={(event) => form.setData('preview_image', event.target.value)}
|
||||
error={form.errors.preview_image}
|
||||
placeholder="Paste a URL or leave empty if you upload a file"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-300">
|
||||
<div className="font-semibold text-white">Stored value</div>
|
||||
<div className="mt-1 break-all text-slate-400">{form.data.preview_image_file?.name || form.data.preview_image || previewUrl || 'None yet'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.errors.preview_image_file ? <p className="mt-3 text-sm text-rose-300">{form.errors.preview_image_file}</p> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
const form = useForm({ ...record, preview_image_file: null })
|
||||
const categoryField = useMemo(() => getField(fields, 'category_id'), [fields])
|
||||
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
||||
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
||||
const publishedAtField = useMemo(() => getField(fields, 'published_at'), [fields])
|
||||
const featuredField = useMemo(() => getField(fields, 'featured'), [fields])
|
||||
const promptOfWeekField = useMemo(() => getField(fields, 'prompt_of_week'), [fields])
|
||||
const activeField = useMemo(() => getField(fields, 'active'), [fields])
|
||||
const seoDescriptionField = useMemo(() => getField(fields, 'seo_description'), [fields])
|
||||
const previewUrl = form.data.preview_image_url || ''
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const payload = normalizePayload(fields, form.data)
|
||||
form.transform(() => payload)
|
||||
|
||||
if (method === 'patch') {
|
||||
form.patch(submitUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(submitUrl)
|
||||
}
|
||||
|
||||
const tagCount = String(form.data.tags || '')
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean).length
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,340px)]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
<SectionCard
|
||||
eyebrow="Identity"
|
||||
title="Core prompt details"
|
||||
description="Set the catalog identity first so the prompt is easy to find, sort, and preview."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{categoryField ? <NovaSelect label={categoryField.label} value={form.data.category_id ?? ''} onChange={(nextValue) => form.setData('category_id', nextValue ?? '')} options={categoryField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null}
|
||||
{difficultyField ? <NovaSelect label={difficultyField.label} value={form.data.difficulty ?? ''} onChange={(nextValue) => form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{accessField ? <NovaSelect label={accessField.label} value={form.data.access_level ?? ''} onChange={(nextValue) => form.setData('access_level', nextValue ?? '')} options={accessField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.access_level} /> : null}
|
||||
<TextField label="Aspect ratio" value={form.data.aspect_ratio || ''} onChange={(event) => form.setData('aspect_ratio', event.target.value)} error={form.errors.aspect_ratio} placeholder="1:1, 16:9, 3:2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextField label="Title" value={form.data.title || ''} onChange={(event) => form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} />
|
||||
<TextField label="Slug" value={form.data.slug || ''} onChange={(event) => form.setData('slug', event.target.value)} error={form.errors.slug} maxLength={180} placeholder="prompt-template-slug" />
|
||||
</div>
|
||||
|
||||
<TextAreaField label="Excerpt" value={form.data.excerpt || ''} onChange={(event) => form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short summary shown in the library and preview cards." />
|
||||
|
||||
<TextField label="Tags" value={form.data.tags || ''} onChange={(event) => form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="wallpaper, cinematic, neon, portrait" />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
eyebrow="Prompt body"
|
||||
title="Prompt instructions"
|
||||
description="Write the instruction stack, guardrails, and production notes in a way that is easy to scan."
|
||||
>
|
||||
<TextAreaField label="Prompt" value={form.data.prompt || ''} onChange={(event) => form.setData('prompt', event.target.value)} error={form.errors.prompt} rows={10} hint="This is the main model instruction used by creators." />
|
||||
<TextAreaField label="Negative prompt" value={form.data.negative_prompt || ''} onChange={(event) => form.setData('negative_prompt', event.target.value)} error={form.errors.negative_prompt} rows={5} hint="Optional exclusions, artifacts, or anti-patterns to avoid." />
|
||||
<TextAreaField label="Usage notes" value={form.data.usage_notes || ''} onChange={(event) => form.setData('usage_notes', event.target.value)} error={form.errors.usage_notes} rows={5} hint="Explain how to apply the prompt in a practical workflow." />
|
||||
<TextAreaField label="Workflow notes" value={form.data.workflow_notes || ''} onChange={(event) => form.setData('workflow_notes', event.target.value)} error={form.errors.workflow_notes} rows={5} hint="Internal editorial notes, camera settings, or prompt variants." />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
eyebrow="Publishing"
|
||||
title="Release controls"
|
||||
description="Choose when the prompt becomes visible and how it behaves in the academy."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{publishedAtField ? <DateTimePicker label={publishedAtField.label} value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} error={form.errors.published_at} clearable className="bg-black/20" /> : null}
|
||||
<TextField label="SEO title" value={form.data.seo_title || ''} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} />
|
||||
</div>
|
||||
{seoDescriptionField ? <TextAreaField label={seoDescriptionField.label} value={form.data.seo_description || ''} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} /> : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{featuredField ? <ToggleField label={featuredField.label} checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Highlight this prompt in featured rails." error={form.errors.featured} /> : null}
|
||||
{promptOfWeekField ? <ToggleField label={promptOfWeekField.label} checked={Boolean(form.data.prompt_of_week)} onChange={(event) => form.setData('prompt_of_week', event.target.checked)} help="Promote this prompt as the current weekly pick." error={form.errors.prompt_of_week} /> : null}
|
||||
{activeField ? <ToggleField label={activeField.label} checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Keep draft prompts hidden until they are ready." error={form.errors.active} /> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-6 xl:sticky xl:top-6 xl:self-start">
|
||||
<SectionCard
|
||||
eyebrow="At a glance"
|
||||
title="Prompt preview"
|
||||
description="A compact summary of what editors and visitors will see."
|
||||
>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
|
||||
{previewUrl || form.data.preview_image ? (
|
||||
<img src={previewUrl || form.data.preview_image} alt="Prompt preview" className="h-56 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500">No preview image selected yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt summary</p>
|
||||
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{form.data.title || 'Untitled prompt'}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-400">{form.data.excerpt || 'Add a concise excerpt to give the prompt some context in the library.'}</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Difficulty</dt><dd className="mt-1 text-sm text-white">{form.data.difficulty || '—'}</dd></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Access</dt><dd className="mt-1 text-sm text-white">{form.data.access_level || '—'}</dd></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Aspect</dt><dd className="mt-1 text-sm text-white">{form.data.aspect_ratio || '—'}</dd></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Tags</dt><dd className="mt-1 text-sm text-white">{tagCount}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-4 text-xs leading-6 text-slate-500">Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<PromptPreviewDropzone form={form} previewUrl={previewUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save prompt'}</button>
|
||||
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
||||
{destroyUrl ? <button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
const form = useForm(record)
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const payload = normalizePayload(fields, form.data)
|
||||
form.transform(() => payload)
|
||||
|
||||
if (method === 'patch') {
|
||||
form.transform(() => payload).patch(submitUrl)
|
||||
form.patch(submitUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.transform(() => payload).post(submitUrl)
|
||||
form.post(submitUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,13 +467,11 @@ export default function AcademyCrudForm({ title, subtitle, fields, record, submi
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<form onSubmit={submit} className="space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
{field.type !== 'checkbox' ? <label className="text-sm font-semibold text-white">{field.label}</label> : null}
|
||||
<Field field={field} form={form} />
|
||||
{form.errors[field.name] ? <p className="mt-2 text-sm text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="grid gap-5">
|
||||
{fields.map((field) => (
|
||||
<Field key={field.name} field={field} form={form} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save'}</button>
|
||||
@@ -102,4 +481,50 @@ export default function AcademyCrudForm({ title, subtitle, fields, record, submi
|
||||
</form>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
|
||||
if (resource === 'lessons') {
|
||||
return (
|
||||
<LessonEditor
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
fields={fields}
|
||||
record={record}
|
||||
submitUrl={submitUrl}
|
||||
indexUrl={indexUrl}
|
||||
destroyUrl={destroyUrl}
|
||||
method={method}
|
||||
editorContext={editorContext}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (resource === 'prompts') {
|
||||
return (
|
||||
<PromptEditor
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
fields={fields}
|
||||
record={record}
|
||||
submitUrl={submitUrl}
|
||||
indexUrl={indexUrl}
|
||||
destroyUrl={destroyUrl}
|
||||
method={method}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericEditor
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
fields={fields}
|
||||
record={record}
|
||||
submitUrl={submitUrl}
|
||||
indexUrl={indexUrl}
|
||||
destroyUrl={destroyUrl}
|
||||
method={method}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user