chore: commit remaining workspace changes
This commit is contained in:
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
Reference in New Issue
Block a user