Fix: remove Academy families from sitemaps; show total prompts in Academy prompt library; fix void closure in AcademyAdminController
This commit is contained in:
@@ -78,6 +78,7 @@ final class AcademyAdminController extends Controller
|
|||||||
'challenges' => route('admin.academy.challenges.index'),
|
'challenges' => route('admin.academy.challenges.index'),
|
||||||
'submissions' => route('admin.academy.submissions.index'),
|
'submissions' => route('admin.academy.submissions.index'),
|
||||||
'badges' => route('admin.academy.badges.index'),
|
'badges' => route('admin.academy.badges.index'),
|
||||||
|
'analytics' => route('admin.academy.analytics.overview'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1812,6 +1813,7 @@ final class AcademyAdminController extends Controller
|
|||||||
->map(function ($note): ?array {
|
->map(function ($note): ?array {
|
||||||
if (is_string($note)) {
|
if (is_string($note)) {
|
||||||
$normalized = [
|
$normalized = [
|
||||||
|
'display_type' => '',
|
||||||
'provider' => '',
|
'provider' => '',
|
||||||
'model_name' => '',
|
'model_name' => '',
|
||||||
'notes' => trim($note),
|
'notes' => trim($note),
|
||||||
@@ -1833,6 +1835,7 @@ final class AcademyAdminController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$normalized = [
|
$normalized = [
|
||||||
|
'display_type' => trim((string) ($note['display_type'] ?? '')),
|
||||||
'provider' => trim((string) ($note['provider'] ?? '')),
|
'provider' => trim((string) ($note['provider'] ?? '')),
|
||||||
'model_name' => trim((string) ($note['model_name'] ?? '')),
|
'model_name' => trim((string) ($note['model_name'] ?? '')),
|
||||||
'notes' => trim((string) ($note['notes'] ?? '')),
|
'notes' => trim((string) ($note['notes'] ?? '')),
|
||||||
@@ -1847,6 +1850,7 @@ final class AcademyAdminController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
$hasContent = collect([
|
$hasContent = collect([
|
||||||
|
$normalized['display_type'],
|
||||||
$normalized['provider'],
|
$normalized['provider'],
|
||||||
$normalized['model_name'],
|
$normalized['model_name'],
|
||||||
$normalized['notes'],
|
$normalized['notes'],
|
||||||
@@ -1890,7 +1894,9 @@ final class AcademyAdminController extends Controller
|
|||||||
|
|
||||||
$previousPaths
|
$previousPaths
|
||||||
->reject(fn (string $path): bool => in_array($path, $nextPaths, true))
|
->reject(fn (string $path): bool => in_array($path, $nextPaths, true))
|
||||||
->each(fn (string $path): bool => $this->deleteStoredLessonMediaIfLocal($path));
|
->each(function (string $path): void {
|
||||||
|
$this->deleteStoredLessonMediaIfLocal($path);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile
|
private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile
|
||||||
|
|||||||
@@ -83,16 +83,13 @@ return [
|
|||||||
|
|
||||||
'enabled' => [
|
'enabled' => [
|
||||||
'artworks',
|
'artworks',
|
||||||
'academy-lessons',
|
|
||||||
'academy-prompts',
|
|
||||||
'academy-packs',
|
|
||||||
'academy-challenges',
|
|
||||||
'users',
|
'users',
|
||||||
'tags',
|
'tags',
|
||||||
'categories',
|
'categories',
|
||||||
'collections',
|
'collections',
|
||||||
'cards',
|
'cards',
|
||||||
'stories',
|
'stories',
|
||||||
|
'web-stories',
|
||||||
'news',
|
'news',
|
||||||
'news-google',
|
'news-google',
|
||||||
'forum-index',
|
'forum-index',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { Link, router, usePage } from '@inertiajs/react'
|
import { Link, router, usePage } from '@inertiajs/react'
|
||||||
import SeoHead from '../../components/seo/SeoHead'
|
import SeoHead from '../../components/seo/SeoHead'
|
||||||
import NovaSelect from '../../components/ui/NovaSelect'
|
import NovaSelect from '../../components/ui/NovaSelect'
|
||||||
|
import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
||||||
|
|
||||||
function academyHref(section, slug) {
|
function academyHref(section, slug) {
|
||||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||||
@@ -65,7 +66,15 @@ function itemHref(pageType, item) {
|
|||||||
return academyHref('challenges', item.slug)
|
return academyHref('challenges', item.slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PromptLibraryHero({ title, description, items, pricingUrl }) {
|
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 || [])
|
const featuredImages = (items || [])
|
||||||
.map((item) => item?.preview_image)
|
.map((item) => item?.preview_image)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -102,7 +111,7 @@ function PromptLibraryHero({ title, description, items, pricingUrl }) {
|
|||||||
|
|
||||||
<div className="mt-7 flex flex-wrap gap-3">
|
<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>
|
<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">{items?.length || 0} prompts in view</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -134,13 +143,35 @@ function PromptLibraryHero({ title, description, items, pricingUrl }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AcademyCard({ pageType, item }) {
|
function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||||
const lessonSeries = String(item?.series_name || '').trim()
|
const lessonSeries = String(item?.series_name || '').trim()
|
||||||
const promptPreviewImage = item?.preview_image || ''
|
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') {
|
if (pageType === 'prompts') {
|
||||||
return (
|
return (
|
||||||
<Link href={itemHref(pageType, item)} 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))]">
|
<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))]">
|
<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}
|
{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 inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||||
@@ -170,7 +201,16 @@ function AcademyCard({ pageType, item }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={itemHref(pageType, item)} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]">
|
<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">
|
<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>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{pageType.slice(0, -1)}</p>
|
||||||
<LockBadge item={item} />
|
<LockBadge item={item} />
|
||||||
@@ -190,16 +230,101 @@ function AcademyCard({ pageType, item }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) {
|
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 || {}
|
const flash = usePage().props.flash || {}
|
||||||
const visibleItems = Array.isArray(items?.data) ? items.data : []
|
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 (
|
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">
|
<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} />
|
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||||
|
|
||||||
<div className="mx-auto max-w-[1360px] space-y-6">
|
<div className="mx-auto max-w-[1360px] space-y-6">
|
||||||
{pageType === 'prompts' ? <PromptLibraryHero title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} /> : (
|
{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">
|
<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 className="flex flex-wrap items-end justify-between gap-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -207,7 +332,7 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
|||||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
|
<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>
|
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-300">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -220,9 +345,19 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
|||||||
{visibleItems.length === 0 ? (
|
{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="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) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} />)}
|
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
</section>
|
{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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user