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";
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CoursesProvider({ children }) {
|
export function CoursesProvider({ children }) {
|
||||||
@@ -288,7 +304,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchUnit = useCallback(
|
const fetchUnit = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
setUnit(result);
|
setUnit(result);
|
||||||
setLessons(result?.lessons ?? []);
|
setLessons(result?.lessons ?? []);
|
||||||
@@ -315,7 +331,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const updateUnit = useCallback(
|
const updateUnit = useCallback(
|
||||||
(courseId, unitId, payload) =>
|
(courseId, unitId, payload) =>
|
||||||
request(async () => {
|
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;
|
const unit = data?.data?.data ?? null;
|
||||||
if (unit) {
|
if (unit) {
|
||||||
setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u)));
|
setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u)));
|
||||||
@@ -330,7 +346,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const archiveUnit = useCallback(
|
const archiveUnit = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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));
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
||||||
toast("Unit archived.");
|
toast("Unit archived.");
|
||||||
@@ -372,7 +388,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const restoreUnit = useCallback(
|
const restoreUnit = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
@@ -397,7 +413,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const permanentlyDeleteUnit = useCallback(
|
const permanentlyDeleteUnit = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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));
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
||||||
toast("Unit permanently deleted.");
|
toast("Unit permanently deleted.");
|
||||||
@@ -420,7 +436,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchUnitPermanentDeleteImpact = useCallback(
|
const fetchUnitPermanentDeleteImpact = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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 ?? {};
|
const { lessonCount } = data?.data ?? {};
|
||||||
return [{ label: "lesson(s)", count: lessonCount ?? 0 }];
|
return [{ label: "lesson(s)", count: lessonCount ?? 0 }];
|
||||||
}),
|
}),
|
||||||
@@ -434,7 +450,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchQuiz = useCallback(
|
const fetchQuiz = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
setQuestions(result?.questions ?? []);
|
setQuestions(result?.questions ?? []);
|
||||||
@@ -446,7 +462,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const createQuiz = useCallback(
|
const createQuiz = useCallback(
|
||||||
(courseId, unitId, payload) =>
|
(courseId, unitId, payload) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
@@ -460,7 +476,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const updateQuiz = useCallback(
|
const updateQuiz = useCallback(
|
||||||
(courseId, unitId, quizId, payload) =>
|
(courseId, unitId, quizId, payload) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
@@ -474,7 +490,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const deleteQuiz = useCallback(
|
const deleteQuiz = useCallback(
|
||||||
(courseId, unitId, quizId, deletedBy) =>
|
(courseId, unitId, quizId, deletedBy) =>
|
||||||
request(async () => {
|
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);
|
setQuiz(null);
|
||||||
setQuestions([]);
|
setQuestions([]);
|
||||||
toast("Quiz archived.");
|
toast("Quiz archived.");
|
||||||
@@ -488,7 +504,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchArchivedQuiz = useCallback(
|
const fetchArchivedQuiz = useCallback(
|
||||||
(courseId, unitId) =>
|
(courseId, unitId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
return data;
|
return data;
|
||||||
@@ -499,7 +515,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const restoreQuiz = useCallback(
|
const restoreQuiz = useCallback(
|
||||||
(courseId, unitId, quizId, restoredBy) =>
|
(courseId, unitId, quizId, restoredBy) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
@@ -517,7 +533,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchQuizQuestions = useCallback(
|
const fetchQuizQuestions = useCallback(
|
||||||
(courseId, unitId, quizId) =>
|
(courseId, unitId, quizId) =>
|
||||||
request(async () => {
|
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 ?? [];
|
const result = data?.data?.data ?? [];
|
||||||
setQuestions(result);
|
setQuestions(result);
|
||||||
return data;
|
return data;
|
||||||
@@ -528,7 +544,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const createQuizQuestion = useCallback(
|
const createQuizQuestion = useCallback(
|
||||||
(courseId, unitId, quizId, payload) =>
|
(courseId, unitId, quizId, payload) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuestions((prev) => [...prev, result]);
|
setQuestions((prev) => [...prev, result]);
|
||||||
@@ -542,7 +558,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const updateQuizQuestion = useCallback(
|
const updateQuizQuestion = useCallback(
|
||||||
(courseId, unitId, quizId, questionId, payload) =>
|
(courseId, unitId, quizId, questionId, payload) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q)));
|
setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q)));
|
||||||
@@ -556,7 +572,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const bulkSyncQuizQuestions = useCallback(
|
const bulkSyncQuizQuestions = useCallback(
|
||||||
(courseId, unitId, quizId, questions, updatedBy) =>
|
(courseId, unitId, quizId, questions, updatedBy) =>
|
||||||
request(async () => {
|
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 ?? [];
|
const result = data?.data?.data ?? [];
|
||||||
setQuestions(result);
|
setQuestions(result);
|
||||||
toast("Quiz saved.");
|
toast("Quiz saved.");
|
||||||
@@ -568,7 +584,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const deleteQuizQuestion = useCallback(
|
const deleteQuizQuestion = useCallback(
|
||||||
(courseId, unitId, quizId, questionId, deletedBy) =>
|
(courseId, unitId, quizId, questionId, deletedBy) =>
|
||||||
request(async () => {
|
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));
|
setQuestions((prev) => prev.filter((q) => q.question_id !== questionId));
|
||||||
toast("Question archived.");
|
toast("Question archived.");
|
||||||
return data;
|
return data;
|
||||||
@@ -579,7 +595,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const bulkArchiveQuizQuestions = useCallback(
|
const bulkArchiveQuizQuestions = useCallback(
|
||||||
(courseId, unitId, quizId, ids, deletedBy) =>
|
(courseId, unitId, quizId, ids, deletedBy) =>
|
||||||
request(async () => {
|
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)));
|
setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id)));
|
||||||
toast("Questions archived.");
|
toast("Questions archived.");
|
||||||
return data;
|
return data;
|
||||||
@@ -592,7 +608,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchArchivedQuizQuestion = useCallback(
|
const fetchArchivedQuizQuestion = useCallback(
|
||||||
(courseId, unitId, quizId, questionId) =>
|
(courseId, unitId, quizId, questionId) =>
|
||||||
request(async () => {
|
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;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -601,7 +617,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const restoreQuizQuestion = useCallback(
|
const restoreQuizQuestion = useCallback(
|
||||||
(courseId, unitId, quizId, questionId, restoredBy) =>
|
(courseId, unitId, quizId, questionId, restoredBy) =>
|
||||||
request(async () => {
|
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.");
|
toast("Question restored.");
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -611,7 +627,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const bulkRestoreQuizQuestions = useCallback(
|
const bulkRestoreQuizQuestions = useCallback(
|
||||||
(courseId, unitId, quizId, ids, restoredBy) =>
|
(courseId, unitId, quizId, ids, restoredBy) =>
|
||||||
request(async () => {
|
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.");
|
toast("Questions restored.");
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -624,14 +640,14 @@ export function CoursesProvider({ children }) {
|
|||||||
|
|
||||||
const fetchLessons = useCallback(
|
const fetchLessons = useCallback(
|
||||||
(courseId, unitId, params = {}) =>
|
(courseId, unitId, params = {}) =>
|
||||||
paginatedGet(`${BASE}/${courseId}/units/${unitId}/lessons`, setLessons, params),
|
paginatedGet(`${unitBase(courseId, unitId)}/lessons`, setLessons, params),
|
||||||
[paginatedGet],
|
[paginatedGet],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchLesson = useCallback(
|
const fetchLesson = useCallback(
|
||||||
(courseId, unitId, lessonId) =>
|
(courseId, unitId, lessonId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
setLesson(result);
|
setLesson(result);
|
||||||
setLessonPage(result?.page ?? null);
|
setLessonPage(result?.page ?? null);
|
||||||
@@ -643,7 +659,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const createLesson = useCallback(
|
const createLesson = useCallback(
|
||||||
(courseId, unitId, payload) =>
|
(courseId, unitId, payload) =>
|
||||||
request(async () => {
|
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;
|
const lesson = data?.data?.data ?? null;
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
setLessons((prev) => [...prev, lesson]);
|
setLessons((prev) => [...prev, lesson]);
|
||||||
@@ -657,7 +673,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const updateLesson = useCallback(
|
const updateLesson = useCallback(
|
||||||
(courseId, unitId, lessonId, payload) =>
|
(courseId, unitId, lessonId, payload) =>
|
||||||
request(async () => {
|
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;
|
const lesson = data?.data?.data ?? null;
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l)));
|
setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l)));
|
||||||
@@ -672,7 +688,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const archiveLesson = useCallback(
|
const archiveLesson = useCallback(
|
||||||
(courseId, unitId, lessonId) =>
|
(courseId, unitId, lessonId) =>
|
||||||
request(async () => {
|
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));
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
||||||
toast("Lesson archived.");
|
toast("Lesson archived.");
|
||||||
@@ -684,7 +700,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const archiveLessons = useCallback(
|
const archiveLessons = useCallback(
|
||||||
(courseId, unitId, { ids }) =>
|
(courseId, unitId, { ids }) =>
|
||||||
request(async () => {
|
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)));
|
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
||||||
toast("Lessons archived.");
|
toast("Lessons archived.");
|
||||||
return data;
|
return data;
|
||||||
@@ -696,14 +712,14 @@ export function CoursesProvider({ children }) {
|
|||||||
|
|
||||||
const fetchArchivedLessons = useCallback(
|
const fetchArchivedLessons = useCallback(
|
||||||
(courseId, unitId, params = {}) =>
|
(courseId, unitId, params = {}) =>
|
||||||
paginatedGet(`${BASE}/${courseId}/units/${unitId}/lessons/archives`, setLessons, params),
|
paginatedGet(`${unitBase(courseId, unitId)}/lessons/archives`, setLessons, params),
|
||||||
[paginatedGet],
|
[paginatedGet],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchArchivedLesson = useCallback(
|
const fetchArchivedLesson = useCallback(
|
||||||
(courseId, unitId, lessonId) =>
|
(courseId, unitId, lessonId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
setLesson(result);
|
setLesson(result);
|
||||||
return data;
|
return data;
|
||||||
@@ -714,7 +730,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const restoreLesson = useCallback(
|
const restoreLesson = useCallback(
|
||||||
(courseId, unitId, lessonId) =>
|
(courseId, unitId, lessonId) =>
|
||||||
request(async () => {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
@@ -728,7 +744,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const restoreLessons = useCallback(
|
const restoreLessons = useCallback(
|
||||||
(courseId, unitId, { ids }) =>
|
(courseId, unitId, { ids }) =>
|
||||||
request(async () => {
|
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)));
|
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
||||||
toast("Lessons restored.");
|
toast("Lessons restored.");
|
||||||
return data;
|
return data;
|
||||||
@@ -739,7 +755,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const permanentlyDeleteLesson = useCallback(
|
const permanentlyDeleteLesson = useCallback(
|
||||||
(courseId, unitId, lessonId) =>
|
(courseId, unitId, lessonId) =>
|
||||||
request(async () => {
|
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));
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
||||||
toast("Lesson permanently deleted.");
|
toast("Lesson permanently deleted.");
|
||||||
@@ -751,7 +767,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const permanentlyDeleteLessons = useCallback(
|
const permanentlyDeleteLessons = useCallback(
|
||||||
(courseId, unitId, { ids }) =>
|
(courseId, unitId, { ids }) =>
|
||||||
request(async () => {
|
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)));
|
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
||||||
toast("Lessons permanently deleted.");
|
toast("Lessons permanently deleted.");
|
||||||
return data;
|
return data;
|
||||||
@@ -766,7 +782,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchLessonPage = useCallback(
|
const fetchLessonPage = useCallback(
|
||||||
(courseId, unitId, lessonId) =>
|
(courseId, unitId, lessonId) =>
|
||||||
request(async () => {
|
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;
|
const page = data?.data?.data ?? null;
|
||||||
setLessonPage(page);
|
setLessonPage(page);
|
||||||
return data;
|
return data;
|
||||||
@@ -777,7 +793,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const saveLessonPage = useCallback(
|
const saveLessonPage = useCallback(
|
||||||
(courseId, unitId, lessonId, payload) =>
|
(courseId, unitId, lessonId, payload) =>
|
||||||
request(async () => {
|
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;
|
const page = data?.data?.data ?? null;
|
||||||
if (page) {
|
if (page) {
|
||||||
setLessonPage(page);
|
setLessonPage(page);
|
||||||
@@ -987,7 +1003,7 @@ export function CoursesProvider({ children }) {
|
|||||||
(courseId, unitId, quizId) =>
|
(courseId, unitId, quizId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
setCompletions(null);
|
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);
|
setCompletions(data?.data ?? null);
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -1109,7 +1125,7 @@ export function CoursesProvider({ children }) {
|
|||||||
const fetchLessonFieldValues = useCallback(
|
const fetchLessonFieldValues = useCallback(
|
||||||
(courseId, unitId, field) =>
|
(courseId, unitId, field) =>
|
||||||
request(async () => {
|
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 ?? [];
|
const result = data?.data ?? [];
|
||||||
return result;
|
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 { UserProvider } from "../AdminUserContext";
|
||||||
import { UserGroupProvider } from "../AdminUserGroupContext";
|
import { UserGroupProvider } from "../AdminUserGroupContext";
|
||||||
import { CoursesProvider } from "../AdminCoursesContext";
|
import { CoursesProvider } from "../AdminCoursesContext";
|
||||||
|
import { LibraryProvider } from "../AdminLibraryContext";
|
||||||
import { AdminTaskProvider } from "../AdminTaskContext";
|
import { AdminTaskProvider } from "../AdminTaskContext";
|
||||||
import { AdminTiersProvider } from "../AdminTiersContext";
|
import { AdminTiersProvider } from "../AdminTiersContext";
|
||||||
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
|
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
|
||||||
@@ -26,11 +27,13 @@ export const AdminProvider = ({ children }) => {
|
|||||||
<AdminTiersProvider>
|
<AdminTiersProvider>
|
||||||
<AdminCategoriesProvider>
|
<AdminCategoriesProvider>
|
||||||
<CoursesProvider>
|
<CoursesProvider>
|
||||||
<AdminCourseReadingProgressProvider>
|
<LibraryProvider>
|
||||||
<AdminTaskProvider>
|
<AdminCourseReadingProgressProvider>
|
||||||
{children}
|
<AdminTaskProvider>
|
||||||
</AdminTaskProvider>
|
{children}
|
||||||
</AdminCourseReadingProgressProvider>
|
</AdminTaskProvider>
|
||||||
|
</AdminCourseReadingProgressProvider>
|
||||||
|
</LibraryProvider>
|
||||||
</CoursesProvider>
|
</CoursesProvider>
|
||||||
</AdminCategoriesProvider>
|
</AdminCategoriesProvider>
|
||||||
</AdminTiersProvider>
|
</AdminTiersProvider>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ClientCoursesProvider } from "../ClientCoursesContext"
|
import { ClientCoursesProvider } from "../ClientCoursesContext"
|
||||||
|
import { ClientLibraryProvider } from "../ClientLibraryContext"
|
||||||
import { ClientTiersProvider } from "../ClientTiersProvider"
|
import { ClientTiersProvider } from "../ClientTiersProvider"
|
||||||
import { ProfileProvider } from "../ProfileProvider"
|
import { ProfileProvider } from "../ProfileProvider"
|
||||||
import { TaskProgressProvider } from "../ClientTaskProgressContext"
|
import { TaskProgressProvider } from "../ClientTaskProgressContext"
|
||||||
@@ -15,17 +16,19 @@ export const ClientProvider = ({ children }) => {
|
|||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
<ClientAdvertisementsProvider>
|
<ClientAdvertisementsProvider>
|
||||||
<ClientCoursesProvider>
|
<ClientCoursesProvider>
|
||||||
<CourseReadingProgressProvider>
|
<ClientLibraryProvider>
|
||||||
<ClientTiersProvider>
|
<CourseReadingProgressProvider>
|
||||||
<TaskProgressProvider>
|
<ClientTiersProvider>
|
||||||
<TaskProvider>
|
<TaskProgressProvider>
|
||||||
<GroupProvider>
|
<TaskProvider>
|
||||||
{children}
|
<GroupProvider>
|
||||||
</GroupProvider>
|
{children}
|
||||||
</TaskProvider>
|
</GroupProvider>
|
||||||
</TaskProgressProvider>
|
</TaskProvider>
|
||||||
</ClientTiersProvider>
|
</TaskProgressProvider>
|
||||||
</CourseReadingProgressProvider>
|
</ClientTiersProvider>
|
||||||
|
</CourseReadingProgressProvider>
|
||||||
|
</ClientLibraryProvider>
|
||||||
</ClientCoursesProvider>
|
</ClientCoursesProvider>
|
||||||
</ClientAdvertisementsProvider>
|
</ClientAdvertisementsProvider>
|
||||||
</ProfileProvider>
|
</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 = [
|
export const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,8 @@ export const ADMIN_SECTIONS = [
|
|||||||
description: "Manage tasks and courses",
|
description: "Manage tasks and courses",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "courses", label: "Courses", icon: BookText, link: "/admin/courses" },
|
{ 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: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
|
||||||
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
|
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function AdminSideTabs() {
|
|||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ value: "tab-1", label: "Dashboard", paths: ["/admin"] },
|
{ value: "tab-1", label: "Dashboard", paths: ["/admin"] },
|
||||||
{ value: "tab-2", label: "User Management", paths: ["users"] },
|
{ 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"] },
|
{ value: "tab-5", label: "Site Content", paths: ["contents"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
|
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/courses/lessons/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/courses/lessons/toolbar.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 }) {
|
export default function LessonsTable({ courseId, unitId }) {
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const [archiveIds, setArchiveIds] = useState(null);
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -55,17 +59,35 @@ export default function LessonsTable({ courseId, unitId }) {
|
|||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
}), [courseId, unitId]);
|
}), [courseId, unitId]);
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = [
|
||||||
fetchLessons: (params) => fetchLessons(courseId, unitId, params),
|
...buildToolbarActions({
|
||||||
pagination,
|
fetchLessons: (params) => fetchLessons(courseId, unitId, params),
|
||||||
exportConfig,
|
pagination,
|
||||||
navigate,
|
exportConfig,
|
||||||
courseId,
|
navigate,
|
||||||
unitId,
|
courseId,
|
||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
unitId,
|
||||||
getSort: () => tableRefsRef.current.getSort(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
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({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
@@ -138,6 +160,15 @@ export default function LessonsTable({ courseId, unitId }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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 DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/courses/units/toolbar.config";
|
import { buildToolbarActions } from "../../config/courses/units/toolbar.config";
|
||||||
@@ -19,6 +21,7 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
|||||||
export default function UnitsTable({ courseId }) {
|
export default function UnitsTable({ courseId }) {
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const [archiveIds, setArchiveIds] = useState(null);
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -58,16 +61,32 @@ export default function UnitsTable({ courseId }) {
|
|||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
}), [courseId]);
|
}), [courseId]);
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = [
|
||||||
fetchUnits: (params) => fetchUnits(courseId, params),
|
...buildToolbarActions({
|
||||||
pagination,
|
fetchUnits: (params) => fetchUnits(courseId, params),
|
||||||
exportConfig,
|
pagination,
|
||||||
navigate,
|
exportConfig,
|
||||||
courseId,
|
navigate,
|
||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
courseId,
|
||||||
getSort: () => tableRefsRef.current.getSort(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
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({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
@@ -150,6 +169,15 @@ export default function UnitsTable({ courseId }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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">
|
<Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium">
|
||||||
Manage Tier Plans
|
Manage Tier Plans
|
||||||
</Link>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CoursesTable />
|
<CoursesTable />
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { PreviewContent, PreviewChrome } from "../../../components/courses/Lesso
|
|||||||
export default function ViewLessonPage() {
|
export default function ViewLessonPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId, unitId, lessonId } = useParams();
|
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 { fetchLesson, lesson, lessonPage } = useCourses();
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ export default function ViewLessonPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
|
onClick={() => navigate(builderPath)}
|
||||||
>
|
>
|
||||||
Edit Page
|
Edit Page
|
||||||
</Button>
|
</Button>
|
||||||
@@ -73,7 +78,7 @@ export default function ViewLessonPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
|
onClick={() => navigate(builderPath)}
|
||||||
>
|
>
|
||||||
Go to Page Builder
|
Go to Page Builder
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -223,19 +223,32 @@ export default function ModifyQuiz() {
|
|||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const initialSnapshot = useRef(null);
|
const initialSnapshot = useRef(null);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
// Junction revamp — this builder runs course-scoped AND from the standalone
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
// Unit Library (/admin/units/:unitId/quiz/edit, no :courseId param).
|
||||||
{ label: "Courses", to: "/admin/courses" },
|
const scopeBase = courseId
|
||||||
{ label: course?.title ?? "…", to: `/admin/courses/${courseId}` },
|
? `/admin/courses/${courseId}/units/${unitId}`
|
||||||
{ label: unit?.title ?? "…", to: `/admin/courses/${courseId}/units/${unitId}` },
|
: `/admin/units/${unitId}`;
|
||||||
{ label: "Quiz" },
|
|
||||||
];
|
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) ─────────────
|
// ── Fetch — silently treat 404 as "no quiz yet" (create mode) ─────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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;
|
const result = data?.data?.data ?? null;
|
||||||
setLocalQuiz(result);
|
setLocalQuiz(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -245,6 +245,11 @@ export default function ViewUnitQuiz() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId, unitId } = useParams();
|
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 {
|
const {
|
||||||
fetchQuiz, quiz,
|
fetchQuiz, quiz,
|
||||||
fetchQuizCompletions, completions,
|
fetchQuizCompletions, completions,
|
||||||
@@ -293,7 +298,7 @@ export default function ViewUnitQuiz() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
|
onClick={() => navigate(`${scopeBase}/quiz/edit`)}
|
||||||
>
|
>
|
||||||
<NotebookPen className="h-4 w-4 mr-2" />
|
<NotebookPen className="h-4 w-4 mr-2" />
|
||||||
Modify Quiz
|
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">
|
<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" />
|
<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>
|
<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" />
|
<NotebookPen className="h-4 w-4 mr-2" />
|
||||||
Create Quiz
|
Create Quiz
|
||||||
</Button>
|
</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 ModifyQuiz from '../pages/courses/units/ModifyQuiz'
|
||||||
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
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
|
// Task List
|
||||||
|
|
||||||
import TaskList from '../pages/task_list/TaskList'
|
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
|
// Task
|
||||||
{
|
{
|
||||||
path: 'taskList',
|
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 [tierCategories, setTierCategories] = useState([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [levelFilter, setLevelFilter] = useState("All");
|
|
||||||
const [subFilter, setSubFilter] = useState("All");
|
const [subFilter, setSubFilter] = useState("All");
|
||||||
const [categoryFilter, setCategoryFilter] = useState("All");
|
const [categoryFilter, setCategoryFilter] = useState("All");
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -205,13 +204,12 @@ const CoursesList = () => {
|
|||||||
.filter((c) => {
|
.filter((c) => {
|
||||||
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(c.description ?? "").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 matchSub = subFilter === "All" || c.subscription === subFilter;
|
||||||
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
|
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)),
|
.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));
|
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); }}
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4 items-start w-full">
|
<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">
|
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||||
<SelectValue placeholder="Level" />
|
<SelectValue placeholder="Browse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="All">All Levels</SelectItem>
|
<SelectItem value="courses">Courses</SelectItem>
|
||||||
<SelectItem value="Beginner">Beginner</SelectItem>
|
<SelectItem value="units">Units</SelectItem>
|
||||||
<SelectItem value="Intermediate">Intermediate</SelectItem>
|
|
||||||
<SelectItem value="Advanced">Advanced</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { useNavigate, useLocation } from "react-router-dom";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||||
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
|
import UnitUpsellModal from "../components/UnitUpsellModal";
|
||||||
|
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useGroup } from "@/contexts/ClientGroupContext";
|
import { useGroup } from "@/contexts/ClientGroupContext";
|
||||||
@@ -205,6 +208,7 @@ const Client = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { state: navState } = useLocation();
|
const { state: navState } = useLocation();
|
||||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||||
|
const { units, unitsLoading, getUnits } = useLibrary();
|
||||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||||
const {
|
const {
|
||||||
@@ -216,6 +220,9 @@ const Client = () => {
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||||
|
|
||||||
|
const [unitModalOpen, setUnitModalOpen] = useState(false);
|
||||||
|
const [selectedUnit, setSelectedUnit] = useState(null);
|
||||||
|
|
||||||
const [popupOpen, setPopupOpen] = useState(false);
|
const [popupOpen, setPopupOpen] = useState(false);
|
||||||
|
|
||||||
const userTier = myTier?.tier ?? "free";
|
const userTier = myTier?.tier ?? "free";
|
||||||
@@ -235,6 +242,7 @@ const Client = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCourses();
|
getCourses();
|
||||||
|
getUnits();
|
||||||
if (!myTier) getMyTier();
|
if (!myTier) getMyTier();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -253,6 +261,7 @@ const Client = () => {
|
|||||||
|
|
||||||
// Show only first 3
|
// Show only first 3
|
||||||
const featuredCourses = courses.slice(0, 3);
|
const featuredCourses = courses.slice(0, 3);
|
||||||
|
const featuredUnits = units.slice(0, 3);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "My Groups", icon: <Users className="size-4" /> },
|
{ 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -332,6 +350,34 @@ const Client = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -383,6 +429,14 @@ const Client = () => {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
|
|
||||||
|
{/* ── Upsell Modal — only for locked units ── */}
|
||||||
|
<UnitUpsellModal
|
||||||
|
open={unitModalOpen}
|
||||||
|
onOpenChange={setUnitModalOpen}
|
||||||
|
unit={selectedUnit}
|
||||||
|
tierMap={tierMap}
|
||||||
|
/>
|
||||||
</div>
|
</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 ScrollToTop from '@/components/generic/ScrollToTop'
|
||||||
import CourseDetails from '../pages/CourseDetails'
|
import CourseDetails from '../pages/CourseDetails'
|
||||||
import UnitList from '../pages/UnitList'
|
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 { Fragment } from 'react'
|
||||||
import GroupList from '../pages/GroupList'
|
import GroupList from '../pages/GroupList'
|
||||||
import ViewTaskDetails from '../pages/ViewTaskDetails'
|
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 />,
|
path: 'group', element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
Reference in New Issue
Block a user