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 (
)
}
function LessonInfoRow({ label, value }) {
return (
{label}
{value}
)
}
function LockedPanel({ pricingUrl, label }) {
return (
Premium content
Unlock the full {label}.
This preview is visible, but the full Academy content stays server-side until your account has the required Creator or Pro access.
See Academy plans
)
}
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 (
)
}
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 (
AI Model Comparison
{payload.title || block.title || 'Same Prompt, Different AI Models'}
{payload.intro ?
{payload.intro}
: null}
{payload.aspect_ratio ?
Aspect ratio {payload.aspect_ratio}
: null}
{hasPrompt ? (
Prompt used
Shared source prompt across all compared models
{payload.prompt}
{hasNegativePrompt ? (
Negative prompt
{payload.negative_prompt}
) : null}
) : null}
{criteria.length ? (
What we compare
{criteria.map((criterion) => (
{criterion}
))}
) : null}
{results.length ? (
{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 (
{imageUrl ? (

) : (
No comparison image provided.
)}
{result.model_name || result.provider || 'AI model'}
{result.provider ?
{result.provider}
: null}
{hasScore ?
{`Skinbase score ${score}/10`}
: null}
{result.settings ? (
Settings
{result.settings}
) : null}
{result.strengths ? (
Strengths
{result.strengths}
) : null}
{result.weaknesses ? (
Weaknesses
{result.weaknesses}
) : null}
{result.best_for ? (
Best for
{result.best_for}
) : null}
)
})}
) : null}
)
}
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
router.post(completeUrl, {}, {
preserveScroll: true,
onSuccess: () => setCompleted(true),
})
}
const toggleSave = () => {
const url = saved ? unsaveUrl : saveUrl
const method = saved ? router.delete : router.post
method(url, {}, {
preserveScroll: true,
onSuccess: () => setSaved(!saved),
})
}
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 (
{flash.success ?
{flash.success}
: null}
{flash.error ?
{flash.error}
: null}
{item.locked ?
: null}
{pageType === 'lesson' ? (
{lessonCover ?

: null}
Skinbase AI Academy
{lessonCategory}
{lessonDifficulty}
{item.title}
{lessonSummary}
{completeUrl ? : null}
{saveUrl ? : null}
{submitUrl ? Submit artwork : null}
{lessonMinutes}
{Math.round(lessonFontScale * 100)}%
{item.content ? (
{lessonBlocks.map((block) =>
)}
) : (
{item.content_preview}
{lessonBlocks.map((block) =>
)}
)}
) : (
{pageType === 'prompt' ? (
Prompt
{item.prompt || item.prompt_preview}
{item.negative_prompt ?
Negative prompt
{item.negative_prompt} : null}
) : null}
{pageType === 'pack' ? (
{item.description}
{(item.prompts || []).map((prompt) => (
{prompt.title}
{prompt.excerpt || prompt.prompt_preview}
))}
) : null}
{pageType === 'challenge' ? (
Brief
{item.brief || item.description}
Rules
{item.rules || 'No special rules posted yet.'}
{(item.submissions || []).length ? (
Approved submissions
{item.submissions.map((submission) => (
{submission.artwork?.title || 'Submission'}
{submission.user?.name || 'Unknown creator'}
))}
) : null}
) : null}
)}
)
}