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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user