chore: commit remaining workspace changes
This commit is contained in:
@@ -1339,6 +1339,11 @@
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.academy-lesson-prose blockquote p,
|
||||
.academy-lesson-prose li p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose p + p {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -1420,6 +1425,54 @@
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.academy-lesson-prose li:has(> input[type='checkbox']),
|
||||
.academy-lesson-prose li:has(> p input[type='checkbox']) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose li:has(> input[type='checkbox'])::before,
|
||||
.academy-lesson-prose li:has(> p input[type='checkbox'])::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.academy-lesson-prose input[type='checkbox'] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
margin: 0.1rem 0.7rem 0 0;
|
||||
vertical-align: top;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 10px 10px;
|
||||
box-sizing: border-box;
|
||||
opacity: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.academy-lesson-prose input[type='checkbox']:checked {
|
||||
border-color: #E07A21;
|
||||
background-color: #E07A21;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M1.5 6l3 3 6-6' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
box-shadow: 0 0 0 0 rgba(224, 122, 33, 0.45);
|
||||
}
|
||||
|
||||
.academy-lesson-prose input[type='checkbox']:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.academy-lesson-prose li > p:has(input[type='checkbox']) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.academy-lesson-prose ol li::before {
|
||||
counter-increment: lesson-ordered-list;
|
||||
content: counter(lesson-ordered-list);
|
||||
@@ -1605,7 +1658,7 @@
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
border-radius: 1rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
color: rgb(226 232 240 / 0.88);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.55;
|
||||
|
||||
@@ -34,6 +34,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
label: 'Academy',
|
||||
items: [
|
||||
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
||||
{ label: 'Academy Courses', href: '/moderation/academy/courses', icon: 'fa-solid fa-road' },
|
||||
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
||||
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
|
||||
|
||||
125
resources/js/Pages/Academy/CoursesIndex.jsx
Normal file
125
resources/js/Pages/Academy/CoursesIndex.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function CourseCard({ course, variant = 'default' }) {
|
||||
const isFeatured = variant === 'featured'
|
||||
const progress = course?.progress || null
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={course.public_url}
|
||||
className={[
|
||||
'group overflow-hidden rounded-[30px] border border-white/10 transition hover:border-sky-300/25 hover:bg-white/[0.06]',
|
||||
isFeatured ? 'bg-[linear-gradient(135deg,rgba(14,165,233,0.14),rgba(15,23,42,0.92))]' : 'bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="relative">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className={`w-full object-cover ${isFeatured ? 'h-56' : 'h-44'}`} /> : <div className={`w-full bg-[linear-gradient(135deg,rgba(14,165,233,0.22),rgba(15,23,42,0.92))] ${isFeatured ? 'h-56' : 'h-44'}`} />}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
<div className="absolute left-5 top-5 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">{course.difficulty}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200">{course.access_level}</span>
|
||||
{course.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100">Featured</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h2 className={`font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100 ${isFeatured ? 'text-3xl' : 'text-2xl'}`}>{course.title}</h2>
|
||||
{course.subtitle ? <p className="mt-2 text-sm font-medium uppercase tracking-[0.18em] text-slate-400">{course.subtitle}</p> : null}
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Lessons</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{course.lessons_count || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Duration</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Progress</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{progress ? `${progress.percent}%` : 'Start fresh'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const difficultyOptions = [
|
||||
{ value: '', label: 'All levels' },
|
||||
{ value: 'beginner', label: 'Beginner' },
|
||||
{ value: 'intermediate', label: 'Intermediate' },
|
||||
{ value: 'advanced', label: 'Advanced' },
|
||||
]
|
||||
const accessOptions = [
|
||||
{ value: '', label: 'All access' },
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'premium', label: 'Premium' },
|
||||
{ value: 'mixed', label: 'Mixed' },
|
||||
]
|
||||
|
||||
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(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-[1400px] space-y-6">
|
||||
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.96),rgba(14,165,233,0.12))] p-8 shadow-[0_24px_90px_rgba(2,6,23,0.36)] md:p-10 lg:p-12">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<div className="max-w-4xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{title}</h1>
|
||||
<p className="mt-5 text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||
</div>
|
||||
<Link href={pricingUrl} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See Academy plans</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}
|
||||
|
||||
{featuredCourses.length ? (
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<CourseCard course={featuredCourses[0]} variant="featured" />
|
||||
<div className="grid gap-5">
|
||||
{featuredCourses.slice(1, 3).map((course) => <CourseCard key={course.id} course={course} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2">
|
||||
<NovaSelect
|
||||
label="Difficulty"
|
||||
value={filters?.difficulty || ''}
|
||||
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, difficulty: nextValue || undefined }, { preserveScroll: true, preserveState: true })}
|
||||
options={difficultyOptions}
|
||||
searchable={false}
|
||||
className="rounded-2xl bg-white/[0.04]"
|
||||
/>
|
||||
<NovaSelect
|
||||
label="Access"
|
||||
value={filters?.access || ''}
|
||||
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, access: nextValue || undefined }, { preserveScroll: true, preserveState: true })}
|
||||
options={accessOptions}
|
||||
searchable={false}
|
||||
className="rounded-2xl bg-white/[0.04]"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{(items?.data || []).length === 0 ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">No published Academy courses matched these filters.</section>
|
||||
) : (
|
||||
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.data.map((course) => <CourseCard key={course.id} course={course} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
339
resources/js/Pages/Academy/CoursesShow.jsx
Normal file
339
resources/js/Pages/Academy/CoursesShow.jsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function CourseBreadcrumbs({ 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 ProgressMeter({ progress }) {
|
||||
const percent = Math.max(0, Math.min(100, Number(progress?.percent || 0)))
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72))] p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Progress</p>
|
||||
<p className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{percent}%</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">
|
||||
{progress ? 'In progress' : 'Not started'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 h-2.5 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(125,211,252,0.95),rgba(251,191,36,0.9))] transition-[width] duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
{progress
|
||||
? `${progress.completedRequired}/${progress.totalRequired} required lessons completed`
|
||||
: 'Start the course to begin tracking progress through required lessons.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonChip({ lesson }) {
|
||||
const thumbnail = lesson?.cover_image_url || lesson?.article_cover_image_url || lesson?.cover_image || lesson?.article_cover_image || ''
|
||||
const stepLabel = lesson?.course_step_label || null
|
||||
const stepNumber = Number(lesson?.course_step_number || 0)
|
||||
const isCompleted = Boolean(lesson?.completed)
|
||||
const readingMinutes = Number(lesson?.reading_minutes || 0)
|
||||
const ctaLabel = isCompleted ? 'Review lesson' : 'Open lesson'
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={lesson.course_url || `/academy/lessons/${lesson.slug}`}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-[32px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.64))] shadow-[0_24px_50px_rgba(2,6,23,0.2)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_70px_rgba(14,165,233,0.12)]',
|
||||
isCompleted ? 'border-emerald-300/25' : 'border-white/10',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.09),transparent_24%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-70 transition duration-200 group-hover:opacity-100" />
|
||||
|
||||
<div className="relative grid gap-0 lg:grid-cols-[172px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-white/10 bg-slate-950 lg:border-b-0 lg:border-r">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt="" aria-hidden="true" className="h-40 w-full object-cover lg:h-full" />
|
||||
) : (
|
||||
<div className="h-40 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] lg:h-full" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.84))]" />
|
||||
<div className="absolute inset-x-3 top-3 flex items-start justify-between gap-3">
|
||||
{lesson.is_required ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/80 backdrop-blur">
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/65 backdrop-blur">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
{isCompleted ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/25 bg-emerald-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100 backdrop-blur">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M4.75 8.2 7 10.4l4.25-4.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Done
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-sky-100/80">{stepLabel}</p> : null}
|
||||
{stepNumber > 0 ? <p className="mt-1 text-5xl font-semibold tracking-[-0.1em] text-white">{String(stepNumber).padStart(2, '0')}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 md:p-6">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_200px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">{stepLabel}</p> : null}
|
||||
{lesson.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.formatted_lesson_number}</span> : null}
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.difficulty || 'lesson'}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.access_level || 'free'}</span>
|
||||
{readingMinutes > 0 ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{readingMinutes} min</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 max-w-3xl text-[1.65rem] font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{isCompleted ? 'You already finished this lesson.' : 'Follow this step next in the course path.'}</p>
|
||||
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open this lesson inside the course.'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.lesson_type || 'article'}</span>
|
||||
{lesson.category_name ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.category_name}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className={`mt-2 text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{isCompleted ? 'Completed' : 'Up next'}</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{lesson.access_level || 'Free'}</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 xl:justify-end">
|
||||
<span className="text-xs uppercase tracking-[0.16em] text-slate-500">Continue path</span>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition group-hover:border-sky-300/35 group-hover:bg-sky-300/14 group-hover:text-white">
|
||||
{ctaLabel}
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-4 w-4">
|
||||
<path d="M3.5 8h9m0 0-3.5-3.5M12.5 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionBlock({ section, isActive = false }) {
|
||||
if (!section?.is_visible) return null
|
||||
|
||||
return (
|
||||
<section className={`rounded-[32px] border p-6 transition md:p-7 ${isActive ? 'border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.04))] shadow-[0_22px_50px_rgba(14,165,233,0.08)]' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Course section</p>
|
||||
<span className={`rounded-full border px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${isActive ? 'border-sky-300/20 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}>
|
||||
{section.order_num + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{section.title}</h2>
|
||||
{section.description ? <p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{section.lessons?.length || 0} lessons</span>
|
||||
{isActive ? <span className="rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Reading now</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-6">
|
||||
{(section.lessons || []).map((lesson) => (
|
||||
<LessonChip key={lesson.course_lesson_id || lesson.id} lesson={lesson} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ''
|
||||
const progress = course?.progress || null
|
||||
|
||||
const sectionJumpItems = useMemo(
|
||||
() => [
|
||||
...(unsectionedLessons.length ? [{ id: 'course-outline-core', label: 'Core lessons', count: unsectionedLessons.length }] : []),
|
||||
...sections
|
||||
.filter((section) => section?.is_visible)
|
||||
.map((section) => ({ id: `section-${section.id}`, label: section.title, count: (section.lessons || []).length })),
|
||||
],
|
||||
[sections, unsectionedLessons],
|
||||
)
|
||||
|
||||
const [activeJumpId, setActiveJumpId] = useState(sectionJumpItems[0]?.id || null)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Academy', href: '/academy' },
|
||||
{ label: 'Courses', href: '/academy/courses' },
|
||||
{ label: course?.title || 'Course', href: course?.public_url || '#' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionJumpItems.length || typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visibleEntries = entries.filter((entry) => entry.isIntersecting).sort((left, right) => right.intersectionRatio - left.intersectionRatio)
|
||||
|
||||
if (!visibleEntries.length) return
|
||||
|
||||
setActiveJumpId(visibleEntries[0].target.id)
|
||||
},
|
||||
{
|
||||
rootMargin: '-20% 0px -55% 0px',
|
||||
threshold: [0.2, 0.45, 0.7],
|
||||
},
|
||||
)
|
||||
|
||||
const elements = sectionJumpItems.map((item) => document.getElementById(item.id)).filter(Boolean)
|
||||
elements.forEach((element) => observer.observe(element))
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [sectionJumpItems])
|
||||
|
||||
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(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={course?.title} description={course?.excerpt || course?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1400px] 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}
|
||||
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div className="relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.18]" /> : null}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(125,211,252,0.18),_transparent_28%),radial-gradient(circle_at_78%_26%,_rgba(251,191,36,0.12),_transparent_20%),linear-gradient(135deg,_rgba(2,6,23,0.98),_rgba(15,23,42,0.85))]" />
|
||||
<div className="relative z-10 max-w-5xl">
|
||||
<CourseBreadcrumbs items={breadcrumbs} />
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2.5">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">Academy course</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.difficulty}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.access_level}</span>
|
||||
{progress?.percent ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100">{progress.percent}% complete</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h1 className="text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]">{course?.title}</h1>
|
||||
{course?.subtitle ? <p className="mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||
|
||||
<div className="mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" aria-hidden="true" className="w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400">
|
||||
No course cover image yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8">
|
||||
<div className="space-y-4 xl:sticky xl:top-6">
|
||||
<ProgressMeter progress={progress} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Jump through the course</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{sectionJumpItems.length ? (
|
||||
sectionJumpItems.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setActiveJumpId(item.id)}
|
||||
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${activeJumpId === item.id ? 'border-sky-300/25 bg-sky-300/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20 hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">{item.count}</span>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No course outline items are available yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
{unsectionedLessons.length ? (
|
||||
<SectionBlock
|
||||
section={{
|
||||
order_num: -1,
|
||||
title: 'Core lessons',
|
||||
description: 'Lessons shown before the course branches into sections.',
|
||||
is_visible: true,
|
||||
lessons: unsectionedLessons,
|
||||
}}
|
||||
isActive={activeJumpId === 'course-outline-core'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sections.filter((section) => section?.is_visible).map((section) => (
|
||||
<SectionBlock key={section.id} section={section} isActive={activeJumpId === `section-${section.id}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,29 @@ function FeatureCard({ title, description, href, cta }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredLessons, featuredPrompts, featuredChallenges }) {
|
||||
function FeaturedCourseCard({ course }) {
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
|
||||
return (
|
||||
<Link href={course.public_url} className="group overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
||||
<div className="relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
<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-sky-100">{course.difficulty}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{course.access_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{course.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Guided Academy course.'}</p>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{course.lessons_count || 0} lessons · {course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible duration'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges }) {
|
||||
const jsonLd = [{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
@@ -39,6 +61,7 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Skinbase AI Academy is the creative learning hub for AI-assisted art on Skinbase. Start with free lessons, explore prompt templates, and unlock premium workflows later.</p>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link href={links.courses} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Browse courses</Link>
|
||||
<Link href={links.lessons} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18">Browse lessons</Link>
|
||||
<Link href={links.prompts} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open prompt library</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 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans</Link>
|
||||
@@ -57,19 +80,35 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
<FeatureCard title="Courses" description="Follow guided learning paths that stitch reusable Academy lessons into a clean progression with completion tracking." href={links.courses} cta="Browse courses" />
|
||||
<FeatureCard title="Lessons" description="Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits." href={links.lessons} cta="Open lessons" />
|
||||
<FeatureCard title="Prompt Library" description="Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows." href={links.prompts} cta="Explore prompts" />
|
||||
<FeatureCard title="Challenges" description="Join Academy creative briefs and submit artworks once the challenge system is enabled for your account." href={links.challenges} cta="View challenges" />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
<section className="grid gap-5 lg:grid-cols-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Courses</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.courseCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lessons</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.lessonCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompts</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.promptCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Challenges</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.challengeCount || 0}</p></div>
|
||||
</section>
|
||||
|
||||
{featuredCourses?.length ? (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured courses</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.045em] text-white">Guided Academy paths</h2>
|
||||
</div>
|
||||
<Link href={links.courses} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">All courses</Link>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
{featuredCourses.slice(0, 3).map((course) => <FeaturedCourseCard key={course.id} course={course} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-3">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured lessons</p><div className="mt-4 space-y-3">{(featuredLessons || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('lessons', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured lessons</p><div className="mt-4 space-y-3">{(featuredLessons || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('lessons', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{item.lesson_label || 'Featured lesson'}</span><span className="mt-1 block">{item.title}</span></Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured prompts</p><div className="mt-4 space-y-3">{(featuredPrompts || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('prompts', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Current challenges</p><div className="mt-4 space-y-3">{(featuredChallenges || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('challenges', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||
</section>
|
||||
|
||||
@@ -65,15 +65,125 @@ function itemHref(pageType, item) {
|
||||
return academyHref('challenges', item.slug)
|
||||
}
|
||||
|
||||
function PromptLibraryHero({ title, description, items, pricingUrl }) {
|
||||
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">{items?.length || 0} prompts in view</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 }) {
|
||||
const lessonSeries = String(item?.series_name || '').trim()
|
||||
const promptPreviewImage = item?.preview_image || ''
|
||||
|
||||
if (pageType === 'prompts') {
|
||||
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))]">
|
||||
<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={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]">
|
||||
<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>
|
||||
@@ -82,33 +192,36 @@ function AcademyCard({ pageType, item }) {
|
||||
|
||||
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const visibleItems = Array.isArray(items?.data) ? items.data : []
|
||||
|
||||
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">
|
||||
<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>
|
||||
{pageType === 'prompts' ? <PromptLibraryHero title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} /> : (
|
||||
<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} 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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</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} />
|
||||
|
||||
{(items?.data || []).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="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.data.map((item) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} />)}
|
||||
{visibleItems.map((item) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,35 @@ 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()
|
||||
@@ -48,6 +77,29 @@ function LessonInfoRow({ label, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -87,7 +139,7 @@ function copyTextToClipboard(text) {
|
||||
return Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
function PromptCopyButton({ prompt }) {
|
||||
function PromptCopyButton({ prompt, label = 'Copy prompt' }) {
|
||||
const [status, setStatus] = useState('idle')
|
||||
const resetTimerRef = useRef(0)
|
||||
|
||||
@@ -107,11 +159,172 @@ function PromptCopyButton({ prompt }) {
|
||||
aria-label="Copy prompt"
|
||||
>
|
||||
<i className={`fa-solid ${status === 'copied' ? 'fa-check' : status === 'failed' ? 'fa-triangle-exclamation' : 'fa-copy'}`} />
|
||||
<span>{status === 'copied' ? 'Copied' : status === 'failed' ? 'Copy failed' : 'Copy prompt'}</span>
|
||||
<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) : []
|
||||
@@ -227,42 +440,89 @@ function AiComparisonSection({ block }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyShow({ pageType, item, relatedLessons = [], seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
||||
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(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1.04
|
||||
const [lessonFontScale, setLessonFontScale] = useState(1.04)
|
||||
|
||||
const findArticleHeading = (headingId) => {
|
||||
if (!headingId || typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
||||
const escapedHeadingId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(headingId)
|
||||
: String(headingId).replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
|
||||
if (Number.isFinite(storedValue)) {
|
||||
return Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue))
|
||||
}
|
||||
|
||||
return 1.04
|
||||
})
|
||||
return articleContentRef.current?.querySelector(`#${escapedHeadingId}`) || document.getElementById(headingId)
|
||||
}
|
||||
|
||||
const markComplete = () => {
|
||||
if (!completeUrl || completed) return
|
||||
router.post(completeUrl, {}, {
|
||||
router.post(completeUrl, courseContext?.completePayload || {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => setCompleted(true),
|
||||
})
|
||||
@@ -285,6 +545,64 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
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([])
|
||||
@@ -301,6 +619,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
|
||||
seenIds.set(baseId, seenCount + 1)
|
||||
heading.id = nextId
|
||||
heading.style.scrollMarginTop = '128px'
|
||||
|
||||
return {
|
||||
id: nextId,
|
||||
@@ -312,42 +631,98 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
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 headingElements = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
|
||||
const getActiveId = () => {
|
||||
const headings = Array.from(articleContentRef.current.querySelectorAll('h2[id], h3[id]'))
|
||||
if (!headings.length) return ''
|
||||
|
||||
if (!headingElements.length) {
|
||||
setActiveHeadingId('')
|
||||
// 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 observer = new IntersectionObserver((entries) => {
|
||||
const visibleEntries = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top)
|
||||
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
||||
|
||||
if (visibleEntries.length) {
|
||||
setActiveHeadingId((current) => visibleEntries[0].target.id || current)
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '-18% 0px -68% 0px',
|
||||
threshold: [0, 1],
|
||||
})
|
||||
|
||||
headingElements.forEach((heading) => observer.observe(heading))
|
||||
|
||||
const firstVisibleHeading = headingElements.find((heading) => heading.getBoundingClientRect().top >= 0) || headingElements[0]
|
||||
if (firstVisibleHeading?.id) {
|
||||
setActiveHeadingId(firstVisibleHeading.id)
|
||||
if (!Number.isFinite(storedValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [pageType, tableOfContents, lessonFontScale])
|
||||
setLessonFontScale(Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue)))
|
||||
}, [fontScaleMax, fontScaleMin, fontScaleStorageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -452,7 +827,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
}, [item?.content, lessonFontScale, pageType])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<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">
|
||||
@@ -475,9 +850,27 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
<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}
|
||||
@@ -488,7 +881,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
<StatPill label="Category" value={lessonCategory} />
|
||||
<StatPill label="Reading" value={lessonMinutes} />
|
||||
<StatPill label="Updated" value={lessonUpdated} />
|
||||
<StatPill label="Access" value={item.access_level || 'free'} />
|
||||
<StatPill label={courseContext?.title ? 'Course progress' : 'Access'} value={courseContext?.progress ? `${courseContext.progress.percent}%` : (item.access_level || 'free')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +893,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<LessonInfoRow label="Series" value={lessonCategory} />
|
||||
<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} />
|
||||
@@ -508,7 +902,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : 'Full lesson content is available below.'}</p>
|
||||
<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>
|
||||
@@ -516,7 +910,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<article className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200 md:p-8">
|
||||
<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>
|
||||
@@ -549,6 +943,12 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</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
|
||||
@@ -566,11 +966,23 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</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-white/[0.04] p-6 text-slate-200">
|
||||
<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>
|
||||
|
||||
@@ -579,6 +991,10 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
<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' : ''}`}
|
||||
>
|
||||
@@ -590,27 +1006,56 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Series info</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{lessonCategory}</h3>
|
||||
<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="Updated" value={lessonUpdated} />
|
||||
<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-white/[0.04] p-6 text-slate-200">
|
||||
<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 {lessonCategory}</h3>
|
||||
<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={`/academy/lessons/${relatedLesson.slug}`}
|
||||
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">
|
||||
@@ -619,7 +1064,10 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
|
||||
<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>
|
||||
@@ -629,20 +1077,242 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</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 === 'prompt' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
||||
</div>
|
||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'pack' ? (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||
@@ -686,6 +1356,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageLightbox gallery={lightboxGallery} onClose={() => setLightboxGallery(null)} onNavigate={navigateLightboxGallery} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
540
resources/js/Pages/Admin/Academy/CourseBuilder.jsx
Normal file
540
resources/js/Pages/Admin/Academy/CourseBuilder.jsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||
|
||||
function laneKey(sectionId) {
|
||||
return sectionId == null ? 'unsectioned' : `section:${sectionId}`
|
||||
}
|
||||
|
||||
function sortSections(items = []) {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = Number(left?.order_num || 0) - Number(right?.order_num || 0)
|
||||
if (orderDiff !== 0) return orderDiff
|
||||
return Number(left?.id || 0) - Number(right?.id || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function sortLessons(items = []) {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftSection = left?.section_id == null ? -1 : Number(left.section_id)
|
||||
const rightSection = right?.section_id == null ? -1 : Number(right.section_id)
|
||||
|
||||
if (leftSection !== rightSection) return leftSection - rightSection
|
||||
|
||||
const orderDiff = Number(left?.order_num || 0) - Number(right?.order_num || 0)
|
||||
if (orderDiff !== 0) return orderDiff
|
||||
|
||||
return Number(left?.id || 0) - Number(right?.id || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function buildLessonLanes(sections = [], lessons = []) {
|
||||
const orderedSections = sortSections(sections)
|
||||
const orderedLessons = sortLessons(lessons)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'unsectioned',
|
||||
sectionId: null,
|
||||
title: 'Core lessons',
|
||||
description: 'Lessons shown before the course branches into sections.',
|
||||
isVisible: true,
|
||||
lessons: orderedLessons.filter((lesson) => lesson.section_id == null),
|
||||
},
|
||||
...orderedSections.map((section) => ({
|
||||
key: laneKey(section.id),
|
||||
sectionId: section.id,
|
||||
title: section.title,
|
||||
description: section.description || 'Section lessons appear together in this stage.',
|
||||
isVisible: Boolean(section.is_visible),
|
||||
lessons: orderedLessons.filter((lesson) => Number(lesson.section_id) === Number(section.id)),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function reindexLessonsFromLanes(sections = [], lessons = []) {
|
||||
const lanes = buildLessonLanes(sections, lessons)
|
||||
|
||||
return lanes.flatMap((lane) => lane.lessons.map((lesson, index) => ({
|
||||
...lesson,
|
||||
section_id: lane.sectionId,
|
||||
order_num: index,
|
||||
})))
|
||||
}
|
||||
|
||||
function moveLessonToPosition(sections = [], lessons = [], lessonId, nextSectionId, targetIndex) {
|
||||
const lanes = buildLessonLanes(sections, lessons).map((lane) => ({ ...lane, lessons: [...lane.lessons] }))
|
||||
let draggedLesson = null
|
||||
|
||||
lanes.forEach((lane) => {
|
||||
const lessonIndex = lane.lessons.findIndex((lesson) => Number(lesson.id) === Number(lessonId))
|
||||
if (lessonIndex === -1) return
|
||||
draggedLesson = { ...lane.lessons[lessonIndex], section_id: nextSectionId }
|
||||
lane.lessons.splice(lessonIndex, 1)
|
||||
})
|
||||
|
||||
if (!draggedLesson) return lessons
|
||||
|
||||
const destinationLane = lanes.find((lane) => lane.sectionId === nextSectionId)
|
||||
if (!destinationLane) return lessons
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(Number(targetIndex), destinationLane.lessons.length))
|
||||
destinationLane.lessons.splice(nextIndex, 0, draggedLesson)
|
||||
|
||||
return reindexLessonsFromLanes(sections, lanes.flatMap((lane) => lane.lessons))
|
||||
}
|
||||
|
||||
function shiftLesson(sections = [], lessons = [], lessonId, direction) {
|
||||
const lanes = buildLessonLanes(sections, lessons)
|
||||
|
||||
for (const lane of lanes) {
|
||||
const lessonIndex = lane.lessons.findIndex((lesson) => Number(lesson.id) === Number(lessonId))
|
||||
if (lessonIndex === -1) continue
|
||||
|
||||
const nextIndex = lessonIndex + direction
|
||||
if (nextIndex < 0 || nextIndex >= lane.lessons.length) {
|
||||
return lessons
|
||||
}
|
||||
|
||||
return moveLessonToPosition(sections, lessons, lessonId, lane.sectionId, nextIndex)
|
||||
}
|
||||
|
||||
return lessons
|
||||
}
|
||||
|
||||
function placementSignature(lessons = []) {
|
||||
return JSON.stringify(sortLessons(lessons).map((lesson) => ({
|
||||
id: Number(lesson.id),
|
||||
section_id: lesson.section_id == null ? null : Number(lesson.section_id),
|
||||
order_num: Number(lesson.order_num || 0),
|
||||
})))
|
||||
}
|
||||
|
||||
function formatStepLabel(value) {
|
||||
return `Step ${String(value).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function resolveDraggedLessonId(event, fallbackLessonId = null) {
|
||||
const nativeLessonId = event?.dataTransfer?.getData('text/plain') || ''
|
||||
|
||||
if (nativeLessonId !== '') {
|
||||
return Number(nativeLessonId)
|
||||
}
|
||||
|
||||
return fallbackLessonId == null ? null : Number(fallbackLessonId)
|
||||
}
|
||||
|
||||
function FormCard({ title, description, children }) {
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="mb-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Course builder</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 text-sm leading-7 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxCardField({ label, checked, onChange, description }) {
|
||||
return (
|
||||
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
||||
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
||||
<i className="fa-solid fa-check" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-semibold text-white">{label}</span>
|
||||
{description ? <span className="mt-1 block text-xs leading-5 text-slate-400">{description}</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableSectionCard({ section }) {
|
||||
const form = useForm({
|
||||
title: section.title || '',
|
||||
slug: section.slug || '',
|
||||
description: section.description || '',
|
||||
order_num: section.order_num || 0,
|
||||
is_visible: Boolean(section.is_visible),
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => { event.preventDefault(); form.patch(section.updateUrl) }} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Title</span>
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Slug</span>
|
||||
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200 lg:col-span-2">
|
||||
<span>Description</span>
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={3} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Order</span>
|
||||
<input type="number" value={form.data.order_num} onChange={(event) => form.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<CheckboxCardField
|
||||
label="Visible section"
|
||||
checked={Boolean(form.data.is_visible)}
|
||||
onChange={(event) => form.setData('is_visible', event.target.checked)}
|
||||
description="Hide the whole section from the public course outline without deleting its lessons or structure."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save section'}</button>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this section?')) return; router.delete(section.destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableCourseLessonCard({ courseLesson, sectionOptions, stepLabel }) {
|
||||
const form = useForm({
|
||||
section_id: courseLesson.section_id || '',
|
||||
order_num: courseLesson.order_num || 0,
|
||||
is_required: Boolean(courseLesson.is_required),
|
||||
access_override: courseLesson.access_override || '',
|
||||
unlock_after_lesson_id: courseLesson.unlock_after_lesson_id || '',
|
||||
})
|
||||
|
||||
const accessOptions = [
|
||||
{ value: '', label: 'Use lesson access' },
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'creator', label: 'Creator' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
{ value: 'premium', label: 'Premium' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
form.setData('section_id', courseLesson.section_id || '')
|
||||
form.setData('order_num', courseLesson.order_num || 0)
|
||||
}, [courseLesson.order_num, courseLesson.section_id])
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => { event.preventDefault(); form.patch(courseLesson.updateUrl) }} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{stepLabel ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{stepLabel}</span> : null}
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{courseLesson.category || 'Academy'} · {courseLesson.difficulty || 'lesson'}</p>
|
||||
</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{courseLesson.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{courseLesson.excerpt || 'This lesson is attached to the course.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<NovaSelect label="Section" value={form.data.section_id} onChange={(nextValue) => form.setData('section_id', nextValue || '')} options={sectionOptions} searchable={false} className="rounded-2xl bg-white/[0.04]" />
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Order</span>
|
||||
<input type="number" value={form.data.order_num} onChange={(event) => form.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<NovaSelect label="Access override" value={form.data.access_override} onChange={(nextValue) => form.setData('access_override', nextValue || '')} options={accessOptions} searchable={false} className="rounded-2xl bg-white/[0.04]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<CheckboxCardField
|
||||
label="Required for course completion"
|
||||
checked={Boolean(form.data.is_required)}
|
||||
onChange={(event) => form.setData('is_required', event.target.checked)}
|
||||
description="Only required lessons count toward the course completion percentage."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson settings'}</button>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Remove this lesson from the course?')) return; router.delete(courseLesson.destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Detach</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ReorderLessonCard({
|
||||
lesson,
|
||||
lane,
|
||||
laneIndex,
|
||||
laneCount,
|
||||
globalStepNumber,
|
||||
isDragging,
|
||||
isDropTarget,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', String(lesson.id))
|
||||
onDragStart(lesson.id)
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
onDragOver(lesson.id)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onDrop(event, lane.sectionId, lesson.id)
|
||||
}}
|
||||
className={[
|
||||
'rounded-[24px] border bg-black/20 p-4 transition',
|
||||
isDragging ? 'border-sky-300/40 opacity-60' : 'border-white/10 hover:border-white/20',
|
||||
isDropTarget ? 'ring-2 ring-sky-300/35' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{formatStepLabel(globalStepNumber)}</span>
|
||||
{lesson.is_required ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Required</span> : <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">Optional</span>}
|
||||
</div>
|
||||
<h3 className="mt-3 text-base font-semibold tracking-[-0.03em] text-white">{lesson.title}</h3>
|
||||
<p className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{lesson.category || 'Academy'} · {lesson.difficulty || 'lesson'} · order {Number(lesson.order_num) + 1}</p>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">{lesson.excerpt || 'Drag this lesson to reposition it inside the course flow.'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={onMoveUp} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={lane.lessons[0]?.id === lesson.id}>
|
||||
<i className="fa-solid fa-arrow-up" />
|
||||
</button>
|
||||
<button type="button" onClick={onMoveDown} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={lane.lessons[lane.lessons.length - 1]?.id === lesson.id}>
|
||||
<i className="fa-solid fa-arrow-down" />
|
||||
</button>
|
||||
<button type="button" onClick={onMoveLeft} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={laneIndex <= 0}>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
</button>
|
||||
<button type="button" onClick={onMoveRight} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={laneIndex >= laneCount - 1}>
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCourseBuilder({ course, sections = [], courseLessons = [], availableLessons = [], routes = {} }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const sectionForm = useForm({ title: '', slug: '', description: '', order_num: sections.length, is_visible: true })
|
||||
const attachForm = useForm({ lesson_id: '', section_id: '', order_num: courseLessons.length, is_required: true, access_override: '', unlock_after_lesson_id: '' })
|
||||
const [draftLessons, setDraftLessons] = useState(() => reindexLessonsFromLanes(sections, courseLessons))
|
||||
const [draggedLessonId, setDraggedLessonId] = useState(null)
|
||||
const [dropTargetLessonId, setDropTargetLessonId] = useState(null)
|
||||
const [reorderProcessing, setReorderProcessing] = useState(false)
|
||||
|
||||
const sectionOptions = [{ value: '', label: 'Unsectioned' }, ...sections.map((section) => ({ value: section.id, label: section.title }))]
|
||||
const lessonOptions = availableLessons.map((lesson) => ({ value: lesson.id, label: `${lesson.title}${lesson.attached ? ' · attached' : ''}` }))
|
||||
const attachableLessonOptions = availableLessons.filter((lesson) => !lesson.attached).map((lesson) => ({ value: lesson.id, label: lesson.title }))
|
||||
const accessOverrideOptions = [
|
||||
{ value: '', label: 'Use lesson access' },
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'creator', label: 'Creator' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
{ value: 'premium', label: 'Premium' },
|
||||
]
|
||||
const lessonLanes = useMemo(() => buildLessonLanes(sections, draftLessons), [sections, draftLessons])
|
||||
const reorderDirty = useMemo(() => placementSignature(draftLessons) !== placementSignature(reindexLessonsFromLanes(sections, courseLessons)), [courseLessons, draftLessons, sections])
|
||||
const courseLessonMap = useMemo(() => new Map(courseLessons.map((courseLesson) => [Number(courseLesson.id), courseLesson])), [courseLessons])
|
||||
const orderedCourseLessons = useMemo(() => reindexLessonsFromLanes(sections, courseLessons.map((courseLesson) => ({
|
||||
...courseLesson,
|
||||
...(draftLessons.find((draftLesson) => Number(draftLesson.id) === Number(courseLesson.id)) || {}),
|
||||
}))), [courseLessons, draftLessons, sections])
|
||||
const globalStepMap = useMemo(() => new Map(lessonLanes.flatMap((lane) => lane.lessons).map((lesson, index) => [Number(lesson.id), index + 1])), [lessonLanes])
|
||||
|
||||
useEffect(() => {
|
||||
setDraftLessons(reindexLessonsFromLanes(sections, courseLessons))
|
||||
}, [courseLessons, sections])
|
||||
|
||||
const moveLessonAcrossLanes = (lessonId, laneIndexDelta) => {
|
||||
const lanes = buildLessonLanes(sections, draftLessons)
|
||||
const currentLaneIndex = lanes.findIndex((lane) => lane.lessons.some((lesson) => Number(lesson.id) === Number(lessonId)))
|
||||
if (currentLaneIndex === -1) return
|
||||
|
||||
const nextLane = lanes[currentLaneIndex + laneIndexDelta]
|
||||
if (!nextLane) return
|
||||
|
||||
setDraftLessons((current) => moveLessonToPosition(sections, current, lessonId, nextLane.sectionId, nextLane.lessons.length))
|
||||
}
|
||||
|
||||
const saveReorder = () => {
|
||||
setReorderProcessing(true)
|
||||
router.patch(routes.reorder, {
|
||||
sections: sortSections(sections).map((section, index) => ({ id: section.id, order_num: index })),
|
||||
lessons: reindexLessonsFromLanes(sections, draftLessons).map((lesson) => ({
|
||||
id: lesson.id,
|
||||
order_num: lesson.order_num,
|
||||
section_id: lesson.section_id,
|
||||
})),
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onFinish: () => setReorderProcessing(false),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={`${course.title} Builder`} subtitle="Arrange sections, attach lessons, and control course flow.">
|
||||
<Head title={`Admin · ${course.title} Builder`} />
|
||||
|
||||
{flash.success ? <div className="mb-6 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="mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Link href={routes.index} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back to courses</Link>
|
||||
<Link href={routes.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Edit course</Link>
|
||||
<Link href={routes.preview} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Preview public page</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
||||
<div className="space-y-6">
|
||||
<FormCard title="Structure overview" description="The course builder keeps the lesson content reusable while controlling order, access, and required progress here.">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Sections</p><p className="mt-2 text-2xl font-semibold text-white">{sections.length}</p></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Attached lessons</p><p className="mt-2 text-2xl font-semibold text-white">{courseLessons.length}</p></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Published course lessons</p><p className="mt-2 text-2xl font-semibold text-white">{courseLessons.filter((lesson) => lesson.title).length}</p></div>
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Sections" description="Chapters are optional, but they help group lessons into cleaner stages.">
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => (
|
||||
<EditableSectionCard key={section.id} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Lesson flow" description="Manage the course order as one visual path. Drag lessons between lanes, use arrows for exact moves, then save the sequence in one action.">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Flow board</p>
|
||||
<p className="mt-1 text-sm text-slate-300">Core lessons stay first, then each visible section keeps its own ordered lane.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{reorderDirty ? <span className="rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-100">Unsaved order changes</span> : <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-100">Order is saved</span>}
|
||||
<button type="button" onClick={() => setDraftLessons(reindexLessonsFromLanes(sections, courseLessons))} disabled={!reorderDirty || reorderProcessing} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white disabled:opacity-40">Reset</button>
|
||||
<button type="button" onClick={saveReorder} disabled={!reorderDirty || reorderProcessing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-40">{reorderProcessing ? 'Saving order...' : 'Save order'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||
{lessonLanes.map((lane, laneIndex) => (
|
||||
<section
|
||||
key={lane.key}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const activeLessonId = resolveDraggedLessonId(event, draggedLessonId)
|
||||
if (activeLessonId == null) return
|
||||
setDraftLessons((current) => moveLessonToPosition(sections, current, activeLessonId, lane.sectionId, lane.lessons.length))
|
||||
setDraggedLessonId(null)
|
||||
setDropTargetLessonId(null)
|
||||
}}
|
||||
className={`rounded-[26px] border p-4 ${lane.isVisible ? 'border-white/10 bg-white/[0.03]' : 'border-amber-300/20 bg-amber-300/8'}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{lane.sectionId == null ? 'Core lane' : 'Section lane'}</p>
|
||||
{!lane.isVisible ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Hidden on public page</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white">{lane.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{lane.description}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{lane.lessons.length} lessons</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3 min-h-20">
|
||||
{lane.lessons.length ? lane.lessons.map((lesson) => (
|
||||
<ReorderLessonCard
|
||||
key={lesson.id}
|
||||
lesson={lesson}
|
||||
lane={lane}
|
||||
laneIndex={laneIndex}
|
||||
laneCount={lessonLanes.length}
|
||||
globalStepNumber={globalStepMap.get(Number(lesson.id)) || 1}
|
||||
isDragging={Number(draggedLessonId) === Number(lesson.id)}
|
||||
isDropTarget={Number(dropTargetLessonId) === Number(lesson.id)}
|
||||
onDragStart={setDraggedLessonId}
|
||||
onDragEnd={() => {
|
||||
setDraggedLessonId(null)
|
||||
setDropTargetLessonId(null)
|
||||
}}
|
||||
onDragOver={setDropTargetLessonId}
|
||||
onDrop={(event, targetSectionId, targetLessonId) => {
|
||||
const activeLessonId = resolveDraggedLessonId(event, draggedLessonId)
|
||||
if (activeLessonId == null) return
|
||||
if (Number(activeLessonId) === Number(targetLessonId)) return
|
||||
const targetLane = lessonLanes.find((entry) => entry.sectionId === targetSectionId)
|
||||
const targetIndex = Math.max(0, (targetLane?.lessons || []).findIndex((entry) => Number(entry.id) === Number(targetLessonId)))
|
||||
setDraftLessons((current) => moveLessonToPosition(sections, current, activeLessonId, targetSectionId, targetIndex))
|
||||
setDraggedLessonId(null)
|
||||
setDropTargetLessonId(null)
|
||||
}}
|
||||
onMoveUp={() => setDraftLessons((current) => shiftLesson(sections, current, lesson.id, -1))}
|
||||
onMoveDown={() => setDraftLessons((current) => shiftLesson(sections, current, lesson.id, 1))}
|
||||
onMoveLeft={() => moveLessonAcrossLanes(lesson.id, -1)}
|
||||
onMoveRight={() => moveLessonAcrossLanes(lesson.id, 1)}
|
||||
/>
|
||||
)) : <div className="rounded-[22px] border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-slate-500">Drop lessons here to move them into this lane.</div>}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Attached lessons" description="Each lesson stays reusable across courses. Adjust order, requirement status, and access overrides here.">
|
||||
<div className="space-y-4">
|
||||
{orderedCourseLessons.map((courseLesson, index) => (
|
||||
<EditableCourseLessonCard key={courseLesson.id} courseLesson={{ ...courseLessonMap.get(Number(courseLesson.id)), ...courseLesson }} sectionOptions={sectionOptions} stepLabel={formatStepLabel(index + 1)} />
|
||||
))}
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
||||
<FormCard title="Create section" description="Create a chapter before attaching lessons into it.">
|
||||
<form onSubmit={(event) => { event.preventDefault(); sectionForm.post(routes.sectionStore) }} className="space-y-4">
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Title</span><input value={sectionForm.data.title} onChange={(event) => sectionForm.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" /></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Slug</span><input value={sectionForm.data.slug} onChange={(event) => sectionForm.setData('slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" placeholder="auto-generated if blank" /></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Description</span><textarea value={sectionForm.data.description} onChange={(event) => sectionForm.setData('description', event.target.value)} rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" /></label>
|
||||
<button type="submit" disabled={sectionForm.processing} className="w-full rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{sectionForm.processing ? 'Creating...' : 'Create section'}</button>
|
||||
</form>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Attach lesson" description="Attach an existing Academy lesson to this course without duplicating content.">
|
||||
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(routes.attachLesson) }} className="space-y-4">
|
||||
<NovaSelect label="Lesson" value={attachForm.data.lesson_id} onChange={(nextValue) => attachForm.setData('lesson_id', nextValue || '')} options={attachableLessonOptions.length ? attachableLessonOptions : lessonOptions} className="rounded-2xl bg-black/20" />
|
||||
<NovaSelect label="Section" value={attachForm.data.section_id} onChange={(nextValue) => attachForm.setData('section_id', nextValue || '')} options={sectionOptions} searchable={false} className="rounded-2xl bg-black/20" />
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Order</span><input type="number" value={attachForm.data.order_num} onChange={(event) => attachForm.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" /></label>
|
||||
<NovaSelect label="Access override" value={attachForm.data.access_override} onChange={(nextValue) => attachForm.setData('access_override', nextValue || '')} options={accessOverrideOptions} searchable={false} className="rounded-2xl bg-black/20" />
|
||||
<CheckboxCardField
|
||||
label="Required for completion"
|
||||
checked={Boolean(attachForm.data.is_required)}
|
||||
onChange={(event) => attachForm.setData('is_required', event.target.checked)}
|
||||
description="Only required lessons count toward course completion."
|
||||
/>
|
||||
<button type="submit" disabled={attachForm.processing || !attachForm.data.lesson_id} className="w-full rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">{attachForm.processing ? 'Attaching...' : 'Attach lesson'}</button>
|
||||
</form>
|
||||
</FormCard>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
960
resources/js/Pages/Admin/Academy/CourseEditor.jsx
Normal file
960
resources/js/Pages/Admin/Academy/CourseEditor.jsx
Normal file
@@ -0,0 +1,960 @@
|
||||
import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import RichTextEditor from '../../../components/forum/RichTextEditor'
|
||||
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
|
||||
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
||||
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||
|
||||
const COURSE_EDITOR_TABS = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
description: 'Title, slug, positioning, and the short summary shown on course cards.',
|
||||
icon: 'fa-compass-drafting',
|
||||
sections: ['course-identity'],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
description: 'Use the richer WYSIWYG surface for the main course description and learning pitch.',
|
||||
icon: 'fa-pen-nib',
|
||||
sections: ['course-description'],
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
label: 'Media',
|
||||
description: 'Upload and tune the cover and teaser visuals used across the public course surfaces.',
|
||||
icon: 'fa-images',
|
||||
sections: ['course-media'],
|
||||
},
|
||||
{
|
||||
id: 'lessons',
|
||||
label: 'Lessons',
|
||||
description: 'Build the lesson sequence, drag to reorder, and add or remove lessons without opening the full builder.',
|
||||
icon: 'fa-list-ol',
|
||||
sections: ['course-lessons-manager'],
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
label: 'Publish',
|
||||
description: 'Control access, status, ordering, scheduling, and featured placement.',
|
||||
icon: 'fa-rocket-launch',
|
||||
sections: ['course-publishing', 'course-seo'],
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
label: 'Preview',
|
||||
description: 'Scan the public-facing course card, media, and rendered long description before publishing.',
|
||||
icon: 'fa-eye',
|
||||
sections: ['course-preview'],
|
||||
},
|
||||
]
|
||||
|
||||
const COURSE_FIELD_TAB_MAP = {
|
||||
title: 'overview',
|
||||
slug: 'overview',
|
||||
subtitle: 'overview',
|
||||
excerpt: 'overview',
|
||||
description: 'content',
|
||||
cover_image: 'media',
|
||||
teaser_image: 'media',
|
||||
access_level: 'publish',
|
||||
difficulty: 'publish',
|
||||
status: 'publish',
|
||||
order_num: 'publish',
|
||||
estimated_minutes: 'publish',
|
||||
published_at: 'publish',
|
||||
is_featured: 'publish',
|
||||
seo_title: 'publish',
|
||||
seo_description: 'publish',
|
||||
meta_keywords: 'publish',
|
||||
og_title: 'publish',
|
||||
og_description: 'publish',
|
||||
og_image: 'publish',
|
||||
}
|
||||
|
||||
function getField(fields, name) {
|
||||
return fields.find((field) => field.name === name) || null
|
||||
}
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null
|
||||
return <p className="text-xs text-rose-300">{message}</p>
|
||||
}
|
||||
|
||||
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '', contentClassName = '' }) {
|
||||
const toneClass = tone === 'feature'
|
||||
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
||||
: 'bg-white/[0.03]'
|
||||
|
||||
return (
|
||||
<section id={id} className={`min-w-0 scroll-mt-24 rounded-[28px] border border-white/10 p-5 ${toneClass} ${className}`.trim()}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
<div className={`mt-5 ${contentClassName}`.trim()}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorWorkspaceTabs({ tabs, activeTab, onChange, errorCounts }) {
|
||||
const activeMeta = tabs.find((tab) => tab.id === activeTab) || tabs[0]
|
||||
|
||||
return (
|
||||
<div className="sticky top-4 z-20 rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(7,11,18,0.92),rgba(5,8,14,0.88))] px-3 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.18)] backdrop-blur">
|
||||
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Course editor sections">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab
|
||||
const errorCount = Number(errorCounts?.[tab.id] || 0)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`course-editor-panel-${tab.id}`}
|
||||
id={`course-editor-tab-${tab.id}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
|
||||
isActive
|
||||
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
||||
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid ${tab.icon} text-xs`} />
|
||||
<span>{tab.label}</span>
|
||||
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-300/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-rose-100">{errorCount}</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<p className="text-sm leading-6 text-slate-400">{activeMeta.description}</p>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
||||
{activeMeta.sections.map((section) => (
|
||||
<span key={section} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">{section.replace('course-', '').replace(/-/g, ' ')}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, error, hint, ...rest }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
||||
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
|
||||
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
||||
<FieldError message={error} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextAreaField({ label, value, onChange, error, rows = 4, hint }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
||||
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
||||
<FieldError message={error} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxCardField({ label, checked, onChange, description, error }) {
|
||||
return (
|
||||
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
||||
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
||||
<i className="fa-solid fa-check" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-lg font-semibold tracking-[-0.02em] text-white">{label}</span>
|
||||
{description ? <span className="mt-1 block text-sm leading-6 text-slate-300">{description}</span> : null}
|
||||
<FieldError message={error} />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function OutlineSectionPill({ section }) {
|
||||
return (
|
||||
<div className="rounded-[20px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-white">{section.title}</p>
|
||||
<p className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{section.is_visible ? 'Visible section' : 'Hidden section'}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200">{section.lesson_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function slugifyCourseTitle(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 180)
|
||||
}
|
||||
|
||||
function formatLessonStep(orderNum) {
|
||||
const numeric = Number(orderNum)
|
||||
if (!Number.isFinite(numeric) || numeric < 0) return null
|
||||
return `Step ${String(numeric + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function normalizeLessonManagerLessons(lessons) {
|
||||
return (Array.isArray(lessons) ? [...lessons] : [])
|
||||
.sort((a, b) => {
|
||||
const diff = Number(a?.order_num || 0) - Number(b?.order_num || 0)
|
||||
return diff !== 0 ? diff : Number(a?.id || 0) - Number(b?.id || 0)
|
||||
})
|
||||
.map((lesson, index) => ({ ...lesson, order_num: index, display_order: index + 1 }))
|
||||
}
|
||||
|
||||
function reorderLessonManagerLessons(lessons, draggedId, targetId) {
|
||||
const current = normalizeLessonManagerLessons(lessons)
|
||||
const di = current.findIndex((l) => Number(l.id) === Number(draggedId))
|
||||
const ti = current.findIndex((l) => Number(l.id) === Number(targetId))
|
||||
if (di === -1 || ti === -1 || di === ti) return current
|
||||
const next = [...current]
|
||||
const [moved] = next.splice(di, 1)
|
||||
next.splice(ti, 0, moved)
|
||||
return normalizeLessonManagerLessons(next)
|
||||
}
|
||||
|
||||
function moveLessonManagerLesson(lessons, lessonId, direction) {
|
||||
const current = normalizeLessonManagerLessons(lessons)
|
||||
const idx = current.findIndex((l) => Number(l.id) === Number(lessonId))
|
||||
const nextIdx = idx + direction
|
||||
if (idx === -1 || nextIdx < 0 || nextIdx >= current.length) return current
|
||||
const next = [...current]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.splice(nextIdx, 0, moved)
|
||||
return normalizeLessonManagerLessons(next)
|
||||
}
|
||||
|
||||
function lessonManagerSignature(lessons) {
|
||||
return JSON.stringify(normalizeLessonManagerLessons(lessons).map((l) => ({
|
||||
id: Number(l.id),
|
||||
order_num: Number(l.order_num || 0),
|
||||
section_id: l.section_id == null ? null : Number(l.section_id),
|
||||
})))
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function countWords(value) {
|
||||
const text = stripHtml(value)
|
||||
return text ? text.split(/\s+/).length : 0
|
||||
}
|
||||
|
||||
function normalizeAssetPreview(value, cdnBaseUrl) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) return trimmed
|
||||
return `${String(cdnBaseUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\//, '')}`
|
||||
}
|
||||
|
||||
function firstCourseErrorTab(errors) {
|
||||
const firstKey = Object.keys(errors || {})[0]
|
||||
if (!firstKey) return null
|
||||
return COURSE_FIELD_TAB_MAP[firstKey] || null
|
||||
}
|
||||
|
||||
function courseTabErrorCounts(errors) {
|
||||
const counts = {}
|
||||
|
||||
Object.keys(errors || {}).forEach((key) => {
|
||||
const tabId = COURSE_FIELD_TAB_MAP[key]
|
||||
if (!tabId) return
|
||||
counts[tabId] = Number(counts[tabId] || 0) + 1
|
||||
})
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
function renderMetaKeywords(value) {
|
||||
return String(value || '')
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
}
|
||||
|
||||
export default function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
|
||||
const form = useForm({
|
||||
...record,
|
||||
description: String(record.description || ''),
|
||||
cover_image: String(record.cover_image || ''),
|
||||
teaser_image: String(record.teaser_image || ''),
|
||||
})
|
||||
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record.cover_image_url || normalizeAssetPreview(record.cover_image, editorContext.coverCdnBaseUrl))
|
||||
const [teaserPreviewUrl, setTeaserPreviewUrl] = useState(record.teaser_image_url || normalizeAssetPreview(record.teaser_image, editorContext.coverCdnBaseUrl))
|
||||
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
||||
const [stagedTeaserPath, setStagedTeaserPath] = useState('')
|
||||
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
||||
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
||||
const statusField = useMemo(() => getField(fields, 'status'), [fields])
|
||||
const wordCount = useMemo(() => countWords(form.data.description), [form.data.description])
|
||||
const excerptLength = String(form.data.excerpt || '').length
|
||||
const tabErrorCounts = useMemo(() => courseTabErrorCounts(form.errors), [form.errors])
|
||||
const deferredDescription = useDeferredValue(form.data.description || '')
|
||||
const visibleSections = useMemo(() => new Set((COURSE_EDITOR_TABS.find((tab) => tab.id === activeTab)?.sections) || []), [activeTab])
|
||||
const activeTabMeta = useMemo(() => COURSE_EDITOR_TABS.find((tab) => tab.id === activeTab) || COURSE_EDITOR_TABS[0], [activeTab])
|
||||
const sectionClassName = (sectionId, className = '') => `${visibleSections.has(sectionId) ? '' : 'hidden'} ${className}`.trim()
|
||||
const editorLinks = editorContext?.links || {}
|
||||
const outlineSummary = editorContext?.outlineSummary || null
|
||||
const coursePathPreview = form.data.slug ? `/academy/courses/${form.data.slug}` : '/academy/courses/course-slug'
|
||||
const metaKeywordItems = renderMetaKeywords(form.data.meta_keywords)
|
||||
const attachLessonUrl = editorContext?.attachLessonUrl || null
|
||||
const reorderUrl = editorContext?.reorderUrl || null
|
||||
const courseLessonsSource = useMemo(() => Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : [], [editorContext])
|
||||
const availableLessons = useMemo(() => Array.isArray(editorContext?.availableLessons) ? editorContext.availableLessons : [], [editorContext])
|
||||
const [lessonManagerDraft, setLessonManagerDraft] = useState(() => normalizeLessonManagerLessons(Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : []))
|
||||
const [lessonDragActive, setLessonDragActive] = useState(null)
|
||||
const [lessonSaveProcessing, setLessonSaveProcessing] = useState(false)
|
||||
const [lessonSearch, setLessonSearch] = useState('')
|
||||
const lessonManagerIsDirty = useMemo(() => lessonManagerSignature(lessonManagerDraft) !== lessonManagerSignature(courseLessonsSource), [lessonManagerDraft, courseLessonsSource])
|
||||
const filteredAvailableLessons = useMemo(() => {
|
||||
const q = lessonSearch.trim().toLowerCase()
|
||||
const unattached = availableLessons.filter((l) => !l.attached)
|
||||
if (!q) return unattached
|
||||
return unattached.filter((l) => l.title.toLowerCase().includes(q) || l.category.toLowerCase().includes(q))
|
||||
}, [availableLessons, lessonSearch])
|
||||
|
||||
useEffect(() => {
|
||||
setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource))
|
||||
}, [courseLessonsSource])
|
||||
|
||||
useEffect(() => {
|
||||
if (slugTouchedRef.current) return
|
||||
form.setData('slug', slugifyCourseTitle(form.data.title))
|
||||
}, [form, form.data.title])
|
||||
|
||||
useEffect(() => {
|
||||
const nextTab = firstCourseErrorTab(form.errors)
|
||||
if (!nextTab) return
|
||||
setActiveTab(nextTab)
|
||||
}, [form.errors])
|
||||
|
||||
const handleManualCoverChange = (nextValue) => {
|
||||
setStagedCoverPath('')
|
||||
form.setData('cover_image', nextValue)
|
||||
setCoverPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
|
||||
}
|
||||
|
||||
const attachLesson = (lesson) => {
|
||||
if (!attachLessonUrl) return
|
||||
router.post(attachLessonUrl, {
|
||||
lesson_id: lesson.id,
|
||||
order_num: courseLessonsSource.length,
|
||||
is_required: true,
|
||||
}, { preserveScroll: true })
|
||||
}
|
||||
|
||||
const detachLesson = (courseLesson) => {
|
||||
if (!courseLesson.destroy_url) return
|
||||
if (!window.confirm(`Remove "${courseLesson.title}" from this course?`)) return
|
||||
router.delete(courseLesson.destroy_url, { preserveScroll: true })
|
||||
}
|
||||
|
||||
const saveLessonOrder = () => {
|
||||
if (!reorderUrl) return
|
||||
setLessonSaveProcessing(true)
|
||||
router.patch(reorderUrl, {
|
||||
sections: [],
|
||||
lessons: lessonManagerDraft.map((l) => ({
|
||||
id: l.id,
|
||||
order_num: l.order_num,
|
||||
section_id: l.section_id ?? null,
|
||||
})),
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onFinish: () => setLessonSaveProcessing(false),
|
||||
})
|
||||
}
|
||||
|
||||
const handleManualTeaserChange = (nextValue) => {
|
||||
setStagedTeaserPath('')
|
||||
form.setData('teaser_image', nextValue)
|
||||
setTeaserPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
|
||||
}
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (method === 'patch') {
|
||||
form.patch(submitUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(submitUrl)
|
||||
}
|
||||
|
||||
const deleteCourse = () => {
|
||||
if (!destroyUrl) return
|
||||
if (!window.confirm('Delete this course?')) return
|
||||
router.delete(destroyUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<form onSubmit={submit} className="space-y-6 pb-16">
|
||||
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to courses</Link>
|
||||
<span>{destroyUrl ? 'Edit course' : 'New course'}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy course'}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Design the course like a polished editorial landing page: keep the structure clear, use the rich description editor, and upload visuals that look intentional on the public cards and hero.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{editorLinks.builder ? <Link href={editorLinks.builder} className="rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110">Open builder</Link> : null}
|
||||
{editorLinks.preview ? <Link href={editorLinks.preview} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Preview public page</Link> : null}
|
||||
<button type="submit" disabled={form.processing} className="rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save course'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorWorkspaceTabs tabs={COURSE_EDITOR_TABS} activeTab={activeTab} onChange={setActiveTab} errorCounts={tabErrorCounts} />
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Current workspace</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{activeTabMeta.label}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">{activeTabMeta.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Words</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{wordCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{excerptLength}/800</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Errors</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Object.keys(form.errors || {}).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0 space-y-6" role="tabpanel" id={`course-editor-panel-${activeTab}`} aria-labelledby={`course-editor-tab-${activeTab}`}>
|
||||
<SectionCard id="course-identity" eyebrow="Positioning" title="Identity and summary" description="Start with the public-facing identity shown on the course index, hero, and internal Academy modules." tone="feature" className={sectionClassName('course-identity')}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextField
|
||||
label="Title"
|
||||
value={form.data.title}
|
||||
onChange={(event) => form.setData('title', event.target.value)}
|
||||
error={form.errors.title}
|
||||
maxLength={180}
|
||||
placeholder="AI-Assisted Digital Art Foundations"
|
||||
/>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
<span>Slug</span>
|
||||
<button type="button" onClick={() => {
|
||||
slugTouchedRef.current = false
|
||||
form.setData('slug', slugifyCourseTitle(form.data.title))
|
||||
}} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Sync</button>
|
||||
</span>
|
||||
<input
|
||||
value={form.data.slug}
|
||||
onChange={(event) => {
|
||||
slugTouchedRef.current = String(event.target.value).trim() !== ''
|
||||
form.setData('slug', event.target.value)
|
||||
}}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
placeholder="ai-assisted-digital-art-foundations"
|
||||
maxLength={180}
|
||||
/>
|
||||
<span className="text-xs leading-5 text-slate-500">The public course URL updates from the title until you override it.</span>
|
||||
<FieldError message={form.errors.slug} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextField
|
||||
label="Subtitle"
|
||||
value={form.data.subtitle}
|
||||
onChange={(event) => form.setData('subtitle', event.target.value)}
|
||||
error={form.errors.subtitle}
|
||||
maxLength={255}
|
||||
placeholder="A guided path for Skinbase creators"
|
||||
/>
|
||||
<TextField
|
||||
label="Estimated minutes"
|
||||
value={form.data.estimated_minutes ?? ''}
|
||||
onChange={(event) => form.setData('estimated_minutes', event.target.value)}
|
||||
error={form.errors.estimated_minutes}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="90"
|
||||
hint="Shown on public course cards and the course hero."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextAreaField
|
||||
label="Excerpt"
|
||||
value={form.data.excerpt}
|
||||
onChange={(event) => form.setData('excerpt', event.target.value)}
|
||||
error={form.errors.excerpt}
|
||||
rows={5}
|
||||
hint="Keep this tight and outcome-focused. This summary is reused on cards, related modules, and SEO helpers."
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="course-description" eyebrow="Long-form content" title="Course description" description="Use the same richer WYSIWYG surface as lessons so the course page can carry structured copy, lists, and supporting media." tone="feature" className={sectionClassName('course-description')}>
|
||||
<RichTextEditor
|
||||
content={form.data.description}
|
||||
onChange={(nextHtml) => form.setData('description', nextHtml)}
|
||||
placeholder="Explain what the course covers, who it is for, what workflows it teaches, and why a Skinbase creator should follow this path from start to finish."
|
||||
error={form.errors.description}
|
||||
minHeight={24}
|
||||
maxHeightRem={42}
|
||||
autofocus={false}
|
||||
advancedNews
|
||||
mediaSupport={{
|
||||
uploadUrl: editorContext.bodyMediaUploadUrl,
|
||||
deleteUrl: editorContext.bodyMediaDeleteUrl,
|
||||
assetsUrl: editorContext.bodyMediaAssetsUrl,
|
||||
slot: 'body',
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="course-media" eyebrow="Visual system" title="Cover and teaser media" description="Upload clean landscape images that work across the featured course rail, the course index cards, and the public course hero." className={sectionClassName('course-media')}>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<WorldMediaUploadField
|
||||
label="Cover image"
|
||||
slot="cover"
|
||||
value={form.data.cover_image}
|
||||
previewUrl={coverPreviewUrl}
|
||||
emptyLabel="Course cover"
|
||||
helperText="Preferred 1600×900 at 16:9. Minimum upload is 1200×630. Use this as the main hero image for the course page and featured cards."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
isTemporaryValue={Boolean(stagedCoverPath) && stagedCoverPath === form.data.cover_image}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedCoverPath(path || '')
|
||||
form.setData('cover_image', path || '')
|
||||
setCoverPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Cover image path override"
|
||||
value={form.data.cover_image}
|
||||
onChange={(event) => handleManualCoverChange(event.target.value)}
|
||||
error={form.errors.cover_image}
|
||||
placeholder="academy/lessons/covers/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<WorldMediaUploadField
|
||||
label="Teaser image"
|
||||
slot="cover"
|
||||
value={form.data.teaser_image}
|
||||
previewUrl={teaserPreviewUrl}
|
||||
emptyLabel="Course teaser"
|
||||
helperText="Preferred 1600×900 at 16:9. Use this as the lighter secondary image for index cards or fallback thumbnail treatment when the main cover is too dense."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
isTemporaryValue={Boolean(stagedTeaserPath) && stagedTeaserPath === form.data.teaser_image}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedTeaserPath(path || '')
|
||||
form.setData('teaser_image', path || '')
|
||||
setTeaserPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Teaser image path override"
|
||||
value={form.data.teaser_image}
|
||||
onChange={(event) => handleManualTeaserChange(event.target.value)}
|
||||
error={form.errors.teaser_image}
|
||||
placeholder="academy/lessons/covers/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300">
|
||||
The public course index and the course hero both render landscape imagery first. If you only prepare one asset, prioritize the cover image. If you prepare both, keep them in the same visual family so the course feels consistent across list and detail pages.
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
id="course-lessons-manager"
|
||||
eyebrow="Lesson sequence"
|
||||
title="Manage course lessons"
|
||||
description="Add lessons from the library, drag rows to reorder, use the arrows for precision, and save the updated sequence. Removing a lesson detaches it from this course immediately."
|
||||
tone="feature"
|
||||
className={sectionClassName('course-lessons-manager')}
|
||||
actions={
|
||||
editorLinks.builder
|
||||
? <a href={editorLinks.builder} className="rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110">Open full builder</a>
|
||||
: null
|
||||
}
|
||||
>
|
||||
{/* Current lesson sequence */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
Lesson sequence
|
||||
{lessonManagerDraft.length > 0 ? <span className="ml-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] text-slate-300">{lessonManagerDraft.length}</span> : null}
|
||||
{lessonManagerIsDirty ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] text-amber-200">Unsaved order</span> : null}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource))}
|
||||
disabled={!lessonManagerIsDirty || lessonSaveProcessing}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40"
|
||||
>
|
||||
Reset order
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveLessonOrder}
|
||||
disabled={!lessonManagerIsDirty || lessonSaveProcessing}
|
||||
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100 disabled:opacity-40"
|
||||
>
|
||||
{lessonSaveProcessing ? 'Saving…' : 'Save order'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lessonManagerDraft.length === 0 ? (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-6 text-sm text-slate-400">
|
||||
No lessons attached to this course yet. Add lessons from the library below.
|
||||
</div>
|
||||
) : lessonManagerDraft.map((lesson, lessonIndex) => (
|
||||
<div
|
||||
key={lesson.id}
|
||||
draggable
|
||||
onDragStart={() => setLessonDragActive({ id: lesson.id })}
|
||||
onDragEnd={() => setLessonDragActive(null)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (!lessonDragActive) return
|
||||
setLessonManagerDraft(reorderLessonManagerLessons(lessonManagerDraft, lessonDragActive.id, lesson.id))
|
||||
setLessonDragActive(null)
|
||||
}}
|
||||
className={[
|
||||
'flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3 transition',
|
||||
'border-white/10 bg-white/[0.03] cursor-grab',
|
||||
lessonDragActive && Number(lessonDragActive.id) === Number(lesson.id) ? 'opacity-50 border-sky-300/30' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<i className="fa-solid fa-grip-vertical text-xs text-slate-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">
|
||||
{formatLessonStep(lesson.order_num) || `#${lesson.display_order}`}
|
||||
</span>
|
||||
{lesson.formatted_lesson_number ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{lesson.formatted_lesson_number}</span>
|
||||
) : null}
|
||||
{lesson.section_title ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.section_title}</span>
|
||||
) : null}
|
||||
{lesson.difficulty ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.difficulty}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLessonManagerDraft(moveLessonManagerLesson(lessonManagerDraft, lesson.id, -1))}
|
||||
disabled={lessonIndex === 0}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-semibold text-white disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLessonManagerDraft(moveLessonManagerLesson(lessonManagerDraft, lesson.id, 1))}
|
||||
disabled={lessonIndex === lessonManagerDraft.length - 1}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-semibold text-white disabled:opacity-30"
|
||||
title="Move down"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-down" />
|
||||
</button>
|
||||
{lesson.edit_url ? (
|
||||
<a href={lesson.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">
|
||||
Edit
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => detachLesson(lesson)}
|
||||
className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Available lessons library */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Add from lesson library</p>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
|
||||
<input
|
||||
type="search"
|
||||
value={lessonSearch}
|
||||
onChange={(e) => setLessonSearch(e.target.value)}
|
||||
placeholder="Search lessons by title or category…"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 py-2.5 pl-9 pr-4 text-sm text-white outline-none placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
{filteredAvailableLessons.length === 0 ? (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-500">
|
||||
{lessonSearch.trim() ? 'No unattached lessons match your search.' : 'All lessons are already attached to this course.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{filteredAvailableLessons.map((lesson) => (
|
||||
<div key={lesson.id} className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{lesson.difficulty ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.difficulty}</span> : null}
|
||||
{lesson.category ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.category}</span> : null}
|
||||
{!lesson.active ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-200">Inactive</span> : null}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => attachLesson(lesson)}
|
||||
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100"
|
||||
>
|
||||
Add to course
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="course-publishing" eyebrow="Release controls" title="Access, status, and placement" description="Choose how the course appears in Academy discovery surfaces and when it goes live." className={sectionClassName('course-publishing')}>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Access" value={form.data.access_level || ''} onChange={(nextValue) => form.setData('access_level', String(nextValue || ''))} options={accessField?.options || []} searchable={false} className="bg-black/20" error={form.errors.access_level} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Difficulty" value={form.data.difficulty || ''} onChange={(nextValue) => form.setData('difficulty', String(nextValue || ''))} options={difficultyField?.options || []} searchable={false} className="bg-black/20" error={form.errors.difficulty} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Status" value={form.data.status || ''} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={statusField?.options || []} searchable={false} className="bg-black/20" error={form.errors.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextField
|
||||
label="Order number"
|
||||
value={form.data.order_num ?? ''}
|
||||
onChange={(event) => form.setData('order_num', event.target.value)}
|
||||
error={form.errors.order_num}
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="10"
|
||||
hint="Lower numbers float higher in featured and published course lists."
|
||||
/>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
||||
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} clearable className="bg-black/20" />
|
||||
<span className="text-xs leading-5 text-slate-500">If the status is set to published and this is empty, the backend will timestamp it automatically.</span>
|
||||
<FieldError message={form.errors.published_at} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckboxCardField
|
||||
label="Feature on newsroom surfaces"
|
||||
checked={Boolean(form.data.is_featured)}
|
||||
onChange={(event) => form.setData('is_featured', event.target.checked)}
|
||||
description="Use the featured treatment on Academy homepage rails and the course index. Keep this for courses with strong cover art and a finished outline."
|
||||
error={form.errors.is_featured}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="course-seo" eyebrow="Search surfaces" title="SEO and OpenGraph" description="Keep the course crawlable and shareable without overstuffing the main title." className={sectionClassName('course-seo')}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextField label="SEO title" value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} placeholder="Optional search title" />
|
||||
<TextField label="OpenGraph title" value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} error={form.errors.og_title} maxLength={180} placeholder="Optional social title" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextAreaField label="SEO description" value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} hint="Keep this short and aligned with the course promise." />
|
||||
<TextAreaField label="OpenGraph description" value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} error={form.errors.og_description} rows={4} hint="Used when the course page is shared into external platforms." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextAreaField label="Meta keywords" value={form.data.meta_keywords} onChange={(event) => form.setData('meta_keywords', event.target.value)} error={form.errors.meta_keywords} rows={3} hint="Comma-separated terms. Keep this focused and editorial, not spammy." />
|
||||
<TextField label="OpenGraph image" value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} error={form.errors.og_image} placeholder="Leave empty to fall back to the course artwork" />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="course-preview" eyebrow="Public preview" title="Rendered course snapshot" description="Use this tab to scan the media mix, course promise, and rendered long description without the rest of the form competing for attention." tone="feature" className={sectionClassName('course-preview')}>
|
||||
<div className="space-y-5">
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
|
||||
{coverPreviewUrl || teaserPreviewUrl ? (
|
||||
<img src={coverPreviewUrl || teaserPreviewUrl} alt="Course hero preview" className="h-64 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500">No course artwork selected yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6">
|
||||
<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.18em] text-sky-100">{form.data.difficulty || 'beginner'}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{form.data.access_level || 'free'}</span>
|
||||
{form.data.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy course'}</h3>
|
||||
{form.data.subtitle ? <p className="mt-2 text-sm font-semibold uppercase tracking-[0.18em] text-amber-100">{form.data.subtitle}</p> : null}
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{form.data.excerpt || 'Add a short course summary to explain what this path helps creators accomplish.'}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Description preview</p>
|
||||
{String(deferredDescription || '').trim() ? (
|
||||
<div className="prose prose-invert mt-4 max-w-none prose-headings:tracking-[-0.03em] prose-p:text-slate-300 prose-li:text-slate-300" dangerouslySetInnerHTML={{ __html: deferredDescription }} />
|
||||
) : (
|
||||
<p className="mt-4 text-sm leading-7 text-slate-400">The long description is still empty.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
||||
<SectionCard eyebrow="At a glance" title="Course summary" description="A compact view of the public URL, media readiness, and the metadata editors see most often.">
|
||||
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Public path</p>
|
||||
<p className="mt-2 break-all text-sm font-semibold text-white">{coursePathPreview}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">Use a concise slug so the course URL stays readable in search results and internal links.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Cover</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{coverPreviewUrl ? 'Ready' : 'Missing'}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Teaser</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{teaserPreviewUrl ? 'Ready' : 'Missing'}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{form.data.status || 'draft'}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Duration</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{form.data.estimated_minutes ? `${form.data.estimated_minutes} min` : 'Flexible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{outlineSummary ? (
|
||||
<SectionCard eyebrow="Builder pulse" title="Course outline" description="A quick summary of what the course builder currently contains so editors do not need to leave this form just to check structure.">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sections</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.section_count}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visible sections</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.visible_section_count}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Attached lessons</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.lesson_count}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required lessons</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.required_lesson_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300">
|
||||
{outlineSummary.unsectioned_lesson_count > 0
|
||||
? `${outlineSummary.unsectioned_lesson_count} lesson${outlineSummary.unsectioned_lesson_count === 1 ? '' : 's'} still sit outside sections. Use the builder if you want the outline to read like a guided chapter path.`
|
||||
: 'All attached lessons are currently grouped into sections.'}
|
||||
</div>
|
||||
|
||||
{outlineSummary.sections?.length ? (
|
||||
<div className="grid gap-3">
|
||||
{outlineSummary.sections.map((section) => <OutlineSectionPill key={section.id} section={section} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-400">No sections yet. The builder will still allow unsectioned lessons, but adding chapters usually makes the public course easier to scan.</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
<SectionCard eyebrow="Metadata pulse" title="Search and share" description="A quick scan of the metadata that most often gets missed before publish.">
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">SEO title</p>
|
||||
<p className="mt-2 text-sm text-white">{form.data.seo_title || 'Uses course title by default'}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Keywords</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{metaKeywordItems.length ? metaKeywordItems.map((item) => <span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200">{item}</span>) : <span className="text-sm text-slate-400">No meta keywords yet.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save course'}</button>
|
||||
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
||||
{destroyUrl ? <button type="button" onClick={deleteCourse} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,400 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
const PROMPT_VIEW_STORAGE_KEY = 'skinbase.admin.academy.prompts.view'
|
||||
const PROMPT_VIEW_OPTIONS = [
|
||||
{ value: 'gallery', label: 'Gallery', icon: 'fa-images' },
|
||||
{ value: 'grid', label: 'Grid', icon: 'fa-grid-2' },
|
||||
{ value: 'table', label: 'Table', icon: 'fa-table-list' },
|
||||
]
|
||||
|
||||
function formatDateLabel(value) {
|
||||
if (!value) return 'Recently updated'
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) return 'Recently updated'
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
function paginationLabel(label) {
|
||||
return String(label || '')
|
||||
.replace(/«/g, 'Previous')
|
||||
.replace(/»/g, 'Next')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function promptSummary(items = []) {
|
||||
return items.reduce((summary, item) => ({
|
||||
total: summary.total + 1,
|
||||
active: summary.active + (item.active ? 1 : 0),
|
||||
featured: summary.featured + (item.featured ? 1 : 0),
|
||||
promptOfWeek: summary.promptOfWeek + (item.prompt_of_week ? 1 : 0),
|
||||
comparisons: summary.comparisons + Number(item.comparisons_count || 0),
|
||||
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0 })
|
||||
}
|
||||
|
||||
function PromptFlag({ children, tone = 'default' }) {
|
||||
const toneClass = tone === 'warm'
|
||||
? 'border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
|
||||
: tone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
|
||||
: 'border-white/10 bg-white/[0.05] text-slate-200'
|
||||
|
||||
return <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClass}`}>{children}</span>
|
||||
}
|
||||
|
||||
function PromptActions({ item }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{item.preview_url ? <Link href={item.preview_url} className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]">Preview</Link> : null}
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this prompt?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptPreview({ item, compact = false }) {
|
||||
if (item.preview_image_url) {
|
||||
return <img src={item.preview_image_url} alt={item.title} className={`h-full w-full object-cover transition duration-500 ${compact ? 'group-hover:scale-[1.04]' : 'group-hover:scale-[1.03]'}`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_30%),linear-gradient(135deg,rgba(15,23,42,0.98),rgba(30,41,59,0.94))] p-6 text-center text-slate-300">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Prompt preview</p>
|
||||
<p className="mt-3 text-sm font-semibold text-white">No image attached yet</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptMeta({ item }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.category_name ? <PromptFlag tone="warm">{item.category_name}</PromptFlag> : null}
|
||||
{item.difficulty ? <PromptFlag>{item.difficulty}</PromptFlag> : null}
|
||||
{item.access_level ? <PromptFlag>{item.access_level}</PromptFlag> : null}
|
||||
{item.aspect_ratio ? <PromptFlag>{item.aspect_ratio}</PromptFlag> : null}
|
||||
{item.featured ? <PromptFlag tone="sky">Featured</PromptFlag> : null}
|
||||
{item.prompt_of_week ? <PromptFlag tone="emerald">Prompt of week</PromptFlag> : null}
|
||||
<PromptFlag tone={item.active ? 'sky' : 'default'}>{item.active ? 'Active' : 'Draft'}</PromptFlag>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptGalleryCard({ item }) {
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(135deg,rgba(8,15,28,0.98),rgba(15,23,42,0.92))] shadow-[0_24px_80px_rgba(2,6,23,0.24)]">
|
||||
<div className="grid gap-0 xl:grid-cols-[340px_minmax(0,1fr)]">
|
||||
<div className="relative min-h-[250px] overflow-hidden border-b border-white/10 xl:min-h-full xl:border-b-0 xl:border-r xl:border-white/10">
|
||||
<PromptPreview item={item} />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.32))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<PromptFlag tone="warm">{item.comparisons_count || 0} comparisons</PromptFlag>
|
||||
{item.slug ? <PromptFlag>{item.slug}</PromptFlag> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col justify-between p-6 lg:p-7">
|
||||
<div>
|
||||
<PromptMeta item={item} />
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{item.excerpt || 'Add an excerpt to make this prompt easier to scan in moderation.'}</p>
|
||||
|
||||
{item.tags?.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{item.tags.slice(0, 5).map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Updated</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{formatDateLabel(item.updated_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Access</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.access_level || 'free'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.active ? 'Visible' : 'Hidden'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptActions item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptGridCard({ item }) {
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-[28px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))] shadow-[0_18px_60px_rgba(2,6,23,0.18)]">
|
||||
<div className="relative h-52 overflow-hidden border-b border-white/10">
|
||||
<PromptPreview item={item} compact />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<PromptMeta item={item} />
|
||||
<h2 className="mt-4 text-xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 text-sm text-slate-400">
|
||||
<span>{formatDateLabel(item.updated_at)}</span>
|
||||
<span>{item.comparisons_count || 0} comparisons</span>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<PromptActions item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptTable({ items }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.92))] shadow-[0_24px_80px_rgba(2,6,23,0.22)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-white/10 text-left">
|
||||
<thead className="bg-white/[0.04] text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
||||
<tr>
|
||||
<th className="px-5 py-4">Prompt</th>
|
||||
<th className="px-5 py-4">Category</th>
|
||||
<th className="px-5 py-4">Access</th>
|
||||
<th className="px-5 py-4">Signals</th>
|
||||
<th className="px-5 py-4">Updated</th>
|
||||
<th className="px-5 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 text-sm text-slate-200">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-16 w-20 overflow-hidden rounded-2xl border border-white/10 bg-black/30 shrink-0">
|
||||
<PromptPreview item={item} compact />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">{item.title}</p>
|
||||
<p className="mt-1 max-w-md text-sm leading-6 text-slate-400">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">{item.category_name || 'Uncategorized'}</td>
|
||||
<td className="px-5 py-4">{item.access_level || 'free'}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<p>{item.comparisons_count || 0} comparisons</p>
|
||||
<p>{item.difficulty || 'No difficulty'}</p>
|
||||
<p>{item.active ? 'Active' : 'Draft'}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">{formatDateLabel(item.updated_at)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{item.preview_url ? <Link href={item.preview_url} className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-2 text-xs font-semibold text-[#fff0ea]">Preview</Link> : null}
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white">Edit</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptHeroCollage({ items = [] }) {
|
||||
const images = items
|
||||
.map((item) => item?.preview_image_url)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Prompt preview wall</p>
|
||||
<p className="mt-4 text-lg font-semibold text-white">Preview images will appear here as prompts get covers.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[420px] grid-cols-2 gap-3">
|
||||
{images.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.2)] ${index === 0 ? 'col-span-2 aspect-[16/9]' : index === 3 ? 'aspect-[4/5]' : 'aspect-square'}`}
|
||||
>
|
||||
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationLinks({ links = [] }) {
|
||||
if (!Array.isArray(links) || links.length <= 3) return null
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{links.map((link, index) => {
|
||||
const label = paginationLabel(link.label)
|
||||
const className = link.active
|
||||
? 'border-sky-300/25 bg-sky-300/12 text-sky-100'
|
||||
: 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'
|
||||
|
||||
return link.url ? (
|
||||
<Link key={`${label}-${index}`} href={link.url} className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${className}`} preserveScroll>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={`${label}-${index}`} className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-slate-500">{label}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
const promptItems = items?.data || []
|
||||
const summary = promptSummary(promptItems)
|
||||
const [viewMode, setViewMode] = useState('gallery')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const storedView = window.localStorage.getItem(PROMPT_VIEW_STORAGE_KEY)
|
||||
if (PROMPT_VIEW_OPTIONS.some((option) => option.value === storedView)) {
|
||||
setViewMode(storedView)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(PROMPT_VIEW_STORAGE_KEY, viewMode)
|
||||
}, [viewMode])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
|
||||
<div className="grid gap-8 p-6 xl:grid-cols-[minmax(0,1.08fr)_420px] xl:items-end xl:p-10">
|
||||
<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]">Academy moderation</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>
|
||||
|
||||
<h2 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h2>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{subtitle} Review prompts in a visual-first moderation surface, jump into edits quickly, and switch between gallery, grid, or table depending on the task in front of you.</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">Curate covers and prompt outputs before opening the form.</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">Workflow-ready</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">Switch between gallery, compact cards, and scan-heavy tables.</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-aware</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">Spot prompts with provider notes and attached result references.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{PROMPT_VIEW_OPTIONS.map((option) => {
|
||||
const active = option.value === viewMode
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setViewMode(option.value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
<i className={`fa-solid ${option.icon}`} />
|
||||
<span>{option.label} view</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create prompt</Link>
|
||||
<Link href="/academy/prompts" className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">Open public library</Link>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">{summary.total} prompts in view</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<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">Active</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.active}</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">Featured</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.featured}</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">Prompt of week</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.promptOfWeek}</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">Comparisons</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.comparisons}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PromptHeroCollage items={promptItems} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/academy/prompts" className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">View public library</Link>
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create prompt</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{promptItems.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No prompt templates exist yet.</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<PromptTable items={promptItems} />
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{promptItems.map((item) => <PromptGridCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{promptItems.map((item) => <PromptGalleryCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaginationLinks links={items?.links} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCrudIndex({ title, subtitle, items, columns, createUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
|
||||
@@ -11,35 +404,42 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
||||
|
||||
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create record</Link>
|
||||
</div>
|
||||
|
||||
{(items?.data || []).length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No records exist yet.</div>
|
||||
{usePage().props.resource === 'prompts' ? (
|
||||
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.data.map((item) => (
|
||||
<div key={item.id} className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{columns.map((column) => (
|
||||
<div key={column}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{column.replaceAll('_', ' ')}</p>
|
||||
<p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create record</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||
{(items?.data || []).length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No records exist yet.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.data.map((item) => (
|
||||
<div key={item.id} className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{columns.map((column) => (
|
||||
<div key={column}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{column.replaceAll('_', ' ')}</p>
|
||||
<p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
{item.builder_url ? <Link href={item.builder_url} className="rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100">Builder</Link> : null}
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function AcademyDashboard({ stats, links }) {
|
||||
<Head title="Admin · Academy Dashboard" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Courses" value={stats.courses} />
|
||||
<StatCard label="Lessons" value={stats.lessons} />
|
||||
<StatCard label="Prompts" value={stats.prompts} />
|
||||
<StatCard label="Prompt Packs" value={stats.packs} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ export default function ForumSection({ category, boards = [], seo = {} }) {
|
||||
) : (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{boards.map((board) => (
|
||||
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04]">
|
||||
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04] block">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{board.title}</h3>
|
||||
|
||||
103
resources/js/Pages/News/NewsImagePreview.jsx
Normal file
103
resources/js/Pages/News/NewsImagePreview.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
let previewOverlay = null
|
||||
let previewImage = null
|
||||
let previewCaption = null
|
||||
let scrollPosition = 0
|
||||
|
||||
function ensurePreviewOverlay() {
|
||||
if (previewOverlay) {
|
||||
return previewOverlay
|
||||
}
|
||||
|
||||
previewOverlay = document.createElement('div')
|
||||
previewOverlay.className = 'fixed inset-0 z-[130] hidden items-center justify-center bg-[#020611e8] p-4 backdrop-blur-md'
|
||||
previewOverlay.setAttribute('role', 'dialog')
|
||||
previewOverlay.setAttribute('aria-modal', 'true')
|
||||
previewOverlay.setAttribute('aria-label', 'Image preview')
|
||||
|
||||
const frame = document.createElement('div')
|
||||
frame.className = 'relative max-h-[92vh] max-w-6xl'
|
||||
|
||||
previewImage = document.createElement('img')
|
||||
previewImage.className = 'max-h-[92vh] max-w-full rounded-[28px] border border-white/10 shadow-[0_28px_90px_rgba(2,6,23,0.6)]'
|
||||
previewImage.alt = 'Image preview'
|
||||
|
||||
previewCaption = document.createElement('div')
|
||||
previewCaption.className = 'absolute inset-x-0 bottom-0 rounded-b-[28px] bg-gradient-to-t from-black/80 to-transparent px-5 py-4 text-sm font-medium text-white/90'
|
||||
|
||||
const closeButton = document.createElement('button')
|
||||
closeButton.type = 'button'
|
||||
closeButton.setAttribute('aria-label', 'Close image preview')
|
||||
closeButton.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'
|
||||
closeButton.innerHTML = '<i class="fa-solid fa-xmark text-lg"></i>'
|
||||
closeButton.addEventListener('click', hidePreview)
|
||||
|
||||
frame.appendChild(previewImage)
|
||||
frame.appendChild(previewCaption)
|
||||
frame.appendChild(closeButton)
|
||||
previewOverlay.appendChild(frame)
|
||||
|
||||
previewOverlay.addEventListener('click', (event) => {
|
||||
if (event.target === previewOverlay) {
|
||||
hidePreview()
|
||||
}
|
||||
})
|
||||
|
||||
document.body.appendChild(previewOverlay)
|
||||
return previewOverlay
|
||||
}
|
||||
|
||||
function showPreview(src, alt) {
|
||||
if (!src) return
|
||||
|
||||
ensurePreviewOverlay()
|
||||
scrollPosition = window.scrollY || document.documentElement.scrollTop || 0
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.top = `-${scrollPosition}px`
|
||||
document.body.style.left = '0'
|
||||
document.body.style.right = '0'
|
||||
document.body.style.width = '100%'
|
||||
|
||||
previewImage.src = src
|
||||
previewImage.alt = alt || 'Image preview'
|
||||
previewCaption.textContent = alt || 'Image preview'
|
||||
previewOverlay.classList.remove('hidden')
|
||||
previewOverlay.classList.add('flex')
|
||||
}
|
||||
|
||||
function hidePreview() {
|
||||
if (!previewOverlay) return
|
||||
|
||||
previewOverlay.classList.add('hidden')
|
||||
previewOverlay.classList.remove('flex')
|
||||
previewImage.removeAttribute('src')
|
||||
document.body.style.position = ''
|
||||
document.body.style.top = ''
|
||||
document.body.style.left = ''
|
||||
document.body.style.right = ''
|
||||
document.body.style.width = ''
|
||||
window.scrollTo(0, scrollPosition)
|
||||
}
|
||||
|
||||
function handleNewsImagePreview(event) {
|
||||
const trigger = event.target?.closest?.('[data-news-image-preview]')
|
||||
if (!trigger) return
|
||||
|
||||
const src = trigger.getAttribute('data-news-image-src') || trigger.getAttribute('href')
|
||||
if (!src) return
|
||||
|
||||
event.preventDefault()
|
||||
showPreview(src, trigger.getAttribute('data-news-image-alt') || trigger.getAttribute('aria-label') || 'Image preview')
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
hidePreview()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleNewsImagePreview)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
export default null
|
||||
@@ -20,7 +20,6 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
|
||||
const isEdited = post?.is_edited
|
||||
const postId = post?.id
|
||||
const threadSlug = thread?.slug
|
||||
|
||||
const handleReaction = async (reaction) => {
|
||||
if (reacting || !isAuthenticated) return
|
||||
setReacting(true)
|
||||
|
||||
@@ -46,6 +46,21 @@ function Divider() {
|
||||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||
}
|
||||
|
||||
function getRootFontSizePx() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 16
|
||||
}
|
||||
|
||||
return Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||
}
|
||||
|
||||
function formatViewportHeightLabel(value) {
|
||||
const rounded = Number(value || 0)
|
||||
const displayValue = Number.isInteger(rounded) ? rounded : Number(rounded.toFixed(1))
|
||||
|
||||
return `${displayValue}rem`
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(rawValue) {
|
||||
const trimmed = String(rawValue || '').trim()
|
||||
if (trimmed === '') {
|
||||
@@ -922,10 +937,16 @@ function AssetPickerDialog({
|
||||
function Toolbar({
|
||||
editor,
|
||||
advancedNews = false,
|
||||
sourceMode = false,
|
||||
activeSourceMode = null,
|
||||
sourceModeLabel = 'HTML',
|
||||
sourceModeTitle = 'View or edit source HTML',
|
||||
secondarySourceModeLabel = null,
|
||||
secondarySourceModeTitle = '',
|
||||
showStructureOutlines = false,
|
||||
showComparisonTool = false,
|
||||
fullHeightMode = false,
|
||||
onToggleSourceMode,
|
||||
onToggleSecondarySourceMode,
|
||||
onToggleStructureOutlines,
|
||||
onInsertArtwork,
|
||||
onInsertImage,
|
||||
@@ -937,7 +958,7 @@ function Toolbar({
|
||||
editorViewportHeight,
|
||||
onIncreaseEditorViewportHeight,
|
||||
onDecreaseEditorViewportHeight,
|
||||
onResetEditorViewportHeight,
|
||||
onToggleFullHeightMode,
|
||||
}) {
|
||||
if (!editor) return null
|
||||
|
||||
@@ -1027,9 +1048,14 @@ function Toolbar({
|
||||
{advancedNews ? (
|
||||
<>
|
||||
<Divider />
|
||||
<ToolbarBtn onClick={onToggleSourceMode} active={sourceMode} title="View or edit source HTML" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">HTML</span>
|
||||
<ToolbarBtn onClick={onToggleSourceMode} active={activeSourceMode === 'primary'} title={sourceModeTitle} className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{sourceModeLabel}</span>
|
||||
</ToolbarBtn>
|
||||
{secondarySourceModeLabel ? (
|
||||
<ToolbarBtn onClick={onToggleSecondarySourceMode} active={activeSourceMode === 'secondary'} title={secondarySourceModeTitle} className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{secondarySourceModeLabel}</span>
|
||||
</ToolbarBtn>
|
||||
) : null}
|
||||
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
|
||||
</ToolbarBtn>
|
||||
@@ -1053,12 +1079,12 @@ function Toolbar({
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A-</span>
|
||||
</ToolbarBtn>
|
||||
<div className="mx-1 flex min-w-[5.25rem] items-center justify-center rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
{editorViewportHeight}rem
|
||||
{formatViewportHeightLabel(editorViewportHeight)}
|
||||
</div>
|
||||
<ToolbarBtn onClick={onIncreaseEditorViewportHeight} title="Taller editor" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A+</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onResetEditorViewportHeight} title="Reset editor height" className="w-auto px-2.5">
|
||||
<ToolbarBtn onClick={onToggleFullHeightMode} active={fullHeightMode} title={fullHeightMode ? 'Exit full height editor' : 'Expand editor to full browser size'} className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Fit</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
|
||||
@@ -1078,16 +1104,26 @@ export default function RichTextEditor({
|
||||
placeholder = 'Write something…',
|
||||
error,
|
||||
minHeight = 12,
|
||||
maxHeightRem = 42,
|
||||
autofocus = false,
|
||||
advancedNews = false,
|
||||
sourceModeLabel = 'HTML',
|
||||
sourceModeTitle = 'View or edit source HTML',
|
||||
sourceModeDescription = 'Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.',
|
||||
secondarySourceModeLabel = null,
|
||||
secondarySourceModeTitle = '',
|
||||
secondarySourceModeDescription = '',
|
||||
secondarySourceModeValue = null,
|
||||
onSecondarySourceModeValueChange = null,
|
||||
searchEntities = null,
|
||||
mediaSupport = null,
|
||||
}) {
|
||||
const viewportStorageKey = 'rich-text-editor.viewport-height'
|
||||
const viewportMinHeight = Math.max(minHeight + 6, 18)
|
||||
const viewportMaxHeight = 42
|
||||
const viewportMaxHeight = Math.max(viewportMinHeight, Number(maxHeightRem) || 42)
|
||||
const viewportStep = 4
|
||||
const [sourceMode, setSourceMode] = useState(false)
|
||||
const [activeSourceMode, setActiveSourceMode] = useState(null)
|
||||
const [fullHeightMode, setFullHeightMode] = useState(false)
|
||||
const [sourceValue, setSourceValue] = useState(String(content || ''))
|
||||
const [showStructureOutlines, setShowStructureOutlines] = useState(false)
|
||||
const [helperMessage, setHelperMessage] = useState('')
|
||||
@@ -1142,6 +1178,8 @@ export default function RichTextEditor({
|
||||
return Math.min(viewportMaxHeight, viewportMinHeight)
|
||||
})
|
||||
const editorRef = useRef(null)
|
||||
const resizeCleanupRef = useRef(null)
|
||||
const usesSecondarySourceMode = typeof onSecondarySourceModeValueChange === 'function' && secondarySourceModeValue != null
|
||||
const csrfToken = useMemo(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
@@ -1491,7 +1529,7 @@ export default function RichTextEditor({
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
if (!sourceMode) {
|
||||
if (!activeSourceMode) {
|
||||
onChange?.(currentEditor.getHTML())
|
||||
}
|
||||
},
|
||||
@@ -1520,7 +1558,7 @@ export default function RichTextEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (sourceMode) return
|
||||
if (activeSourceMode) return
|
||||
if ((content || '') === editor.getHTML()) return
|
||||
|
||||
editor.commands.setContent(content || '', false)
|
||||
@@ -1529,13 +1567,13 @@ export default function RichTextEditor({
|
||||
if (normalizedHtml !== (content || '')) {
|
||||
onChange?.(normalizedHtml)
|
||||
}
|
||||
}, [content, editor, onChange, sourceMode])
|
||||
}, [activeSourceMode, content, editor, onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode) {
|
||||
if (activeSourceMode === 'primary') {
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
}
|
||||
}, [content, editor, sourceMode])
|
||||
}, [activeSourceMode, content, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -1544,6 +1582,42 @@ export default function RichTextEditor({
|
||||
window.localStorage.setItem(viewportStorageKey, String(editorViewportHeight))
|
||||
}, [editorViewportHeight])
|
||||
|
||||
const stopViewportResize = useCallback(() => {
|
||||
if (resizeCleanupRef.current) {
|
||||
resizeCleanupRef.current()
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => stopViewportResize, [stopViewportResize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullHeightMode || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setFullHeightMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [fullHeightMode])
|
||||
|
||||
const decreaseEditorViewportHeight = useCallback(() => {
|
||||
setEditorViewportHeight((current) => Math.max(viewportMinHeight, Number((current - viewportStep).toFixed(1))))
|
||||
}, [viewportMinHeight, viewportStep])
|
||||
@@ -1552,27 +1626,98 @@ export default function RichTextEditor({
|
||||
setEditorViewportHeight((current) => Math.min(viewportMaxHeight, Number((current + viewportStep).toFixed(1))))
|
||||
}, [viewportMaxHeight, viewportStep])
|
||||
|
||||
const resetEditorViewportHeight = useCallback(() => {
|
||||
setEditorViewportHeight(Math.min(viewportMaxHeight, viewportMinHeight))
|
||||
}, [viewportMaxHeight, viewportMinHeight])
|
||||
const toggleFullHeightMode = useCallback(() => {
|
||||
setFullHeightMode((current) => !current)
|
||||
}, [])
|
||||
|
||||
const startViewportResize = useCallback((event) => {
|
||||
if (fullHeightMode || event.button !== 0 || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const startY = event.clientY
|
||||
const startHeight = editorViewportHeight
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
const deltaRem = (moveEvent.clientY - startY) / getRootFontSizePx()
|
||||
const nextHeight = Number((startHeight + deltaRem).toFixed(1))
|
||||
|
||||
setEditorViewportHeight(Math.min(viewportMaxHeight, Math.max(viewportMinHeight, nextHeight)))
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
stopViewportResize()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
window.addEventListener('pointercancel', handlePointerUp)
|
||||
resizeCleanupRef.current = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
window.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'ns-resize'
|
||||
}, [editorViewportHeight, fullHeightMode, stopViewportResize, viewportMaxHeight, viewportMinHeight])
|
||||
|
||||
const pushHelperMessage = useCallback((message) => {
|
||||
setHelperMessage(message)
|
||||
}, [])
|
||||
|
||||
const commitPrimarySourceToEditor = useCallback(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceValue || '', false)
|
||||
}
|
||||
}, [editor, sourceValue])
|
||||
|
||||
const handleToggleSourceMode = useCallback(() => {
|
||||
if (sourceMode) {
|
||||
setSourceMode(false)
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceValue || '', false)
|
||||
}
|
||||
if (activeSourceMode === 'primary') {
|
||||
setActiveSourceMode(null)
|
||||
commitPrimarySourceToEditor()
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(editor?.getHTML() || String(content || ''))
|
||||
setSourceMode(true)
|
||||
}, [content, editor, pushHelperMessage, sourceMode, sourceValue])
|
||||
if (activeSourceMode === 'secondary') {
|
||||
setActiveSourceMode('primary')
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
setActiveSourceMode('primary')
|
||||
}, [activeSourceMode, commitPrimarySourceToEditor, content, editor, pushHelperMessage])
|
||||
|
||||
const handleToggleSecondarySourceMode = useCallback(() => {
|
||||
if (!usesSecondarySourceMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSourceMode === 'secondary') {
|
||||
setActiveSourceMode(null)
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSourceMode === 'primary') {
|
||||
commitPrimarySourceToEditor()
|
||||
}
|
||||
|
||||
setActiveSourceMode('secondary')
|
||||
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage, usesSecondarySourceMode])
|
||||
|
||||
const handleCloseSourceMode = useCallback(() => {
|
||||
if (activeSourceMode === 'primary') {
|
||||
commitPrimarySourceToEditor()
|
||||
}
|
||||
|
||||
setActiveSourceMode(null)
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage])
|
||||
|
||||
const insertArtworkEmbed = useCallback((item) => {
|
||||
if (!editor || !item) return
|
||||
@@ -1867,23 +2012,52 @@ export default function RichTextEditor({
|
||||
editor.chain().focus().insertContent(`#${value}`).run()
|
||||
}, [editor])
|
||||
|
||||
const shellClassName = fullHeightMode
|
||||
? 'fixed inset-0 z-[980] flex min-h-0 w-screen flex-col bg-[#04070df2] p-3 backdrop-blur-sm md:p-4'
|
||||
: 'flex w-full min-w-0 flex-col gap-1.5'
|
||||
|
||||
const bodyClassName = fullHeightMode
|
||||
? 'flex min-h-0 w-full flex-1 flex-col gap-1.5'
|
||||
: 'flex w-full min-w-0 flex-col gap-1.5'
|
||||
|
||||
const editorCardClassName = [
|
||||
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
fullHeightMode ? 'flex min-h-0 flex-1 flex-col rounded-2xl' : '',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
const editorViewportStyle = fullHeightMode
|
||||
? { flex: 1 }
|
||||
: { height: `${editorViewportHeight}rem` }
|
||||
|
||||
const sourceTextareaStyle = fullHeightMode
|
||||
? { flex: 1 }
|
||||
: {
|
||||
height: `${Math.max(minHeight, editorViewportHeight)}rem`,
|
||||
minHeight: `${Math.max(minHeight, 20)}rem`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1.5">
|
||||
<div className={shellClassName}>
|
||||
<div className={bodyClassName}>
|
||||
<div
|
||||
className={[
|
||||
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].join(' ')}
|
||||
className={editorCardClassName}
|
||||
>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
advancedNews={advancedNews}
|
||||
sourceMode={sourceMode}
|
||||
activeSourceMode={activeSourceMode}
|
||||
sourceModeLabel={sourceModeLabel}
|
||||
sourceModeTitle={sourceModeTitle}
|
||||
secondarySourceModeLabel={secondarySourceModeLabel}
|
||||
secondarySourceModeTitle={secondarySourceModeTitle}
|
||||
showStructureOutlines={showStructureOutlines}
|
||||
showComparisonTool={Boolean(mediaSupport?.uploadUrl)}
|
||||
fullHeightMode={fullHeightMode}
|
||||
onToggleSourceMode={handleToggleSourceMode}
|
||||
onToggleSecondarySourceMode={handleToggleSecondarySourceMode}
|
||||
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
|
||||
onInsertArtwork={handleInsertArtwork}
|
||||
onInsertImage={handleInsertImage}
|
||||
@@ -1895,44 +2069,65 @@ export default function RichTextEditor({
|
||||
editorViewportHeight={editorViewportHeight}
|
||||
onIncreaseEditorViewportHeight={increaseEditorViewportHeight}
|
||||
onDecreaseEditorViewportHeight={decreaseEditorViewportHeight}
|
||||
onResetEditorViewportHeight={resetEditorViewportHeight}
|
||||
onToggleFullHeightMode={toggleFullHeightMode}
|
||||
/>
|
||||
|
||||
{advancedNews && sourceMode ? (
|
||||
<div className="border-t border-white/[0.04] bg-black/10 px-4 py-3">
|
||||
{advancedNews && activeSourceMode ? (
|
||||
<div className={[
|
||||
'border-t border-white/[0.04] bg-black/10 px-4 py-3',
|
||||
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
|
||||
].filter(Boolean).join(' ')}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span>Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.</span>
|
||||
<button type="button" onClick={handleToggleSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>{activeSourceMode === 'secondary' ? secondarySourceModeDescription : sourceModeDescription}</span>
|
||||
<button type="button" onClick={handleCloseSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
Back to visual
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={sourceValue}
|
||||
value={activeSourceMode === 'secondary' ? String(secondarySourceModeValue || '') : sourceValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
if (activeSourceMode === 'secondary') {
|
||||
onSecondarySourceModeValueChange?.(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(nextValue)
|
||||
onChange?.(nextValue)
|
||||
}}
|
||||
spellCheck={false}
|
||||
className="nova-scrollbar min-h-[20rem] w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
style={{ minHeight: `${Math.max(minHeight, 20)}rem` }}
|
||||
className="nova-scrollbar w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
style={sourceTextareaStyle}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={[
|
||||
'rich-text-editor-viewport nova-scrollbar w-full min-w-0 border-t border-white/[0.04] bg-black/15',
|
||||
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
|
||||
advancedNews && showStructureOutlines ? 'news-editor-outline' : '',
|
||||
].filter(Boolean).join(' ')} style={{ maxHeight: `${editorViewportHeight}rem` }}>
|
||||
].filter(Boolean).join(' ')} style={editorViewportStyle}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullHeightMode ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize editor height"
|
||||
title="Drag to resize editor height"
|
||||
onPointerDown={startViewportResize}
|
||||
className="group flex h-5 w-full cursor-row-resize items-center justify-center border-t border-white/[0.04] bg-black/10 text-slate-500 transition hover:bg-white/[0.03] hover:text-slate-300"
|
||||
>
|
||||
<span className="h-1 w-16 rounded-full bg-current opacity-70 transition group-hover:opacity-100" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{advancedNews && helperMessage ? (
|
||||
<p className="text-xs text-sky-300">{helperMessage}</p>
|
||||
) : null}
|
||||
|
||||
{!sourceMode ? (
|
||||
{!activeSourceMode ? (
|
||||
<RichTableControls editor={editor} />
|
||||
) : null}
|
||||
|
||||
@@ -2028,6 +2223,7 @@ export default function RichTextEditor({
|
||||
onClose={() => setTableInsertOpen(false)}
|
||||
onInsert={handleTableInsert}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export default function ThreadRow({ thread, isFirst = false }) {
|
||||
const isPinned = thread?.is_pinned ?? false
|
||||
|
||||
const href = `/forum/topic/${slug}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@include('partials.seo.forum-microdata', ['forumMicrodata' => $forum_microdata ?? null])
|
||||
@inertia
|
||||
@endsection
|
||||
|
||||
@@ -59,9 +59,76 @@
|
||||
'per_page' => $posts->perPage(),
|
||||
'total' => $posts->total(),
|
||||
];
|
||||
|
||||
$makeForumAuthor = function ($user) {
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim((string) ($user->name ?? $user->username ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$username = trim((string) ($user->username ?? ''));
|
||||
|
||||
return array_filter([
|
||||
'name' => $name,
|
||||
'url' => $username !== '' ? url('/@' . ltrim($username, '@')) : null,
|
||||
]);
|
||||
};
|
||||
|
||||
// Ensure we always provide a top-level author object for structured data.
|
||||
$topAuthor = $makeForumAuthor($author ?? $opPost?->user ?? null);
|
||||
if (! $topAuthor) {
|
||||
$topAuthor = ['name' => (string) ($opPost?->user?->name ?? $thread->user?->name ?? 'Skinbase')];
|
||||
}
|
||||
|
||||
$forumMicrodata = [
|
||||
'kind' => 'topic',
|
||||
'canonical' => route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]),
|
||||
'title' => (string) $thread->title,
|
||||
'text' => $threadDescription,
|
||||
'date_published' => $thread->created_at?->toIso8601String(),
|
||||
'date_modified' => ($thread->last_post_at ?? $thread->updated_at)?->toIso8601String(),
|
||||
'comment_count' => (int) ($reply_count ?? 0),
|
||||
'author' => $topAuthor,
|
||||
'comments' => collect([$opPost])
|
||||
->filter()
|
||||
->merge($posts->getCollection())
|
||||
->map(function ($post) use ($makeForumAuthor, $thread) {
|
||||
$rendered = (string) ForumPostContent::render((string) ($post->content ?? ''));
|
||||
$text = trim((string) preg_replace('/\s+/u', ' ', strip_tags(html_entity_decode($rendered, ENT_QUOTES | ENT_HTML5, 'UTF-8'))));
|
||||
|
||||
// If a post has no textual content, provide a short fallback so
|
||||
// the Comment structured data contains one of the required
|
||||
// representations (text/image/video). Prefer a simple label for
|
||||
// attachments or a generic reply placeholder.
|
||||
if ($text === '') {
|
||||
$hasAttachments = ! empty($post->attachments ?? null) && count($post->attachments ?? []) > 0;
|
||||
if ($hasAttachments) {
|
||||
$text = 'Attachment';
|
||||
} else {
|
||||
$text = 'Reply';
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'url' => route('forum.thread.show', ['thread' => $post->thread_id, 'slug' => $thread->slug]) . '#post-' . $post->id,
|
||||
'text' => Str::limit($text, 300),
|
||||
'date_published' => $post->created_at?->toIso8601String(),
|
||||
'date_modified' => ($post->edited_at ?? $post->created_at)?->toIso8601String(),
|
||||
'author' => $makeForumAuthor($post->user ?? null) ?: ['name' => (string) ($post->user?->name ?? 'Skinbase')],
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
})
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
@include('partials.seo.forum-microdata', ['forumMicrodata' => $forumMicrodata])
|
||||
<div id="forum-thread-root"></div>
|
||||
@php
|
||||
$forumThreadProps = json_encode([
|
||||
@@ -76,7 +143,7 @@
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
],
|
||||
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
|
||||
'author' => ['name' => $author->name ?? 'Unknown'],
|
||||
'author' => ['name' => $author->name ?? 'Skinbase'],
|
||||
'opPost' => $serializedOp,
|
||||
'posts' => $serializedPosts,
|
||||
'pagination' => $paginationData,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<title>{{ $page_title ?? 'Similar Artworks — Skinbase' }}</title>
|
||||
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
||||
<link rel="canonical" href="{{ $page_canonical ?? url()->current() }}">
|
||||
<meta name="robots" content="{{ $page_robots ?? 'noindex,follow' }}">
|
||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||
|
||||
{{-- OpenGraph --}}
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@php
|
||||
$isPreview = (bool) ($previewMode ?? false);
|
||||
$articleUrl = $isPreview ? ($previewCanonical ?? url()->current()) : route('news.show', $article->slug);
|
||||
$articleImageLicenseUrl = route('terms-of-service');
|
||||
$articleImageCreditText = (string) config('seo.site_name', 'Skinbase');
|
||||
$articleSchemaImage = $article->effective_og_image
|
||||
? url($article->effective_og_image)
|
||||
: url((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
|
||||
@@ -24,6 +26,30 @@
|
||||
: null,
|
||||
])->filter()->values(),
|
||||
])
|
||||
->addJsonLd($articleSchemaImage
|
||||
? array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ImageObject',
|
||||
'url' => $articleSchemaImage,
|
||||
'contentUrl' => $articleSchemaImage,
|
||||
'thumbnailUrl' => $article->cover_mobile_url,
|
||||
'caption' => $article->title,
|
||||
'creditText' => $articleImageCreditText,
|
||||
'license' => $articleImageLicenseUrl,
|
||||
'acquireLicensePage' => $articleImageLicenseUrl,
|
||||
'creator' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => $articleImageCreditText,
|
||||
'url' => url('/'),
|
||||
],
|
||||
'publisher' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => $articleImageCreditText,
|
||||
'url' => url('/'),
|
||||
],
|
||||
'representativeOfPage' => true,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '')
|
||||
: null)
|
||||
->addJsonLd(array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'NewsArticle',
|
||||
@@ -36,6 +62,9 @@
|
||||
'contentUrl' => $articleSchemaImage,
|
||||
'thumbnailUrl' => $article->cover_mobile_url,
|
||||
'caption' => $article->title,
|
||||
'creditText' => $articleImageCreditText,
|
||||
'license' => $articleImageLicenseUrl,
|
||||
'acquireLicensePage' => $articleImageLicenseUrl,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '')
|
||||
: null,
|
||||
'datePublished' => $article->published_at?->toIso8601String(),
|
||||
@@ -133,7 +162,14 @@
|
||||
<article class="min-w-0">
|
||||
@if($article->cover_url)
|
||||
<div class="overflow-hidden rounded-[32px] border border-white/[0.06] bg-black/20 shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||
<a href="{{ $articleCoverPreloadHref }}" class="group block focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950" aria-label="Open full cover image">
|
||||
<a
|
||||
href="{{ $articleCoverPreloadHref }}"
|
||||
class="group block focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
|
||||
aria-label="Open full cover image"
|
||||
data-news-image-preview
|
||||
data-news-image-src="{{ $articleCoverPreloadHref }}"
|
||||
data-news-image-alt="{{ $article->title }}"
|
||||
>
|
||||
<div class="relative">
|
||||
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="{{ $articleCoverSizes }}" @endif alt="{{ $article->title }}" fetchpriority="high" loading="eager" decoding="async" class="h-auto max-h-[520px] w-full object-cover transition duration-300 group-hover:scale-[1.01]">
|
||||
<div class="pointer-events-none absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-full border border-white/10 bg-slate-950/72 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/82 backdrop-blur-sm">
|
||||
@@ -263,6 +299,8 @@
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/Pages/News/NewsImagePreview.jsx'])
|
||||
|
||||
@if($needsFacebookEmbeds)
|
||||
<div id="fb-root"></div>
|
||||
<script async defer crossorigin="anonymous" src="https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v19.0"></script>
|
||||
|
||||
100
resources/views/partials/seo/forum-microdata.blade.php
Normal file
100
resources/views/partials/seo/forum-microdata.blade.php
Normal file
@@ -0,0 +1,100 @@
|
||||
@php
|
||||
$forumMicrodata = is_array($forumMicrodata ?? null) ? $forumMicrodata : null;
|
||||
@endphp
|
||||
|
||||
@if($forumMicrodata)
|
||||
<div class="hidden" aria-hidden="true">
|
||||
@if(($forumMicrodata['kind'] ?? null) === 'topic')
|
||||
<div itemscope itemtype="https://schema.org/DiscussionForumPosting">
|
||||
@if(!empty($forumMicrodata['canonical']))<meta itemprop="mainEntityOfPage" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['canonical']))<meta itemprop="url" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['title']))<meta itemprop="headline" content="{{ $forumMicrodata['title'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['text']))<meta itemprop="text" content="{{ $forumMicrodata['text'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['date_published']))<meta itemprop="datePublished" content="{{ $forumMicrodata['date_published'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['date_modified']))<meta itemprop="dateModified" content="{{ $forumMicrodata['date_modified'] }}" />@endif
|
||||
@if(isset($forumMicrodata['comment_count']))<meta itemprop="commentCount" content="{{ (int) $forumMicrodata['comment_count'] }}" />@endif
|
||||
|
||||
@if(!empty($forumMicrodata['author']))
|
||||
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||
@if(!empty($forumMicrodata['author']['url']))<meta itemprop="url" content="{{ $forumMicrodata['author']['url'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['author']['name']))<meta itemprop="name" content="{{ $forumMicrodata['author']['name'] }}" />@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($forumMicrodata['board']))
|
||||
<div itemprop="isPartOf" itemscope itemtype="https://schema.org/WebPage">
|
||||
@if(!empty($forumMicrodata['board']['url']))<meta itemprop="url" content="{{ $forumMicrodata['board']['url'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['board']['name']))<meta itemprop="name" content="{{ $forumMicrodata['board']['name'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['board']['category']))
|
||||
<div itemprop="isPartOf" itemscope itemtype="https://schema.org/WebPage">
|
||||
@if(!empty($forumMicrodata['board']['category']['url']))<meta itemprop="url" content="{{ $forumMicrodata['board']['category']['url'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['board']['category']['name']))<meta itemprop="name" content="{{ $forumMicrodata['board']['category']['name'] }}" />@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach(($forumMicrodata['interactions'] ?? []) as $interaction)
|
||||
<div itemprop="interactionStatistic" itemscope itemtype="https://schema.org/InteractionCounter">
|
||||
@if(!empty($interaction['type']))<meta itemprop="interactionType" content="{{ $interaction['type'] }}" />@endif
|
||||
@if(isset($interaction['count']))<meta itemprop="userInteractionCount" content="{{ (int) $interaction['count'] }}" />@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@foreach(($forumMicrodata['comments'] ?? []) as $comment)
|
||||
<div itemprop="comment" itemscope itemtype="https://schema.org/Comment">
|
||||
@if(!empty($comment['url']))<meta itemprop="url" content="{{ $comment['url'] }}" />@endif
|
||||
@if(!empty($comment['text']))<meta itemprop="text" content="{{ $comment['text'] }}" />@endif
|
||||
@if(!empty($comment['date_published']))<meta itemprop="datePublished" content="{{ $comment['date_published'] }}" />@endif
|
||||
@if(!empty($comment['date_modified']))<meta itemprop="dateModified" content="{{ $comment['date_modified'] }}" />@endif
|
||||
|
||||
@if(!empty($comment['author']))
|
||||
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||
@if(!empty($comment['author']['url']))<meta itemprop="url" content="{{ $comment['author']['url'] }}" />@endif
|
||||
@if(!empty($comment['author']['name']))<meta itemprop="name" content="{{ $comment['author']['name'] }}" />@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach(($comment['interactions'] ?? []) as $interaction)
|
||||
<div itemprop="interactionStatistic" itemscope itemtype="https://schema.org/InteractionCounter">
|
||||
@if(!empty($interaction['type']))<meta itemprop="interactionType" content="{{ $interaction['type'] }}" />@endif
|
||||
@if(isset($interaction['count']))<meta itemprop="userInteractionCount" content="{{ (int) $interaction['count'] }}" />@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif(($forumMicrodata['kind'] ?? null) === 'collection')
|
||||
<div itemscope itemtype="https://schema.org/CollectionPage">
|
||||
@if(!empty($forumMicrodata['canonical']))<meta itemprop="url" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['name']))<meta itemprop="name" content="{{ $forumMicrodata['name'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['description']))<meta itemprop="description" content="{{ $forumMicrodata['description'] }}" />@endif
|
||||
|
||||
<div itemprop="mainEntity" itemscope itemtype="https://schema.org/ItemList">
|
||||
@if(!empty($forumMicrodata['list_name']))<meta itemprop="name" content="{{ $forumMicrodata['list_name'] }}" />@endif
|
||||
|
||||
@foreach(($forumMicrodata['items'] ?? []) as $index => $item)
|
||||
<div itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<meta itemprop="position" content="{{ $index + 1 }}" />
|
||||
<div itemprop="item" itemscope itemtype="https://schema.org/{{ $item['type'] ?? 'WebPage' }}">
|
||||
@if(!empty($item['url']))<meta itemprop="url" content="{{ $item['url'] }}" />@endif
|
||||
@if(!empty($item['title']))<meta itemprop="{{ ($item['type'] ?? null) === 'DiscussionForumPosting' ? 'headline' : 'name' }}" content="{{ $item['title'] }}" />@endif
|
||||
@if(!empty($item['description']))<meta itemprop="description" content="{{ $item['description'] }}" />@endif
|
||||
@if(!empty($item['text']))<meta itemprop="text" content="{{ $item['text'] }}" />@endif
|
||||
@if(!empty($item['date_published']))<meta itemprop="datePublished" content="{{ $item['date_published'] }}" />@endif
|
||||
@if(isset($item['comment_count']))<meta itemprop="commentCount" content="{{ (int) $item['comment_count'] }}" />@endif
|
||||
@if(!empty($item['date_modified']))<meta itemprop="dateModified" content="{{ $item['date_modified'] }}" />@endif
|
||||
@if(!empty($item['author']))
|
||||
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||
@if(!empty($item['author']['url']))<meta itemprop="url" content="{{ $item['author']['url'] }}" />@endif
|
||||
@if(!empty($item['author']['name']))<meta itemprop="name" content="{{ $item['author']['name'] }}" />@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
Reference in New Issue
Block a user