mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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 <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>;
|
||||
}
|
||||
@@ -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 (
|
||||
<ClientLibraryContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientLibraryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientLibraryContext;
|
||||
@@ -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 }) => {
|
||||
<AdminTiersProvider>
|
||||
<AdminCategoriesProvider>
|
||||
<CoursesProvider>
|
||||
<AdminCourseReadingProgressProvider>
|
||||
<AdminTaskProvider>
|
||||
{children}
|
||||
</AdminTaskProvider>
|
||||
</AdminCourseReadingProgressProvider>
|
||||
<LibraryProvider>
|
||||
<AdminCourseReadingProgressProvider>
|
||||
<AdminTaskProvider>
|
||||
{children}
|
||||
</AdminTaskProvider>
|
||||
</AdminCourseReadingProgressProvider>
|
||||
</LibraryProvider>
|
||||
</CoursesProvider>
|
||||
</AdminCategoriesProvider>
|
||||
</AdminTiersProvider>
|
||||
|
||||
@@ -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 }) => {
|
||||
<ProfileProvider>
|
||||
<ClientAdvertisementsProvider>
|
||||
<ClientCoursesProvider>
|
||||
<CourseReadingProgressProvider>
|
||||
<ClientTiersProvider>
|
||||
<TaskProgressProvider>
|
||||
<TaskProvider>
|
||||
<GroupProvider>
|
||||
{children}
|
||||
</GroupProvider>
|
||||
</TaskProvider>
|
||||
</TaskProgressProvider>
|
||||
</ClientTiersProvider>
|
||||
</CourseReadingProgressProvider>
|
||||
<ClientLibraryProvider>
|
||||
<CourseReadingProgressProvider>
|
||||
<ClientTiersProvider>
|
||||
<TaskProgressProvider>
|
||||
<TaskProvider>
|
||||
<GroupProvider>
|
||||
{children}
|
||||
</GroupProvider>
|
||||
</TaskProvider>
|
||||
</TaskProgressProvider>
|
||||
</ClientTiersProvider>
|
||||
</CourseReadingProgressProvider>
|
||||
</ClientLibraryProvider>
|
||||
</ClientCoursesProvider>
|
||||
</ClientAdvertisementsProvider>
|
||||
</ProfileProvider>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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"] },
|
||||
]
|
||||
|
||||
|
||||
@@ -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: <Link2 className="h-3.5 w-3.5" />,
|
||||
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 ── */}
|
||||
<AttachLessonsDialog
|
||||
open={attachOpen}
|
||||
onOpenChange={setAttachOpen}
|
||||
attachedLessonIds={lessons.map((l) => l.lesson_id)}
|
||||
onAttach={handleAttachLessons}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: <Link2 className="h-3.5 w-3.5" />,
|
||||
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 ── */}
|
||||
<AttachUnitsDialog
|
||||
open={attachOpen}
|
||||
onOpenChange={setAttachOpen}
|
||||
attachedUnitIds={units.map((u) => u.unit_id)}
|
||||
onAttach={handleAttachUnits}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<DataTable
|
||||
title="Archived Lessons"
|
||||
data={lessons}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={(field) => fetchLessonFieldValues(field)}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="lesson"
|
||||
emptyMessage="No archived lessons."
|
||||
/>
|
||||
|
||||
{/* ── Restore ── */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Lesson"
|
||||
getName={(l) => l?.title}
|
||||
onRestore={(l) => restoreLesson(l?.lesson_id)}
|
||||
loading={loading}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Lesson"
|
||||
onRestore={(ids) => restoreLessons(ids)}
|
||||
loading={loading}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
|
||||
{/* ── Permanent delete ── */}
|
||||
<PermanentDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(v) => !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 },
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<PermanentDeleteDialog
|
||||
open={!!deleteIds}
|
||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||
ids={deleteIds ?? []}
|
||||
entityLabel="Lesson"
|
||||
onDelete={(ids) => permanentlyDeleteLessons(ids)}
|
||||
loading={loading}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DataTable
|
||||
title="Archived Units"
|
||||
data={units}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={(field) => fetchUnitFieldValues(field)}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="unit"
|
||||
emptyMessage="No archived units."
|
||||
/>
|
||||
|
||||
{/* ── Restore ── */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Unit"
|
||||
getName={(u) => u?.title}
|
||||
onRestore={(u) => restoreUnit(u?.unit_id)}
|
||||
loading={loading}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Unit"
|
||||
onRestore={(ids) => restoreUnits(ids)}
|
||||
loading={loading}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
|
||||
{/* ── Permanent delete ── */}
|
||||
<PermanentDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(v) => !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 },
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<PermanentDeleteDialog
|
||||
open={!!deleteIds}
|
||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||
ids={deleteIds ?? []}
|
||||
entityLabel="Unit"
|
||||
onDelete={(ids) => permanentlyDeleteUnits(ids)}
|
||||
loading={loading}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" /> Attach Existing Lessons
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Lessons live independently in the library — attaching adds them to this unit without copying.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search lessons..."
|
||||
className="pl-8"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-64 rounded-md border">
|
||||
{libraryLoading ? (
|
||||
<div className="flex items-center justify-center h-full py-10">
|
||||
<Spinner className="h-5 w-5" />
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
{query ? "No lessons match your search." : "Every library lesson is already attached."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{candidates.map((l) => (
|
||||
<label
|
||||
key={l.lesson_id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(l.lesson_id)}
|
||||
onCheckedChange={() => toggle(l.lesson_id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{l.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{formatDuration(l.duration_seconds ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
{Number(l.unit_count) > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
in {l.unit_count} unit{Number(l.unit_count) === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAttach} disabled={loading || !selected.length}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Attach {selected.length > 0 ? `(${selected.length})` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" /> Attach Existing Units
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Units live independently in the library — attaching adds them to this course without copying.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search units..."
|
||||
className="pl-8"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-64 rounded-md border">
|
||||
{libraryLoading ? (
|
||||
<div className="flex items-center justify-center h-full py-10">
|
||||
<Spinner className="h-5 w-5" />
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
{query ? "No units match your search." : "Every library unit is already attached."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{candidates.map((u) => (
|
||||
<label
|
||||
key={u.unit_id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(u.unit_id)}
|
||||
onCheckedChange={() => toggle(u.unit_id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{u.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
{Number(u.course_count) > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
in {u.course_count} course{Number(u.course_count) === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAttach} disabled={loading || !selected.length}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Attach {selected.length > 0 ? `(${selected.length})` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DataTable
|
||||
title="Lesson Library"
|
||||
data={lessons}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={(field) => fetchLessonFieldValues(field)}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="lesson"
|
||||
emptyMessage="No lessons in the library yet."
|
||||
/>
|
||||
|
||||
{/* ── Single archive ── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Lesson"
|
||||
getName={(l) => l?.title}
|
||||
onArchive={(l) => archiveLesson(l?.lesson_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
|
||||
{/* ── Bulk archive ── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||
ids={archiveIds ?? []}
|
||||
entityLabel="Lesson"
|
||||
onArchive={(ids) => archiveLessons(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DataTable
|
||||
title="Unit Library"
|
||||
data={units}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={(field) => fetchUnitFieldValues(field)}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="unit"
|
||||
emptyMessage="No units in the library yet."
|
||||
/>
|
||||
|
||||
{/* ── Single archive ── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !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 ── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||
ids={archiveIds ?? []}
|
||||
entityLabel="Unit"
|
||||
onArchive={(ids) => archiveUnits(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{formatDuration(seconds)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
unit_count: (info) => {
|
||||
const n = parseInt(info.getValue() ?? 0, 10);
|
||||
return n > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
in {n} unit{n === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Lesson Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -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: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Lesson",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
{
|
||||
key: "build_page",
|
||||
label: "Page Builder",
|
||||
icon: <LayoutTemplate className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onBuildPage(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "view_page",
|
||||
label: "View Page",
|
||||
icon: <FileText className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onViewPage(row),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildArchivedRowActions({ onRestore, onDelete }) {
|
||||
return [
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete Permanently",
|
||||
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onDelete(row),
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "restore-selected",
|
||||
label: "Restore",
|
||||
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||
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: <Trash2 className="h-3.5 w-3.5" />,
|
||||
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);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchLessons({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "create",
|
||||
type: "button",
|
||||
label: "New Lesson",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/lessons/add"),
|
||||
},
|
||||
{
|
||||
key: "archived-lessons",
|
||||
type: "button",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
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: <ArrowLeft className="h-3.5 w-3.5" />,
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: () => navigate("/admin/lessons"),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
label: "Refresh",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchArchivedLessons({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{formatDuration(seconds)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
lesson_count: (info) => (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
),
|
||||
course_count: (info) => {
|
||||
const n = parseInt(info.getValue() ?? 0, 10);
|
||||
return n > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
in {n} course{n === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Unit Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -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: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Unit",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
{
|
||||
key: "manage_lessons",
|
||||
label: "Manage Lessons",
|
||||
icon: <BookCheck className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onManageLessons(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "create_quiz",
|
||||
label: "Create Quiz",
|
||||
icon: <PlusCircle className="h-3.5 w-3.5" />,
|
||||
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: <ClipboardList className="h-3.5 w-3.5" />,
|
||||
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: <NotebookPen className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onQuiz(row),
|
||||
hidden: (row) => !(row.quiz_id || row.quiz),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildArchivedRowActions({ onRestore, onDelete }) {
|
||||
return [
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete Permanently",
|
||||
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onDelete(row),
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
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: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "restore-selected",
|
||||
label: "Restore",
|
||||
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||
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: <Trash2 className="h-3.5 w-3.5" />,
|
||||
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);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchUnits({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "create",
|
||||
type: "button",
|
||||
label: "New Unit",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/units/add"),
|
||||
},
|
||||
{
|
||||
key: "archived-units",
|
||||
type: "button",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
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: <ArrowLeft className="h-3.5 w-3.5" />,
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: () => navigate("/admin/units"),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
label: "Refresh",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchArchivedUnits({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -26,6 +26,15 @@ export default function CourseList() {
|
||||
<Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium">
|
||||
Manage Tier Plans
|
||||
</Link>
|
||||
{" "}· Units and Lessons now run independently — build them once in the{" "}
|
||||
<Link to="/admin/units" className="text-primary hover:underline font-medium">
|
||||
Units Library
|
||||
</Link>
|
||||
{" "}and{" "}
|
||||
<Link to="/admin/lessons" className="text-primary hover:underline font-medium">
|
||||
Lessons Library
|
||||
</Link>
|
||||
, then attach them to any course.
|
||||
</p>
|
||||
</div>
|
||||
<CoursesTable />
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
|
||||
onClick={() => navigate(builderPath)}
|
||||
>
|
||||
Edit Page
|
||||
</Button>
|
||||
@@ -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
|
||||
</Button>
|
||||
|
||||
@@ -223,19 +223,32 @@ export default function ModifyQuiz() {
|
||||
const headerRef = useRef(null);
|
||||
const initialSnapshot = useRef(null);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, 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: <House className="size-4" />, 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: <House className="size-4" />, 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) {
|
||||
|
||||
@@ -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() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
|
||||
onClick={() => navigate(`${scopeBase}/quiz/edit`)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Modify Quiz
|
||||
@@ -330,7 +335,7 @@ export default function ViewUnitQuiz() {
|
||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`${scopeBase}/quiz/edit`)}>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Create Quiz
|
||||
</Button>
|
||||
|
||||
@@ -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 <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Add Lesson - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Create Lesson</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import ArchivedLessonLibraryTable from "../../../components/library/ArchivedLessonLibraryTable";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function ArchivedLessonLibraryList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Lessons", to: "/admin/lessons" },
|
||||
{ label: "Archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Archived Lessons - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ArchivedLessonLibraryTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } 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 <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function EditLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchLesson(lessonId);
|
||||
}, [lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson && String(lesson.lesson_id) === String(lessonId)) {
|
||||
reset({ title: lesson.title ?? "", description: lesson.description ?? "" });
|
||||
}
|
||||
}, [lesson, lessonId, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(`/admin/lessons/${lessonId}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title={lesson ? `Edit Lesson – ${lesson.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Edit Lesson</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changes apply everywhere this lesson is attached.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { House, Layers } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import LessonLibraryTable from "../../../components/library/LessonLibraryTable";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function LessonLibraryList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Lessons" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Lessons Library - STARR" description="Manage standalone lessons — attach them to any unit." />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
|
||||
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
Lessons run <span className="font-medium text-foreground">independently</span> — build content here once,
|
||||
then attach it to any number of{" "}
|
||||
<Link to="/admin/units" className="text-primary hover:underline font-medium">
|
||||
Units
|
||||
</Link>
|
||||
. Removing a lesson from a unit only detaches it; the lesson stays in this library.
|
||||
</p>
|
||||
</div>
|
||||
<LessonLibraryTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import {
|
||||
House, Pencil, LayoutTemplate, FileText, Clock, BookCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
|
||||
export default function ViewLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { lessonId } = useParams();
|
||||
const { fetchLesson, lesson, loading } = useLibrary();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [lessonId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Lessons", to: "/admin/lessons" },
|
||||
{ label: lesson?.title ?? "..." },
|
||||
];
|
||||
|
||||
const units = lesson?.units ?? [];
|
||||
const blocks = lesson?.page?.blocks ?? [];
|
||||
const objectives = lesson?.objectives ?? [];
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title={lesson ? `${lesson.title} - Lessons - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-6 pb-10">
|
||||
|
||||
{/* ── Lesson header ── */}
|
||||
<div className="bg-card rounded-xl border p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h6 className="text-xs tracking-widest mb-1">STANDALONE LESSON</h6>
|
||||
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
|
||||
{lesson?.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/page/view`)}>
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" /> View Page
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate(`/admin/lessons/${lessonId}/page`)}>
|
||||
<LayoutTemplate className="h-3.5 w-3.5 mr-1.5" /> Page Builder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Blocks</p>
|
||||
<p className="font-semibold text-sm">{blocks.length}</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Duration</p>
|
||||
<p className="font-semibold text-sm flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{formatDuration(lesson?.duration_seconds ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Objectives</p>
|
||||
<p className="font-semibold text-sm">{objectives.length}</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Used In</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{units.length > 0 ? `${units.length} unit${units.length === 1 ? "" : "s"}` : "Standalone"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attached units */}
|
||||
{units.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Units:</span>
|
||||
{units.map((u) => (
|
||||
<Link key={u.unit_id} to={`/admin/units/${u.unit_id}/view`}>
|
||||
<Badge variant="secondary" className="hover:bg-muted cursor-pointer">
|
||||
<BookCheck className="h-3 w-3 mr-1" /> {u.title}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
{objectives.length > 0 && (
|
||||
<div className="bg-card rounded-xl border p-6 space-y-3">
|
||||
<h2 className="font-semibold">Objectives</h2>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
{[...objectives]
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map((o) => <li key={o.objective_id}>{o.text}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useNavigate } 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 <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function AddLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { createUnit, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createUnit({ ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate("/admin/units");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Add Unit - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Create Unit</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Units are standalone — attach this one to any course later, or run it on its own.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Unit title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Unit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import ArchivedUnitLibraryTable from "../../../components/library/ArchivedUnitLibraryTable";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function ArchivedUnitLibraryList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Units", to: "/admin/units" },
|
||||
{ label: "Archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Archived Units - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ArchivedUnitLibraryTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } 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 <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function EditLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnit(unitId);
|
||||
}, [unitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (unit && String(unit.unit_id) === String(unitId)) {
|
||||
reset({ title: unit.title ?? "", description: unit.description ?? "" });
|
||||
}
|
||||
}, [unit, unitId, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await updateUnit(unitId, { ...data, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(`/admin/units/${unitId}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title={unit ? `Edit Unit – ${unit.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Edit Unit</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changes apply everywhere this unit is attached.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Unit title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { House, Layers } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import UnitLibraryTable from "../../../components/library/UnitLibraryTable";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function UnitLibraryList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Units" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Units Library - STARR" description="Manage standalone units — attach them to any course." />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
|
||||
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
Units run <span className="font-medium text-foreground">independently</span> — build them here once,
|
||||
then attach them to any number of{" "}
|
||||
<Link to="/admin/courses" className="text-primary hover:underline font-medium">
|
||||
Courses
|
||||
</Link>
|
||||
. Removing a unit from a course only detaches it; the unit stays in this library.
|
||||
</p>
|
||||
</div>
|
||||
<UnitLibraryTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, House, Plus, Link2, Unlink, Pencil,
|
||||
ChevronUp, ChevronDown, Clock, ClipboardList, PlusCircle,
|
||||
LayoutTemplate, BookOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import AttachLessonsDialog from "../../../components/library/AttachLessonsDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
|
||||
export default function ViewLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { unitId } = useParams();
|
||||
const {
|
||||
fetchUnit, unit, loading,
|
||||
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
|
||||
} = useLibrary();
|
||||
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchUnit(unitId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [unitId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Units", to: "/admin/units" },
|
||||
{ label: unit?.title ?? "..." },
|
||||
];
|
||||
|
||||
const lessons = unit?.lessons ?? [];
|
||||
const courses = unit?.courses ?? [];
|
||||
|
||||
const moveLesson = async (index, direction) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= lessons.length) return;
|
||||
const reordered = [...lessons];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
await reorderUnitLessons(unitId, reordered.map((l) => l.lesson_id));
|
||||
fetchUnit(unitId);
|
||||
};
|
||||
|
||||
const handleDetach = async (lessonId) => {
|
||||
await detachLessonFromUnit(unitId, lessonId);
|
||||
fetchUnit(unitId);
|
||||
};
|
||||
|
||||
const handleAttach = async (lessonIds) => {
|
||||
await attachLessonsToUnit(unitId, lessonIds);
|
||||
fetchUnit(unitId);
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title={unit ? `${unit.title} - Units - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-6 pb-10">
|
||||
|
||||
{/* ── Unit header ── */}
|
||||
<div className="bg-card rounded-xl border p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h6 className="text-xs tracking-widest mb-1">STANDALONE UNIT</h6>
|
||||
<h1 className="text-xl font-semibold">{unit?.title}</h1>
|
||||
{unit?.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{unit.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/edit`)}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||
</Button>
|
||||
{unit?.quiz ? (
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/quiz/view`)}>
|
||||
<ClipboardList className="h-3.5 w-3.5 mr-1.5" /> View Quiz
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/quiz/edit`)}>
|
||||
<PlusCircle className="h-3.5 w-3.5 mr-1.5" /> Create Quiz
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Lessons</p>
|
||||
<p className="font-semibold text-sm">{lessons.length}</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Duration</p>
|
||||
<p className="font-semibold text-sm">{formatDuration(unit?.duration_seconds ?? 0)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Quiz</p>
|
||||
<p className="font-semibold text-sm">{unit?.quiz ? unit.quiz.title || "Unit Quiz" : "None"}</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Used In</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{courses.length > 0 ? `${courses.length} course${courses.length === 1 ? "" : "s"}` : "Standalone"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attached courses */}
|
||||
{courses.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Courses:</span>
|
||||
{courses.map((c) => (
|
||||
<Link key={c.course_id} to={`/admin/courses/${c.course_id}/view`}>
|
||||
<Badge variant="secondary" className="hover:bg-muted cursor-pointer">
|
||||
<BookOpen className="h-3 w-3 mr-1" /> {c.title}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Lessons manager ── */}
|
||||
<div className="bg-card rounded-xl border">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h2 className="font-semibold">Lessons</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lessons are standalone too — attach existing ones or create new. Order here is this unit's order.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setAttachOpen(true)}>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach Existing
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate(`/admin/lessons/add?unit_id=${unitId}`)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lessons.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
No lessons attached yet — attach one from the library or create a new one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{lessons.map((l, i) => (
|
||||
<div key={l.lesson_id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="text-xs font-mono text-muted-foreground w-6 text-right shrink-0">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
disabled={i === 0 || loading}
|
||||
onClick={() => moveLesson(i, -1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
disabled={i === lessons.length - 1 || loading}
|
||||
onClick={() => moveLesson(i, 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{l.title}</p>
|
||||
{l.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{l.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs shrink-0 tabular-nums">
|
||||
<Clock className="h-3 w-3 mr-1" /> {formatDuration(l.duration_seconds ?? 0)}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={() => navigate(`/admin/lessons/${l.lesson_id}/page`)}
|
||||
title="Page Builder"
|
||||
>
|
||||
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDetach(l.lesson_id)}
|
||||
disabled={loading}
|
||||
title="Detach from unit (lesson stays in library)"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachLessonsDialog
|
||||
open={attachOpen}
|
||||
onOpenChange={setAttachOpen}
|
||||
attachedLessonIds={lessons.map((l) => l.lesson_id)}
|
||||
onAttach={handleAttach}
|
||||
loading={loading}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,18 @@ import ViewAssessment from '../pages/courses/ViewAssessment'
|
||||
import ModifyQuiz from '../pages/courses/units/ModifyQuiz'
|
||||
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
||||
|
||||
// Standalone Libraries (junction revamp — Units/Lessons run independently)
|
||||
import UnitLibraryList from '../pages/library/units/UnitLibraryList'
|
||||
import AddLibraryUnit from '../pages/library/units/AddLibraryUnit'
|
||||
import EditLibraryUnit from '../pages/library/units/EditLibraryUnit'
|
||||
import ViewLibraryUnit from '../pages/library/units/ViewLibraryUnit'
|
||||
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
|
||||
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
|
||||
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
|
||||
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
|
||||
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
|
||||
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
|
||||
|
||||
// Task List
|
||||
|
||||
import TaskList from '../pages/task_list/TaskList'
|
||||
@@ -231,6 +243,38 @@ export const AdminRoutes = {
|
||||
},
|
||||
|
||||
|
||||
// Units Library — standalone Units (junction revamp)
|
||||
{
|
||||
path: 'units',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <UnitLibraryList /> },
|
||||
{ path: 'add', element: <AddLibraryUnit /> },
|
||||
{ path: 'archived', element: <ArchivedUnitLibraryList /> },
|
||||
{ path: ':unitId/view', element: <ViewLibraryUnit /> },
|
||||
{ path: ':unitId/edit', element: <EditLibraryUnit /> },
|
||||
// Quiz builder runs in library mode (no :courseId param)
|
||||
{ path: ':unitId/quiz/edit', element: <ModifyQuiz /> },
|
||||
{ path: ':unitId/quiz/view', element: <ViewUnitQuiz /> },
|
||||
]
|
||||
},
|
||||
|
||||
// Lessons Library — standalone Lessons (junction revamp)
|
||||
{
|
||||
path: 'lessons',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <LessonLibraryList /> },
|
||||
{ path: 'add', element: <AddLibraryLesson /> },
|
||||
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
|
||||
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
|
||||
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
|
||||
// Page builder runs in library mode (no :courseId/:unitId params)
|
||||
{ path: ':lessonId/page', element: <LessonPageBuilder /> },
|
||||
{ path: ':lessonId/page/view', element: <ViewLessonPage /> },
|
||||
]
|
||||
},
|
||||
|
||||
// Task
|
||||
{
|
||||
path: 'taskList',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// UnitCard — grid card for a standalone Unit. Shared by UnitsList.jsx and
|
||||
// Dashboard.jsx's "Featured Units" section.
|
||||
|
||||
import { Timer, LockIcon, Layers, BookOpen, ClipboardList } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDuration(seconds = 0) {
|
||||
if (!seconds) return null;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h && m) return `${h}h ${m}m`;
|
||||
if (h) return `${h}h`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export const UnitCard = ({ unit, onViewDetails }) => {
|
||||
const locked = unit.is_locked;
|
||||
const duration = formatDuration(unit.duration_seconds);
|
||||
const lessonCount = Number(unit.lesson_count ?? 0);
|
||||
const courseCount = Number(unit.course_count ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
|
||||
"hover:shadow-sm",
|
||||
locked
|
||||
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
|
||||
: "hover:bg-muted/60 dark:hover:border-blue-500"
|
||||
)}
|
||||
onClick={() => onViewDetails(unit)}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{locked ? (
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="size-3" /> Locked
|
||||
</Badge>
|
||||
) : courseCount > 0 ? (
|
||||
<Badge variant="outline"><BookOpen className="size-3" /> In {courseCount} course{courseCount === 1 ? "" : "s"}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
|
||||
)}
|
||||
{unit.quiz_id && (
|
||||
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
|
||||
{unit.title}
|
||||
</h1>
|
||||
{unit.description && (
|
||||
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
|
||||
{unit.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 mt-auto border-t">
|
||||
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Layers /> {lessonCount} {lessonCount === 1 ? "Lesson" : "Lessons"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Timer /> {duration ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
{locked && (
|
||||
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UnitCardSkeleton = () => (
|
||||
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="pt-2 mt-auto border-t">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
|
||||
// Units aren't independently purchasable (no Product row keyed to unit_id, only
|
||||
// to course_id), so unlike CourseCard's single "Buy $X" button, this lists every
|
||||
// course the unit is attached to so the learner can pick one to view/buy, plus a
|
||||
// generic "View Plans" fallback. Shared by UnitsList, UnitDetails, and Dashboard.
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LockIcon, BookOpen } from "lucide-react";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
|
||||
const navigate = useNavigate();
|
||||
const courses = unit?.courses ?? [];
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={unit?.title ?? "Unit Details"}
|
||||
description="This unit is part of one or more courses that require a plan upgrade."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
|
||||
<LockIcon /> View Plans
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 py-2">
|
||||
{courses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade your plan to access this content.
|
||||
</p>
|
||||
) : (
|
||||
courses.map((course) => {
|
||||
const { label, cls } = resolveTierBadge(course.subscription, tierMap);
|
||||
return (
|
||||
<div
|
||||
key={course.course_id}
|
||||
className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<BookOpen className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{course.title}</p>
|
||||
<Badge className={`${cls} mt-1`}>
|
||||
<LockIcon className="size-3" /> {label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}`); }}
|
||||
>
|
||||
View Course
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
);
|
||||
}
|
||||
@@ -171,7 +171,6 @@ const CoursesList = () => {
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [levelFilter, setLevelFilter] = useState("All");
|
||||
const [subFilter, setSubFilter] = useState("All");
|
||||
const [categoryFilter, setCategoryFilter] = useState("All");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -205,13 +204,12 @@ const CoursesList = () => {
|
||||
.filter((c) => {
|
||||
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
|
||||
const matchSub = subFilter === "All" || c.subscription === subFilter;
|
||||
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
|
||||
return matchSearch && matchLevel && matchSub && matchCategory;
|
||||
return matchSearch && matchSub && matchCategory;
|
||||
})
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
|
||||
[courses, search, levelFilter, subFilter, categoryFilter]
|
||||
[courses, search, subFilter, categoryFilter]
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
|
||||
@@ -251,15 +249,13 @@ const CoursesList = () => {
|
||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||
/>
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<Select value={levelFilter} onValueChange={(v) => { setLevelFilter(v); setCurrentPage(1); }}>
|
||||
<Select value="courses" onValueChange={(v) => { if (v === "units") navigate("/units"); }}>
|
||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||
<SelectValue placeholder="Level" />
|
||||
<SelectValue placeholder="Browse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All Levels</SelectItem>
|
||||
<SelectItem value="Beginner">Beginner</SelectItem>
|
||||
<SelectItem value="Intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="Advanced">Advanced</SelectItem>
|
||||
<SelectItem value="courses">Courses</SelectItem>
|
||||
<SelectItem value="units">Units</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import UnitUpsellModal from "../components/UnitUpsellModal";
|
||||
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useGroup } from "@/contexts/ClientGroupContext";
|
||||
@@ -205,6 +208,7 @@ const Client = () => {
|
||||
const navigate = useNavigate();
|
||||
const { state: navState } = useLocation();
|
||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||
const { units, unitsLoading, getUnits } = useLibrary();
|
||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||
const {
|
||||
@@ -216,6 +220,9 @@ const Client = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||
|
||||
const [unitModalOpen, setUnitModalOpen] = useState(false);
|
||||
const [selectedUnit, setSelectedUnit] = useState(null);
|
||||
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
|
||||
const userTier = myTier?.tier ?? "free";
|
||||
@@ -235,6 +242,7 @@ const Client = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getCourses();
|
||||
getUnits();
|
||||
if (!myTier) getMyTier();
|
||||
}, []);
|
||||
|
||||
@@ -253,6 +261,7 @@ const Client = () => {
|
||||
|
||||
// Show only first 3
|
||||
const featuredCourses = courses.slice(0, 3);
|
||||
const featuredUnits = units.slice(0, 3);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "My Groups", icon: <Users className="size-4" /> },
|
||||
@@ -269,6 +278,15 @@ const Client = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewUnitDetails = (unit) => {
|
||||
if (unit.is_locked) {
|
||||
setSelectedUnit(unit);
|
||||
setUnitModalOpen(true);
|
||||
} else {
|
||||
navigate(`/units/${unit.uuid}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -332,6 +350,34 @@ const Client = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Featured Units (first 3) ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<h1 className="text-2xl font-medium">Units</h1>
|
||||
<Button onClick={() => navigate(`/units`)}>View All</Button>
|
||||
</div>
|
||||
|
||||
{unitsLoading ? (
|
||||
<div className="grid lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<UnitCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : featuredUnits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No units available yet.</p>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-3 gap-4">
|
||||
{featuredUnits.map((unit) => (
|
||||
<UnitCard
|
||||
key={unit.unit_id}
|
||||
unit={unit}
|
||||
onViewDetails={handleViewUnitDetails}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -383,6 +429,14 @@ const Client = () => {
|
||||
})()}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Upsell Modal — only for locked units ── */}
|
||||
<UnitUpsellModal
|
||||
open={unitModalOpen}
|
||||
onOpenChange={setUnitModalOpen}
|
||||
unit={selectedUnit}
|
||||
tierMap={tierMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
House, Timer, Layers, LockIcon, SendHorizonal, CheckCheck, CheckCircle2, Circle,
|
||||
FileQuestion, Hourglass, Zap, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(seconds = 0) {
|
||||
if (!seconds) return null;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}hr ${m}min`;
|
||||
return `${m}min`;
|
||||
}
|
||||
|
||||
// ─── Content card (single unit — lessons + quiz, no multi-unit spine) ─────────
|
||||
|
||||
const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
|
||||
const lessons = unitDetail.lessons ?? [];
|
||||
const quiz = unitDetail.quiz ?? null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-xl border bg-card"
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>
|
||||
<div className="px-4 py-4 border-b flex flex-col gap-2">
|
||||
<div className="[&_svg]:size-4 text-md text-muted-foreground flex items-center gap-4">
|
||||
{lessons.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers /> {lessons.length} {lessons.length === 1 ? "Lesson" : "Lessons"}
|
||||
</div>
|
||||
)}
|
||||
{unitDetail.duration_seconds > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer /> {formatDuration(unitDetail.duration_seconds)}
|
||||
</div>
|
||||
)}
|
||||
{quiz && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileQuestion /> Quiz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1.5">
|
||||
{lessons.map((lesson) => (
|
||||
<div
|
||||
key={lesson.lesson_id}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
|
||||
onClick={() => onLessonClick(lesson)}
|
||||
>
|
||||
<div className="flex items-center gap-3 select-none min-w-0">
|
||||
{lesson.status === "completed"
|
||||
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
|
||||
: <Circle className="size-4 text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="text-md text-card-foreground truncate">{lesson.title}</span>
|
||||
</div>
|
||||
{lesson.duration_seconds > 0 && (
|
||||
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{quiz && (
|
||||
<div
|
||||
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
|
||||
onClick={() => onQuizClick()}
|
||||
>
|
||||
<div className="flex items-center gap-3 select-none min-w-0">
|
||||
{quiz.has_passed
|
||||
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
|
||||
: <ClipboardList className="size-4 text-blue-500 shrink-0" />
|
||||
}
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
|
||||
</div>
|
||||
<Badge className={cn(
|
||||
"shrink-0 ml-2 text-[10px]",
|
||||
quiz.has_passed
|
||||
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
|
||||
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
|
||||
)}>
|
||||
{quiz.has_passed ? "Passed" : "Quiz"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Unit Details ───────────────────────────────────────────────────────────
|
||||
|
||||
const UnitDetails = () => {
|
||||
const { uuid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
|
||||
const { tierMap, getTierCategories } = useClientTiers();
|
||||
|
||||
const hasCompleted = !!unitDetail?.is_completed;
|
||||
const contentNotReady = !unitDetail?.duration_seconds;
|
||||
|
||||
useEffect(() => {
|
||||
getTierCategories();
|
||||
getUnitDetail(uuid);
|
||||
return () => resetUnitDetail();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uuid]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: "Units", to: `/units` },
|
||||
{ label: unitDetail?.title ?? "Unit" },
|
||||
];
|
||||
|
||||
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
|
||||
if (unitBlocked) {
|
||||
const course = unitBlockedInfo?.course;
|
||||
const tier = course?.subscription ? tierMap[course.subscription] : null;
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
||||
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<LockIcon className="size-7 text-amber-500" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{course
|
||||
? `This unit is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
|
||||
: "Upgrade your plan to access this unit."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||||
<Zap className="size-4" /> View Available Plans
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (unitDetailLoading) {
|
||||
return (
|
||||
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-5 w-full max-w-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLessonClick = (lesson) => {
|
||||
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
|
||||
};
|
||||
const handleQuizClick = () => {
|
||||
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
|
||||
<div className="my-17">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Hero */}
|
||||
<div className="bg-primary dark:bg-accent/50">
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
|
||||
<AppBreadcrumb
|
||||
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
|
||||
items={items}
|
||||
/>
|
||||
<div className="flex lg:flex-row items-start justify-between w-full text-white">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
|
||||
<p className="max-w-2xl xs:text-sm lg:text-lg">{unitDetail?.description ?? ""}</p>
|
||||
{unitDetail?.duration_seconds > 0 && (
|
||||
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
|
||||
<Timer />
|
||||
{formatDuration(unitDetail.duration_seconds)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-fit">
|
||||
{contentNotReady ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
|
||||
<Hourglass className="size-4 shrink-0" />
|
||||
This unit is currently being prepared. Please check back later.
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-fit bg-blue-500"
|
||||
onClick={() => navigate(`/units/${uuid}/read`)}
|
||||
>
|
||||
{hasCompleted
|
||||
? <><CheckCheck /> Start Again</>
|
||||
: <><SendHorizonal /> Start Learning</>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
|
||||
<div className="flex flex-col gap-8 max-w-3xl">
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">About this unit</div>
|
||||
<div className="space-y-4 text-muted-foreground lg:text-lg">
|
||||
<p>{unitDetail?.description ?? ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unitDetail && !contentNotReady && (
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">Unit content</div>
|
||||
<UnitContentCard
|
||||
unitDetail={unitDetail}
|
||||
onLessonClick={handleLessonClick}
|
||||
onQuizClick={handleQuizClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnitDetails;
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import LessonBlock from "../components/LessonBlock.jsx";
|
||||
import QuizBlock from "../components/blocks/QuizBlock.jsx";
|
||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── Sidebar (single unit — flat lessons + quiz, no unit-accordion nesting) ──
|
||||
|
||||
const SidebarContent = ({
|
||||
lessons, quiz, selectedLessonId, selectedQuizId,
|
||||
onLessonClick, onQuizClick, loading,
|
||||
}) => (
|
||||
<ScrollArea className="h-full p-3 md:p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2">
|
||||
Unit Content
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-2 pt-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{lessons.map((lesson) => (
|
||||
<li
|
||||
key={lesson.lesson_id}
|
||||
onClick={() => onLessonClick(lesson)}
|
||||
className={`flex items-center gap-2 pl-3 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 hover:text-foreground cursor-pointer transition-colors ${selectedLessonId === lesson.lesson_id
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{lesson.status === "completed"
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: <Circle className="size-3.5 text-muted-foreground/30 shrink-0" />
|
||||
}
|
||||
<span className="truncate lg:w-44">{lesson.title}</span>
|
||||
</li>
|
||||
))}
|
||||
{quiz && (
|
||||
<li
|
||||
onClick={onQuizClick}
|
||||
className={`flex items-center gap-2 pl-3 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${selectedQuizId
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: quiz.has_passed
|
||||
? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
|
||||
: "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{quiz.has_passed
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: <ClipboardList className="size-3.5 shrink-0" />
|
||||
}
|
||||
{quiz.title || "Quiz"}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<ScrollBar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// ─── UnitReader ─────────────────────────────────────────────────────────────
|
||||
|
||||
const UnitReader = () => {
|
||||
const { uuid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail,
|
||||
lesson, lessonLoading, getLesson, resetLesson,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft,
|
||||
upsertLessonProgress,
|
||||
} = useLibrary();
|
||||
|
||||
// Tracks which lessons have been marked completed this session to avoid duplicate calls
|
||||
const completedSessionRef = useRef(new Set());
|
||||
|
||||
// ── Local UI state ──────────────────────────────────────────────────────
|
||||
const [selectedLessonId, setSelectedLessonId] = useState(null);
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
|
||||
|
||||
// ── Session guard — block navigation while a quiz is in progress ─────────
|
||||
const quizActiveRef = useRef(false);
|
||||
const [quizSessionActive, setQuizSessionActive] = useState(false);
|
||||
const [pendingNav, setPendingNav] = useState(null);
|
||||
|
||||
const setQuizActive = useCallback((active) => {
|
||||
quizActiveRef.current = active;
|
||||
setQuizSessionActive(active);
|
||||
}, []);
|
||||
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
quizSessionActive && currentLocation.pathname !== nextLocation.pathname
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") return;
|
||||
setPendingNav(() => () => blocker.proceed());
|
||||
}, [blocker.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!quizSessionActive) return;
|
||||
const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [quizSessionActive]);
|
||||
|
||||
// ── Flatten content: lessons + optional quiz ──────────────────────────
|
||||
const lessons = unitDetail?.lessons ?? [];
|
||||
const allContent = [
|
||||
...lessons.map((l) => ({ type: "lesson", lesson: l })),
|
||||
...(unitDetail?.quiz ? [{ type: "quiz", quiz: unitDetail.quiz }] : []),
|
||||
];
|
||||
|
||||
// ── Scroll progress ────────────────────────────────────────────────────
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setScrollProgress(0);
|
||||
window.scrollTo(0, 0);
|
||||
}, [selectedLessonId, selectedQuizId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (scrollHeight <= 0) { setScrollProgress(100); return; }
|
||||
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// ── Mark lesson completed when user scrolls to the bottom ─────────────
|
||||
useEffect(() => {
|
||||
if (scrollProgress < 100 || !selectedLessonId || !lesson?.uuid) return;
|
||||
if (completedSessionRef.current.has(selectedLessonId)) return;
|
||||
const stub = lessons.find((l) => l.lesson_id === selectedLessonId);
|
||||
if (stub?.status === 'completed') return;
|
||||
completedSessionRef.current.add(selectedLessonId);
|
||||
upsertLessonProgress(lesson.uuid, 'completed', uuid).then((result) => {
|
||||
if (result?.unit && unitDetail && lessons.every((l) => l.lesson_id === selectedLessonId || l.status === 'completed')) {
|
||||
toast.success('Unit complete!', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scrollProgress]);
|
||||
|
||||
// ── Fetch unit + reset on unmount ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
getUnitDetail(uuid);
|
||||
return () => {
|
||||
resetUnitDetail();
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
completedSessionRef.current.clear();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uuid]);
|
||||
|
||||
// ── Auto-load lesson once unit data is available ───────────────────────
|
||||
useEffect(() => {
|
||||
if (!unitDetail || selectedLessonId || selectedQuizId) return;
|
||||
const { lessonId, quizId } = location.state ?? {};
|
||||
|
||||
if (quizId && unitDetail.quiz) {
|
||||
setSelectedQuizId(unitDetail.quiz.quiz_id);
|
||||
getUnitQuiz(uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lessonId) {
|
||||
const target = lessons.find((l) => l.lesson_id === lessonId);
|
||||
if (target) {
|
||||
setSelectedLessonId(target.lesson_id);
|
||||
getLesson(target.uuid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const firstIncomplete = lessons.find((l) => l.status !== 'completed') ?? lessons[0];
|
||||
if (firstIncomplete) {
|
||||
setSelectedLessonId(firstIncomplete.lesson_id);
|
||||
getLesson(firstIncomplete.uuid);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [unitDetail]);
|
||||
|
||||
// ── Breadcrumbs ─────────────────────────────────────────────────────────
|
||||
const currentLessonStub = lessons.find((l) => l.lesson_id === selectedLessonId) ?? null;
|
||||
const pageTitle = (() => {
|
||||
if (!unitDetail) return undefined;
|
||||
if (selectedQuizId) return `${unitDetail.title} – Quiz - STARR`;
|
||||
const lessonTitle = lesson?.title ?? currentLessonStub?.title;
|
||||
if (selectedLessonId && lessonTitle) return `${lessonTitle} - STARR`;
|
||||
return `${unitDetail.title} - STARR`;
|
||||
})();
|
||||
|
||||
const breadcrumbItemsFull = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: "Units", to: `/units` },
|
||||
{ label: unitDetail?.title ?? "Unit", to: `/units/${uuid}` },
|
||||
{ label: selectedQuizId ? "Quiz" : (currentLessonStub?.title ?? "Select a lesson") },
|
||||
];
|
||||
const breadcrumbItemsMobile = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: selectedQuizId ? "Quiz" : (currentLessonStub?.title ?? unitDetail?.title ?? "Unit") },
|
||||
];
|
||||
|
||||
// ── Session guard helpers ──────────────────────────────────────────────
|
||||
const handleConfirmNav = useCallback(() => {
|
||||
const fn = pendingNav;
|
||||
setPendingNav(null);
|
||||
quizActiveRef.current = false;
|
||||
setQuizSessionActive(false);
|
||||
if (blocker.state === "blocked") blocker.proceed();
|
||||
else fn?.();
|
||||
}, [pendingNav, blocker]);
|
||||
|
||||
const handleCancelNav = useCallback(() => {
|
||||
setPendingNav(null);
|
||||
if (blocker.state === "blocked") blocker.reset();
|
||||
}, [blocker]);
|
||||
|
||||
// ── Lesson click ───────────────────────────────────────────────────────
|
||||
const handleLessonClick = useCallback(async (lessonStub) => {
|
||||
if (lessonStub.lesson_id === selectedLessonId) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = async () => {
|
||||
setSelectedLessonId(lessonStub.lesson_id);
|
||||
setSelectedQuizId(null);
|
||||
resetQuiz();
|
||||
setSidebarOpen(false);
|
||||
await getLesson(lessonStub.uuid);
|
||||
};
|
||||
|
||||
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
|
||||
await doNav();
|
||||
}, [selectedLessonId, getLesson, resetQuiz]);
|
||||
|
||||
// ── Quiz click ─────────────────────────────────────────────────────────
|
||||
const handleQuizClick = useCallback(async () => {
|
||||
if (selectedQuizId) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = async () => {
|
||||
setSelectedQuizId(unitDetail.quiz.quiz_id);
|
||||
setSelectedLessonId(null);
|
||||
resetLesson();
|
||||
setSidebarOpen(false);
|
||||
await getUnitQuiz(uuid);
|
||||
};
|
||||
|
||||
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
|
||||
await doNav();
|
||||
}, [selectedQuizId, uuid, getUnitQuiz, resetLesson, unitDetail]);
|
||||
|
||||
const handleQuizDraft = useCallback((answers) => {
|
||||
saveUnitQuizDraft(uuid, selectedQuizId, answers);
|
||||
}, [uuid, selectedQuizId, saveUnitQuizDraft]);
|
||||
|
||||
// ── Next content item ──────────────────────────────────────────────────
|
||||
const getNextContent = useCallback(() => {
|
||||
const idx = allContent.findIndex((item) =>
|
||||
(item.type === "lesson" && item.lesson.lesson_id === selectedLessonId) ||
|
||||
(item.type === "quiz" && selectedQuizId)
|
||||
);
|
||||
return idx !== -1 && idx < allContent.length - 1 ? allContent[idx + 1] : null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedLessonId, selectedQuizId, allContent]);
|
||||
|
||||
const nextContent = getNextContent();
|
||||
const nextLabel = nextContent
|
||||
? nextContent.type === "lesson" ? nextContent.lesson.title : (nextContent.quiz.title || "Quiz")
|
||||
: null;
|
||||
const showSidebarContent = desktopSidebarOpen && !quizSessionActive;
|
||||
|
||||
const handleNextContentClick = () => {
|
||||
if (!nextContent) return;
|
||||
if (nextContent.type === "lesson") handleLessonClick(nextContent.lesson);
|
||||
else handleQuizClick();
|
||||
};
|
||||
|
||||
// ── Access blocked (403 from getUnitDetail) ──────────────────────────────
|
||||
if (unitBlocked) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
||||
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<Lock className="size-7 text-amber-500" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
To access this unit, subscribe to one of our available tier plans.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||||
<Zap className="size-4" /> View Available Plans
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Content not ready ──────────────────────────────────────────────────
|
||||
if (!unitDetailLoading && unitDetail && !unitDetail.duration_seconds) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Hourglass className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<h2 className="text-xl font-semibold">Unit Not Yet Available</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
This unit is currently being prepared. Please check back later.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(`/units/${uuid}`)} className="gap-1.5">
|
||||
Back to Unit Details
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title={pageTitle} />
|
||||
|
||||
{/* ── Session-guard dialog ── */}
|
||||
<ResponsiveModal
|
||||
open={!!pendingNav || blocker.state === "blocked"}
|
||||
onOpenChange={(open) => !open && handleCancelNav()}
|
||||
title="Leave Quiz?"
|
||||
description=""
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancelNav}>Stay</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmNav}>Leave Anyway</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
You have an ongoing <strong className="text-foreground">quiz</strong> session in progress.
|
||||
Leaving now will not submit your answers — your session will remain open.
|
||||
</p>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Up next floating button ── */}
|
||||
{scrollProgress >= 100 && nextContent && !selectedQuizId && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div
|
||||
onClick={handleNextContentClick}
|
||||
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm text-muted-foreground font-normal">Up next</span>
|
||||
{nextContent.type === "lesson" ? nextContent.lesson.title : (nextContent.quiz.title || "Quiz")}
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div
|
||||
onClick={handleNextContentClick}
|
||||
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm text-muted-foreground font-normal">Up next</span>
|
||||
{nextContent.type === "lesson" ? nextContent.lesson.title : (nextContent.quiz.title || "Quiz")}
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Top sticky bar ── */}
|
||||
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
|
||||
<div className="w-full flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<div className="hidden sm:block min-w-0">
|
||||
<AppBreadcrumb items={breadcrumbItemsFull} />
|
||||
</div>
|
||||
<div className="sm:hidden min-w-0 truncate">
|
||||
<AppBreadcrumb items={breadcrumbItemsMobile} />
|
||||
</div>
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="lg:hidden flex-shrink-0" aria-label="Open unit content">
|
||||
<TableOfContents className="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-80">
|
||||
<SidebarContent
|
||||
lessons={lessons}
|
||||
quiz={unitDetail?.quiz}
|
||||
selectedLessonId={selectedLessonId}
|
||||
selectedQuizId={selectedQuizId}
|
||||
onLessonClick={handleLessonClick}
|
||||
onQuizClick={handleQuizClick}
|
||||
loading={unitDetailLoading}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lesson && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-border">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-150 ease-out"
|
||||
style={{ width: `${scrollProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Desktop sidebar ── */}
|
||||
<div className="hidden lg:flex flex-row fixed top-[112px] bottom-0 left-0 z-30 bg-muted border-r">
|
||||
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
|
||||
disabled={quizSessionActive}
|
||||
title={showSidebarContent ? "Collapse sidebar" : "Expand sidebar"}
|
||||
>
|
||||
{showSidebarContent ? <ChevronsLeft className="size-4" /> : <ChevronsRight className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSidebarContent && (
|
||||
<div className="w-[266px] border-l overflow-hidden">
|
||||
<SidebarContent
|
||||
lessons={lessons}
|
||||
quiz={unitDetail?.quiz}
|
||||
selectedLessonId={selectedLessonId}
|
||||
selectedQuizId={selectedQuizId}
|
||||
onLessonClick={handleLessonClick}
|
||||
onQuizClick={handleQuizClick}
|
||||
loading={unitDetailLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`mt-32 ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
{selectedQuizId ? (
|
||||
<QuizBlock
|
||||
quiz={quiz}
|
||||
loading={quizLoading}
|
||||
onDraft={handleQuizDraft}
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitUnitQuiz(uuid, selectedQuizId, answers);
|
||||
await getUnitDetail(uuid);
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getUnitQuiz(uuid)}
|
||||
onActiveChange={setQuizActive}
|
||||
onNextContent={nextContent ? handleNextContentClick : undefined}
|
||||
nextLabel={nextLabel}
|
||||
/>
|
||||
) : (
|
||||
<LessonBlock lesson={lesson} loading={lessonLoading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnitReader;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import UnitUpsellModal from "../components/UnitUpsellModal";
|
||||
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// ─── Pagination ───────────────────────────────────────────────────────────────
|
||||
|
||||
const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
|
||||
const start = (currentPage - 1) * itemsPerPage + 1;
|
||||
const end = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
const getPages = () => {
|
||||
const pages = [];
|
||||
if (totalPages <= 5) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push("...");
|
||||
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
|
||||
if (currentPage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{start}–{end}</span> of{" "}
|
||||
<span className="font-medium text-foreground">{totalItems}</span> units
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1} variant="ghost" size="sm">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
{getPages().map((page, i) =>
|
||||
page === "..." ? (
|
||||
<span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span>
|
||||
) : (
|
||||
<Button key={page} size="sm" variant={currentPage === page ? "default" : "outline"} onClick={() => onPageChange(page)}>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button onClick={() => onPageChange(currentPage + 1)} disabled={currentPage === totalPages} variant="ghost" size="sm">
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const UnitsList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { units, unitsLoading, getUnits } = useLibrary();
|
||||
const { tierMap, getTierCategories } = useClientTiers();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [lockFilter, setLockFilter] = useState("All");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedUnit, setSelectedUnit] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getUnits();
|
||||
getTierCategories();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
units
|
||||
.filter((u) => {
|
||||
const matchSearch = u.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(u.description ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchLock = lockFilter === "All"
|
||||
|| (lockFilter === "Unlocked" && !u.is_locked)
|
||||
|| (lockFilter === "Locked" && u.is_locked);
|
||||
return matchSearch && matchLock;
|
||||
})
|
||||
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
[units, search, lockFilter]
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
|
||||
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
const handleViewDetails = (unit) => {
|
||||
if (unit.is_locked) {
|
||||
setSelectedUnit(unit);
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
navigate(`/units/${unit.uuid}`);
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: "Units" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageMeta title="Units - STARR" description="Browse standalone units you can start right away." />
|
||||
<div className="py-24 bg-accent/70 min-h-screen">
|
||||
<div className="flex flex-col gap-4 justify-between lg:container lg:mx-auto pt-2">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
|
||||
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
|
||||
<Input
|
||||
placeholder="Search units..."
|
||||
className="w-full bg-card lg:max-w-64 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||
/>
|
||||
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
|
||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||
<SelectValue placeholder="Access" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All Units</SelectItem>
|
||||
<SelectItem value="Unlocked">Unlocked</SelectItem>
|
||||
<SelectItem value="Locked">Locked</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value="units" onValueChange={(v) => { if (v === "courses") navigate("/course"); }}>
|
||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||
<SelectValue placeholder="Browse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="courses">Courses</SelectItem>
|
||||
<SelectItem value="units">Units</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unit Grid */}
|
||||
{unitsLoading ? (
|
||||
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => <UnitCardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : paginated.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Layers className="size-40 text-primary" />
|
||||
<p className="text-md">No units found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4 xs:px-4 lg:px-0">
|
||||
{paginated.map((unit) => (
|
||||
<UnitCard
|
||||
key={unit.unit_id}
|
||||
unit={unit}
|
||||
onViewDetails={handleViewDetails}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!unitsLoading && filtered.length > ITEMS_PER_PAGE && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={filtered.length}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UnitUpsellModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
unit={selectedUnit}
|
||||
tierMap={tierMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnitsList;
|
||||
@@ -6,6 +6,9 @@ import { Navigate, Outlet } from "react-router-dom"
|
||||
import ScrollToTop from '@/components/generic/ScrollToTop'
|
||||
import CourseDetails from '../pages/CourseDetails'
|
||||
import UnitList from '../pages/UnitList'
|
||||
import UnitsList from '../pages/UnitsList'
|
||||
import UnitDetails from '../pages/UnitDetails'
|
||||
import UnitReader from '../pages/UnitReader'
|
||||
import { Fragment } from 'react'
|
||||
import GroupList from '../pages/GroupList'
|
||||
import ViewTaskDetails from '../pages/ViewTaskDetails'
|
||||
@@ -87,6 +90,21 @@ export const ClientRoutes = {
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: 'units',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <UnitsList /> },
|
||||
{
|
||||
path: ':uuid', element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <UnitDetails /> },
|
||||
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: 'group', element: <Outlet />,
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user