1363 lines
74 KiB
JavaScript
1363 lines
74 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import { Link, router, usePage } from '@inertiajs/react'
|
|
import SeoHead from '../../components/seo/SeoHead'
|
|
|
|
function academyHref(section, slug) {
|
|
return `/academy/${section}/${encodeURIComponent(slug)}`
|
|
}
|
|
|
|
function AcademyBreadcrumbs({ items = [] }) {
|
|
if (!items.length) return null
|
|
|
|
return (
|
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-sm text-slate-400">
|
|
{items.map((item, index) => {
|
|
const isLast = index === items.length - 1
|
|
|
|
return (
|
|
<React.Fragment key={`${item.label}-${index}`}>
|
|
{index > 0 ? <span className="text-slate-600">/</span> : null}
|
|
{isLast ? (
|
|
<span className="font-medium text-slate-200">{item.label}</span>
|
|
) : (
|
|
<Link href={item.href} className="transition hover:text-white">
|
|
{item.label}
|
|
</Link>
|
|
)}
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
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 LessonNavCard({ direction, lesson }) {
|
|
if (!lesson) return null
|
|
|
|
const eyebrow = direction === 'previous' ? 'Previous lesson' : 'Next lesson'
|
|
const alignClass = direction === 'previous' ? 'items-start text-left' : 'items-end text-right'
|
|
const href = lesson.course_url || `/academy/lessons/${lesson.slug}`
|
|
|
|
return (
|
|
<Link
|
|
href={href}
|
|
aria-label={`${eyebrow}: ${lesson.title}`}
|
|
className={`group flex min-h-full flex-col justify-between rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-sky-300/25 hover:bg-white/[0.06] ${alignClass}`}
|
|
>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">{eyebrow}</p>
|
|
{lesson.lesson_label ? <p className="mt-3 text-xs font-semibold uppercase tracking-[0.2em] text-amber-100">{lesson.lesson_label}</p> : null}
|
|
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
|
|
</div>
|
|
<p className="mt-4 text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open the next step in this Academy sequence.'}</p>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function LockedPanel({ pricingUrl, label }) {
|
|
return (
|
|
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Premium content</p>
|
|
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em]">Unlock the full {label}.</h2>
|
|
<p className="mt-3 text-sm leading-7 text-amber-50/90">This preview is visible, but the full Academy content stays server-side until your account has the required Creator or Pro access.</p>
|
|
<Link href={pricingUrl} className="mt-5 inline-flex rounded-full border border-amber-200/25 bg-white/10 px-5 py-3 text-sm font-semibold text-white">See Academy plans</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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, label = 'Copy 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' : label}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function ImageLightbox({ gallery, onClose, onNavigate }) {
|
|
useEffect(() => {
|
|
if (!gallery?.images?.length) return undefined
|
|
|
|
const handleEscape = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose()
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowLeft') {
|
|
onNavigate(-1)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowRight') {
|
|
onNavigate(1)
|
|
}
|
|
}
|
|
|
|
document.body.style.overflow = 'hidden'
|
|
window.addEventListener('keydown', handleEscape)
|
|
|
|
return () => {
|
|
document.body.style.overflow = ''
|
|
window.removeEventListener('keydown', handleEscape)
|
|
}
|
|
}, [gallery, onClose, onNavigate])
|
|
|
|
const images = Array.isArray(gallery?.images) ? gallery.images : []
|
|
const currentIndex = Math.max(0, Math.min(images.length - 1, Number(gallery?.index || 0)))
|
|
const currentImage = images[currentIndex]
|
|
|
|
if (!currentImage?.src) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-[#020611e6] p-4 backdrop-blur-md" onClick={onClose} role="dialog" aria-modal="true" aria-label={currentImage.alt || 'Image preview'}>
|
|
<button type="button" onClick={onClose} className="absolute right-4 top-4 inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10" aria-label="Close image preview">
|
|
<i className="fa-solid fa-xmark text-lg" />
|
|
</button>
|
|
{images.length > 1 ? (
|
|
<button type="button" onClick={(event) => { event.stopPropagation(); onNavigate(-1) }} className="absolute left-4 top-1/2 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10" aria-label="Previous image">
|
|
<i className="fa-solid fa-chevron-left" />
|
|
</button>
|
|
) : null}
|
|
{images.length > 1 ? (
|
|
<button type="button" onClick={(event) => { event.stopPropagation(); onNavigate(1) }} className="absolute right-4 top-1/2 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10" aria-label="Next image">
|
|
<i className="fa-solid fa-chevron-right" />
|
|
</button>
|
|
) : null}
|
|
<div className="max-h-[92vh] max-w-[min(1400px,96vw)] overflow-hidden rounded-[30px] border border-white/10 bg-black/30 shadow-[0_30px_120px_rgba(0,0,0,0.5)]" onClick={(event) => event.stopPropagation()}>
|
|
<img src={currentImage.src} alt={currentImage.alt || ''} className="max-h-[92vh] w-full object-contain" />
|
|
{images.length > 1 ? (
|
|
<div className="flex items-center justify-between gap-4 border-t border-white/10 bg-black/35 px-5 py-3 text-sm text-slate-200">
|
|
<div>
|
|
<p className="font-semibold text-white">{currentImage.alt || `Image ${currentIndex + 1}`}</p>
|
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">{`Image ${currentIndex + 1} of ${images.length}`}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{images.map((image, index) => (
|
|
<button
|
|
key={`${image.src}-${index}`}
|
|
type="button"
|
|
onClick={() => onNavigate(index - currentIndex)}
|
|
className={`h-2.5 w-2.5 rounded-full transition ${index === currentIndex ? 'bg-white' : 'bg-white/25 hover:bg-white/45'}`}
|
|
aria-label={`Go to image ${index + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PromptToolNoteCard({ note, index, galleryIndex, onOpenImage }) {
|
|
if (!note || typeof note !== 'object') return null
|
|
|
|
const title = note.model_name || note.provider || `Comparison ${String(index + 1).padStart(2, '0')}`
|
|
const subtitle = [note.provider, note.model_name].filter(Boolean).join(' · ')
|
|
const previewUrl = note.image_url || note.thumb_url || ''
|
|
const hasContent = Boolean(note.notes || note.strengths || note.weaknesses || note.best_for || note.settings || previewUrl || note.score || subtitle)
|
|
|
|
if (!hasContent) return null
|
|
|
|
return (
|
|
<article className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(15,23,42,0.22))] p-5 shadow-[0_16px_40px_rgba(2,6,23,0.16)]">
|
|
{previewUrl ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenImage?.(galleryIndex)}
|
|
className="group mb-5 block w-full overflow-hidden rounded-[24px] border border-white/10 bg-slate-950 text-left transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35"
|
|
aria-label={`Open comparison image for ${title}`}
|
|
>
|
|
<div className="relative">
|
|
<img src={previewUrl} alt={title} loading="lazy" className="aspect-[4/3] w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
|
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))] px-4 py-3">
|
|
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-100/90">Click to zoom</span>
|
|
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25 text-white">
|
|
<i className="fa-solid fa-expand" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
) : null}
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#ffcfbf]">AI comparison</p>
|
|
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h3>
|
|
{subtitle ? <p className="mt-1 text-sm text-slate-400">{subtitle}</p> : null}
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{String(index + 1).padStart(2, '0')}</span>
|
|
{note.score ? <span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-xs font-semibold text-[#fff0ea]">{`Score ${note.score}/10`}</span> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-4">
|
|
{note.settings ? (
|
|
<div className="rounded-[22px] border border-white/10 bg-black/25 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Generated in</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{note.settings}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{note.notes ? (
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Overall notes</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{note.notes}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{note.best_for ? (
|
|
<div className="rounded-[22px] border border-sky-300/15 bg-sky-300/10 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">Best for</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100">{note.best_for}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{note.strengths ? (
|
|
<div className="rounded-[22px] border border-emerald-300/15 bg-emerald-300/10 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100">Strengths</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100">{note.strengths}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{note.weaknesses ? (
|
|
<div className="rounded-[22px] border border-amber-300/15 bg-amber-300/10 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Weaknesses</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100">{note.weaknesses}</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
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 = [], relatedCourses = [], previousLesson = null, nextLesson = null, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl, courseContext = null }) {
|
|
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 [lightboxGallery, setLightboxGallery] = useState(null)
|
|
const articleContentRef = useRef(null)
|
|
const handledInitialHashRef = useRef(false)
|
|
const lessonCover = item?.cover_image_url || item?.cover_image || ''
|
|
const articleCover = item?.article_cover_image_url || item?.article_cover_image || ''
|
|
const lessonCategory = item?.category?.name || 'Academy'
|
|
const lessonSeries = String(item?.series_name || '').trim() || lessonCategory
|
|
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 relatedCourseList = Array.isArray(relatedCourses) ? relatedCourses : []
|
|
const courseOutline = Array.isArray(courseContext?.outline) ? courseContext.outline : []
|
|
const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.'
|
|
const lessonTags = Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []
|
|
const promptPreviewImage = item?.preview_image || ''
|
|
const promptBody = item?.prompt || item?.prompt_preview || ''
|
|
const promptComparisons = Array.isArray(item?.tool_notes)
|
|
? item.tool_notes.filter((note) => note && typeof note === 'object' && note.active !== false && [
|
|
note.provider,
|
|
note.model_name,
|
|
note.notes,
|
|
note.strengths,
|
|
note.weaknesses,
|
|
note.best_for,
|
|
note.image_path,
|
|
note.image_url,
|
|
note.thumb_path,
|
|
note.thumb_url,
|
|
note.settings,
|
|
note.score,
|
|
].some(Boolean))
|
|
: []
|
|
const promptUsageNotes = String(item?.usage_notes || '').trim()
|
|
const promptWorkflowNotes = String(item?.workflow_notes || '').trim()
|
|
const promptHasFullAccess = Boolean(item?.prompt)
|
|
const promptModelsCovered = promptComparisons.map((note, index) => note.model_name || note.provider || `Model ${index + 1}`)
|
|
const promptComparisonGalleryImages = promptComparisons
|
|
.map((note, index) => {
|
|
const src = note.image_url || note.thumb_url || ''
|
|
if (!src) return null
|
|
|
|
return {
|
|
src,
|
|
alt: note.model_name || note.provider || `Comparison ${index + 1}`,
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
const academyBreadcrumbs = pageType === 'prompt'
|
|
? [
|
|
{ label: 'Academy', href: '/academy' },
|
|
{ label: 'Prompt Library', href: '/academy/prompts' },
|
|
{ label: item?.title || 'Prompt' },
|
|
]
|
|
: []
|
|
const fontScaleStorageKey = 'academy.lesson.font-scale'
|
|
const fontScaleMin = 0.95
|
|
const fontScaleMax = 1.12
|
|
const fontScaleStep = 0.04
|
|
const [lessonFontScale, setLessonFontScale] = useState(1.04)
|
|
|
|
const findArticleHeading = (headingId) => {
|
|
if (!headingId || typeof document === 'undefined') {
|
|
return null
|
|
}
|
|
|
|
const escapedHeadingId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
|
? CSS.escape(headingId)
|
|
: String(headingId).replace(/[^a-zA-Z0-9_-]/g, '')
|
|
|
|
return articleContentRef.current?.querySelector(`#${escapedHeadingId}`) || document.getElementById(headingId)
|
|
}
|
|
|
|
const markComplete = () => {
|
|
if (!completeUrl || completed) return
|
|
router.post(completeUrl, courseContext?.completePayload || {}, {
|
|
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))))
|
|
}
|
|
|
|
const openPromptPreviewImage = () => {
|
|
if (!promptPreviewImage) return
|
|
|
|
setLightboxGallery({
|
|
images: [{ src: promptPreviewImage, alt: item?.title || 'Prompt preview' }],
|
|
index: 0,
|
|
})
|
|
}
|
|
|
|
const openPromptComparisonGallery = (index) => {
|
|
if (!promptComparisonGalleryImages.length) return
|
|
|
|
setLightboxGallery({
|
|
images: promptComparisonGalleryImages,
|
|
index: Math.max(0, Math.min(promptComparisonGalleryImages.length - 1, Number(index || 0))),
|
|
})
|
|
}
|
|
|
|
const navigateLightboxGallery = (direction) => {
|
|
setLightboxGallery((current) => {
|
|
if (!current?.images?.length) return current
|
|
|
|
const total = current.images.length
|
|
const nextIndex = typeof direction === 'number' && Math.abs(direction) > 1
|
|
? Math.max(0, Math.min(total - 1, current.index + direction))
|
|
: (current.index + direction + total) % total
|
|
|
|
return {
|
|
...current,
|
|
index: nextIndex,
|
|
}
|
|
})
|
|
}
|
|
|
|
const scrollToHeading = (headingId, behavior = 'smooth') => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
|
|
const heading = findArticleHeading(headingId)
|
|
|
|
if (!heading) {
|
|
return
|
|
}
|
|
|
|
const top = Math.max(0, window.scrollY + heading.getBoundingClientRect().top - 112)
|
|
window.scrollTo({ top, behavior })
|
|
setActiveHeadingId(headingId)
|
|
|
|
if (window.history?.replaceState) {
|
|
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${headingId}`)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
handledInitialHashRef.current = false
|
|
}, [item?.slug])
|
|
|
|
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
|
|
heading.style.scrollMarginTop = '128px'
|
|
|
|
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 || handledInitialHashRef.current || typeof window === 'undefined') {
|
|
return
|
|
}
|
|
|
|
const hash = window.location.hash.replace(/^#/, '').trim()
|
|
|
|
if (!hash) {
|
|
handledInitialHashRef.current = true
|
|
return
|
|
}
|
|
|
|
const matchingEntry = tableOfContents.find((entry) => entry.id === hash)
|
|
|
|
if (!matchingEntry) {
|
|
handledInitialHashRef.current = true
|
|
return
|
|
}
|
|
|
|
handledInitialHashRef.current = true
|
|
window.requestAnimationFrame(() => scrollToHeading(matchingEntry.id, 'auto'))
|
|
}, [pageType, tableOfContents])
|
|
|
|
useEffect(() => {
|
|
if (pageType !== 'lesson' || tableOfContents.length === 0 || typeof window === 'undefined') {
|
|
return undefined
|
|
}
|
|
|
|
const handleHashChange = () => {
|
|
const hash = window.location.hash.replace(/^#/, '').trim()
|
|
|
|
if (!hash) {
|
|
return
|
|
}
|
|
|
|
const matchingEntry = tableOfContents.find((entry) => entry.id === hash)
|
|
|
|
if (!matchingEntry) {
|
|
return
|
|
}
|
|
|
|
window.requestAnimationFrame(() => scrollToHeading(matchingEntry.id, 'auto'))
|
|
}
|
|
|
|
window.addEventListener('hashchange', handleHashChange)
|
|
return () => window.removeEventListener('hashchange', handleHashChange)
|
|
}, [pageType, tableOfContents])
|
|
|
|
useEffect(() => {
|
|
if (pageType !== 'lesson' || tableOfContents.length === 0 || !articleContentRef.current) {
|
|
setActiveHeadingId('')
|
|
return
|
|
}
|
|
|
|
const getActiveId = () => {
|
|
const headings = Array.from(articleContentRef.current.querySelectorAll('h2[id], h3[id]'))
|
|
if (!headings.length) return ''
|
|
|
|
// offset accounts for sticky header height + small buffer
|
|
const offset = 140
|
|
let activeId = headings[0].id
|
|
|
|
for (const heading of headings) {
|
|
if (heading.getBoundingClientRect().top <= offset) {
|
|
activeId = heading.id
|
|
}
|
|
}
|
|
|
|
return activeId
|
|
}
|
|
|
|
setActiveHeadingId(getActiveId())
|
|
|
|
const onScroll = () => setActiveHeadingId(getActiveId())
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
|
|
return () => window.removeEventListener('scroll', onScroll)
|
|
}, [pageType, tableOfContents, lessonFontScale])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
|
|
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
|
|
|
if (!Number.isFinite(storedValue)) {
|
|
return
|
|
}
|
|
|
|
setLessonFontScale(Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue)))
|
|
}, [fontScaleMax, fontScaleMin, fontScaleStorageKey])
|
|
|
|
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.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.14),_transparent_26%),linear-gradient(180deg,_#0b1220_0%,_#111827_46%,_#0f172a_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-[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}
|
|
|
|
{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>
|
|
|
|
{item.lesson_label ? <p className="mt-5 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100">{item.lesson_label}</p> : null}
|
|
|
|
<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>
|
|
|
|
{lessonTags.length ? (
|
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
{lessonTags.map((tag) => (
|
|
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{tag}</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{courseContext?.title ? (
|
|
<div className="mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-black/25 p-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Part of course</p>
|
|
<Link href={courseContext.showUrl} className="mt-2 inline-flex text-lg font-semibold text-sky-100 transition hover:text-white">{courseContext.title}</Link>
|
|
<p className="mt-2 text-sm leading-7 text-slate-300">{courseContext.subtitle || 'This lesson is being viewed inside a structured Academy course path.'}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<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={courseContext?.title ? 'Course progress' : 'Access'} value={courseContext?.progress ? `${courseContext.progress.percent}%` : (item.access_level || 'free')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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={lessonSeries} />
|
|
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
|
<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.' : courseContext?.title ? 'This lesson is being tracked inside a course. Completion updates your course progress.' : '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-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] 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">
|
|
{articleCover ? (
|
|
<div className="mb-8 overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
|
|
<img src={articleCover} alt={`${item.title} article cover`} className="w-full object-cover" />
|
|
</div>
|
|
) : null}
|
|
|
|
{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>
|
|
|
|
{(previousLesson || nextLesson) ? (
|
|
<section className="mt-10 border-t border-white/10 pt-8">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">{courseContext?.title ? 'Course navigation' : 'Lesson navigation'}</p>
|
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{courseContext?.title ? 'Continue this course' : 'Continue in order'}</h3>
|
|
|
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
|
<LessonNavCard direction="previous" lesson={previousLesson} />
|
|
<LessonNavCard direction="next" lesson={nextLesson} />
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</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-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<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}`}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
scrollToHeading(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-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">{courseContext?.title ? 'Course progress' : 'Series info'}</p>
|
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{courseContext?.title ? courseContext.title : lessonSeries}</h3>
|
|
<div className="mt-5 space-y-3">
|
|
<LessonInfoRow label="Category" value={lessonCategory} />
|
|
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
|
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
|
<LessonInfoRow label="Reading" value={lessonMinutes} />
|
|
<LessonInfoRow label={courseContext?.title ? 'Progress' : 'Updated'} value={courseContext?.progress ? `${courseContext.progress.completedRequired}/${courseContext.progress.totalRequired} completed` : lessonUpdated} />
|
|
</div>
|
|
</section>
|
|
|
|
{courseOutline.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Course outline</p>
|
|
<div className="mt-4 space-y-2">
|
|
{courseOutline.map((outlineLesson, index) => (
|
|
<Link key={outlineLesson.course_lesson_id || outlineLesson.id || index} href={outlineLesson.course_url || `/academy/lessons/${outlineLesson.slug}`} className={`flex items-start gap-3 rounded-[20px] border px-4 py-3 text-sm transition ${outlineLesson.slug === item.slug ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300 hover:border-sky-300/25 hover:bg-white/[0.06]'}`}>
|
|
<span className="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-[10px] font-semibold">{String(index + 1).padStart(2, '0')}</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block font-semibold">{outlineLesson.title}</span>
|
|
<span className="mt-1 block text-xs uppercase tracking-[0.16em] text-slate-500">{outlineLesson.is_required ? 'Required' : 'Optional'}</span>
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{lessonTags.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{lessonTags.map((tag) => (
|
|
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{relatedLessonList.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<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 {lessonSeries}</h3>
|
|
|
|
<div className="mt-5 space-y-3">
|
|
{relatedLessonList.map((relatedLesson, index) => (
|
|
<Link
|
|
key={relatedLesson.id}
|
|
href={relatedLesson.course_url || `/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">
|
|
<div>
|
|
{relatedLesson.formatted_lesson_number ? <p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{relatedLesson.formatted_lesson_number}</p> : null}
|
|
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
|
|
</div>
|
|
<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}
|
|
|
|
{relatedCourseList.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Related courses</p>
|
|
<div className="mt-4 space-y-3">
|
|
{relatedCourseList.map((course) => (
|
|
<Link key={course.id} href={course.public_url} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{course.difficulty} · {course.access_level}</p>
|
|
<h4 className="mt-2 text-sm font-semibold text-white">{course.title}</h4>
|
|
<p className="mt-2 text-xs leading-6 text-slate-400">{course.excerpt || course.description || 'Open this course to continue with a guided path.'}</p>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
) : pageType === 'prompt' ? (
|
|
<div className="space-y-8">
|
|
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(4,10,20,0.98),rgba(15,23,42,0.9))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
|
<div className="grid gap-0 lg:grid-cols-[minmax(420px,0.92fr)_minmax(0,1.08fr)]">
|
|
<div className="relative border-b border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(255,183,139,0.18),transparent_32%),linear-gradient(180deg,rgba(5,10,20,0.98),rgba(10,17,30,0.94))] p-6 md:p-8 lg:min-h-[760px] lg:border-b-0 lg:border-r lg:border-white/10 lg:p-10">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_80%_75%,rgba(255,207,191,0.12),transparent_28%)]" />
|
|
<div className="relative flex h-full flex-col">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Preview artwork</p>
|
|
{promptPreviewImage ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">Click to zoom</span> : null}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={openPromptPreviewImage}
|
|
className="group mt-4 flex-1 overflow-hidden rounded-[32px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35"
|
|
disabled={!promptPreviewImage}
|
|
aria-label={promptPreviewImage ? `Open preview image for ${item.title}` : 'Preview image unavailable'}
|
|
>
|
|
{promptPreviewImage ? (
|
|
<div className="relative h-full min-h-[360px] overflow-hidden lg:min-h-[620px]">
|
|
<img src={promptPreviewImage} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.28))]" />
|
|
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 backdrop-blur-md">
|
|
<div>
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Prompt visual</p>
|
|
<p className="mt-1 text-sm font-semibold text-white">Open full-size preview</p>
|
|
</div>
|
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/10 text-white">
|
|
<i className="fa-solid fa-expand" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center lg:min-h-[620px]">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Visual placeholder</p>
|
|
<p className="mt-4 text-lg font-semibold text-white">Preview image coming soon</p>
|
|
<p className="mt-3 text-sm leading-7 text-slate-300">This prompt page will feel much better once the generated cover image is attached.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative overflow-hidden p-8 md:p-10 lg:p-12">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,183,139,0.14),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_28%)]" />
|
|
<div className="relative z-10 max-w-4xl">
|
|
{academyBreadcrumbs.length ? (
|
|
<div className="mb-6">
|
|
<AcademyBreadcrumbs items={academyBreadcrumbs} />
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">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>
|
|
{item.aspect_ratio ? <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">{item.aspect_ratio}</span> : null}
|
|
{item.prompt_of_week ? <span className="rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-100">Prompt of the week</span> : null}
|
|
{item.featured ? <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">Featured</span> : null}
|
|
</div>
|
|
|
|
<p className="mt-8 text-sm font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Prompt template</p>
|
|
<h1 className="mt-4 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{item.title}</h1>
|
|
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
|
|
|
<div className="mt-7 flex flex-wrap gap-3">
|
|
{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}
|
|
{promptBody ? <PromptCopyButton prompt={promptBody} /> : null}
|
|
{item.negative_prompt ? <PromptCopyButton prompt={item.negative_prompt} label="Copy negative" /> : null}
|
|
</div>
|
|
|
|
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
<StatPill label="Category" value={lessonCategory} />
|
|
<StatPill label="Access" value={item.access_level || 'free'} />
|
|
<StatPill label="Difficulty" value={lessonDifficulty} />
|
|
<StatPill label="Updated" value={lessonUpdated} />
|
|
</div>
|
|
|
|
{lessonTags.length ? (
|
|
<div className="mt-8 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{lessonTags.map((tag) => (
|
|
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-8 grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(280px,0.95fr)]">
|
|
<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">Prompt status</p>
|
|
<p className="mt-3 text-sm leading-7 text-slate-300">
|
|
{item.locked
|
|
? 'This page shows the prompt summary, but the full prompt text and editor notes stay locked until your Academy access level matches the template.'
|
|
: 'This template includes the main prompt, reuse guidance, and model-specific comparison notes in one place.'}
|
|
</p>
|
|
</div>
|
|
|
|
{promptModelsCovered.length ? (
|
|
<div className="rounded-[28px] border border-[#ffcfbf]/12 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Compared with</p>
|
|
<p className="mt-2 text-sm text-slate-300">{promptModelsCovered.length} model{promptModelsCovered.length > 1 ? 's' : ''} documented for this prompt.</p>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">{promptModelsCovered.length}</span>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{promptModelsCovered.map((model) => (
|
|
<span key={model} className="rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">{model}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
<div className="space-y-8">
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] 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-[#ffd8cd]">Prompt body</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Prompt text and exclusions</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-6">
|
|
<div className="rounded-[28px] border border-[#ffcfbf]/15 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5 md:p-6">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#fff0ea]">{promptHasFullAccess ? 'Full prompt' : 'Preview prompt'}</p>
|
|
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">{promptHasFullAccess ? 'Ready to paste into your generation workflow.' : 'Upgrade your Academy access to reveal the complete prompt text.'}</p>
|
|
</div>
|
|
</div>
|
|
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 md:p-5">{promptBody || 'Prompt text is not available yet.'}</pre>
|
|
</div>
|
|
|
|
{item.negative_prompt ? (
|
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5 md:p-6">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p>
|
|
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 md:p-5">{item.negative_prompt}</pre>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
{(promptUsageNotes || promptWorkflowNotes) ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)] md:p-8">
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Prompt guidance</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">How to use this prompt</h2>
|
|
</div>
|
|
{!promptHasFullAccess ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Full notes visible with access</span> : null}
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
|
{promptUsageNotes ? (
|
|
<div className="rounded-[26px] border border-white/10 bg-black/20 p-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75">Usage notes</p>
|
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{promptUsageNotes}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{promptWorkflowNotes ? (
|
|
<div className="rounded-[26px] border border-white/10 bg-black/20 p-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-200/75">Workflow notes</p>
|
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{promptWorkflowNotes}</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{promptComparisons.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
|
|
<div className="max-w-3xl">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffcfbf]">AI model comparisons</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">How different models respond to the same prompt</h2>
|
|
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">Use these notes to decide which provider fits the result you want before you start tuning or post-processing.</p>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-5 xl:grid-cols-2">
|
|
{promptComparisons.map((note, index) => <PromptToolNoteCard key={`${note.provider || 'provider'}-${note.model_name || 'model'}-${index}`} note={note} index={index} galleryIndex={index} onOpenImage={openPromptComparisonGallery} />)}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
|
|
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
|
{lessonTags.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{lessonTags.map((tag) => (
|
|
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Best use case</p>
|
|
<p className="mt-3 text-sm leading-7 text-slate-300">{promptComparisons[0]?.best_for || promptUsageNotes || lessonSummary}</p>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
|
{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>
|
|
|
|
<ImageLightbox gallery={lightboxGallery} onClose={() => setLightboxGallery(null)} onNavigate={navigateLightboxGallery} />
|
|
</main>
|
|
)
|
|
} |