mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/courses/archive/columns.config";
|
||||
import { buildToolbarActions } from "../../config/courses/archive/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/courses/archive/selection.config";
|
||||
import { buildRowActions } from "../../config/courses/archive/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function ArchivedCoursesTable() {
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { courses, attributes, pagination, setPagination, loading, fetchArchivedCourses, restoreCourse, restoreCourses } = useCourses();
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: courses,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_ArchivedCourses`,
|
||||
sheetName: "Archived Courses",
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
navigate,
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
}), []);
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchArchivedCourses,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
restoreCourse: (row) => setRestoreTarget(row), // single
|
||||
restoreCourses: (ids) => setRestoreIds(ids), // bulk
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes]
|
||||
);
|
||||
|
||||
const handleRestoreSuccess = () => {
|
||||
setRestoreTarget(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchArchivedCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Archived Courses"
|
||||
data={courses}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={fetchArchivedCourses}
|
||||
onFetchFilterData={() => []}
|
||||
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="course"
|
||||
emptyMessage="No courses found."
|
||||
/>
|
||||
|
||||
{/* Single restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Course"
|
||||
getName={(c) => c?.title}
|
||||
onRestore={(c) => restoreCourse(c?.course_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleRestoreSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Course"
|
||||
onRestore={restoreCourses}
|
||||
loading={loading}
|
||||
onSuccess={handleRestoreSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function CoursesTable() {
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
@@ -26,9 +27,8 @@ export default function CoursesTable() {
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { courses, attributes, pagination, setPagination, loading, fetchCourses, deleteCourse, } = useCourses();
|
||||
const { courses, attributes, pagination, setPagination, loading, fetchCourses, archiveCourse, archiveCourses, fetchCourseFieldValues } = useCourses();
|
||||
|
||||
const handleRefsReady = (refs) => {
|
||||
tableRefsRef.current = refs;
|
||||
@@ -42,7 +42,9 @@ export default function CoursesTable() {
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/courses/${row.course_id}`),
|
||||
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
|
||||
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
|
||||
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
}), []);
|
||||
@@ -60,16 +62,18 @@ export default function CoursesTable() {
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes, rowActions]
|
||||
[attributes]
|
||||
);
|
||||
|
||||
const handleArchiveSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
setArchiveIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
||||
};
|
||||
@@ -85,7 +89,7 @@ export default function CoursesTable() {
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={fetchCourses}
|
||||
onFetchFilterData={() => []}
|
||||
onFetchFilterData={fetchCourseFieldValues}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
@@ -104,13 +108,25 @@ export default function CoursesTable() {
|
||||
emptyMessage="No courses found."
|
||||
/>
|
||||
|
||||
{/* ── Single archive ── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Course"
|
||||
getName={(c) => c?.title}
|
||||
onArchive={(c) => deleteCourse(c?.course_id)}
|
||||
onArchive={(c) => archiveCourse(c?.course_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
|
||||
{/* ── Bulk archive ── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||
ids={archiveIds ?? []}
|
||||
entityLabel="Course"
|
||||
onArchive={(ids) => archiveCourses(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Eye, ImageIcon, VideoIcon } from "lucide-react";
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/TextBlock";
|
||||
|
||||
export function LessonHeader({ lesson }) {
|
||||
if (!lesson) return null;
|
||||
return (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.875rem", fontWeight: 700, lineHeight: 1.2, margin: "0 0 0.4rem 0" }}>
|
||||
{lesson.title}
|
||||
</h1>
|
||||
{lesson.description && (
|
||||
<p style={{ margin: "0.2rem 0", lineHeight: 1.75, textAlign: "justify" }}
|
||||
className="text-muted-foreground">
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lesson.objectives?.length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-md bg-green-100 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">Objective</p>
|
||||
</div>
|
||||
<ul className="space-y-1.5 list-disc list-inside">
|
||||
{lesson.objectives.map((o) => (
|
||||
<li key={o.objective_id} className="text-sm text-muted-foreground">
|
||||
{o.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewImage({ url, alt }) {
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img src={url} alt={alt ?? ""} className="w-full rounded-md object-cover aspect-video" />
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewVideo({ url, thumb }) {
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<VideoIcon className="h-4 w-4" />
|
||||
No video
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative rounded-md overflow-hidden aspect-video bg-muted">
|
||||
{thumb ? (
|
||||
<img src={thumb} alt="Video thumbnail" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<VideoIcon className="h-10 w-10 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="h-10 w-10 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<VideoIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
|
||||
<p className="text-white text-[10px] truncate">{url}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewBlock({ block }) {
|
||||
const { type, content } = block;
|
||||
|
||||
if (type === "text") {
|
||||
if (!content.body) {
|
||||
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "image") {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
No image selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<figure>
|
||||
<img src={content.url} alt={content.alt ?? ""} className="w-full rounded-md object-cover" />
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "text-image") {
|
||||
const imgLeft = content.image_position === "left";
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
{imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "video") {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<VideoIcon className="h-4 w-4" />
|
||||
No video selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <PreviewVideo url={content.url} thumb={content.thumbnail_url} />;
|
||||
}
|
||||
|
||||
if (type === "text-video") {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
{vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
|
||||
return (
|
||||
<>
|
||||
<LessonHeader lesson={lesson} />
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Eye className="h-8 w-8 opacity-20" />
|
||||
<p>{empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewChrome({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 bg-muted/60 border-b">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-400" />
|
||||
<div className="flex-1 mx-3 h-5 rounded bg-background/60 border text-[10px] flex items-center px-2 text-muted-foreground/60 truncate">
|
||||
{title ?? "Lesson Preview"}
|
||||
</div>
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,9 @@ export default function LessonsTable({ courseId, unitId }) {
|
||||
);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}`),
|
||||
onViewPage: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/page/view`),
|
||||
onCreatePage: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/page`),
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/view`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/edit`),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
}), [courseId, unitId]);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, GripVertical, CheckCircle2, Circle } from "lucide-react";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: "true_false", label: "True / False" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice (1 correct)" },
|
||||
{ value: "multi_select", label: "Multi Select (multiple correct)" },
|
||||
];
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
// ── Single Option Row ─────────────────────────────────────────────────────────
|
||||
function OptionRow({ option, index, questionType, onUpdate, onRemove, onToggleCorrect }) {
|
||||
const isCorrect = option.is_correct;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 transition-colors",
|
||||
isCorrect ? "border-green-300 bg-green-50/60" : "border-border bg-background"
|
||||
)}>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
||||
|
||||
{/* Correct toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleCorrect(index)}
|
||||
className="shrink-0"
|
||||
title={isCorrect ? "Mark as incorrect" : "Mark as correct"}
|
||||
>
|
||||
{isCorrect
|
||||
? <CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
: <Circle className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) => onUpdate(index, { ...option, text: e.target.value })}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 border-0 shadow-none focus-visible:ring-0 p-0 h-auto text-sm"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question Card ─────────────────────────────────────────────────────────────
|
||||
export function QuestionCard({ question, index, onChange, onRemove, error }) {
|
||||
const updateField = (field, value) => onChange({ ...question, [field]: value });
|
||||
|
||||
const updateOption = (i, updated) => {
|
||||
const options = [...question.options];
|
||||
options[i] = updated;
|
||||
onChange({ ...question, options });
|
||||
};
|
||||
|
||||
const toggleCorrect = (i) => {
|
||||
const options = question.options.map((o, idx) => {
|
||||
if (question.type === "multiple_choice" || question.type === "true_false") {
|
||||
// single correct — deselect all others
|
||||
return { ...o, is_correct: idx === i };
|
||||
}
|
||||
// multi_select — toggle individual
|
||||
return idx === i ? { ...o, is_correct: !o.is_correct } : o;
|
||||
});
|
||||
onChange({ ...question, options });
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
onChange({
|
||||
...question,
|
||||
options: [
|
||||
...question.options,
|
||||
{ text: "", is_correct: false, order_index: question.options.length },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeOption = (i) => {
|
||||
onChange({
|
||||
...question,
|
||||
options: question.options.filter((_, idx) => idx !== i),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTypeChange = (type) => {
|
||||
// Reset options based on type
|
||||
const defaultOptions =
|
||||
type === "true_false"
|
||||
? [
|
||||
{ text: "True", is_correct: true, order_index: 0 },
|
||||
{ text: "False", is_correct: false, order_index: 1 },
|
||||
]
|
||||
: question.options.map((o) => ({ ...o, is_correct: false }));
|
||||
|
||||
onChange({ ...question, type, options: defaultOptions });
|
||||
};
|
||||
|
||||
const correctCount = question.options.filter((o) => o.is_correct).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/40 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-semibold">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Select value={question.type} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger className="h-7 text-xs border-0 bg-transparent shadow-none w-auto gap-1 px-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value} className="text-xs">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
{correctCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs text-green-600 border-green-300">
|
||||
{correctCount} correct
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Question text */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">Question</Label>
|
||||
<Textarea
|
||||
value={question.question}
|
||||
onChange={(e) => updateField("question", e.target.value)}
|
||||
placeholder="Enter your question here…"
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<FieldError message={error?.question} />
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Options
|
||||
<span className="ml-1 normal-case text-muted-foreground/60">
|
||||
— click circle to mark correct
|
||||
</span>
|
||||
</Label>
|
||||
{question.type !== "true_false" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
className="h-6 text-xs gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add option
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option, i) => (
|
||||
<OptionRow
|
||||
key={i}
|
||||
option={option}
|
||||
index={i}
|
||||
questionType={question.type}
|
||||
onUpdate={updateOption}
|
||||
onRemove={removeOption}
|
||||
onToggleCorrect={toggleCorrect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FieldError message={error?.options} />
|
||||
</div>
|
||||
|
||||
{/* Points + Explanation row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">Points</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={question.points ?? 1}
|
||||
onChange={(e) => updateField("points", parseInt(e.target.value) || 1)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Explanation <span className="normal-case text-muted-foreground/60">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={question.explanation ?? ""}
|
||||
onChange={(e) => updateField("explanation", e.target.value)}
|
||||
placeholder="Shown after answer"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Make a blank question ─────────────────────────────────────────────────────
|
||||
export function makeQuestion(type = "multiple_choice") {
|
||||
const defaultOptions = {
|
||||
true_false: [
|
||||
{ text: "True", is_correct: true, order_index: 0 },
|
||||
{ text: "False", is_correct: false, order_index: 1 },
|
||||
],
|
||||
multiple_choice: [
|
||||
{ text: "", is_correct: true, order_index: 0 },
|
||||
{ text: "", is_correct: false, order_index: 1 },
|
||||
{ text: "", is_correct: false, order_index: 2 },
|
||||
],
|
||||
multi_select: [
|
||||
{ text: "", is_correct: true, order_index: 0 },
|
||||
{ text: "", is_correct: true, order_index: 1 },
|
||||
{ text: "", is_correct: false, order_index: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
_tempId: crypto.randomUUID(),
|
||||
type,
|
||||
question: "",
|
||||
explanation: "",
|
||||
points: 1,
|
||||
order_index: 0,
|
||||
options: defaultOptions[type] ?? [],
|
||||
};
|
||||
}
|
||||
@@ -48,7 +48,9 @@ export default function UnitsTable({ courseId }) {
|
||||
);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}`),
|
||||
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz`),
|
||||
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/edit`),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
}), [courseId]);
|
||||
|
||||
@@ -12,13 +12,13 @@ export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View",
|
||||
label: "View Info",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users table.
|
||||
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Book, BookOpenCheck, Clock } from "lucide-react";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
unitCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Book className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "unit" : "units"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
lessonCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "lesson" : "lessons"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// modules/admin/config/assets/rowActions.config.jsx
|
||||
import { RotateCcw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.onView (row) → void — navigate to view page
|
||||
* @param {Function} deps.onEdit (row) → void — navigate to edit page
|
||||
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||
*/
|
||||
export function buildRowActions({ onRestore }) {
|
||||
return [
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
className: "text-emerald-600 focus:text-emerald-600",
|
||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
hidden: (row) => row.is_active,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// config/assets/archive/selection.config.jsx
|
||||
import { Download, ArchiveRestore } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, 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" />,
|
||||
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700",
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.course_id);
|
||||
ids.length === 1
|
||||
? restoreCourse(rows[0])
|
||||
: restoreCourses(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchArchivedCourses,
|
||||
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: () => fetchArchivedCourses({
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,38 +1,70 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users table.
|
||||
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Book, BookOpenCheck, Clock } from "lucide-react";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
|
||||
export const columnPinning = {
|
||||
left: ["select", "title"],
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const base = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-xs block">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.order}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return rowActions ? [...base, rowActions] : base;
|
||||
}
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
unitCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Book className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "unit" : "units"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
lessonCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "lesson" : "lessons"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users 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: [],
|
||||
};
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
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,41 @@
|
||||
import { Eye, Pencil, Archive, SquarePlus, SquareChartGantt } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onCreatePage, onViewPage, onView, onEdit, onArchive }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Lesson",
|
||||
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: "edit",
|
||||
label: "View Content",
|
||||
icon: <SquareChartGantt className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onViewPage(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Modify Content",
|
||||
icon: <SquarePlus className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onCreatePage(row),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Download, Archive } 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.asset_id);
|
||||
ids.length === 1
|
||||
? onArchive(rows[0])
|
||||
: onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// config/courses/lessons/toolbar.config.jsx
|
||||
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchLessons,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
courseId,
|
||||
unitId,
|
||||
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/courses/${courseId}/units/${unitId}/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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,52 +1,47 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users 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 = {
|
||||
left: ["select", "title"],
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const base = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-xs block">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.order}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "page",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const blocks = row.original.page?.blocks ?? [];
|
||||
return blocks.length ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No content</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
duration_seconds: (info) => {
|
||||
const seconds = parseInt(info.getValue() ?? 0, 10);
|
||||
|
||||
return rowActions ? [...base, rowActions] : base;
|
||||
}
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Lesson Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -1,35 +1,41 @@
|
||||
// config/courses/lessons/rowActions.config.jsx
|
||||
import { Eye, Pencil, Archive, SquarePlus, SquareChartGantt } from "lucide-react";
|
||||
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
export function buildRowActions({ onCreatePage, onViewPage, onView, onEdit, onArchive }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Lesson",
|
||||
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: "edit",
|
||||
label: "View Content",
|
||||
icon: <SquareChartGantt className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onViewPage(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Modify Content",
|
||||
icon: <SquarePlus className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onCreatePage(row),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Download, Archive } 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.asset_id);
|
||||
ids.length === 1
|
||||
? onArchive(rows[0])
|
||||
: onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// config/courses/lessons/toolbar.config.jsx
|
||||
|
||||
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -46,8 +46,17 @@ export function buildToolbarActions({
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate(
|
||||
`/admin/courses/${courseId}/units/${unitId}/lessons/create`
|
||||
`/admin/courses/${courseId}/units/${unitId}/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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Info",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
{
|
||||
key: "view_units",
|
||||
label: "View Units",
|
||||
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onViewUnits(row),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "modify_assessment",
|
||||
label: "Modify Assessment",
|
||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onAssessment(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive Course",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({ exportConfig, getTableInstance }) {
|
||||
export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance }) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
@@ -14,5 +14,17 @@ export function buildSelectionActions({ exportConfig, getTableInstance }) {
|
||||
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.course_id);
|
||||
ids.length === 1
|
||||
? onArchive(rows[0])
|
||||
: onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -41,7 +41,16 @@ export function buildToolbarActions({
|
||||
label: "New Course",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/courses/create"),
|
||||
onClick: () => navigate("/admin/courses/add"),
|
||||
},
|
||||
{
|
||||
key: "archived-courses",
|
||||
type: "button",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
label: "Archived Courses",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: () => navigate("/admin/courses/archived"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users 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: [],
|
||||
};
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
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,42 @@
|
||||
import { Eye, Pencil, Archive, BookCheck, NotebookPen } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Info",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
{
|
||||
key: "view_units",
|
||||
label: "View Lessons",
|
||||
icon: <BookCheck className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onViewLessons(row),
|
||||
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),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Download, Archive } 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.asset_id);
|
||||
ids.length === 1
|
||||
? onArchive(rows[0])
|
||||
: onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchUnits,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
courseId,
|
||||
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/courses/${courseId}/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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,40 +1,47 @@
|
||||
// config/courses/units/columns.config.jsx
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users 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 = {
|
||||
left: ["select", "title"],
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const base = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-xs block">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order",
|
||||
header: "Order",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.order}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
duration_seconds: (info) => {
|
||||
const seconds = parseInt(info.getValue() ?? 0, 10);
|
||||
|
||||
return rowActions ? [...base, rowActions] : base;
|
||||
}
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Unit Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -1,33 +1,42 @@
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, BookCheck, NotebookPen } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(row.original)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Info",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
{
|
||||
key: "view_units",
|
||||
label: "View Lessons",
|
||||
icon: <BookCheck className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => onViewLessons(row),
|
||||
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),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Download, Archive } 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.asset_id);
|
||||
ids.length === 1
|
||||
? onArchive(rows[0])
|
||||
: onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -42,7 +42,16 @@ export function buildToolbarActions({
|
||||
label: "New Unit",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate(`/admin/courses/${courseId}/units/create`),
|
||||
onClick: () => navigate(`/admin/courses/${courseId}/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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ export function buildRowActions({ navigate, onEdit, onArchive }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View details",
|
||||
label: "View Info",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`view/${row.group_id}`),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit details",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onEdit(row),
|
||||
},
|
||||
|
||||
@@ -15,13 +15,13 @@ export function buildRowActions({ navigate, onArchive }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View details",
|
||||
label: "View Info",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`view/${row.user_id}`),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit details",
|
||||
label: "Edit Info",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`edit/${row.user_id}`),
|
||||
disabled: (row) => row.role === "super_admin",
|
||||
|
||||
@@ -42,12 +42,12 @@ const AdminLayout = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="philproperties-admin">
|
||||
<section id="philproperties-admin" className="min-h-screen flex flex-col">
|
||||
<TooltipProvider>
|
||||
<div className={cn('')} >
|
||||
<div className={cn('sticky top-0 z-50 bg-background')} >
|
||||
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin/${id}`)}>
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
|
||||
<img src="/philpro-white.png" alt="" className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -81,11 +81,16 @@ const AdminLayout = () => {
|
||||
|
||||
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
|
||||
<AdminProvider>
|
||||
<div id="main-body">
|
||||
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col">
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</div>
|
||||
</AdminProvider>
|
||||
|
||||
{/* Footer sits outside AdminProvider, at the bottom of the flex column */}
|
||||
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
||||
© Philproperties, 2026
|
||||
</footer>
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
{/* ── Sticky tab bar — driven by the same array ── */}
|
||||
<div className="sticky top-0 z-10 bg-background border-b">
|
||||
<div className="sticky z-10 bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
|
||||
<Tabs defaultValue="">
|
||||
<ScrollArea className="max-w-full overflow-x-auto w-full">
|
||||
<TabsList className="bg-background rounded-none justify-start mx-2 my-1 flex gap-1">
|
||||
@@ -38,7 +38,7 @@ export default function AdminDashboard() {
|
||||
|
||||
{/* ── Sections — same array, one DashboardGrid per entry ── */}
|
||||
{ADMIN_SECTIONS.map((s) => (
|
||||
<div key={s.id} id={s.id} className="scroll-mt-12 min-h-64">
|
||||
<div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64">
|
||||
{s.tiles.length > 0 ? (
|
||||
<DashboardGrid sections={[s]} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.enum(["free", "premium"]).default("free"),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { createCourse, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
course_code: "",
|
||||
order_index: 0,
|
||||
level: "beginner",
|
||||
subscription: "free",
|
||||
objectives: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
useFieldArray({ control, name: "objectives" });
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const payload = {
|
||||
...values,
|
||||
objectives: values.objectives.map((o) => o.text),
|
||||
level: values.level || null,
|
||||
course_code: values.course_code || null,
|
||||
createdBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const result = await createCourse(payload);
|
||||
if (!result) return;
|
||||
navigate("/admin/courses");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start 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">Course Details</h1>
|
||||
<p className="text-sm text-muted-foreground">View course information.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Basic Info ── */}
|
||||
<SectionCard title="Basic Information">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course_code">Course Code</Label>
|
||||
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
|
||||
<FieldError message={errors.course_code?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="order_index">Order</Label>
|
||||
<Input id="order_index" type="number" min={0} {...register("order_index")} />
|
||||
<FieldError message={errors.order_index?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Level</Label>
|
||||
<Select
|
||||
value={watch("level") ?? ""}
|
||||
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.level?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watch("subscription") ?? "free"}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select subscription" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
<SectionCard
|
||||
title="Learning Objectives"
|
||||
description="What will learners be able to do after completing this course?"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{objectiveFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeObjective(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-1"
|
||||
onClick={() => appendObjective({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Objective
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<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 Course
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import ArchivedCoursesTable from "../../components/courses/ArchivedCourseTable";
|
||||
|
||||
export default function ArchivedCourseList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: "Archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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">
|
||||
<ArchivedCoursesTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor";
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
function validate(questions) {
|
||||
const errors = {};
|
||||
questions.forEach((q, i) => {
|
||||
const qErr = {};
|
||||
if (!q.question?.trim()) qErr.question = "Question text is required.";
|
||||
const correctCount = q.options.filter((o) => o.is_correct).length;
|
||||
if (correctCount === 0) qErr.options = "At least one correct answer is required.";
|
||||
if (q.options.some((o) => !o.text?.trim())) qErr.options = "All option texts are required.";
|
||||
if (Object.keys(qErr).length) errors[i] = qErr;
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── Jump to input ─────────────────────────────────────────────────────────────
|
||||
|
||||
function JumpToInput({ max, onJump }) {
|
||||
const [val, setVal] = useState("");
|
||||
|
||||
const handleJump = () => {
|
||||
const n = parseInt(val, 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= max) {
|
||||
onJump(n - 1);
|
||||
setVal("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleJump()}
|
||||
placeholder={`1–${max}`}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs shrink-0"
|
||||
onClick={handleJump}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question Navigator ────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_LABEL = {
|
||||
multiple_choice: "MC",
|
||||
multi_select: "MS",
|
||||
true_false: "TF",
|
||||
};
|
||||
|
||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, errors }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-3 py-3 border-b shrink-0">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Questions
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{questions.length} total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||||
No questions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5 px-2">
|
||||
{questions.map((q, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const hasError = !!errors?.[i];
|
||||
const typeLabel = TYPE_LABEL[q.type] ?? "Q";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q._tempId ?? q.question_id ?? i}
|
||||
ref={(el) => (navItemRefs.current[i] = el)}
|
||||
onClick={() => onJump(i)}
|
||||
className={cn(
|
||||
"group flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasError
|
||||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
: "hover:bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
{/* Number badge */}
|
||||
<span className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-semibold",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: hasError
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{i + 1}
|
||||
</span>
|
||||
|
||||
{/* Type */}
|
||||
<span className="text-[10px] font-medium text-muted-foreground shrink-0 w-5">
|
||||
{typeLabel}
|
||||
</span>
|
||||
|
||||
{/* Question preview */}
|
||||
<span className="flex-1 text-xs truncate min-w-0">
|
||||
{q.question?.trim()
|
||||
? q.question.trim()
|
||||
: <span className="italic text-muted-foreground">Untitled</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
{/* Move buttons — show on hover */}
|
||||
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onMove(i, "up"); }}
|
||||
disabled={i === 0}
|
||||
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onMove(i, "down"); }}
|
||||
disabled={i === questions.length - 1}
|
||||
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jump to — only when enough questions */}
|
||||
{questions.length > 5 && (
|
||||
<div className="px-3 py-3 border-t shrink-0 space-y-1.5">
|
||||
<p className="text-xs text-muted-foreground">Jump to question</p>
|
||||
<JumpToInput max={questions.length} onJump={onJump} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CourseAssessment() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const {
|
||||
fetchAssessment, createAssessment, updateAssessment,
|
||||
createAssessmentQuestion, updateAssessmentQuestion,
|
||||
course, assessment, loading,
|
||||
} = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [passingScore, setPassingScore] = useState(70);
|
||||
const [timeLimit, setTimeLimit] = useState("");
|
||||
const [isRequired, setIsRequired] = useState(false);
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
const headerRef = useRef(null);
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchAssessment(courseId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!assessment) return;
|
||||
setTitle(assessment.title ?? "");
|
||||
setPassingScore(assessment.passing_score ?? 70);
|
||||
setTimeLimit(assessment.time_limit_minutes ?? "");
|
||||
setIsRequired(assessment.is_required === true || assessment.is_required === 1);
|
||||
setMaxQuestions(assessment.max_questions ?? "");
|
||||
setQuestions(
|
||||
(assessment.questions ?? []).map((q) => ({
|
||||
...q,
|
||||
_tempId: q.question_id,
|
||||
options: q.options ?? [],
|
||||
}))
|
||||
);
|
||||
}, [assessment]);
|
||||
|
||||
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return;
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--assessment-h",
|
||||
`${headerRef.current.offsetHeight}px`
|
||||
);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
// ── Scroll to keep active nav item visible ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
navItemRefs.current[activeIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, [activeIndex]);
|
||||
|
||||
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
|
||||
useEffect(() => {
|
||||
if (!questions.length) return;
|
||||
const observers = [];
|
||||
|
||||
questionRefs.current.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) setActiveIndex(i); },
|
||||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
observer.observe(el);
|
||||
observers.push(observer);
|
||||
});
|
||||
|
||||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, [questions.length]);
|
||||
|
||||
// ── Scroll helper with sticky offset ──────────────────────────────────────
|
||||
const scrollToQuestion = (index) => {
|
||||
const el = questionRefs.current[index];
|
||||
if (!el) return;
|
||||
const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--navbar-h") || "0", 10);
|
||||
const assessmentH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--assessment-h") || "0", 10);
|
||||
const offset = navbarH + assessmentH + 16;
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// ── Question actions ───────────────────────────────────────────────────────
|
||||
const addQuestion = (type = "multiple_choice") => {
|
||||
setQuestions((prev) => {
|
||||
const next = [...prev, { ...makeQuestion(type), order_index: prev.length }];
|
||||
setTimeout(() => {
|
||||
const idx = next.length - 1;
|
||||
setActiveIndex(idx);
|
||||
scrollToQuestion(idx);
|
||||
}, 50);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuestion = (index, updated) => {
|
||||
setQuestions((prev) => prev.map((q, i) => i === index ? updated : q));
|
||||
setErrors((prev) => { const e = { ...prev }; delete e[index]; return e; });
|
||||
};
|
||||
|
||||
const removeQuestion = (index) => {
|
||||
setQuestions((prev) => prev.filter((_, i) => i !== index));
|
||||
setActiveIndex((prev) => Math.max(0, prev >= index ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const moveQuestion = (index, direction) => {
|
||||
setQuestions((prev) => {
|
||||
const next = [...prev];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= next.length) return prev;
|
||||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
setActiveIndex(newIndex);
|
||||
setTimeout(() => scrollToQuestion(newIndex), 50);
|
||||
};
|
||||
|
||||
const jumpTo = (index) => {
|
||||
setActiveIndex(index);
|
||||
scrollToQuestion(index);
|
||||
};
|
||||
|
||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const errs = validate(questions);
|
||||
if (Object.keys(errs).length) {
|
||||
setErrors(errs);
|
||||
// Jump to first error
|
||||
const firstErr = parseInt(Object.keys(errs)[0], 10);
|
||||
jumpTo(firstErr);
|
||||
return;
|
||||
}
|
||||
|
||||
let assessmentId = assessment?.assessment_id;
|
||||
const meta = {
|
||||
title: title || "Course Assessment",
|
||||
passing_score: passingScore,
|
||||
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
updatedBy: user?.user_id,
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
|
||||
if (!assessmentId) {
|
||||
const res = await createAssessment(courseId, meta);
|
||||
assessmentId = res?.data?.data?.data?.assessment_id;
|
||||
if (!assessmentId) return;
|
||||
} else {
|
||||
await updateAssessment(courseId, assessmentId, meta);
|
||||
}
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
|
||||
if (q.question_id) {
|
||||
await updateAssessmentQuestion(courseId, assessmentId, q.question_id, q);
|
||||
} else {
|
||||
await createAssessmentQuestion(courseId, assessmentId, q);
|
||||
}
|
||||
}
|
||||
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
|
||||
{/* ── Sticky header ── */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
Course Assessment
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Assessment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Split layout ── */}
|
||||
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
|
||||
|
||||
{/* LEFT — Navigator (desktop only) */}
|
||||
<div
|
||||
className="hidden lg:flex flex-col w-60 shrink-0 border-r bg-background"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: `calc(var(--navbar-h) + var(--assessment-h, 0px))`,
|
||||
height: `calc(100vh - var(--navbar-h) - var(--assessment-h, 0px))`,
|
||||
}}
|
||||
>
|
||||
<QuestionNavigator
|
||||
questions={questions}
|
||||
activeIndex={activeIndex}
|
||||
onJump={jumpTo}
|
||||
onMove={moveQuestion}
|
||||
navItemRefs={navItemRefs}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Main content */}
|
||||
<div className="flex-1 min-w-0 px-4 lg:px-8 py-6">
|
||||
{initializing ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-5xl space-y-6 pb-16">
|
||||
|
||||
{/* ── Settings ── */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<p className="text-sm font-medium">Settings</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Course Assessment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Passing Score (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={passingScore}
|
||||
onChange={(e) => setPassingScore(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
Time Limit{" "}
|
||||
<span className="text-muted-foreground font-normal text-xs">(mins, blank = none)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={timeLimit}
|
||||
onChange={(e) => setTimeLimit(e.target.value)}
|
||||
placeholder="No limit"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
Max Questions{" "}
|
||||
<span className="text-muted-foreground font-normal text-xs">(blank = show all)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number" min={1} max={questions.length || undefined}
|
||||
value={maxQuestions}
|
||||
onChange={(e) => setMaxQuestions(e.target.value)}
|
||||
placeholder={`All (${questions.length})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="assessment_required"
|
||||
checked={isRequired === true}
|
||||
onCheckedChange={(val) => setIsRequired(val)}
|
||||
/>
|
||||
<Label htmlFor="assessment_required" className="cursor-pointer">
|
||||
Required to complete course
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||||
<p className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
|
||||
Takers will see <strong>{maxQuestions}</strong> randomly selected questions
|
||||
out of <strong>{questions.length}</strong> in the pool.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Questions ── */}
|
||||
<div className="space-y-4">
|
||||
{questions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No questions yet. Add one below.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<div
|
||||
key={q._tempId ?? q.question_id ?? i}
|
||||
ref={(el) => (questionRefs.current[i] = el)}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
>
|
||||
<QuestionCard
|
||||
question={q}
|
||||
index={i}
|
||||
onChange={(updated) => updateQuestion(i, updated)}
|
||||
onRemove={() => removeQuestion(i)}
|
||||
error={errors[i]}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Add question ── */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-3">
|
||||
Add Question
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ type: "multiple_choice", label: "Multiple Choice" },
|
||||
{ type: "multi_select", label: "Multi Select" },
|
||||
{ type: "true_false", label: "True / False" },
|
||||
].map(({ type, label }) => (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addQuestion(type)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import UnitsTable from "../../components/courses/UnitsTable";
|
||||
|
||||
export default function CourseDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "..." },
|
||||
];
|
||||
|
||||
// Replace the loading check
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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">
|
||||
|
||||
{/* ── Course header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{course?.title}</h1>
|
||||
{course?.description && (
|
||||
<p className="text-sm text-muted-foreground">{course.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/admin/courses/${courseId}/edit`)}>
|
||||
Edit Course
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Units ── */}
|
||||
<UnitsTable courseId={courseId} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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 { House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function CreateCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { createCourse, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: "Create" },
|
||||
];
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createCourse({ ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate("/admin/courses");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-2xl">
|
||||
<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 Course</h1>
|
||||
<p className="text-sm text-muted-foreground">Add a new course.</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="Course 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 className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</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 Course
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -13,63 +13,129 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.enum(["free", "premium"]).default("free"),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EditCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, updateCourse, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
course_code: "",
|
||||
order_index: 0,
|
||||
level: undefined,
|
||||
subscription: "free",
|
||||
objectives: [],
|
||||
},
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: "Edit" },
|
||||
];
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
useFieldArray({ control, name: "objectives" });
|
||||
|
||||
// ─── Load existing course data ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchCourse(courseId);
|
||||
const course = res?.data?.data ?? null;
|
||||
if (!course) return;
|
||||
const c = res?.data?.data ?? null;
|
||||
if (!c) return;
|
||||
|
||||
reset({
|
||||
title: course.title ?? "",
|
||||
description: course.description ?? "",
|
||||
order: course.order ?? 0,
|
||||
title: c.title ?? "",
|
||||
description: c.description ?? "",
|
||||
course_code: c.course_code ?? "",
|
||||
order_index: c.order_index ?? 0,
|
||||
level: c.level ?? undefined,
|
||||
subscription: c.subscription ?? "free",
|
||||
objectives: (c.objectives ?? []).map((o) => ({
|
||||
objective_id: o.objective_id ?? null, // ← carry the id
|
||||
value: o.text ?? "", // ← form field is "value"
|
||||
})),
|
||||
});
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const onSubmit = async (values) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateCourse(courseId, { ...data, updatedBy: user?.user_id });
|
||||
|
||||
console.log(values)
|
||||
const payload = {
|
||||
...values,
|
||||
objectives: values.objectives?.map((o, i) => ({
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.text,
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
level: values.level || null,
|
||||
course_code: values.course_code || null,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const result = await updateCourse(courseId, payload);
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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>
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<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">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<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" />
|
||||
@@ -80,29 +146,129 @@ export default function EditCourse() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Basic Info ── */}
|
||||
<SectionCard title="Basic Information">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Course title" {...register("title")} />
|
||||
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...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")} />
|
||||
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course_code">Course Code</Label>
|
||||
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
|
||||
<FieldError message={errors.course_code?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="order_index">Order</Label>
|
||||
<Input id="order_index" type="number" min={0} {...register("order_index")} />
|
||||
<FieldError message={errors.order_index?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Level</Label>
|
||||
<Select
|
||||
value={watch("level") ?? ""}
|
||||
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.level?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watch("subscription") ?? "free"}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select subscription" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
<SectionCard
|
||||
title="Learning Objectives"
|
||||
description="What will learners be able to do after completing this course?"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{objectiveFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeObjective(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-1"
|
||||
onClick={() => appendObjective({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Objective
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !isDirty}>
|
||||
@@ -110,9 +276,10 @@ export default function EditCourse() {
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// modules/admin/pages/courses/LessonDetail.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Layout } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function LessonDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, course, unit, lesson, lessonPage, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: lesson?.title ?? "..." },
|
||||
];
|
||||
|
||||
// Replace the loading check
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-3xl space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
|
||||
{lesson?.description && (
|
||||
<p className="text-sm text-muted-foreground">{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
|
||||
>
|
||||
Edit Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Page builder card ── */}
|
||||
<div className="rounded-lg border bg-card p-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Layout className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Lesson Content</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lessonPage?.blocks?.length
|
||||
? `${lessonPage.blocks.length} block${lessonPage.blocks.length !== 1 ? "s" : ""}`
|
||||
: "No content yet"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(
|
||||
`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
||||
)}
|
||||
>
|
||||
{lessonPage?.blocks?.length ? "Edit Content" : "Add Content"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Save } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
||||
|
||||
function makeBlock(type) {
|
||||
return {
|
||||
id: nanoid(),
|
||||
type,
|
||||
content: { ...DEFAULT_CONTENT[type] },
|
||||
};
|
||||
}
|
||||
|
||||
export default function LessonPageBuilder() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
// ── Populate blocks from saved page ───────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (lessonPage?.blocks?.length) {
|
||||
setBlocks(lessonPage.blocks);
|
||||
}
|
||||
}, [lessonPage]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: lesson?.title ?? "Lesson", to: `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}` },
|
||||
{ label: "Page Builder" },
|
||||
];
|
||||
|
||||
// ── Block actions ─────────────────────────────────────────────────────────
|
||||
|
||||
const addBlock = (type) =>
|
||||
setBlocks((prev) => [...prev, makeBlock(type)]);
|
||||
|
||||
const updateBlock = (id, content) =>
|
||||
setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, content } : b)));
|
||||
|
||||
const deleteBlock = (id) =>
|
||||
setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
|
||||
const moveBlock = (id, direction) => {
|
||||
setBlocks((prev) => {
|
||||
const index = prev.findIndex((b) => b.id === id);
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveLessonPage(courseId, unitId, lessonId, {
|
||||
blocks,
|
||||
updatedBy: user?.user_id,
|
||||
});
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 max-w-3xl space-y-6 pb-10">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Page Builder</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lesson?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Blocks ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
Content Blocks
|
||||
{blocks.length > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
{blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BlockList
|
||||
blocks={blocks}
|
||||
onUpdate={updateBlock}
|
||||
onMove={moveBlock}
|
||||
onDelete={deleteBlock}
|
||||
/>
|
||||
|
||||
<AddBlockMenu onAdd={addBlock} />
|
||||
</div>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading
|
||||
? <Spinner className="h-4 w-4 mr-2" />
|
||||
: <Save className="h-4 w-4 mr-2" />
|
||||
}
|
||||
Save Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, House, Pencil, Clock, BookOpen, Layers,
|
||||
BadgeCheck, Tag, Star, Lock, ListChecks,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LEVEL_BADGE = {
|
||||
beginner: "secondary",
|
||||
intermediate: "outline",
|
||||
advanced: "destructive",
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_BADGE = {
|
||||
free: "secondary",
|
||||
premium: "default",
|
||||
};
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourse(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<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">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-start justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Course Details</h1>
|
||||
<p className="text-sm text-muted-foreground">View course information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && !course ? (
|
||||
<LoadingSkeleton />
|
||||
) : !course ? (
|
||||
<div className="text-sm text-muted-foreground">Course not found.</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* ── Basic Info ── */}
|
||||
<SectionCard icon={BookOpen} title="Basic Information">
|
||||
<div className="space-y-4">
|
||||
<InfoRow label="Title">{course.title}</InfoRow>
|
||||
|
||||
{course.description && (
|
||||
<InfoRow label="Description">
|
||||
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
|
||||
{course.description}
|
||||
</span>
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Course Code">
|
||||
{course.course_code ?? <span className="text-muted-foreground italic text-sm">—</span>}
|
||||
</InfoRow>
|
||||
<InfoRow label="Order Index">{course.order_index ?? 0}</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard icon={Tag} title="Settings">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Level">
|
||||
{course.level ? (
|
||||
<Badge variant={LEVEL_BADGE[course.level] ?? "outline"} className="capitalize mt-0.5">
|
||||
{course.level}
|
||||
</Badge>
|
||||
) : null}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow label="Subscription">
|
||||
<Badge variant={SUBSCRIPTION_BADGE[course.subscription] ?? "outline"} className="capitalize mt-0.5">
|
||||
{course.subscription ?? "free"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Duration & Stats ── */}
|
||||
<SectionCard icon={Clock} title="Duration & Stats">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<InfoRow label="Duration">
|
||||
{course.duration_formatted ?? course.duration_seconds
|
||||
? `${course.duration_seconds}s`
|
||||
: "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Units">
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{course.unitCount ?? course.units?.length ?? 0}
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow label="Lessons">
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{course.lessonCount ?? 0}
|
||||
</div>
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
{course.objectives?.length > 0 && (
|
||||
<SectionCard icon={ListChecks} title="Learning Objectives">
|
||||
<ul className="space-y-2">
|
||||
{course.objectives.map((obj, i) => (
|
||||
<li key={obj.objective_id ?? i} className="flex items-start gap-2 text-sm">
|
||||
<BadgeCheck className="h-4 w-4 text-primary mt-0.5 shrink-0" />
|
||||
{obj.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Prerequisites ── */}
|
||||
{course.prerequisites?.length > 0 && (
|
||||
<SectionCard icon={Star} title="Prerequisites">
|
||||
<ul className="space-y-2">
|
||||
{course.prerequisites.map((p, i) => (
|
||||
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
|
||||
<span className="text-muted-foreground">ID: {p.ref_id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Assessment ── */}
|
||||
{course.assessment && (
|
||||
<SectionCard icon={Lock} title="Final Assessment">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
|
||||
<InfoRow label="Required">
|
||||
<Badge variant={course.assessment.is_required ? "default" : "secondary"}>
|
||||
{course.assessment.is_required ? "Required" : "Optional"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
|
||||
<InfoRow label="Time Limit">
|
||||
{course.assessment.time_limit_minutes
|
||||
? `${course.assessment.time_limit_minutes} mins`
|
||||
: "No limit"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Audit ── */}
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
|
||||
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+73
-22
@@ -1,12 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -17,6 +17,9 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -24,39 +27,39 @@ function FieldError({ message }) {
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function CreateLesson() {
|
||||
export default function AddLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { createLesson, course, unit, loading } = useCourses();
|
||||
const { createLesson, fetchUnit, course, unit, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
const { register, handleSubmit, control, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [] },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: "Create Lesson" },
|
||||
];
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnit(courseId, unitId);
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createLesson(courseId, unitId, { ...data, createdBy: user?.user_id });
|
||||
const payload = {
|
||||
...data,
|
||||
objectives: data.objectives?.map((o) => o.value) ?? [],
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
const result = await createLesson(courseId, unitId, payload);
|
||||
if (!result) return;
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}`);
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
<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" />
|
||||
@@ -88,6 +91,54 @@ export default function CreateLesson() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<p className="text-xs text-muted-foreground">What learners will achieve from this lesson.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No objectives yet. Click Add to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.value?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="text-muted-foreground hover:text-destructive mt-0.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
@@ -102,4 +153,4 @@ export default function CreateLesson() {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
-17
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -17,6 +18,9 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -29,29 +33,26 @@ export default function EditLesson() {
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, course, unit, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
const [lessonTitle, setLessonTitle] = useState("");
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
const { register, handleSubmit, reset, control, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [] },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: "Edit Lesson" },
|
||||
];
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchLesson(courseId, unitId, lessonId);
|
||||
const lesson = res?.data?.data ?? null;
|
||||
if (!lesson) return;
|
||||
setLessonTitle(lesson.title ?? "");
|
||||
reset({
|
||||
title: lesson.title ?? "",
|
||||
description: lesson.description ?? "",
|
||||
order: lesson.order ?? 0,
|
||||
objectives: lesson.objectives?.map((v) => ({ value: v.text })) ?? [],
|
||||
});
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
@@ -60,6 +61,11 @@ export default function EditLesson() {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateLesson(courseId, unitId, lessonId, {
|
||||
...data,
|
||||
objectives: data.objectives?.map((o, i) => ({
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.value,
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
updatedBy: user?.user_id,
|
||||
});
|
||||
if (!result) return;
|
||||
@@ -68,10 +74,7 @@ export default function EditLesson() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -105,6 +108,54 @@ export default function EditLesson() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<p className="text-xs text-muted-foreground">What learners will achieve from this lesson.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No objectives yet. Click Add to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.value?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="text-muted-foreground hover:text-destructive mt-0.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
@@ -119,4 +170,4 @@ export default function EditLesson() {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// pages/admin/LessonPageBuilder.jsx
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Save, Pencil, Monitor, Eye, EyeOff, X } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
|
||||
function makeBlock(type) {
|
||||
return {
|
||||
id: nanoid(),
|
||||
type,
|
||||
content: { ...DEFAULT_CONTENT[type] },
|
||||
};
|
||||
}
|
||||
|
||||
export default function LessonPageBuilder() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [previewVisible, setPreviewVisible] = useState(true);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
const headerRef = useRef(null);
|
||||
const blocksSeeded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lessonPage?.blocks?.length && !blocksSeeded.current) {
|
||||
setBlocks(lessonPage.blocks);
|
||||
blocksSeeded.current = true;
|
||||
}
|
||||
}, [lessonPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return;
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--builder-h",
|
||||
`${headerRef.current.offsetHeight}px`
|
||||
);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
const addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
|
||||
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
|
||||
const deleteBlock = (id) => setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
const moveBlock = (id, direction) => {
|
||||
setBlocks((prev) => {
|
||||
const index = prev.findIndex((b) => b.id === id);
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveLessonPage(courseId, unitId, lessonId, {
|
||||
blocks,
|
||||
updatedBy: user?.user_id,
|
||||
});
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const editorStyle = previewVisible
|
||||
? { top: "calc(var(--navbar-h) + var(--builder-h, 0px))" }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
|
||||
{/* ── Sticky builder header ── */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight">Page Builder</h1>
|
||||
<p className="text-sm text-muted-foreground truncate">{lesson?.title}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPreviewVisible((v) => !v)}
|
||||
className="hidden lg:inline-flex gap-2"
|
||||
>
|
||||
{previewVisible
|
||||
? <><EyeOff className="h-4 w-4" /> Hide Preview</>
|
||||
: <><Eye className="h-4 w-4" /> Show Preview</>
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
className="lg:hidden gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Split pane ── */}
|
||||
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
|
||||
|
||||
{/* LEFT — Editor */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-r",
|
||||
previewVisible ? "lg:w-[40%] lg:shrink-0 lg:sticky" : "lg:w-full"
|
||||
)}
|
||||
style={editorStyle}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 p-4 pb-10",
|
||||
previewVisible && "lg:overflow-y-auto"
|
||||
)}
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
const applyHeight = () => {
|
||||
if (window.innerWidth >= 1024 && previewVisible) {
|
||||
el.style.height = `calc(100vh - var(--navbar-h) - var(--builder-h, 0px))`;
|
||||
} else {
|
||||
el.style.height = "auto";
|
||||
}
|
||||
};
|
||||
applyHeight();
|
||||
window.addEventListener("resize", applyHeight);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
|
||||
<Pencil className="h-4 w-4" />
|
||||
Editor
|
||||
{blocks.length > 0 && (
|
||||
<span className="text-xs font-normal">
|
||||
· {blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{initializing ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<BlockList blocks={blocks} onUpdate={updateBlock} onMove={moveBlock} onDelete={deleteBlock} />
|
||||
<AddBlockMenu onAdd={addBlock} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Live Preview (desktop only) */}
|
||||
{previewVisible && (
|
||||
<div className="hidden lg:flex flex-1 flex-col gap-3 p-4 pb-10">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Live Preview
|
||||
</div>
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-6 space-y-5 min-h-[300px]">
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
empty="Your content will appear here as you build."
|
||||
/>
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Mobile preview bottom sheet ── */}
|
||||
{previewOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40 flex flex-col">
|
||||
<div className="flex-1 bg-black/50" onClick={() => setPreviewOpen(false)} />
|
||||
<div className="bg-background rounded-t-2xl border-t shadow-xl flex flex-col max-h-[85dvh]">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
Live Preview
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setPreviewOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-4">
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-4 space-y-4 min-h-[200px]">
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
empty="No content yet."
|
||||
/>
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
-10
@@ -6,16 +6,17 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import LessonsTable from "../../components/courses/LessonsTable";
|
||||
import LessonsTable from "../../../components/courses/LessonsTable";
|
||||
|
||||
export default function UnitDetail() {
|
||||
export default function LessonsList() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchUnit, course, unit, loading } = useCourses();
|
||||
const { fetchCourse, fetchUnit, course, unit, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId)
|
||||
await fetchUnit(courseId, unitId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
@@ -24,7 +25,7 @@ export default function UnitDetail() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: course?.title, to: `/admin/courses/${courseId}/units` },
|
||||
{ label: unit?.title ?? "..." },
|
||||
];
|
||||
|
||||
@@ -49,9 +50,6 @@ export default function UnitDetail() {
|
||||
{/* ── Unit header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{unit?.title}</h1>
|
||||
{unit?.description && (
|
||||
@@ -59,9 +57,6 @@ export default function UnitDetail() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}>
|
||||
Edit Unit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Lessons ── */}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
function InfoField({ label, value }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">{label}</p>
|
||||
<p className="text-sm font-medium">{value ?? "—"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, course, unit } = useCourses();
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchLesson(courseId, unitId, lessonId);
|
||||
setLesson(res?.data?.data ?? null);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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 space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">Lesson details.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
||||
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> Duration
|
||||
</p>
|
||||
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||
<p className="font-semibold text-sm">{formatDate(lesson?.createdAt)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||
<p className="font-semibold text-sm">{formatDate(lesson?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<InfoField label="Title" value={lesson?.title} />
|
||||
<InfoField label="Description" value={lesson?.description || "No description provided."} />
|
||||
<InfoField label="Unit" value={lesson?.unit?.title} />
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<Badge variant="secondary" className="ml-auto">{lesson?.objectives?.length ?? 0}</Badge>
|
||||
</div>
|
||||
|
||||
{lesson?.objectives?.length === 0 || !lesson?.objectives ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No objectives defined.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{lesson.objectives.map((obj, i) => (
|
||||
<li key={obj.objective_id} className="flex items-start gap-3 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{obj.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
|
||||
export default function ViewLessonPage() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, lesson, lessonPage } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchLesson(courseId, unitId, lessonId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId, lessonId]);
|
||||
|
||||
const blocks = lessonPage?.blocks ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
|
||||
{/* Sticky header */}
|
||||
<div
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight">Page Preview</h1>
|
||||
<p className="text-sm text-muted-foreground truncate">{lesson?.title}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
|
||||
>
|
||||
Edit Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-8 space-y-6 min-h-[400px]">
|
||||
{initializing ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
empty="No content blocks yet."
|
||||
/>
|
||||
{blocks.length === 0 && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
|
||||
>
|
||||
Go to Page Builder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-15
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -24,10 +25,10 @@ function FieldError({ message }) {
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
export default function CreateUnit() {
|
||||
export default function AddUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { createUnit, course, loading } = useCourses();
|
||||
const { createUnit, fetchCourse, course, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
@@ -35,27 +36,21 @@ export default function CreateUnit() {
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: "Create Unit" },
|
||||
];
|
||||
useEffect(() => {
|
||||
fetchCourse(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(`/admin/courses/${courseId}`);
|
||||
navigate(`/admin/courses/${courseId}/units`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
<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" />
|
||||
@@ -101,4 +96,4 @@ export default function CreateUnit() {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-15
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -7,7 +7,6 @@ import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -26,23 +25,17 @@ function FieldError({ message }) {
|
||||
}
|
||||
|
||||
export default function EditUnit() {
|
||||
const navigate = useNavigate();
|
||||
const [unitTitle, setUnitTitle] = useState("");
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, course, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||
{ label: "Edit Unit" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchUnit(courseId, unitId);
|
||||
@@ -53,9 +46,11 @@ export default function EditUnit() {
|
||||
description: unit.description ?? "",
|
||||
order: unit.order ?? 0,
|
||||
});
|
||||
setUnitTitle(unit.title ?? "");
|
||||
})();
|
||||
}, [courseId, unitId]);
|
||||
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||
@@ -65,12 +60,9 @@ export default function EditUnit() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
<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" />
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuestionCard, makeQuestion } from "../../../components/courses/QuestionEditor";
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
function validate(questions) {
|
||||
const errors = {};
|
||||
questions.forEach((q, i) => {
|
||||
const qErr = {};
|
||||
if (!q.question?.trim()) qErr.question = "Question text is required.";
|
||||
const correctCount = q.options.filter((o) => o.is_correct).length;
|
||||
if (correctCount === 0) qErr.options = "At least one correct answer is required.";
|
||||
if (q.options.some((o) => !o.text?.trim())) qErr.options = "All option texts are required.";
|
||||
if (Object.keys(qErr).length) errors[i] = qErr;
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── Jump to input ─────────────────────────────────────────────────────────────
|
||||
|
||||
function JumpToInput({ max, onJump }) {
|
||||
const [val, setVal] = useState("");
|
||||
|
||||
const handleJump = () => {
|
||||
const n = parseInt(val, 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= max) {
|
||||
onJump(n - 1);
|
||||
setVal("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleJump()}
|
||||
placeholder={`1–${max}`}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs shrink-0"
|
||||
onClick={handleJump}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question Navigator ────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_LABEL = {
|
||||
multiple_choice: "MC",
|
||||
multi_select: "MS",
|
||||
true_false: "TF",
|
||||
};
|
||||
|
||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, errors }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
<div className="px-3 py-3 border-b shrink-0">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Questions
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{questions.length} total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||||
No questions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5 px-2">
|
||||
{questions.map((q, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const hasError = !!errors?.[i];
|
||||
const typeLabel = TYPE_LABEL[q.type] ?? "Q";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q._tempId ?? q.question_id ?? i}
|
||||
ref={(el) => (navItemRefs.current[i] = el)}
|
||||
onClick={() => onJump(i)}
|
||||
className={cn(
|
||||
"group flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasError
|
||||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
: "hover:bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-semibold",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: hasError
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted-foreground/20 text-muted-foreground"
|
||||
)}>
|
||||
{i + 1}
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] font-medium text-muted-foreground shrink-0 w-5">
|
||||
{typeLabel}
|
||||
</span>
|
||||
|
||||
<span className="flex-1 text-xs truncate min-w-0">
|
||||
{q.question?.trim()
|
||||
? q.question.trim()
|
||||
: <span className="italic text-muted-foreground">Untitled</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onMove(i, "up"); }}
|
||||
disabled={i === 0}
|
||||
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onMove(i, "down"); }}
|
||||
disabled={i === questions.length - 1}
|
||||
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{questions.length > 5 && (
|
||||
<div className="px-3 py-3 border-t shrink-0 space-y-1.5">
|
||||
<p className="text-xs text-muted-foreground">Jump to question</p>
|
||||
<JumpToInput max={questions.length} onJump={onJump} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UnitQuiz() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const {
|
||||
fetchQuiz, createQuiz, updateQuiz,
|
||||
createQuizQuestion, updateQuizQuestion,
|
||||
course, unit, quiz, loading,
|
||||
} = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [passingScore, setPassingScore] = useState(70);
|
||||
const [isRequired, setIsRequired] = useState(false);
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
const headerRef = useRef(null);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "…", to: `/admin/courses/${courseId}` },
|
||||
{ label: unit?.title ?? "…", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||
{ label: "Quiz" },
|
||||
];
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchQuiz(courseId, unitId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, unitId]);
|
||||
|
||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!quiz) return;
|
||||
setTitle(quiz.title ?? "");
|
||||
setPassingScore(quiz.passing_score ?? 70);
|
||||
setIsRequired(quiz.is_required === true || quiz.is_required === 1);
|
||||
setMaxQuestions(quiz.max_questions ?? "");
|
||||
setQuestions(
|
||||
(quiz.questions ?? []).map((q) => ({
|
||||
...q,
|
||||
_tempId: q.question_id,
|
||||
options: q.options ?? [],
|
||||
}))
|
||||
);
|
||||
}, [quiz]);
|
||||
|
||||
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return;
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--quiz-h",
|
||||
`${headerRef.current.offsetHeight}px`
|
||||
);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
// ── Scroll active nav item into view ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
navItemRefs.current[activeIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, [activeIndex]);
|
||||
|
||||
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
|
||||
useEffect(() => {
|
||||
if (!questions.length) return;
|
||||
const observers = [];
|
||||
|
||||
questionRefs.current.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) setActiveIndex(i); },
|
||||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
observer.observe(el);
|
||||
observers.push(observer);
|
||||
});
|
||||
|
||||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, [questions.length]);
|
||||
|
||||
// ── Scroll helper with sticky offset ──────────────────────────────────────
|
||||
const scrollToQuestion = (index) => {
|
||||
const el = questionRefs.current[index];
|
||||
if (!el) return;
|
||||
const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--navbar-h") || "0", 10);
|
||||
const quizH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--quiz-h") || "0", 10);
|
||||
const offset = navbarH + quizH + 16;
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// ── Question actions ───────────────────────────────────────────────────────
|
||||
const addQuestion = (type = "multiple_choice") => {
|
||||
setQuestions((prev) => {
|
||||
const next = [...prev, { ...makeQuestion(type), order_index: prev.length }];
|
||||
setTimeout(() => {
|
||||
const idx = next.length - 1;
|
||||
setActiveIndex(idx);
|
||||
scrollToQuestion(idx);
|
||||
}, 50);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuestion = (index, updated) => {
|
||||
setQuestions((prev) => prev.map((q, i) => i === index ? updated : q));
|
||||
setErrors((prev) => { const e = { ...prev }; delete e[index]; return e; });
|
||||
};
|
||||
|
||||
const removeQuestion = (index) => {
|
||||
setQuestions((prev) => prev.filter((_, i) => i !== index));
|
||||
setActiveIndex((prev) => Math.max(0, prev >= index ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const moveQuestion = (index, direction) => {
|
||||
setQuestions((prev) => {
|
||||
const next = [...prev];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= next.length) return prev;
|
||||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
setActiveIndex(newIndex);
|
||||
setTimeout(() => scrollToQuestion(newIndex), 50);
|
||||
};
|
||||
|
||||
const jumpTo = (index) => {
|
||||
setActiveIndex(index);
|
||||
scrollToQuestion(index);
|
||||
};
|
||||
|
||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const errs = validate(questions);
|
||||
if (Object.keys(errs).length) {
|
||||
setErrors(errs);
|
||||
jumpTo(parseInt(Object.keys(errs)[0], 10));
|
||||
return;
|
||||
}
|
||||
|
||||
let quizId = quiz?.quiz_id;
|
||||
const meta = {
|
||||
title: title || "Unit Quiz",
|
||||
passing_score: passingScore,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
updatedBy: user?.user_id,
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
|
||||
if (!quizId) {
|
||||
const res = await createQuiz(courseId, unitId, meta);
|
||||
quizId = res?.data?.data?.data?.quiz_id;
|
||||
if (!quizId) return;
|
||||
} else {
|
||||
await updateQuiz(courseId, unitId, quizId, meta);
|
||||
}
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
|
||||
if (q.question_id) {
|
||||
await updateQuizQuestion(courseId, unitId, quizId, q.question_id, q);
|
||||
} else {
|
||||
await createQuizQuestion(courseId, unitId, quizId, q);
|
||||
}
|
||||
}
|
||||
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
|
||||
{/* ── Sticky header ── */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
||||
Unit Quiz
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Quiz
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Split layout ── */}
|
||||
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
|
||||
|
||||
{/* LEFT — Navigator */}
|
||||
<div
|
||||
className="hidden lg:flex flex-col w-60 shrink-0 border-r bg-background"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: `calc(var(--navbar-h) + var(--quiz-h, 0px))`,
|
||||
height: `calc(100vh - var(--navbar-h) - var(--quiz-h, 0px))`,
|
||||
}}
|
||||
>
|
||||
<QuestionNavigator
|
||||
questions={questions}
|
||||
activeIndex={activeIndex}
|
||||
onJump={jumpTo}
|
||||
onMove={moveQuestion}
|
||||
navItemRefs={navItemRefs}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Content */}
|
||||
<div className="flex-1 min-w-0 px-4 lg:px-8 py-6">
|
||||
{initializing ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl space-y-6 pb-16">
|
||||
|
||||
{/* Settings */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<p className="text-sm font-medium">Settings</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Unit Quiz"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[200px]">
|
||||
<Label>Passing Score (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={passingScore}
|
||||
onChange={(e) => setPassingScore(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
Max Questions{" "}
|
||||
<span className="text-muted-foreground font-normal text-xs">(blank = show all)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number" min={1} max={questions.length || undefined}
|
||||
value={maxQuestions}
|
||||
onChange={(e) => setMaxQuestions(e.target.value)}
|
||||
placeholder={`All (${questions.length})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="quiz_required"
|
||||
checked={isRequired === true}
|
||||
onCheckedChange={(val) => setIsRequired(val)}
|
||||
/>
|
||||
<Label htmlFor="quiz_required" className="cursor-pointer">
|
||||
Required to proceed to next unit
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||||
<p className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
|
||||
Takers will see <strong>{maxQuestions}</strong> randomly selected questions
|
||||
out of <strong>{questions.length}</strong> in the pool.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Questions */}
|
||||
<div className="space-y-4">
|
||||
{questions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No questions yet. Add one below.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<div
|
||||
key={q._tempId ?? q.question_id ?? i}
|
||||
ref={(el) => (questionRefs.current[i] = el)}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
>
|
||||
<QuestionCard
|
||||
question={q}
|
||||
index={i}
|
||||
onChange={(updated) => updateQuestion(i, updated)}
|
||||
onRemove={() => removeQuestion(i)}
|
||||
error={errors[i]}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add question */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-3">
|
||||
Add Question
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ type: "multiple_choice", label: "Multiple Choice" },
|
||||
{ type: "multi_select", label: "Multi Select" },
|
||||
{ type: "true_false", label: "True / False" },
|
||||
].map(({ type, label }) => (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addQuestion(type)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import UnitsTable from "../../../components/courses/UnitsTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function CourseDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Courses", to: "/admin/courses" },
|
||||
{ label: course?.title ?? "..." },
|
||||
];
|
||||
|
||||
// Replace the loading check
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<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">
|
||||
|
||||
{/* ── Course header ── */}
|
||||
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||
{/* Title + Status */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">{course?.title}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${!course.deletedBy
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{!course.deletedBy ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
{course?.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{course.description}</p>
|
||||
)}
|
||||
</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">Level</p>
|
||||
<p className="font-semibold text-sm capitalize">{course?.level}</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">Units</p>
|
||||
<p className="font-semibold text-sm">{course?.units.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">Created</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{course?.createdAt ? new Date(course.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
</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">Last Updated</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{course?.updatedAt ? new Date(course.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Units ── */}
|
||||
<UnitsTable courseId={courseId} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Pencil, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export default function ViewUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchUnit, course } = useCourses();
|
||||
const [unit, setUnit] = useState(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchUnit(courseId, unitId);
|
||||
setUnit(res?.data?.data ?? null);
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId, 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/60 h-full">
|
||||
<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">
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Unit Details</h1>
|
||||
<p className="text-sm text-muted-foreground">View unit information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Title</p>
|
||||
<p className="text-sm font-medium">{unit?.title ?? "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Description</p>
|
||||
<p className="text-sm text-muted-foreground">{unit?.description || "No description provided."}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
||||
<p className="text-sm font-medium">{unit?.order ?? 0}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,16 +8,19 @@ import AdminLayout from '../layouts/AdminLayout'
|
||||
import AdminDashboard from '../pages/AdminDashboard'
|
||||
import ProfilePage from '@/components/generic/Profile'
|
||||
|
||||
// Users
|
||||
import UserList from '../pages/users/UserList'
|
||||
import AddUser from '../pages/users/AddStaffUser'
|
||||
import ViewUser from '../pages/users/ViewUser'
|
||||
|
||||
// User Groups
|
||||
import GroupList from '../pages/user_groups/GroupList'
|
||||
import ViewGroup from '../pages/user_groups/ViewGroup'
|
||||
import EditUser from '../pages/users/EditUser'
|
||||
import ArchivedUserList from '../pages/users/ArchivedUserList'
|
||||
import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList'
|
||||
|
||||
// Assets
|
||||
import AddAsset from "../pages/assets/AddAsset";
|
||||
import ArchivedAssets from "../pages/assets/ArchivedAssets";
|
||||
import ViewImageAsset from "../pages/assets/ViewImageAsset";
|
||||
@@ -25,17 +28,32 @@ import ViewVideoAsset from "../pages/assets/ViewVideoAsset";
|
||||
import ViewDocumentAsset from "../pages/assets/ViewDocumentAsset";
|
||||
import AssetList from '../pages/assets/AssetList'
|
||||
import EditAsset from '../pages/assets/EditAsset'
|
||||
|
||||
// Courses
|
||||
import CourseList from '../pages/courses/CourseList'
|
||||
import CreateCourse from '../pages/courses/CreateCourse'
|
||||
import AddCourse from '../pages/courses/AddCourse'
|
||||
import ViewCourse from '../pages/courses/ViewCourse'
|
||||
import EditCourse from '../pages/courses/EditCourse'
|
||||
import CourseDetail from '../pages/courses/CourseDetail'
|
||||
import CreateUnit from '../pages/courses/CreateUnit'
|
||||
import UnitDetail from '../pages/courses/UnitDetail'
|
||||
import CreateLesson from '../pages/courses/CreateLesson'
|
||||
import LessonDetail from '../pages/courses/LessonDetail'
|
||||
import LessonPageBuilder from '../pages/courses/LessonPageBuilder'
|
||||
import EditUnit from '../pages/courses/EditUnit'
|
||||
import EditLesson from '../pages/courses/EditLesson'
|
||||
import ArchivedCourseList from '../pages/courses/ArchivedCourseList'
|
||||
|
||||
// Units
|
||||
import UnitsList from '../pages/courses/units/UnitsList'
|
||||
import AddUnit from '../pages/courses/units/AddUnit'
|
||||
import ViewUnit from '../pages/courses/units/ViewUnit'
|
||||
import EditUnit from '../pages/courses/units/EditUnit'
|
||||
|
||||
// Lessons
|
||||
import LessonsList from '../pages/courses/lessons/LessonsList'
|
||||
import AddLesson from '../pages/courses/lessons/AddLesson'
|
||||
import ViewLesson from '../pages/courses/lessons/ViewLesson'
|
||||
import EditLesson from '../pages/courses/lessons/EditLesson'
|
||||
|
||||
// Lesson Page Builder
|
||||
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
|
||||
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
|
||||
import CourseAssessment from '../pages/courses/CourseAssessment'
|
||||
import UnitQuiz from '../pages/courses/units/UnitQuiz'
|
||||
|
||||
|
||||
export const AdminRoutes = {
|
||||
element: <ProtectedRoute allowedRoles={['admin']} />,
|
||||
@@ -93,19 +111,40 @@ export const AdminRoutes = {
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <CourseList /> },
|
||||
{ path: 'create', element: <CreateCourse /> },
|
||||
{ path: ':courseId', element: <CourseDetail /> },
|
||||
{ path: 'archived', element: <ArchivedCourseList /> },
|
||||
{ path: 'add', element: <AddCourse /> },
|
||||
{ path: ':courseId/view', element: <ViewCourse /> },
|
||||
{ path: ':courseId/edit', element: <EditCourse /> },
|
||||
{ path: ':courseId/units/create', element: <CreateUnit /> },
|
||||
{ path: ':courseId/units/:unitId', element: <UnitDetail /> },
|
||||
{ path: ':courseId/units/:unitId/edit', element: <EditUnit /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/create', element: <CreateLesson /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/:lessonId', element: <LessonDetail /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/:lessonId/edit', element: <EditLesson /> },
|
||||
{ path: ':courseId/units/:unitId/lessons/:lessonId/page', element: <LessonPageBuilder /> },
|
||||
{ path: ":courseId/assessment", element: <CourseAssessment /> },
|
||||
|
||||
// Units
|
||||
{
|
||||
path: ":courseId/units",
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <UnitsList /> },
|
||||
{ path: 'add', element: <AddUnit /> },
|
||||
{ path: ':unitId/view', element: <ViewUnit /> },
|
||||
{ path: ':unitId/edit', element: <EditUnit /> },
|
||||
{ path: ":unitId/quiz", element: <UnitQuiz /> },
|
||||
|
||||
// Lessons
|
||||
{
|
||||
path: ":unitId/lessons",
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <LessonsList /> },
|
||||
{ path: 'add', element: <AddLesson /> },
|
||||
{ path: ':lessonId/view', element: <ViewLesson /> },
|
||||
{ path: ':lessonId/edit', element: <EditLesson /> },
|
||||
{ path: ':lessonId/page', element: <LessonPageBuilder /> },
|
||||
{ path: ':lessonId/page/view', element: <ViewLessonPage /> },
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// Add here
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user