Files
SkinbaseNova/resources/js/Pages/Academy/List.jsx

365 lines
19 KiB
JavaScript

import React from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import NovaSelect from '../../components/ui/NovaSelect'
import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
function academyHref(section, slug) {
return `/academy/${section}/${encodeURIComponent(slug)}`
}
function QueryFilters({ pageType, filters, categories }) {
if (pageType !== 'lessons' && pageType !== 'prompts') {
return null
}
const categoryOptions = [{ value: '', label: 'All categories' }, ...(categories || []).map((category) => ({ value: category.slug, label: category.name }))]
const difficultyOptions = [
{ value: '', label: 'All levels' },
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'pro', label: 'Pro' },
]
return (
<div className="grid gap-3 rounded-[28px] border border-white/10 bg-black/20 p-5 md:grid-cols-3">
<input
defaultValue={filters?.q || ''}
placeholder={`Search ${pageType}`}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
onKeyDown={(event) => {
if (event.key !== 'Enter') return
router.get(window.location.pathname, { ...filters, q: event.currentTarget.value }, { preserveState: true, preserveScroll: true })
}}
/>
<NovaSelect
value={filters?.category || ''}
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, category: nextValue || undefined }, { preserveState: true, preserveScroll: true })}
options={categoryOptions}
searchable={false}
className="rounded-2xl bg-white/[0.04]"
placeholder="All categories"
/>
<NovaSelect
value={filters?.difficulty || ''}
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, difficulty: nextValue || undefined }, { preserveState: true, preserveScroll: true })}
options={difficultyOptions}
searchable={false}
className="rounded-2xl bg-white/[0.04]"
placeholder="All levels"
/>
</div>
)
}
function LockBadge({ item }) {
if (!item?.locked) return <span className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-100">{item.access_level}</span>
return <span className="rounded-full border border-amber-300/25 bg-amber-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">Locked · {item.access_level}</span>
}
function itemHref(pageType, item) {
if (pageType === 'lessons') return academyHref('lessons', item.slug)
if (pageType === 'prompts') return academyHref('prompts', item.slug)
if (pageType === 'packs') return academyHref('packs', item.slug)
return academyHref('challenges', item.slug)
}
function searchResultContentType(pageType) {
if (pageType === 'prompts') return 'academy_prompt'
if (pageType === 'lessons') return 'academy_lesson'
if (pageType === 'packs') return 'academy_prompt_pack'
if (pageType === 'challenges') return 'academy_challenge'
return null
}
function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) {
const featuredImages = (items || [])
.map((item) => item?.preview_image)
.filter(Boolean)
.slice(0, 3)
const primaryImage = featuredImages[0] || ''
const supportingImages = featuredImages.slice(1, 3)
return (
<section className="overflow-hidden rounded-[38px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.14),transparent_26%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_26%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.28)] md:p-10 lg:p-12">
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_420px] xl:items-end">
<div>
<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">Prompt Library</span>
</div>
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{description}</p>
<div className="mt-7 grid gap-3 sm:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visual-first</p>
<p className="mt-2 text-sm font-semibold text-white">Preview prompt results before opening the detail page.</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reusable</p>
<p className="mt-2 text-sm font-semibold text-white">Templates for wallpapers, covers, worlds, portraits, and more.</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparison-ready</p>
<p className="mt-2 text-sm font-semibold text-white">See which prompts include provider-specific notes and outputs.</p>
</div>
</div>
<div className="mt-7 flex flex-wrap gap-3">
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview</Link>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">{totalCount || 0} prompts available</span>
</div>
</div>
<div className="grid gap-3">
{primaryImage ? (
<>
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-[16/10]">
<img src={primaryImage} alt="" aria-hidden="true" className="h-full w-full object-cover" />
</div>
{supportingImages.length ? (
<div className={`grid gap-3 ${supportingImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
{supportingImages.map((image, index) => (
<div key={`${image}-${index}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square">
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
</div>
))}
</div>
) : null}
</>
) : (
<div className="col-span-2 flex aspect-[16/10] items-center justify-center rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(17,24,39,0.92))] px-8 text-center text-sm font-semibold uppercase tracking-[0.24em] text-slate-300">
Prompt preview images will appear here
</div>
)}
</div>
</div>
</section>
)
}
function AcademyCard({ pageType, item, analytics, searchContext, position }) {
const lessonSeries = String(item?.series_name || '').trim()
const promptPreviewImage = item?.preview_image || ''
const contentType = searchResultContentType(pageType)
const href = itemHref(pageType, item)
const trackSearchClick = () => {
if (!searchContext?.query || !contentType) {
return
}
trackAcademySearchResultClick(analytics, searchContext, {
contentType,
contentId: item?.id,
position,
})
}
if (pageType === 'prompts') {
return (
<Link
href={href}
onClick={trackSearchClick}
data-academy-content-type={contentType || undefined}
data-academy-content-id={item?.id || undefined}
data-academy-search-query={searchContext?.query || undefined}
data-academy-search-results-count={searchContext?.resultsCount || undefined}
data-academy-search-position={position || undefined}
className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-sky-300/25 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
>
<div className="relative aspect-[16/11] overflow-hidden bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))]">
{promptPreviewImage ? <img src={promptPreviewImage} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">Prompt template</span>
<LockBadge item={item} />
</div>
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap gap-2">
{item?.difficulty ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{item.difficulty}</span> : null}
{item?.aspect_ratio ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{item.aspect_ratio}</span> : null}
</div>
</div>
</div>
<div className="p-5">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{item?.category?.name || 'Academy'}</p>
{Array.isArray(item?.tool_notes) && item.tool_notes.length ? <span className="rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">{item.tool_notes.length} comparisons</span> : null}
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{item.title}</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || 'No description yet.'}</p>
{item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
</div>
</Link>
)
}
return (
<Link
href={href}
onClick={trackSearchClick}
data-academy-content-type={contentType || undefined}
data-academy-content-id={item?.id || undefined}
data-academy-search-query={searchContext?.query || undefined}
data-academy-search-results-count={searchContext?.resultsCount || undefined}
data-academy-search-position={position || undefined}
className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]"
>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{pageType.slice(0, -1)}</p>
<LockBadge item={item} />
</div>
{pageType === 'lessons' && item?.formatted_lesson_number ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">{item.formatted_lesson_number}</span>
{lessonSeries ? <span className="text-xs font-medium uppercase tracking-[0.18em] text-slate-500">{lessonSeries}</span> : null}
</div>
) : null}
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview || 'No description yet.'}</p>
{pageType === 'lessons' && item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
{pageType === 'prompts' && item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
{pageType === 'challenges' ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.status} · {item.submission_count ?? 0} submissions</p> : null}
</Link>
)
}
async function fetchAcademyPage(url) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Failed to load the next page.')
}
return response.json()
}
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl, analytics }) {
const flash = usePage().props.flash || {}
useAcademyPageAnalytics(analytics)
const searchContext = analytics?.search ? {
query: analytics.search.query,
normalizedQuery: analytics.search.normalizedQuery,
resultsCount: analytics.search.resultsCount,
filters,
} : null
const initialItems = React.useMemo(() => (Array.isArray(items?.data) ? items.data : []), [items])
const [visibleItems, setVisibleItems] = React.useState(initialItems)
const [pagination, setPagination] = React.useState({
currentPage: Number(items?.current_page || 1),
lastPage: Number(items?.last_page || 1),
nextPageUrl: items?.next_page_url || null,
})
const [loadingMore, setLoadingMore] = React.useState(false)
const sentinelRef = React.useRef(null)
React.useEffect(() => {
setVisibleItems(initialItems)
setPagination({
currentPage: Number(items?.current_page || 1),
lastPage: Number(items?.last_page || 1),
nextPageUrl: items?.next_page_url || null,
})
setLoadingMore(false)
}, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, pageType])
const hasMorePages = pageType === 'prompts' && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl)
const loadMore = React.useCallback(async () => {
if (pageType !== 'prompts' || loadingMore || !pagination.nextPageUrl) {
return
}
setLoadingMore(true)
try {
const payload = await fetchAcademyPage(pagination.nextPageUrl)
const nextItems = Array.isArray(payload?.data) ? payload.data : []
setVisibleItems((current) => [...current, ...nextItems.filter((item) => !current.some((existing) => String(existing.id) === String(item.id)))])
setPagination({
currentPage: Number(payload?.current_page || pagination.currentPage),
lastPage: Number(payload?.last_page || pagination.lastPage),
nextPageUrl: payload?.next_page_url || null,
})
} catch {
setPagination((current) => ({ ...current, nextPageUrl: null }))
} finally {
setLoadingMore(false)
}
}, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl])
React.useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || !hasMorePages || loadingMore || typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
return undefined
}
const observer = new window.IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) {
void loadMore()
}
}, { rootMargin: '360px 0px' })
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMorePages, loadMore, loadingMore])
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={title} description={description} />
<div className="mx-auto max-w-[1360px] space-y-6">
{pageType === 'prompts' ? <PromptLibraryHero title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} totalCount={Number(items?.total || visibleItems.length || 0)} /> : (
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
<div className="flex flex-wrap items-end justify-between gap-5">
<div>
<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">{title}</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-300">{description}</p>
</div>
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: `${pageType}_list_hero` })} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview</Link>
</div>
</section>
)}
{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}
<QueryFilters pageType={pageType} filters={filters} categories={categories} />
{visibleItems.length === 0 ? (
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">Nothing matched this Academy view yet.</section>
) : (
<>
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{visibleItems.map((item, index) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
</section>
{pageType === 'prompts' ? (
<div className="pt-2">
<div ref={sentinelRef} className="h-10 w-full" aria-hidden="true" />
{loadingMore ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-300">Loading more prompts...</div> : null}
{!hasMorePages && visibleItems.length > initialItems.length ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-400">You have reached the end of the prompt library.</div> : null}
</div>
) : null}
</>
)}
</div>
</main>
)
}