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 (
Course builder
{title}
{description ?
{description}
: null}
{children}
)
}
function CheckboxCardField({ label, checked, onChange, description }) {
return (
)
}
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 (
)
}
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 (
)
}
function ReorderLessonCard({
lesson,
lane,
laneIndex,
laneCount,
globalStepNumber,
isDragging,
isDropTarget,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onMoveUp,
onMoveDown,
onMoveLeft,
onMoveRight,
}) {
return (
{
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(' ')}
>
{formatStepLabel(globalStepNumber)}
{lesson.is_required ? Required : Optional}
{lesson.title}
{lesson.category || 'Academy'} · {lesson.difficulty || 'lesson'} · order {Number(lesson.order_num) + 1}
{lesson.excerpt || 'Drag this lesson to reposition it inside the course flow.'}
)
}
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 (
{flash.success ? {flash.success}
: null}
{flash.error ? {flash.error}
: null}
Back to courses
Edit course
Preview public page
Sections
{sections.length}
Attached lessons
{courseLessons.length}
Published course lessons
{courseLessons.filter((lesson) => lesson.title).length}
{sections.map((section) => (
))}
Flow board
Core lessons stay first, then each visible section keeps its own ordered lane.
{reorderDirty ? Unsaved order changes : Order is saved}
{lessonLanes.map((lane, laneIndex) => (
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'}`}
>
{lane.sectionId == null ? 'Core lane' : 'Section lane'}
{!lane.isVisible ?
Hidden on public page : null}
{lane.title}
{lane.description}
{lane.lessons.length} lessons
{lane.lessons.length ? lane.lessons.map((lesson) => (
{
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)}
/>
)) : Drop lessons here to move them into this lane.
}
))}
{orderedCourseLessons.map((courseLesson, index) => (
))}
)
}