diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index cee9906..1371104 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -23,6 +23,22 @@ const PAGINATION_INIT = { const BASE = "/admin/courses"; +// Junction revamp — unit-scoped endpoints resolve either through a course +// (/admin/courses/:courseId/units/:unitId) or straight against the standalone +// unit library (/admin/units/:unitId) when no courseId is in scope. This lets +// the quiz builder and unit pages run identically in both contexts. +const unitBase = (courseId, unitId) => + courseId != null && courseId !== "" + ? `${BASE}/${courseId}/units/${unitId}` + : `/admin/units/${unitId}`; + +// Lesson-scoped endpoints resolve through a unit when one is in scope, +// otherwise straight against the standalone lesson library (/admin/lessons/:id). +const lessonBase = (courseId, unitId, lessonId) => + unitId != null && unitId !== "" + ? `${unitBase(courseId, unitId)}/lessons/${lessonId}` + : `/admin/lessons/${lessonId}`; + // ─── Provider ───────────────────────────────────────────────────────────────── export function CoursesProvider({ children }) { @@ -288,7 +304,7 @@ export function CoursesProvider({ children }) { const fetchUnit = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}`); + const { data } = await api.get(`${unitBase(courseId, unitId)}`); const result = data?.data?.data ?? null; setUnit(result); setLessons(result?.lessons ?? []); @@ -315,7 +331,7 @@ export function CoursesProvider({ children }) { const updateUnit = useCallback( (courseId, unitId, payload) => request(async () => { - const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}`, payload); + const { data } = await api.put(`${unitBase(courseId, unitId)}`, payload); const unit = data?.data?.data ?? null; if (unit) { setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u))); @@ -330,7 +346,7 @@ export function CoursesProvider({ children }) { const archiveUnit = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}`); + const { data } = await api.delete(`${unitBase(courseId, unitId)}`); setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); setUnit((prev) => (prev?.unit_id === unitId ? null : prev)); toast("Unit archived."); @@ -372,7 +388,7 @@ export function CoursesProvider({ children }) { const restoreUnit = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/restore`); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/restore`); const result = data?.data?.data ?? null; if (result) { setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); @@ -397,7 +413,7 @@ export function CoursesProvider({ children }) { const permanentlyDeleteUnit = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/permanent`); + const { data } = await api.delete(`${unitBase(courseId, unitId)}/permanent`); setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); setUnit((prev) => (prev?.unit_id === unitId ? null : prev)); toast("Unit permanently deleted."); @@ -420,7 +436,7 @@ export function CoursesProvider({ children }) { const fetchUnitPermanentDeleteImpact = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/permanent-delete-impact`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/permanent-delete-impact`); const { lessonCount } = data?.data ?? {}; return [{ label: "lesson(s)", count: lessonCount ?? 0 }]; }), @@ -434,7 +450,7 @@ export function CoursesProvider({ children }) { const fetchQuiz = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/quiz`); const result = data?.data?.data ?? null; setQuiz(result); setQuestions(result?.questions ?? []); @@ -446,7 +462,7 @@ export function CoursesProvider({ children }) { const createQuiz = useCallback( (courseId, unitId, payload) => request(async () => { - const { data } = await api.post(`${BASE}/${courseId}/units/${unitId}/quiz`, payload); + const { data } = await api.post(`${unitBase(courseId, unitId)}/quiz`, payload); const result = data?.data?.data ?? null; if (result) { setQuiz(result); @@ -460,7 +476,7 @@ export function CoursesProvider({ children }) { const updateQuiz = useCallback( (courseId, unitId, quizId, payload) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}`, payload); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/quiz/${quizId}`, payload); const result = data?.data?.data ?? null; if (result) { setQuiz(result); @@ -474,7 +490,7 @@ export function CoursesProvider({ children }) { const deleteQuiz = useCallback( (courseId, unitId, quizId, deletedBy) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}`, { data: { deletedBy } }); + const { data } = await api.delete(`${unitBase(courseId, unitId)}/quiz/${quizId}`, { data: { deletedBy } }); setQuiz(null); setQuestions([]); toast("Quiz archived."); @@ -488,7 +504,7 @@ export function CoursesProvider({ children }) { const fetchArchivedQuiz = useCallback( (courseId, unitId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz/archives`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/quiz/archives`); const result = data?.data?.data ?? null; setQuiz(result); return data; @@ -499,7 +515,7 @@ export function CoursesProvider({ children }) { const restoreQuiz = useCallback( (courseId, unitId, quizId, restoredBy) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/restore`, { restoredBy }); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/quiz/${quizId}/restore`, { restoredBy }); const result = data?.data?.data ?? null; if (result) { setQuiz(result); @@ -517,7 +533,7 @@ export function CoursesProvider({ children }) { const fetchQuizQuestions = useCallback( (courseId, unitId, quizId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions`); const result = data?.data?.data ?? []; setQuestions(result); return data; @@ -528,7 +544,7 @@ export function CoursesProvider({ children }) { const createQuizQuestion = useCallback( (courseId, unitId, quizId, payload) => request(async () => { - const { data } = await api.post(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions`, payload); + const { data } = await api.post(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions`, payload); const result = data?.data?.data ?? null; if (result) { setQuestions((prev) => [...prev, result]); @@ -542,7 +558,7 @@ export function CoursesProvider({ children }) { const updateQuizQuestion = useCallback( (courseId, unitId, quizId, questionId, payload) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}`, payload); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/${questionId}`, payload); const result = data?.data?.data ?? null; if (result) { setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q))); @@ -556,7 +572,7 @@ export function CoursesProvider({ children }) { const bulkSyncQuizQuestions = useCallback( (courseId, unitId, quizId, questions, updatedBy) => request(async () => { - const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy }); + const { data } = await api.put(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy }); const result = data?.data?.data ?? []; setQuestions(result); toast("Quiz saved."); @@ -568,7 +584,7 @@ export function CoursesProvider({ children }) { const deleteQuizQuestion = useCallback( (courseId, unitId, quizId, questionId, deletedBy) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}`, { data: { deletedBy } }); + const { data } = await api.delete(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/${questionId}`, { data: { deletedBy } }); setQuestions((prev) => prev.filter((q) => q.question_id !== questionId)); toast("Question archived."); return data; @@ -579,7 +595,7 @@ export function CoursesProvider({ children }) { const bulkArchiveQuizQuestions = useCallback( (courseId, unitId, quizId, ids, deletedBy) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk`, { data: { ids, deletedBy } }); + const { data } = await api.delete(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/bulk`, { data: { ids, deletedBy } }); setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id))); toast("Questions archived."); return data; @@ -592,7 +608,7 @@ export function CoursesProvider({ children }) { const fetchArchivedQuizQuestion = useCallback( (courseId, unitId, quizId, questionId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/archives/${questionId}`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/archives/${questionId}`); return data; }), [request], @@ -601,7 +617,7 @@ export function CoursesProvider({ children }) { const restoreQuizQuestion = useCallback( (courseId, unitId, quizId, questionId, restoredBy) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}/restore`, { restoredBy }); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/${questionId}/restore`, { restoredBy }); toast("Question restored."); return data; }), @@ -611,7 +627,7 @@ export function CoursesProvider({ children }) { const bulkRestoreQuizQuestions = useCallback( (courseId, unitId, quizId, ids, restoredBy) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/restore/bulk`, { ids, restoredBy }); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/quiz/${quizId}/questions/restore/bulk`, { ids, restoredBy }); toast("Questions restored."); return data; }), @@ -624,14 +640,14 @@ export function CoursesProvider({ children }) { const fetchLessons = useCallback( (courseId, unitId, params = {}) => - paginatedGet(`${BASE}/${courseId}/units/${unitId}/lessons`, setLessons, params), + paginatedGet(`${unitBase(courseId, unitId)}/lessons`, setLessons, params), [paginatedGet], ); const fetchLesson = useCallback( (courseId, unitId, lessonId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`); + const { data } = await api.get(`${lessonBase(courseId, unitId, lessonId)}`); const result = data?.data?.data ?? null; setLesson(result); setLessonPage(result?.page ?? null); @@ -643,7 +659,7 @@ export function CoursesProvider({ children }) { const createLesson = useCallback( (courseId, unitId, payload) => request(async () => { - const { data } = await api.post(`${BASE}/${courseId}/units/${unitId}/lessons`, payload); + const { data } = await api.post(`${unitBase(courseId, unitId)}/lessons`, payload); const lesson = data?.data?.data ?? null; if (lesson) { setLessons((prev) => [...prev, lesson]); @@ -657,7 +673,7 @@ export function CoursesProvider({ children }) { const updateLesson = useCallback( (courseId, unitId, lessonId, payload) => request(async () => { - const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`, payload); + const { data } = await api.put(`${lessonBase(courseId, unitId, lessonId)}`, payload); const lesson = data?.data?.data ?? null; if (lesson) { setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l))); @@ -672,7 +688,7 @@ export function CoursesProvider({ children }) { const archiveLesson = useCallback( (courseId, unitId, lessonId) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`); + const { data } = await api.delete(`${lessonBase(courseId, unitId, lessonId)}`); setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev)); toast("Lesson archived."); @@ -684,7 +700,7 @@ export function CoursesProvider({ children }) { const archiveLessons = useCallback( (courseId, unitId, { ids }) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk`, { data: { ids } }); + const { data } = await api.delete(`${unitBase(courseId, unitId)}/lessons/bulk`, { data: { ids } }); setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); toast("Lessons archived."); return data; @@ -696,14 +712,14 @@ export function CoursesProvider({ children }) { const fetchArchivedLessons = useCallback( (courseId, unitId, params = {}) => - paginatedGet(`${BASE}/${courseId}/units/${unitId}/lessons/archives`, setLessons, params), + paginatedGet(`${unitBase(courseId, unitId)}/lessons/archives`, setLessons, params), [paginatedGet], ); const fetchArchivedLesson = useCallback( (courseId, unitId, lessonId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/lessons/archives/${lessonId}`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/lessons/archives/${lessonId}`); const result = data?.data?.data ?? null; setLesson(result); return data; @@ -714,7 +730,7 @@ export function CoursesProvider({ children }) { const restoreLesson = useCallback( (courseId, unitId, lessonId) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/restore`); + const { data } = await api.patch(`${lessonBase(courseId, unitId, lessonId)}/restore`); const result = data?.data?.data ?? null; if (result) { setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); @@ -728,7 +744,7 @@ export function CoursesProvider({ children }) { const restoreLessons = useCallback( (courseId, unitId, { ids }) => request(async () => { - const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/lessons/restore/bulk`, { ids }); + const { data } = await api.patch(`${unitBase(courseId, unitId)}/lessons/restore/bulk`, { ids }); setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); toast("Lessons restored."); return data; @@ -739,7 +755,7 @@ export function CoursesProvider({ children }) { const permanentlyDeleteLesson = useCallback( (courseId, unitId, lessonId) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/permanent`); + const { data } = await api.delete(`${lessonBase(courseId, unitId, lessonId)}/permanent`); setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev)); toast("Lesson permanently deleted."); @@ -751,7 +767,7 @@ export function CoursesProvider({ children }) { const permanentlyDeleteLessons = useCallback( (courseId, unitId, { ids }) => request(async () => { - const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk/permanent`, { data: { ids } }); + const { data } = await api.delete(`${unitBase(courseId, unitId)}/lessons/bulk/permanent`, { data: { ids } }); setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); toast("Lessons permanently deleted."); return data; @@ -766,7 +782,7 @@ export function CoursesProvider({ children }) { const fetchLessonPage = useCallback( (courseId, unitId, lessonId) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/page`); + const { data } = await api.get(`${lessonBase(courseId, unitId, lessonId)}/page`); const page = data?.data?.data ?? null; setLessonPage(page); return data; @@ -777,7 +793,7 @@ export function CoursesProvider({ children }) { const saveLessonPage = useCallback( (courseId, unitId, lessonId, payload) => request(async () => { - const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/page`, payload); + const { data } = await api.put(`${lessonBase(courseId, unitId, lessonId)}/page`, payload); const page = data?.data?.data ?? null; if (page) { setLessonPage(page); @@ -987,7 +1003,7 @@ export function CoursesProvider({ children }) { (courseId, unitId, quizId) => request(async () => { setCompletions(null); - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/completions`); + const { data } = await api.get(`${unitBase(courseId, unitId)}/quiz/${quizId}/completions`); setCompletions(data?.data ?? null); return data; }), @@ -1109,7 +1125,7 @@ export function CoursesProvider({ children }) { const fetchLessonFieldValues = useCallback( (courseId, unitId, field) => request(async () => { - const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/field-values`, { params: { field } }); + const { data } = await api.get(`${unitBase(courseId, unitId)}/field-values`, { params: { field } }); const result = data?.data ?? []; return result; }), diff --git a/src/contexts/AdminLibraryContext.jsx b/src/contexts/AdminLibraryContext.jsx new file mode 100644 index 0000000..5551fb1 --- /dev/null +++ b/src/contexts/AdminLibraryContext.jsx @@ -0,0 +1,423 @@ +// ─── AdminLibraryContext.jsx ─────────────────────────────────────────────────── +// Standalone Units & Lessons libraries (junction revamp). +// Units and Lessons run independently of Courses: +// /admin/units → unit library CRUD + lesson attach/detach/reorder + quiz +// /admin/lessons → lesson library CRUD + page content +// Course membership stays under /admin/courses/:courseId/units (AdminCoursesContext). + +import { createContext, useCallback, useContext, useState } from "react"; +import api from "@/utils/api.util"; +import { toast } from "sonner"; + +const LibraryContext = createContext(null); + +export function useLibrary() { + const ctx = useContext(LibraryContext); + if (!ctx) throw new Error("useLibrary must be used within a LibraryProvider"); + return ctx; +} + +const PAGINATION_INIT = { + page: 1, + limit: 10, + totalRecords: 0, + totalPages: 0, + hasPrevPage: false, + hasNextPage: false, +}; + +const UNITS_BASE = "/admin/units"; +const LESSONS_BASE = "/admin/lessons"; + +export function LibraryProvider({ children }) { + + // ─── State ──────────────────────────────────────────────────────────────── + const [units, setUnits] = useState([]); + const [unit, setUnit] = useState(null); + const [unitsFlat, setUnitsFlat] = useState([]); + const [lessons, setLessons] = useState([]); + const [lesson, setLesson] = useState(null); + const [lessonsFlat, setLessonsFlat] = useState([]); + const [attributes, setAttributes] = useState([]); + const [pagination, setPagination] = useState(PAGINATION_INIT); + const [loading, setLoading] = useState(false); + + // ─── Request wrapper ────────────────────────────────────────────────────── + const request = useCallback(async (fn) => { + setLoading(true); + try { + return await fn(); + } catch (err) { + const message = err?.response?.data?.message ?? "Something went wrong."; + toast(message); + return null; + } finally { + setLoading(false); + } + }, []); + + // ─── Paginated GET helper ───────────────────────────────────────────────── + const paginatedGet = useCallback( + (url, setter, params = {}) => + request(async () => { + const { filters, sort, ...rest } = params; + const { data } = await api.get(url, { + params: { + ...rest, + ...(filters?.length ? { filters: JSON.stringify(filters) } : {}), + ...(sort?.length ? { sort: JSON.stringify(sort) } : {}), + }, + }); + const final_data = data?.data; + setter(final_data?.data ?? []); + setPagination(final_data?.pagination ?? PAGINATION_INIT); + if (final_data?.attributes?.length) setAttributes(final_data.attributes); + return data; + }), + [request], + ); + + // ========================================================================= + // UNIT LIBRARY + // ========================================================================= + + const fetchUnits = useCallback( + (params = {}) => paginatedGet(UNITS_BASE, setUnits, params), + [paginatedGet], + ); + + const fetchUnitsFlat = useCallback( + () => + request(async () => { + const { data } = await api.get(`${UNITS_BASE}/flat`); + setUnitsFlat(data?.data ?? []); + return data; + }), + [request], + ); + + const fetchUnit = useCallback( + (unitId) => + request(async () => { + const { data } = await api.get(`${UNITS_BASE}/${unitId}`); + const result = data?.data?.data ?? null; + setUnit(result); + return data; + }), + [request], + ); + + const createUnit = useCallback( + (payload) => + request(async () => { + const { data } = await api.post(UNITS_BASE, payload); + const created = data?.data?.data ?? null; + if (created) toast("Unit created successfully."); + return data; + }), + [request], + ); + + const updateUnit = useCallback( + (unitId, payload) => + request(async () => { + const { data } = await api.put(`${UNITS_BASE}/${unitId}`, payload); + const updated = data?.data?.data ?? null; + if (updated) { + setUnit(updated); + toast("Unit updated successfully."); + } + return data; + }), + [request], + ); + + const archiveUnit = useCallback( + (unitId) => + request(async () => { + const { data } = await api.delete(`${UNITS_BASE}/${unitId}`); + toast("Unit archived."); + return data; + }), + [request], + ); + + const archiveUnits = useCallback( + (ids) => + request(async () => { + const { data } = await api.delete(`${UNITS_BASE}/bulk`, { data: { ids } }); + toast(data?.message ?? "Units archived."); + return data; + }), + [request], + ); + + const fetchArchivedUnits = useCallback( + (params = {}) => paginatedGet(`${UNITS_BASE}/archives`, setUnits, params), + [paginatedGet], + ); + + const restoreUnit = useCallback( + (unitId) => + request(async () => { + const { data } = await api.patch(`${UNITS_BASE}/${unitId}/restore`, {}); + toast("Unit restored."); + return data; + }), + [request], + ); + + const restoreUnits = useCallback( + (ids) => + request(async () => { + const { data } = await api.patch(`${UNITS_BASE}/restore/bulk`, { ids }); + toast(data?.message ?? "Units restored."); + return data; + }), + [request], + ); + + const permanentlyDeleteUnit = useCallback( + (unitId) => + request(async () => { + const { data } = await api.delete(`${UNITS_BASE}/${unitId}/permanent`); + toast("Unit permanently deleted."); + return data; + }), + [request], + ); + + const permanentlyDeleteUnits = useCallback( + (ids) => + request(async () => { + const { data } = await api.delete(`${UNITS_BASE}/bulk/permanent`, { data: { ids } }); + toast(data?.message ?? "Units permanently deleted."); + return data; + }), + [request], + ); + + const fetchUnitArchiveImpact = useCallback( + (unitId) => + request(async () => { + const { data } = await api.get(`${UNITS_BASE}/${unitId}/archive-impact`); + return data?.data ?? null; + }), + [request], + ); + + const fetchUnitPermanentDeleteImpact = useCallback( + (unitId) => + request(async () => { + const { data } = await api.get(`${UNITS_BASE}/${unitId}/permanent-delete-impact`); + return data?.data ?? null; + }), + [request], + ); + + const fetchUnitFieldValues = useCallback( + (field) => + request(async () => { + const { data } = await api.get(`${UNITS_BASE}/field-values`, { params: { field } }); + return data; + }), + [request], + ); + + // ── Unit ⇄ Lesson membership ──────────────────────────────────────────── + + const attachLessonsToUnit = useCallback( + (unitId, lessonIds) => + request(async () => { + const { data } = await api.post(`${UNITS_BASE}/${unitId}/lessons`, { lesson_ids: lessonIds }); + toast(data?.message ?? "Lessons attached."); + return data; + }), + [request], + ); + + const detachLessonFromUnit = useCallback( + (unitId, lessonId) => + request(async () => { + const { data } = await api.delete(`${UNITS_BASE}/${unitId}/lessons/${lessonId}`); + toast("Lesson detached."); + return data; + }), + [request], + ); + + const reorderUnitLessons = useCallback( + (unitId, lessonIds) => + request(async () => { + const { data } = await api.put(`${UNITS_BASE}/${unitId}/lessons/order`, { lesson_ids: lessonIds }); + toast("Lesson order updated."); + return data; + }), + [request], + ); + + // ========================================================================= + // LESSON LIBRARY + // ========================================================================= + + const fetchLessons = useCallback( + (params = {}) => paginatedGet(LESSONS_BASE, setLessons, params), + [paginatedGet], + ); + + const fetchLessonsFlat = useCallback( + () => + request(async () => { + const { data } = await api.get(`${LESSONS_BASE}/flat`); + setLessonsFlat(data?.data ?? []); + return data; + }), + [request], + ); + + const fetchLesson = useCallback( + (lessonId) => + request(async () => { + const { data } = await api.get(`${LESSONS_BASE}/${lessonId}`); + const result = data?.data?.data ?? null; + setLesson(result); + return data; + }), + [request], + ); + + const createLesson = useCallback( + (payload) => + request(async () => { + const { data } = await api.post(LESSONS_BASE, payload); + const created = data?.data?.data ?? null; + if (created) toast("Lesson created successfully."); + return data; + }), + [request], + ); + + const updateLesson = useCallback( + (lessonId, payload) => + request(async () => { + const { data } = await api.put(`${LESSONS_BASE}/${lessonId}`, payload); + const updated = data?.data?.data ?? null; + if (updated) { + setLesson(updated); + toast("Lesson updated successfully."); + } + return data; + }), + [request], + ); + + const archiveLesson = useCallback( + (lessonId) => + request(async () => { + const { data } = await api.delete(`${LESSONS_BASE}/${lessonId}`); + toast("Lesson archived."); + return data; + }), + [request], + ); + + const archiveLessons = useCallback( + (ids) => + request(async () => { + const { data } = await api.delete(`${LESSONS_BASE}/bulk`, { data: { ids } }); + toast(data?.message ?? "Lessons archived."); + return data; + }), + [request], + ); + + const fetchArchivedLessons = useCallback( + (params = {}) => paginatedGet(`${LESSONS_BASE}/archives`, setLessons, params), + [paginatedGet], + ); + + const restoreLesson = useCallback( + (lessonId) => + request(async () => { + const { data } = await api.patch(`${LESSONS_BASE}/${lessonId}/restore`, {}); + toast("Lesson restored."); + return data; + }), + [request], + ); + + const restoreLessons = useCallback( + (ids) => + request(async () => { + const { data } = await api.patch(`${LESSONS_BASE}/restore/bulk`, { ids }); + toast(data?.message ?? "Lessons restored."); + return data; + }), + [request], + ); + + const permanentlyDeleteLesson = useCallback( + (lessonId) => + request(async () => { + const { data } = await api.delete(`${LESSONS_BASE}/${lessonId}/permanent`); + toast("Lesson permanently deleted."); + return data; + }), + [request], + ); + + const permanentlyDeleteLessons = useCallback( + (ids) => + request(async () => { + const { data } = await api.delete(`${LESSONS_BASE}/bulk/permanent`, { data: { ids } }); + toast(data?.message ?? "Lessons permanently deleted."); + return data; + }), + [request], + ); + + const fetchLessonPermanentDeleteImpact = useCallback( + (lessonId) => + request(async () => { + const { data } = await api.get(`${LESSONS_BASE}/${lessonId}/permanent-delete-impact`); + return data?.data ?? null; + }), + [request], + ); + + const fetchLessonFieldValues = useCallback( + (field) => + request(async () => { + const { data } = await api.get(`${LESSONS_BASE}/field-values`, { params: { field } }); + return data; + }), + [request], + ); + + // ─── Value ──────────────────────────────────────────────────────────────── + const value = { + // shared table state + attributes, pagination, setPagination, loading, + + // unit library + units, unit, unitsFlat, + fetchUnits, fetchUnitsFlat, fetchUnit, createUnit, updateUnit, + archiveUnit, archiveUnits, fetchArchivedUnits, + restoreUnit, restoreUnits, + permanentlyDeleteUnit, permanentlyDeleteUnits, + fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact, + fetchUnitFieldValues, + attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons, + + // lesson library + lessons, lesson, lessonsFlat, + fetchLessons, fetchLessonsFlat, fetchLesson, createLesson, updateLesson, + archiveLesson, archiveLessons, fetchArchivedLessons, + restoreLesson, restoreLessons, + permanentlyDeleteLesson, permanentlyDeleteLessons, + fetchLessonPermanentDeleteImpact, + fetchLessonFieldValues, + }; + + return {children}; +} diff --git a/src/contexts/ClientLibraryContext.jsx b/src/contexts/ClientLibraryContext.jsx new file mode 100644 index 0000000..0f50b9c --- /dev/null +++ b/src/contexts/ClientLibraryContext.jsx @@ -0,0 +1,182 @@ +// ─── ClientLibraryContext.jsx ─────────────────────────────────────────────── +// Standalone Unit / Lesson browsing (junction revamp). Units run independently +// of Courses — this context drives the /units grid, /units/:uuid detail page, +// and /units/:uuid/read reader. + +import { createContext, useCallback, useContext, useState } from "react"; +import api from "@/utils/api.util"; +import { toast } from "sonner"; + +const ClientLibraryContext = createContext(null); + +export function useLibrary() { + const ctx = useContext(ClientLibraryContext); + if (!ctx) throw new Error("useLibrary must be used within a ClientLibraryProvider"); + return ctx; +} + +export function ClientLibraryProvider({ children }) { + // ── List state + const [units, setUnits] = useState([]); + const [unitsLoading, setUnitsLoading] = useState(false); + + // ── Detail state — GET /client/units/:uuid/lessons returns unit + lessons + quiz in one call + const [unitDetail, setUnitDetail] = useState(null); + const [unitDetailLoading, setUnitDetailLoading] = useState(false); + const [unitBlocked, setUnitBlocked] = useState(false); + const [unitBlockedInfo, setUnitBlockedInfo] = useState(null); // { message, course } + + // ── Lesson state (standalone reader) + const [lesson, setLesson] = useState(null); + const [lessonLoading, setLessonLoading] = useState(false); + + // ── Quiz state + const [quiz, setQuiz] = useState(null); + const [quizLoading, setQuizLoading] = useState(false); + + // ─── Actions ──────────────────────────────────────────────────────────── + + const getUnits = useCallback(async () => { + setUnitsLoading(true); + try { + const { data } = await api.get("/client/units"); + setUnits(data.data ?? []); + } catch (err) { + toast(err?.response?.data?.message ?? "Could not load units."); + } finally { + setUnitsLoading(false); + } + }, []); + + const getUnitDetail = useCallback(async (uuid) => { + setUnitDetailLoading(true); + setUnitBlocked(false); + setUnitBlockedInfo(null); + try { + const { data } = await api.get(`/client/units/${uuid}/lessons`); + setUnitDetail(data.data ?? null); + } catch (err) { + if (err?.response?.status === 403) { + setUnitBlocked(true); + setUnitBlockedInfo(err.response.data ?? null); + } else { + toast(err?.response?.data?.message ?? "Could not load unit."); + } + } finally { + setUnitDetailLoading(false); + } + }, []); + + const getLesson = useCallback(async (uuid) => { + setLessonLoading(true); + try { + const { data } = await api.get(`/client/lessons/${uuid}`); + setLesson(data.data ?? null); + } catch (err) { + if (err?.response?.status === 403) { + setUnitBlocked(true); + setUnitBlockedInfo(err.response.data ?? null); + } else { + toast(err?.response?.data?.message ?? "Could not load lesson."); + } + } finally { + setLessonLoading(false); + } + }, []); + + const getUnitQuiz = useCallback(async (uuid) => { + setQuizLoading(true); + try { + const { data } = await api.get(`/client/units/${uuid}/quiz`); + setQuiz(data.data ?? null); + } catch (err) { + toast(err?.response?.data?.message ?? "Could not load quiz."); + } finally { + setQuizLoading(false); + } + }, []); + + const submitUnitQuiz = useCallback(async (uuid, quizId, answers) => { + try { + const { data } = await api.post(`/client/units/${uuid}/quiz/${quizId}/submit`, { answers }); + return data.data ?? null; + } catch (err) { + toast(err?.response?.data?.message ?? "Could not submit quiz."); + return null; + } + }, []); + + const saveUnitQuizDraft = useCallback(async (uuid, quizId, answers) => { + try { + await api.patch(`/client/units/${uuid}/quiz/${quizId}/draft`, { answers }); + } catch { /* silent — draft saves are best-effort */ } + }, []); + + // Writes lesson progress, then locally patches unitDetail so the reader/detail + // page updates without a full refetch (matched by numeric lesson_id — the + // response is keyed by id, not uuid). + const upsertLessonProgress = useCallback(async (lessonUuid, status, unitUuid) => { + try { + const { data } = await api.post(`/client/lessons/${lessonUuid}/progress`, { + status, + ...(unitUuid ? { unit_uuid: unitUuid } : {}), + }); + const result = data.data ?? null; + + setUnitDetail((prev) => { + if (!prev || !result?.lesson) return prev; + const nextLessons = prev.lessons.map((l) => + l.lesson_id === result.lesson.lesson_id + ? { ...l, status: result.lesson.status, completed_at: status === "completed" ? new Date().toISOString() : l.completed_at } + : l + ); + const is_completed = nextLessons.length > 0 && nextLessons.every((l) => l.status === "completed"); + return { ...prev, lessons: nextLessons, is_completed }; + }); + + return result; + } catch (err) { + toast(err?.response?.data?.message ?? "Could not update progress."); + return null; + } + }, []); + + // ─── Resets ───────────────────────────────────────────────────────────── + + const resetUnitDetail = useCallback(() => { + setUnitDetail(null); + setUnitBlocked(false); + setUnitBlockedInfo(null); + }, []); + const resetLesson = useCallback(() => setLesson(null), []); + const resetQuiz = useCallback(() => setQuiz(null), []); + + // ─── Value ────────────────────────────────────────────────────────────── + + const value = { + units, unitsLoading, + unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, + lesson, lessonLoading, + quiz, quizLoading, + + getUnits, + getUnitDetail, + getLesson, + getUnitQuiz, + submitUnitQuiz, + saveUnitQuizDraft, + upsertLessonProgress, + + resetUnitDetail, + resetLesson, + resetQuiz, + }; + + return ( + + {children} + + ); +} + +export default ClientLibraryContext; diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx index 3dfc0c4..ffbd07e 100644 --- a/src/contexts/provider/AdminProvider.jsx +++ b/src/contexts/provider/AdminProvider.jsx @@ -4,6 +4,7 @@ import { AdminDashboardProvider } from "../AdminDashboardContext" import { UserProvider } from "../AdminUserContext"; import { UserGroupProvider } from "../AdminUserGroupContext"; import { CoursesProvider } from "../AdminCoursesContext"; +import { LibraryProvider } from "../AdminLibraryContext"; import { AdminTaskProvider } from "../AdminTaskContext"; import { AdminTiersProvider } from "../AdminTiersContext"; import { AdminCategoriesProvider } from "../AdminCategoriesContext"; @@ -26,11 +27,13 @@ export const AdminProvider = ({ children }) => { - - - {children} - - + + + + {children} + + + diff --git a/src/contexts/provider/ClientProvider.jsx b/src/contexts/provider/ClientProvider.jsx index f4ebd02..96bbd3d 100644 --- a/src/contexts/provider/ClientProvider.jsx +++ b/src/contexts/provider/ClientProvider.jsx @@ -1,4 +1,5 @@ import { ClientCoursesProvider } from "../ClientCoursesContext" +import { ClientLibraryProvider } from "../ClientLibraryContext" import { ClientTiersProvider } from "../ClientTiersProvider" import { ProfileProvider } from "../ProfileProvider" import { TaskProgressProvider } from "../ClientTaskProgressContext" @@ -15,17 +16,19 @@ export const ClientProvider = ({ children }) => { - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index f554c13..3b1527e 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -1,4 +1,4 @@ -import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy } from "lucide-react"; +import { Users, GitFork, FolderOpen, BookText, BookCheck, FileText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy } from "lucide-react"; export const ADMIN_SECTIONS = [ { @@ -28,6 +28,8 @@ export const ADMIN_SECTIONS = [ description: "Manage tasks and courses", tiles: [ { key: "courses", label: "Courses", icon: BookText, link: "/admin/courses" }, + { key: "units", label: "Units", icon: BookCheck, link: "/admin/units" }, + { key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" }, { key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" }, { key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" }, ], diff --git a/src/modules/admin/components/AdminSideTabs.jsx b/src/modules/admin/components/AdminSideTabs.jsx index 49ed3e6..726f9e1 100644 --- a/src/modules/admin/components/AdminSideTabs.jsx +++ b/src/modules/admin/components/AdminSideTabs.jsx @@ -12,7 +12,7 @@ export default function AdminSideTabs() { const tabItems = [ { value: "tab-1", label: "Dashboard", paths: ["/admin"] }, { value: "tab-2", label: "User Management", paths: ["users"] }, - { value: "tab-3", label: "Content Management", paths: ["content", "courses"] }, + { value: "tab-3", label: "Content Management", paths: ["content", "courses", "units", "lessons"] }, { value: "tab-5", label: "Site Content", paths: ["contents"] }, ] diff --git a/src/modules/admin/components/courses/LessonsTable.jsx b/src/modules/admin/components/courses/LessonsTable.jsx index 49234a1..8dd159e 100644 --- a/src/modules/admin/components/courses/LessonsTable.jsx +++ b/src/modules/admin/components/courses/LessonsTable.jsx @@ -3,9 +3,12 @@ import { useNavigate } from "react-router-dom"; import { useCourses } from "@/contexts/AdminCoursesContext"; +import api from "@/utils/api.util"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; +import AttachLessonsDialog from "../library/AttachLessonsDialog"; +import { Link2 } from "lucide-react"; import { buildDataColumns, columnPinning } from "../../config/courses/lessons/columns.config"; import { buildToolbarActions } from "../../config/courses/lessons/toolbar.config"; @@ -17,6 +20,7 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function LessonsTable({ courseId, unitId }) { const [archiveTarget, setArchiveTarget] = useState(null); const [archiveIds, setArchiveIds] = useState(null); + const [attachOpen, setAttachOpen] = useState(false); const tableRefsRef = useRef({ getFilters: () => [], @@ -55,17 +59,35 @@ export default function LessonsTable({ courseId, unitId }) { onArchive: (row) => setArchiveTarget(row), }), [courseId, unitId]); - const toolbarActions = buildToolbarActions({ - fetchLessons: (params) => fetchLessons(courseId, unitId, params), - pagination, - exportConfig, - navigate, - courseId, - unitId, - getFilters: () => tableRefsRef.current.getFilters(), - getSort: () => tableRefsRef.current.getSort(), - getTableInstance: () => tableRefsRef.current.tableInstance, - }); + const toolbarActions = [ + ...buildToolbarActions({ + fetchLessons: (params) => fetchLessons(courseId, unitId, params), + pagination, + exportConfig, + navigate, + courseId, + unitId, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }), + // Junction revamp — lessons live standalone in the library; attach without re-creating + { + key: "attach-existing", + type: "button", + label: "Attach Existing", + icon: , + variant: "outline", + onClick: () => setAttachOpen(true), + }, + ]; + + const handleAttachLessons = async (lessonIds) => { + for (const lesson_id of lessonIds) { + await api.post(`/admin/courses/${courseId}/units/${unitId}/lessons`, { lesson_id }); + } + fetchLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 }); + }; const selectionActions = buildSelectionActions({ exportConfig, @@ -138,6 +160,15 @@ export default function LessonsTable({ courseId, unitId }) { loading={loading} onSuccess={handleArchiveSuccess} /> + + {/* ── Attach existing library lessons ── */} + l.lesson_id)} + onAttach={handleAttachLessons} + loading={loading} + /> ); } diff --git a/src/modules/admin/components/courses/UnitsTable.jsx b/src/modules/admin/components/courses/UnitsTable.jsx index a3206e2..ebfd47a 100644 --- a/src/modules/admin/components/courses/UnitsTable.jsx +++ b/src/modules/admin/components/courses/UnitsTable.jsx @@ -8,6 +8,8 @@ import { useAuth } from "@/contexts/AuthContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; +import AttachUnitsDialog from "../library/AttachUnitsDialog"; +import { Link2 } from "lucide-react"; import { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config"; import { buildToolbarActions } from "../../config/courses/units/toolbar.config"; @@ -19,6 +21,7 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function UnitsTable({ courseId }) { const [archiveTarget, setArchiveTarget] = useState(null); const [archiveIds, setArchiveIds] = useState(null); + const [attachOpen, setAttachOpen] = useState(false); const tableRefsRef = useRef({ getFilters: () => [], @@ -58,16 +61,32 @@ export default function UnitsTable({ courseId }) { onArchive: (row) => setArchiveTarget(row), }), [courseId]); - const toolbarActions = buildToolbarActions({ - fetchUnits: (params) => fetchUnits(courseId, params), - pagination, - exportConfig, - navigate, - courseId, - getFilters: () => tableRefsRef.current.getFilters(), - getSort: () => tableRefsRef.current.getSort(), - getTableInstance: () => tableRefsRef.current.tableInstance, - }); + const toolbarActions = [ + ...buildToolbarActions({ + fetchUnits: (params) => fetchUnits(courseId, params), + pagination, + exportConfig, + navigate, + courseId, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }), + // Junction revamp — units live standalone in the library; attach without re-creating + { + key: "attach-existing", + type: "button", + label: "Attach Existing", + icon: , + variant: "outline", + onClick: () => setAttachOpen(true), + }, + ]; + + const handleAttachUnits = async (unitIds) => { + await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds }); + fetchUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 }); + }; const selectionActions = buildSelectionActions({ exportConfig, @@ -150,6 +169,15 @@ export default function UnitsTable({ courseId }) { loading={loading} onSuccess={handleArchiveSuccess} /> + + {/* ── Attach existing library units ── */} + u.unit_id)} + onAttach={handleAttachUnits} + loading={loading} + /> ); } diff --git a/src/modules/admin/components/library/ArchivedLessonLibraryTable.jsx b/src/modules/admin/components/library/ArchivedLessonLibraryTable.jsx new file mode 100644 index 0000000..74961b3 --- /dev/null +++ b/src/modules/admin/components/library/ArchivedLessonLibraryTable.jsx @@ -0,0 +1,171 @@ +import { useMemo, useRef, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; + +import { buildDataColumns, columnPinning } from "../../config/library/lessons/columns.config"; +import { buildArchivedToolbarActions } from "../../config/library/lessons/toolbar.config"; +import { buildArchivedSelectionActions } from "../../config/library/lessons/selection.config"; +import { buildArchivedRowActions } from "../../config/library/lessons/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function ArchivedLessonLibraryTable() { + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => { }, + setFilters: () => { }, + }); + + const navigate = useNavigate(); + + const { + lessons, attributes, pagination, setPagination, loading, + fetchArchivedLessons, restoreLesson, restoreLessons, + permanentlyDeleteLesson, permanentlyDeleteLessons, + fetchLessonFieldValues, fetchLessonPermanentDeleteImpact, + } = useLibrary(); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: lessons, + attributes, + filename: `${getTimestamp()}_ArchivedLessonLibrary`, + sheetName: "Archived Lessons", + }; + + const handleFetch = useCallback((params) => fetchArchivedLessons(params), [fetchArchivedLessons]); + + const rowActions = useMemo(() => buildArchivedRowActions({ + onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), + }), []); + + const toolbarActions = buildArchivedToolbarActions({ + fetchArchivedLessons, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildArchivedSelectionActions({ + exportConfig, + onRestore: (row) => setRestoreTarget(row), + onRestoreMany: (ids) => setRestoreIds(ids), + onDelete: (row) => setDeleteTarget(row), + onDeleteMany: (ids) => setDeleteIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes, rowActions] + ); + + const refresh = () => { + setRestoreTarget(null); + setRestoreIds(null); + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedLessons({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + fetchLessonFieldValues(field)} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="lesson" + emptyMessage="No archived lessons." + /> + + {/* ── Restore ── */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Lesson" + getName={(l) => l?.title} + onRestore={(l) => restoreLesson(l?.lesson_id)} + loading={loading} + onSuccess={refresh} + /> + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Lesson" + onRestore={(ids) => restoreLessons(ids)} + loading={loading} + onSuccess={refresh} + /> + + {/* ── Permanent delete ── */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Lesson" + getName={(l) => l?.title} + onDelete={(l) => permanentlyDeleteLesson(l?.lesson_id)} + loading={loading} + onSuccess={refresh} + onImpactCheck={async () => { + const impact = await fetchLessonPermanentDeleteImpact(deleteTarget?.lesson_id); + const { unitCount } = impact?.data ?? impact ?? {}; + return [ + { label: "unit attachment(s) will be removed", count: unitCount ?? 0 }, + ]; + }} + /> + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Lesson" + onDelete={(ids) => permanentlyDeleteLessons(ids)} + loading={loading} + onSuccess={refresh} + /> + + ); +} diff --git a/src/modules/admin/components/library/ArchivedUnitLibraryTable.jsx b/src/modules/admin/components/library/ArchivedUnitLibraryTable.jsx new file mode 100644 index 0000000..e8f4329 --- /dev/null +++ b/src/modules/admin/components/library/ArchivedUnitLibraryTable.jsx @@ -0,0 +1,172 @@ +import { useMemo, useRef, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; + +import { buildDataColumns, columnPinning } from "../../config/library/units/columns.config"; +import { buildArchivedToolbarActions } from "../../config/library/units/toolbar.config"; +import { buildArchivedSelectionActions } from "../../config/library/units/selection.config"; +import { buildArchivedRowActions } from "../../config/library/units/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function ArchivedUnitLibraryTable() { + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => { }, + setFilters: () => { }, + }); + + const navigate = useNavigate(); + + const { + units, attributes, pagination, setPagination, loading, + fetchArchivedUnits, restoreUnit, restoreUnits, + permanentlyDeleteUnit, permanentlyDeleteUnits, + fetchUnitFieldValues, fetchUnitPermanentDeleteImpact, + } = useLibrary(); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: units, + attributes, + filename: `${getTimestamp()}_ArchivedUnitLibrary`, + sheetName: "Archived Units", + }; + + const handleFetch = useCallback((params) => fetchArchivedUnits(params), [fetchArchivedUnits]); + + const rowActions = useMemo(() => buildArchivedRowActions({ + onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), + }), []); + + const toolbarActions = buildArchivedToolbarActions({ + fetchArchivedUnits, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildArchivedSelectionActions({ + exportConfig, + onRestore: (row) => setRestoreTarget(row), + onRestoreMany: (ids) => setRestoreIds(ids), + onDelete: (row) => setDeleteTarget(row), + onDeleteMany: (ids) => setDeleteIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes, rowActions] + ); + + const refresh = () => { + setRestoreTarget(null); + setRestoreIds(null); + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedUnits({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + fetchUnitFieldValues(field)} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="unit" + emptyMessage="No archived units." + /> + + {/* ── Restore ── */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Unit" + getName={(u) => u?.title} + onRestore={(u) => restoreUnit(u?.unit_id)} + loading={loading} + onSuccess={refresh} + /> + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Unit" + onRestore={(ids) => restoreUnits(ids)} + loading={loading} + onSuccess={refresh} + /> + + {/* ── Permanent delete ── */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Unit" + getName={(u) => u?.title} + onDelete={(u) => permanentlyDeleteUnit(u?.unit_id)} + loading={loading} + onSuccess={refresh} + onImpactCheck={async () => { + const impact = await fetchUnitPermanentDeleteImpact(deleteTarget?.unit_id); + const { lessonCount, courseCount } = impact?.data ?? impact ?? {}; + return [ + { label: "lesson attachment(s) will be removed", count: lessonCount ?? 0 }, + { label: "course attachment(s) will be removed", count: courseCount ?? 0 }, + ]; + }} + /> + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Unit" + onDelete={(ids) => permanentlyDeleteUnits(ids)} + loading={loading} + onSuccess={refresh} + /> + + ); +} diff --git a/src/modules/admin/components/library/AttachLessonsDialog.jsx b/src/modules/admin/components/library/AttachLessonsDialog.jsx new file mode 100644 index 0000000..b9d4bb5 --- /dev/null +++ b/src/modules/admin/components/library/AttachLessonsDialog.jsx @@ -0,0 +1,129 @@ +// AttachLessonsDialog — pick existing library Lessons and attach them to a Unit. +// Junction revamp: attaching creates unit_lessons rows; the Lessons stay standalone. + +import { useEffect, useMemo, useState } from "react"; +import { Search, Link2 } from "lucide-react"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, + DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Spinner } from "@/components/ui/spinner"; +import { formatDuration } from "@/utils/timestamp.util"; + +export default function AttachLessonsDialog({ open, onOpenChange, attachedLessonIds = [], onAttach, loading }) { + const { lessonsFlat, fetchLessonsFlat, loading: libraryLoading } = useLibrary(); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState([]); + + useEffect(() => { + if (open) { + setSelected([]); + setQuery(""); + fetchLessonsFlat(); + } + }, [open, fetchLessonsFlat]); + + const attachedSet = useMemo( + () => new Set(attachedLessonIds.map(String)), + [attachedLessonIds] + ); + + const candidates = useMemo(() => { + const q = query.trim().toLowerCase(); + return (lessonsFlat ?? []) + .filter((l) => !attachedSet.has(String(l.lesson_id))) + .filter((l) => !q || l.title?.toLowerCase().includes(q)); + }, [lessonsFlat, attachedSet, query]); + + const toggle = (lessonId) => + setSelected((prev) => + prev.includes(lessonId) ? prev.filter((id) => id !== lessonId) : [...prev, lessonId] + ); + + const handleAttach = async () => { + if (!selected.length) return; + await onAttach(selected); + onOpenChange(false); + }; + + return ( + + + + + Attach Existing Lessons + + + Lessons live independently in the library — attaching adds them to this unit without copying. + + + +
+ + setQuery(e.target.value)} + /> +
+ + + {libraryLoading ? ( +
+ +
+ ) : candidates.length === 0 ? ( +

+ {query ? "No lessons match your search." : "Every library lesson is already attached."} +

+ ) : ( +
+ {candidates.map((l) => ( + + ))} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/modules/admin/components/library/AttachUnitsDialog.jsx b/src/modules/admin/components/library/AttachUnitsDialog.jsx new file mode 100644 index 0000000..c796e46 --- /dev/null +++ b/src/modules/admin/components/library/AttachUnitsDialog.jsx @@ -0,0 +1,129 @@ +// AttachUnitsDialog — pick existing library Units and attach them to a Course. +// Junction revamp: attaching creates course_units rows; the Units stay standalone. + +import { useEffect, useMemo, useState } from "react"; +import { Search, Link2 } from "lucide-react"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, + DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Spinner } from "@/components/ui/spinner"; +import { formatDuration } from "@/utils/timestamp.util"; + +export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds = [], onAttach, loading }) { + const { unitsFlat, fetchUnitsFlat, loading: libraryLoading } = useLibrary(); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState([]); + + useEffect(() => { + if (open) { + setSelected([]); + setQuery(""); + fetchUnitsFlat(); + } + }, [open, fetchUnitsFlat]); + + const attachedSet = useMemo( + () => new Set(attachedUnitIds.map(String)), + [attachedUnitIds] + ); + + const candidates = useMemo(() => { + const q = query.trim().toLowerCase(); + return (unitsFlat ?? []) + .filter((u) => !attachedSet.has(String(u.unit_id))) + .filter((u) => !q || u.title?.toLowerCase().includes(q)); + }, [unitsFlat, attachedSet, query]); + + const toggle = (unitId) => + setSelected((prev) => + prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId] + ); + + const handleAttach = async () => { + if (!selected.length) return; + await onAttach(selected); + onOpenChange(false); + }; + + return ( + + + + + Attach Existing Units + + + Units live independently in the library — attaching adds them to this course without copying. + + + +
+ + setQuery(e.target.value)} + /> +
+ + + {libraryLoading ? ( +
+ +
+ ) : candidates.length === 0 ? ( +

+ {query ? "No units match your search." : "Every library unit is already attached."} +

+ ) : ( +
+ {candidates.map((u) => ( + + ))} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/modules/admin/components/library/LessonLibraryTable.jsx b/src/modules/admin/components/library/LessonLibraryTable.jsx new file mode 100644 index 0000000..290ca46 --- /dev/null +++ b/src/modules/admin/components/library/LessonLibraryTable.jsx @@ -0,0 +1,140 @@ +import { useMemo, useRef, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; + +import { buildDataColumns, columnPinning } from "../../config/library/lessons/columns.config"; +import { buildToolbarActions } from "../../config/library/lessons/toolbar.config"; +import { buildSelectionActions } from "../../config/library/lessons/selection.config"; +import { buildRowActions } from "../../config/library/lessons/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function LessonLibraryTable() { + const [archiveTarget, setArchiveTarget] = useState(null); + const [archiveIds, setArchiveIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => { }, + setFilters: () => { }, + }); + + const navigate = useNavigate(); + + const { + lessons, attributes, pagination, setPagination, loading, + fetchLessons, archiveLesson, archiveLessons, + fetchLessonFieldValues, + } = useLibrary(); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: lessons, + attributes, + filename: `${getTimestamp()}_LessonLibrary`, + sheetName: "Lessons", + }; + + const handleFetch = useCallback((params) => fetchLessons(params), [fetchLessons]); + + const rowActions = useMemo(() => buildRowActions({ + onView: (row) => navigate(`/admin/lessons/${row.lesson_id}/view`), + onEdit: (row) => navigate(`/admin/lessons/${row.lesson_id}/edit`), + onBuildPage: (row) => navigate(`/admin/lessons/${row.lesson_id}/page`), + onViewPage: (row) => navigate(`/admin/lessons/${row.lesson_id}/page/view`), + onArchive: (row) => setArchiveTarget(row), + }), [navigate]); + + const toolbarActions = buildToolbarActions({ + fetchLessons, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + onArchive: (row) => setArchiveTarget(row), + onArchiveMany: (ids) => setArchiveIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes, rowActions] + ); + + const handleArchiveSuccess = () => { + setArchiveTarget(null); + setArchiveIds(null); + tableRefsRef.current.resetSelection?.(); + fetchLessons({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + fetchLessonFieldValues(field)} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="lesson" + emptyMessage="No lessons in the library yet." + /> + + {/* ── Single archive ── */} + !v && setArchiveTarget(null)} + entity={archiveTarget} + entityLabel="Lesson" + getName={(l) => l?.title} + onArchive={(l) => archiveLesson(l?.lesson_id)} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + {/* ── Bulk archive ── */} + !v && setArchiveIds(null)} + ids={archiveIds ?? []} + entityLabel="Lesson" + onArchive={(ids) => archiveLessons(ids)} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + ); +} diff --git a/src/modules/admin/components/library/UnitLibraryTable.jsx b/src/modules/admin/components/library/UnitLibraryTable.jsx new file mode 100644 index 0000000..68cb0e7 --- /dev/null +++ b/src/modules/admin/components/library/UnitLibraryTable.jsx @@ -0,0 +1,150 @@ +import { useMemo, useRef, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; + +import { buildDataColumns, columnPinning } from "../../config/library/units/columns.config"; +import { buildToolbarActions } from "../../config/library/units/toolbar.config"; +import { buildSelectionActions } from "../../config/library/units/selection.config"; +import { buildRowActions } from "../../config/library/units/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function UnitLibraryTable() { + const [archiveTarget, setArchiveTarget] = useState(null); + const [archiveIds, setArchiveIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => { }, + setFilters: () => { }, + }); + + const navigate = useNavigate(); + + const { + units, attributes, pagination, setPagination, loading, + fetchUnits, archiveUnit, archiveUnits, + fetchUnitFieldValues, fetchUnitArchiveImpact, + } = useLibrary(); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: units, + attributes, + filename: `${getTimestamp()}_UnitLibrary`, + sheetName: "Units", + }; + + const handleFetch = useCallback((params) => fetchUnits(params), [fetchUnits]); + + const rowActions = useMemo(() => buildRowActions({ + onView: (row) => navigate(`/admin/units/${row.unit_id}/view`), + onEdit: (row) => navigate(`/admin/units/${row.unit_id}/edit`), + onManageLessons: (row) => navigate(`/admin/units/${row.unit_id}/view`), + onQuiz: (row) => navigate(`/admin/units/${row.unit_id}/quiz/edit`), + onViewQuiz: (row) => navigate(`/admin/units/${row.unit_id}/quiz/view`), + onArchive: (row) => setArchiveTarget(row), + }), [navigate]); + + const toolbarActions = buildToolbarActions({ + fetchUnits, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + onArchive: (row) => setArchiveTarget(row), + onArchiveMany: (ids) => setArchiveIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes, rowActions] + ); + + const handleArchiveSuccess = () => { + setArchiveTarget(null); + setArchiveIds(null); + tableRefsRef.current.resetSelection?.(); + fetchUnits({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + fetchUnitFieldValues(field)} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="unit" + emptyMessage="No units in the library yet." + /> + + {/* ── Single archive ── */} + !v && setArchiveTarget(null)} + entity={archiveTarget} + entityLabel="Unit" + getName={(u) => u?.title} + onArchive={(u) => archiveUnit(u?.unit_id)} + loading={loading} + onSuccess={handleArchiveSuccess} + onImpactCheck={async () => { + const impact = await fetchUnitArchiveImpact(archiveTarget?.unit_id); + const { completionCount, progressCount, courseCount } = impact?.data ?? impact ?? {}; + return [ + { label: "student(s) have completed this unit", count: completionCount ?? 0 }, + { label: "student(s) have reading progress in this unit", count: progressCount ?? 0 }, + { label: "course(s) currently include this unit", count: courseCount ?? 0 }, + ]; + }} + /> + + {/* ── Bulk archive ── */} + !v && setArchiveIds(null)} + ids={archiveIds ?? []} + entityLabel="Unit" + onArchive={(ids) => archiveUnits(ids)} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + ); +} diff --git a/src/modules/admin/config/library/lessons/columns.config.jsx b/src/modules/admin/config/library/lessons/columns.config.jsx new file mode 100644 index 0000000..34d97dc --- /dev/null +++ b/src/modules/admin/config/library/lessons/columns.config.jsx @@ -0,0 +1,48 @@ +// config/library/lessons/columns.config.jsx +// Column definitions and pinning for the standalone Lesson Library table. + +import { Badge } from "@/components/ui/badge"; +import { Clock } from "lucide-react"; +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; +import { formatDuration } from "@/utils/timestamp.util"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +const cellOverrides = { + duration_seconds: (info) => { + const seconds = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {formatDuration(seconds)} + +
+ ); + }, + unit_count: (info) => { + const n = parseInt(info.getValue() ?? 0, 10); + return n > 0 ? ( + + in {n} unit{n === 1 ? "" : "s"} + + ) : ( + standalone + ); + }, +}; + +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Lesson Actions" }), + ]; +} diff --git a/src/modules/admin/config/library/lessons/rowActions.config.jsx b/src/modules/admin/config/library/lessons/rowActions.config.jsx new file mode 100644 index 0000000..8bcb376 --- /dev/null +++ b/src/modules/admin/config/library/lessons/rowActions.config.jsx @@ -0,0 +1,66 @@ +// config/library/lessons/rowActions.config.jsx +// Row actions for the Lesson Library (active + archived variants). + +import { + Eye, Archive, Pencil, FileText, LayoutTemplate, + ArchiveRestore, Trash2, +} from "lucide-react"; + +export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArchive }) { + return [ + { + key: "view", + label: "View Info", + icon: , + onClick: (row) => onView(row), + }, + { + key: "edit", + label: "Edit Lesson", + icon: , + onClick: (row) => onEdit(row), + }, + { + key: "build_page", + label: "Page Builder", + icon: , + className: "text-sky-700 hover:text-sky-600", + onClick: (row) => onBuildPage(row), + separator: true, + }, + { + key: "view_page", + label: "View Page", + icon: , + className: "text-sky-700 hover:text-sky-600", + onClick: (row) => onViewPage(row), + }, + { + key: "archive", + label: "Archive", + icon: , + className: "text-destructive", + onClick: (row) => onArchive(row), + separator: true, + }, + ]; +} + +export function buildArchivedRowActions({ onRestore, onDelete }) { + return [ + { + key: "restore", + label: "Restore", + icon: , + onClick: (row) => onRestore(row), + }, + { + key: "delete", + label: "Delete Permanently", + icon: , + className: "text-destructive", + onClick: (row) => onDelete(row), + separator: true, + }, + ]; +} diff --git a/src/modules/admin/config/library/lessons/selection.config.jsx b/src/modules/admin/config/library/lessons/selection.config.jsx new file mode 100644 index 0000000..efea486 --- /dev/null +++ b/src/modules/admin/config/library/lessons/selection.config.jsx @@ -0,0 +1,66 @@ +// config/library/lessons/selection.config.jsx +// Bulk selection actions for the Lesson Library (active + archived variants). + +import { Download, Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "archive-selected", + label: "Archive", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.lesson_id); + ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids); + }, + }, + ]; +} + +export function buildArchivedSelectionActions({ exportConfig, onRestore, onRestoreMany, onDelete, onDeleteMany, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + onClick: (rows) => { + const ids = rows.map((r) => r.lesson_id); + ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids); + }, + }, + { + key: "delete-selected", + label: "Delete Permanently", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.lesson_id); + ids.length === 1 ? onDelete(rows[0]) : onDeleteMany(ids); + }, + }, + ]; +} diff --git a/src/modules/admin/config/library/lessons/toolbar.config.jsx b/src/modules/admin/config/library/lessons/toolbar.config.jsx new file mode 100644 index 0000000..b07d641 --- /dev/null +++ b/src/modules/admin/config/library/lessons/toolbar.config.jsx @@ -0,0 +1,105 @@ +// config/library/lessons/toolbar.config.jsx +// Toolbar actions for the Lesson Library (active + archived variants). + +import { Plus, RefreshCw, Download, Archive, ArrowLeft } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildToolbarActions({ + fetchLessons, + pagination, + exportConfig, + navigate, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "refresh", + type: "button", + label: "Refresh", + icon: , + variant: "outline", + onClick: () => fetchLessons({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + { + key: "create", + type: "button", + label: "New Lesson", + icon: , + variant: "default", + onClick: () => navigate("/admin/lessons/add"), + }, + { + key: "archived-lessons", + type: "button", + icon: , + label: "Archived Lessons", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/lessons/archived"), + }, + ]; +} + +export function buildArchivedToolbarActions({ + fetchArchivedLessons, + pagination, + exportConfig, + navigate, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "back", + type: "button", + label: "Back to Lessons", + icon: , + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/lessons"), + }, + { + key: "refresh", + type: "button", + label: "Refresh", + icon: , + variant: "outline", + onClick: () => fetchArchivedLessons({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + ]; +} diff --git a/src/modules/admin/config/library/units/columns.config.jsx b/src/modules/admin/config/library/units/columns.config.jsx new file mode 100644 index 0000000..6a70c85 --- /dev/null +++ b/src/modules/admin/config/library/units/columns.config.jsx @@ -0,0 +1,53 @@ +// config/library/units/columns.config.jsx +// Column definitions and pinning for the standalone Unit Library table. + +import { Badge } from "@/components/ui/badge"; +import { Clock } from "lucide-react"; +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; +import { formatDuration } from "@/utils/timestamp.util"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +const cellOverrides = { + duration_seconds: (info) => { + const seconds = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {formatDuration(seconds)} + +
+ ); + }, + lesson_count: (info) => ( + + {info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"} + + ), + course_count: (info) => { + const n = parseInt(info.getValue() ?? 0, 10); + return n > 0 ? ( + + in {n} course{n === 1 ? "" : "s"} + + ) : ( + standalone + ); + }, +}; + +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Unit Actions" }), + ]; +} diff --git a/src/modules/admin/config/library/units/rowActions.config.jsx b/src/modules/admin/config/library/units/rowActions.config.jsx new file mode 100644 index 0000000..0e3ead1 --- /dev/null +++ b/src/modules/admin/config/library/units/rowActions.config.jsx @@ -0,0 +1,85 @@ +// config/library/units/rowActions.config.jsx +// Row actions for the Unit Library (active + archived variants). + +import { + Eye, Archive, BookCheck, NotebookPen, ClipboardList, PlusCircle, + Pencil, ArchiveRestore, Trash2, +} from "lucide-react"; + +export function buildRowActions({ onView, onEdit, onManageLessons, onArchive, onQuiz, onViewQuiz }) { + return [ + { + key: "view", + label: "View Info", + icon: , + onClick: (row) => onView(row), + }, + { + key: "edit", + label: "Edit Unit", + icon: , + onClick: (row) => onEdit(row), + }, + { + key: "manage_lessons", + label: "Manage Lessons", + icon: , + className: "text-sky-700 hover:text-sky-600", + onClick: (row) => onManageLessons(row), + separator: true, + }, + { + key: "create_quiz", + label: "Create Quiz", + icon: , + className: "text-purple-700 hover:text-purple-600", + onClick: (row) => onQuiz(row), + hidden: (row) => !!(row.quiz_id || row.quiz), + separator: true, + }, + { + key: "view_quiz", + label: "View Quiz", + icon: , + className: "text-purple-700 hover:text-purple-600", + onClick: (row) => onViewQuiz(row), + hidden: (row) => !(row.quiz_id || row.quiz), + separator: true, + }, + { + key: "modify_quiz", + label: "Modify Quiz", + icon: , + className: "text-purple-700 hover:text-purple-600", + onClick: (row) => onQuiz(row), + hidden: (row) => !(row.quiz_id || row.quiz), + }, + { + key: "archive", + label: "Archive", + icon: , + className: "text-destructive", + onClick: (row) => onArchive(row), + separator: true, + }, + ]; +} + +export function buildArchivedRowActions({ onRestore, onDelete }) { + return [ + { + key: "restore", + label: "Restore", + icon: , + onClick: (row) => onRestore(row), + }, + { + key: "delete", + label: "Delete Permanently", + icon: , + className: "text-destructive", + onClick: (row) => onDelete(row), + separator: true, + }, + ]; +} diff --git a/src/modules/admin/config/library/units/selection.config.jsx b/src/modules/admin/config/library/units/selection.config.jsx new file mode 100644 index 0000000..e7e1100 --- /dev/null +++ b/src/modules/admin/config/library/units/selection.config.jsx @@ -0,0 +1,66 @@ +// config/library/units/selection.config.jsx +// Bulk selection actions for the Unit Library (active + archived variants). + +import { Download, Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "archive-selected", + label: "Archive", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.unit_id); + ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids); + }, + }, + ]; +} + +export function buildArchivedSelectionActions({ exportConfig, onRestore, onRestoreMany, onDelete, onDeleteMany, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + onClick: (rows) => { + const ids = rows.map((r) => r.unit_id); + ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids); + }, + }, + { + key: "delete-selected", + label: "Delete Permanently", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.unit_id); + ids.length === 1 ? onDelete(rows[0]) : onDeleteMany(ids); + }, + }, + ]; +} diff --git a/src/modules/admin/config/library/units/toolbar.config.jsx b/src/modules/admin/config/library/units/toolbar.config.jsx new file mode 100644 index 0000000..ce4b414 --- /dev/null +++ b/src/modules/admin/config/library/units/toolbar.config.jsx @@ -0,0 +1,105 @@ +// config/library/units/toolbar.config.jsx +// Toolbar actions for the Unit Library (active + archived variants). + +import { Plus, RefreshCw, Download, Archive, ArrowLeft } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildToolbarActions({ + fetchUnits, + pagination, + exportConfig, + navigate, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "refresh", + type: "button", + label: "Refresh", + icon: , + variant: "outline", + onClick: () => fetchUnits({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + { + key: "create", + type: "button", + label: "New Unit", + icon: , + variant: "default", + onClick: () => navigate("/admin/units/add"), + }, + { + key: "archived-units", + type: "button", + icon: , + label: "Archived Units", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/units/archived"), + }, + ]; +} + +export function buildArchivedToolbarActions({ + fetchArchivedUnits, + pagination, + exportConfig, + navigate, + getFilters, + getSort, + getTableInstance, +}) { + return [ + { + key: "back", + type: "button", + label: "Back to Units", + icon: , + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/units"), + }, + { + key: "refresh", + type: "button", + label: "Refresh", + icon: , + variant: "outline", + onClick: () => fetchArchivedUnits({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + ]; +} diff --git a/src/modules/admin/pages/courses/CourseList.jsx b/src/modules/admin/pages/courses/CourseList.jsx index 3b7965b..cad1c84 100644 --- a/src/modules/admin/pages/courses/CourseList.jsx +++ b/src/modules/admin/pages/courses/CourseList.jsx @@ -26,6 +26,15 @@ export default function CourseList() { Manage Tier Plans + {" "}· Units and Lessons now run independently — build them once in the{" "} + + Units Library + + {" "}and{" "} + + Lessons Library + + , then attach them to any course.

diff --git a/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx b/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx index 78c1ecc..cc5b1a2 100644 --- a/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx +++ b/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx @@ -11,6 +11,11 @@ import { PreviewContent, PreviewChrome } from "../../../components/courses/Lesso export default function ViewLessonPage() { const navigate = useNavigate(); const { courseId, unitId, lessonId } = useParams(); + + // Junction revamp — runs course-scoped AND from the standalone Lesson Library. + const builderPath = unitId + ? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page` + : `/admin/lessons/${lessonId}/page`; const { fetchLesson, lesson, lessonPage } = useCourses(); const [initializing, setInitializing] = useState(true); @@ -43,7 +48,7 @@ export default function ViewLessonPage() { @@ -73,7 +78,7 @@ export default function ViewLessonPage() { type="button" variant="outline" size="sm" - onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)} + onClick={() => navigate(builderPath)} > Go to Page Builder diff --git a/src/modules/admin/pages/courses/units/ModifyQuiz.jsx b/src/modules/admin/pages/courses/units/ModifyQuiz.jsx index a8c5b16..1ab411d 100644 --- a/src/modules/admin/pages/courses/units/ModifyQuiz.jsx +++ b/src/modules/admin/pages/courses/units/ModifyQuiz.jsx @@ -223,19 +223,32 @@ export default function ModifyQuiz() { const headerRef = useRef(null); const initialSnapshot = useRef(null); - const breadcrumbItems = [ - { label: "Home", icon: , to: "/admin" }, - { label: "Courses", to: "/admin/courses" }, - { label: course?.title ?? "…", to: `/admin/courses/${courseId}` }, - { label: unit?.title ?? "…", to: `/admin/courses/${courseId}/units/${unitId}` }, - { label: "Quiz" }, - ]; + // Junction revamp — this builder runs course-scoped AND from the standalone + // Unit Library (/admin/units/:unitId/quiz/edit, no :courseId param). + const scopeBase = courseId + ? `/admin/courses/${courseId}/units/${unitId}` + : `/admin/units/${unitId}`; + + const breadcrumbItems = courseId + ? [ + { label: "Home", icon: , to: "/admin" }, + { label: "Courses", to: "/admin/courses" }, + { label: course?.title ?? "…", to: `/admin/courses/${courseId}` }, + { label: unit?.title ?? "…", to: `/admin/courses/${courseId}/units/${unitId}` }, + { label: "Quiz" }, + ] + : [ + { label: "Home", icon: , to: "/admin" }, + { label: "Units Library", to: "/admin/units" }, + { label: unit?.title ?? "…", to: `/admin/units/${unitId}/view` }, + { label: "Quiz" }, + ]; // ── Fetch — silently treat 404 as "no quiz yet" (create mode) ───────────── useEffect(() => { (async () => { try { - const { data } = await api.get(`/admin/courses/${courseId}/units/${unitId}/quiz`); + const { data } = await api.get(`${scopeBase}/quiz`); const result = data?.data?.data ?? null; setLocalQuiz(result); } catch (err) { diff --git a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx index fc0a2e0..de57f3c 100644 --- a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx +++ b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx @@ -245,6 +245,11 @@ export default function ViewUnitQuiz() { const navigate = useNavigate(); const { courseId, unitId } = useParams(); + // Junction revamp — runs course-scoped AND from the standalone Unit Library. + const scopeBase = courseId + ? `/admin/courses/${courseId}/units/${unitId}` + : `/admin/units/${unitId}`; + const { fetchQuiz, quiz, fetchQuizCompletions, completions, @@ -293,7 +298,7 @@ export default function ViewUnitQuiz() { diff --git a/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx new file mode 100644 index 0000000..7936720 --- /dev/null +++ b/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx @@ -0,0 +1,100 @@ +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowLeft } from "lucide-react"; + +import { useLibrary } from "@/contexts/AdminLibraryContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { PageMeta } from "@/contexts/MetadataContext"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Spinner } from "@/components/ui/spinner"; + +const schema = z.object({ + title: z.string().min(1, "Title is required."), + description: z.string().optional(), +}); + +function FieldError({ message }) { + if (!message) return null; + return

{message}

; +} + +export default function AddLibraryLesson() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { createLesson, loading } = useLibrary(); + const { user } = useAuth(); + + // ?unit_id=… → create-and-attach in one call (from the unit lessons manager) + const attachUnitId = searchParams.get("unit_id"); + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "", description: "" }, + }); + + const onSubmit = async (data) => { + const result = await createLesson({ + ...data, + ...(attachUnitId ? { unit_id: attachUnitId } : {}), + createdBy: user?.user_id, + }); + if (!result) return; + navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons"); + }; + + return ( +
+ +
+ +
+
+ +
+

Create Lesson

+

+ {attachUnitId + ? "This lesson will be created and attached to the unit you came from." + : "Lessons are standalone — attach this one to any unit later, or run it on its own."} +

+
+
+ +
+
+ +
+ + + +
+ +
+ +