This commit is contained in:
rgrgogu
2026-05-20 13:25:03 +08:00
parent 2b1955608d
commit 95242709b0
71 changed files with 5535 additions and 1035 deletions
+1
View File
@@ -25,6 +25,7 @@
"framer-motion": "^12.38.0",
"input-otp": "^1.4.2",
"lucide-react": "^1.14.0",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
+10
View File
@@ -53,6 +53,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
nanoid:
specifier: ^5.1.11
version: 5.1.11
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -2647,6 +2650,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.11:
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -5821,6 +5829,8 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.11: {}
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
+36 -35
View File
@@ -1,21 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { Search, CheckCircle2 } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { useAssets } from "@/contexts/AdminAssetsContext";
// ─── Empty ────────────────────────────────────────────────────────────────────
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
@@ -26,8 +18,6 @@ function EmptyState({ fileType }) {
);
}
// ─── Asset Card ───────────────────────────────────────────────────────────────
function AssetCard({ asset, selected, onSelect }) {
const thumb = asset.thumbnail_url ?? asset.file_url;
@@ -43,7 +33,6 @@ function AssetCard({ asset, selected, onSelect }) {
: "border-border",
].join(" ")}
>
{/* Thumbnail */}
<div className="aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<img
@@ -57,13 +46,9 @@ function AssetCard({ asset, selected, onSelect }) {
</div>
)}
</div>
{/* Name */}
<div className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p>
</div>
{/* Selected checkmark */}
{selected && (
<div className="absolute top-1.5 right-1.5">
<CheckCircle2 className="h-5 w-5 text-primary fill-white" />
@@ -73,33 +58,39 @@ function AssetCard({ asset, selected, onSelect }) {
);
}
// ─── Sheet ────────────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [search, setSearch] = useState("");
const [committed, setCommitted] = useState(""); // ← only updates on search trigger
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const LIMIT = 12;
// ── Fetch on open / search / page change ──────────────────────────────────
const triggerSearch = useCallback(() => {
setCommitted(search);
setPage(1);
}, [search]);
// ── Fetch only when committed search, page, or open changes ──────────────
useEffect(() => {
if (!open) return;
fetchAssets({
page,
limit: LIMIT,
filters: [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(search ? [{ id: "display_name", value: [search] }] : []),
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(committed ? [{ id: "display_name", value: [committed] }] : []),
],
});
}, [open, page, search, fileType]);
}, [open, page, committed, fileType]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
if (!open) {
setSearch("");
setCommitted("");
setPage(1);
setSelected(null);
}
@@ -129,14 +120,24 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
{/* ── Search ── */}
<div className="px-6 py-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-9"
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerSearch()}
className="pl-9"
/>
</div>
<Button
type="button"
onClick={triggerSearch}
disabled={loading}
>
{loading ? <Spinner className="h-4 w-4" /> : "Search"}
</Button>
</div>
</div>
+64 -46
View File
@@ -1,66 +1,84 @@
// components/generic/CMS/BlockList.jsx
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/TextBlock";
import { ImageBlock } from "./Blocks/ImageBlock";
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/TextBlock";
import { ImageBlock } from "./Blocks/ImageBlock";
import { TextImageBlock } from "./Blocks/TextImageBlock";
import { VideoBlock } from "./Blocks/VideoBlock";
import { VideoBlock } from "./Blocks/VideoBlock";
import { TextVideoBlock } from "./Blocks/TextVideoBlock";
// ─── Block renderer ───────────────────────────────────────────────────────────
//
// blockId is forwarded to TextBlock (and any future rich-text blocks) so the
// WYSIWYG editor knows exactly when to seed its innerHTML from saved data.
function BlockContent({ type, content, onUpdate }) {
switch (type) {
case "text": return <TextBlock content={content} onUpdate={onUpdate} />;
case "image": return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image": return <TextImageBlock content={content} onUpdate={onUpdate} />;
case "video": return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video": return <TextVideoBlock content={content} onUpdate={onUpdate} />;
default: return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
function BlockContent({ block, onUpdate }) {
const { id, type, content } = block;
switch (type) {
case "text":
return (
<TextBlock
blockId={id}
content={content}
onUpdate={onUpdate}
/>
);
case "image":
return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image":
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
case "video":
return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} onUpdate={onUpdate} />;
default:
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
}
// ─── Default content per type ─────────────────────────────────────────────────
export const DEFAULT_CONTENT = {
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
};
// ─── List ─────────────────────────────────────────────────────────────────────
export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
if (!blocks.length) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 py-16 gap-2">
<p className="text-sm text-muted-foreground">No blocks yet.</p>
<p className="text-xs text-muted-foreground">Use the button below to add your first block.</p>
</div>
);
}
if (!blocks.length) {
return (
<div className="flex flex-col gap-3">
{blocks.map((block, index) => (
<BlockWrapper
key={block.id}
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
type={block.type}
content={block.content}
onUpdate={(updated) => onUpdate(block.id, updated)}
/>
</BlockWrapper>
))}
</div>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 py-16 gap-2">
<p className="text-sm text-muted-foreground">No blocks yet.</p>
<p className="text-xs text-muted-foreground">
Use the button below to add your first block.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{blocks.map((block, index) => (
<BlockWrapper
key={block.id}
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
block={block}
onUpdate={(updated) => onUpdate(block.id, updated)}
/>
</BlockWrapper>
))}
</div>
);
}
+453 -14
View File
@@ -1,18 +1,457 @@
// components/generic/CMS/blocks/TextBlock.jsx
// components/generic/CMS/Blocks/TextBlock.jsx
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { useEffect, useRef, useState } from "react";
import {
Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, AlignJustify,
List, ListOrdered, Indent, Outdent,
Link2, FileText, Table,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../AssetPickerSheet";
import { cn } from "@/lib/utils";
export function TextBlock({ content, onUpdate }) {
return (
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={4}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
/>
// ─── Toolbar button ───────────────────────────────────────────────────────────
function ToolbarBtn({ title, onMouseDown, active, children }) {
return (
<button
type="button"
title={title}
onMouseDown={onMouseDown}
className={cn(
"h-7 w-7 flex items-center justify-center rounded text-muted-foreground",
"hover:bg-accent hover:text-accent-foreground transition-colors shrink-0",
active && "bg-accent text-accent-foreground"
)}
>
{children}
</button>
);
}
// ─── Toolbar divider ──────────────────────────────────────────────────────────
function Divider() {
return <span className="w-px h-4 bg-border mx-0.5 shrink-0" />;
}
// ─── Format options ───────────────────────────────────────────────────────────
const FORMAT_OPTIONS = [
{ label: "Paragraph", val: "p" },
{ label: "Heading 1", val: "h1" },
{ label: "Heading 2", val: "h2" },
{ label: "Heading 3", val: "h3" },
];
// ─── Shared styles ────────────────────────────────────────────────────────────
export const WYSIWYG_STYLES = `
.wysiwyg-editor { line-height: 1.7; }
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.3; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.4; }
.wysiwyg-editor p,
.wysiwyg-preview p {
margin: 0 0 0.85rem 0;
text-align: justify;
line-height: 1.75;
}
.wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor li, .wysiwyg-preview li {
margin-bottom: 0.4rem;
line-height: 1.75;
text-align: justify;
}
.wysiwyg-editor a, .wysiwyg-preview a { color: hsl(var(--primary)); text-decoration: underline; }
.wysiwyg-editor a.doc-link,
.wysiwyg-preview a.doc-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
font-size: 0.8125rem;
text-decoration: none;
border: 1px solid hsl(var(--border));
}
.wysiwyg-editor a.doc-link::before,
.wysiwyg-preview a.doc-link::before {
content: "📄";
font-size: 0.75rem;
}
.wysiwyg-editor table,
.wysiwyg-preview table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 0.75rem 0;
font-size: 0.875rem;
table-layout: fixed;
border: 1.5px solid #cbd5e1;
border-radius: 0.375rem;
overflow: hidden;
}
.wysiwyg-editor th,
.wysiwyg-preview th {
background: #f1f5f9;
color: #1e293b;
font-weight: 600;
text-align: left;
padding: 0.5rem 0.75rem;
word-break: break-word;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.wysiwyg-editor td,
.wysiwyg-preview td {
padding: 0.45rem 0.75rem;
vertical-align: top;
word-break: break-word;
min-width: 2rem;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.wysiwyg-preview tr:nth-child(even) td {
background: #f8fafc;
}
.wysiwyg-editor td:focus,
.wysiwyg-editor th:focus {
outline: 2px solid hsl(var(--ring));
outline-offset: -2px;
background: hsl(var(--accent) / 0.2);
}
`;
// ─── Table picker popover ─────────────────────────────────────────────────────
// A small grid that lets the user hover to choose rows × columns (up to 8×8),
// then click to confirm. Rendered inline in the toolbar.
const MAX_ROWS = 8;
const MAX_COLS = 8;
function TablePicker({ onInsert, onClose }) {
const [hovered, setHovered] = useState({ r: 0, c: 0 });
return (
// Overlay — clicking outside closes the picker without inserting
<div className="fixed inset-0 z-50" onMouseDown={onClose}>
<div
className="absolute z-50 bg-popover border rounded-lg shadow-lg p-3 flex flex-col gap-2"
style={{ top: "var(--table-picker-y, 40px)", left: "var(--table-picker-x, 0px)" }}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Label */}
<p className="text-xs text-muted-foreground select-none">
{hovered.r > 0 && hovered.c > 0
? `${hovered.r} × ${hovered.c} table`
: "Hover to select size"}
</p>
{/* Grid */}
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${MAX_COLS}, 1.25rem)` }}
>
{Array.from({ length: MAX_ROWS }, (_, r) =>
Array.from({ length: MAX_COLS }, (_, c) => (
<div
key={`${r}-${c}`}
onMouseEnter={() => setHovered({ r: r + 1, c: c + 1 })}
onClick={() => {
onInsert(hovered.r, hovered.c);
onClose();
}}
className={cn(
"h-5 w-5 rounded-sm border cursor-pointer transition-colors",
r < hovered.r && c < hovered.c
? "bg-primary/20 border-primary/50"
: "bg-muted border-border"
)}
/>
))
)}
</div>
);
</div>
</div>
);
}
// ─── RichTextEditor ───────────────────────────────────────────────────────────
export function RichTextEditor({ blockId, value, onChange }) {
const editorRef = useRef(null);
const initializedFor = useRef(null);
const savedRange = useRef(null);
const tableButtonRef = useRef(null);
const [docPickerOpen, setDocPickerOpen] = useState(false);
const [tablePickerOpen, setTablePickerOpen] = useState(false);
// ── Seed innerHTML exactly once per blockId ────────────────────────────────
useEffect(() => {
if (!editorRef.current) return;
if (initializedFor.current === blockId) return;
editorRef.current.innerHTML = value ?? "";
initializedFor.current = blockId;
}, [blockId, value]);
// ── Position the table picker below its toolbar button ────────────────────
useEffect(() => {
if (!tablePickerOpen || !tableButtonRef.current) return;
const rect = tableButtonRef.current.getBoundingClientRect();
document.documentElement.style.setProperty("--table-picker-y", `${rect.bottom + 6}px`);
document.documentElement.style.setProperty("--table-picker-x", `${rect.left}px`);
}, [tablePickerOpen]);
// ── execCommand helpers ────────────────────────────────────────────────────
const exec = (cmd, val = null) => {
editorRef.current?.focus();
document.execCommand(cmd, false, val);
};
const applyFormat = (val) => {
editorRef.current?.focus();
document.execCommand("formatBlock", false, `<${val}>`);
};
const isActive = (cmd) => {
try { return document.queryCommandState(cmd); } catch { return false; }
};
// ── URL link ──────────────────────────────────────────────────────────────
const insertUrlLink = () => {
const url = window.prompt("Enter URL:", "https://");
if (url) exec("createLink", url);
};
// ── Document link ─────────────────────────────────────────────────────────
const openDocPicker = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
setDocPickerOpen(true);
};
const handleDocSelect = (asset) => {
setDocPickerOpen(false);
const url = asset.file_url;
const label = asset.display_name ?? "Document";
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
const html = `<a href="${url}" class="doc-link" target="_blank" rel="noopener noreferrer">${label}</a>`;
document.execCommand("insertHTML", false, html);
onChange(editorRef.current.innerHTML);
savedRange.current = null;
};
// ── Table insertion ────────────────────────────────────────────────────────
// Builds a <table> with a header row (th) + (rows-1) data rows.
// Each cell is contenteditable (inherited from the editor).
// A paragraph after the table lets the user continue typing below it.
const insertTable = (rows, cols) => {
if (rows < 1 || cols < 1) return;
// Save cursor before opening picker collapses it
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
// Build header row
const headerCells = Array.from({ length: cols })
.map((_, i) => `<th>Column ${i + 1}</th>`)
.join("");
// Build body rows
const bodyRows = Array.from({ length: rows - 1 })
.map(() =>
`<tr>${Array.from({ length: cols }).map(() => "<td><br></td>").join("")}</tr>`
)
.join("");
const tableHTML = `
<table>
<thead><tr>${headerCells}</tr></thead>
<tbody>${bodyRows}</tbody>
</table>
<p><br></p>
`;
document.execCommand("insertHTML", false, tableHTML);
onChange(editorRef.current.innerHTML);
savedRange.current = null;
};
const openTablePicker = () => {
// Save current cursor so we can restore it after the picker closes
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
setTablePickerOpen(true);
};
// ── Toolbar groups ─────────────────────────────────────────────────────────
const GROUPS = [
[
{ cmd: "bold", Icon: Bold, title: "Bold" },
{ cmd: "italic", Icon: Italic, title: "Italic" },
{ cmd: "underline", Icon: Underline, title: "Underline" },
],
[
{ cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
{ cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
{ cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
{ cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
],
[
{ cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
{ cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
{ cmd: "indent", Icon: Indent, title: "Indent" },
{ cmd: "outdent", Icon: Outdent, title: "Outdent" },
],
];
return (
<>
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring">
{/* ── Toolbar ── */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b bg-muted/40">
{/* Block format select */}
<select
className="h-7 text-xs border rounded px-1 mr-0.5 bg-background cursor-pointer shrink-0"
defaultValue="p"
onChange={(e) => applyFormat(e.target.value)}
>
{FORMAT_OPTIONS.map((o) => (
<option key={o.val} value={o.val}>{o.label}</option>
))}
</select>
<Divider />
{/* Formatting groups */}
{GROUPS.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ cmd, Icon, title }) => (
<ToolbarBtn
key={cmd}
title={title}
active={isActive(cmd)}
onMouseDown={(e) => { e.preventDefault(); exec(cmd); }}
>
<Icon className="h-3.5 w-3.5" />
</ToolbarBtn>
))}
<Divider />
</div>
))}
{/* URL link */}
<ToolbarBtn
title="Insert URL link"
onMouseDown={(e) => { e.preventDefault(); insertUrlLink(); }}
>
<Link2 className="h-3.5 w-3.5" />
</ToolbarBtn>
{/* Document link */}
<ToolbarBtn
title="Embed document link"
onMouseDown={(e) => { e.preventDefault(); openDocPicker(); }}
>
<FileText className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
{/* Insert table */}
<ToolbarBtn
title="Insert table"
active={tablePickerOpen}
onMouseDown={(e) => {
e.preventDefault();
tablePickerOpen ? setTablePickerOpen(false) : openTablePicker();
}}
>
<span ref={tableButtonRef} className="flex items-center justify-center">
<Table className="h-3.5 w-3.5" />
</span>
</ToolbarBtn>
</div>
{/* ── Editable area ── */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={() => onChange(editorRef.current.innerHTML)}
className="wysiwyg-editor min-h-[120px] px-3 m-0 py-0 text-sm focus:outline-none"
/>
<style>{WYSIWYG_STYLES}</style>
</div>
{/* Table size picker */}
{tablePickerOpen && (
<TablePicker
onInsert={insertTable}
onClose={() => setTablePickerOpen(false)}
/>
)}
{/* Document asset picker */}
<AssetPickerSheet
open={docPickerOpen}
onOpenChange={setDocPickerOpen}
fileType="document"
onSelect={handleDocSelect}
/>
</>
);
}
// ─── TextBlock ────────────────────────────────────────────────────────────────
export function TextBlock({ content, onUpdate, blockId }) {
return (
<div className="space-y-1.5">
<Label>Content</Label>
<RichTextEditor
blockId={blockId}
value={content.body ?? ""}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
);
}
@@ -1,10 +1,9 @@
// components/generic/CMS/blocks/TextImageBlock.jsx
// components/generic/CMS/Blocks/TextImageBlock.jsx
import { useState } from "react";
import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -12,15 +11,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../AssetPickerSheet";
export function TextImageBlock({ content, onUpdate }) {
export function TextImageBlock({ content, onUpdate, blockId }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
<div className="space-y-4">
{/* ── Layout ── */}
{/* ── Image position ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Image Position</Label>
<Select
@@ -35,19 +35,15 @@ export function TextImageBlock({ content, onUpdate }) {
</Select>
</div>
<div className={[
"grid gap-4",
"grid-cols-1 md:grid-cols-2",
].join(" ")}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side ── */}
{/* ── Text side — WYSIWYG ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={5}
<RichTextEditor
blockId={`${blockId}_text`}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
@@ -108,4 +104,4 @@ export function TextImageBlock({ content, onUpdate }) {
/>
</div>
);
}
}
@@ -1,7 +1,8 @@
// components/generic/CMS/Blocks/TextVideoBlock.jsx
import { useState } from "react";
import { VideoIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -9,9 +10,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../AssetPickerSheet";
export function TextVideoBlock({ content, onUpdate }) {
export function TextVideoBlock({ content, onUpdate, blockId }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
@@ -19,7 +21,7 @@ export function TextVideoBlock({ content, onUpdate }) {
return (
<div className="space-y-4">
{/* ── Layout ── */}
{/* ── Video position ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Video Position</Label>
<Select
@@ -36,14 +38,13 @@ export function TextVideoBlock({ content, onUpdate }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side ── */}
{/* ── Text side — WYSIWYG ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={5}
<RichTextEditor
blockId={`${blockId}_text`}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
@@ -105,4 +106,4 @@ export function TextVideoBlock({ content, onUpdate }) {
/>
</div>
);
}
}
@@ -259,6 +259,7 @@ export default function DataTable({
isOpen={activeColumn === header.column.id}
onOpenChange={(isOpen) => setActiveColumn(isOpen ? header.column.id : null)}
onFilterClick={(e) => handleOpenFilterSheet(e, header.column, attr)}
showFilter={attr?.filterable}
/>
)}
</TableHead>
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -26,8 +26,8 @@ export const ADMIN_SECTIONS = [
title: "Content Management",
description: "Manage tasks and courses",
tiles: [
{ key: "assets", label: "Courses", icon: BookText, link: "/admin/courses" },
{ key: "assets", label: "Tasks", icon: ListCheck, link: "" },
{ key: "courses", label: "Courses", icon: BookText, link: "/admin/courses" },
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "" },
],
},
{
+1
View File
@@ -152,6 +152,7 @@
}
:root {
--navbar-h: 67px; /* match your actual navbar height */
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
@@ -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",
+9 -4
View File
@@ -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>
)
+2 -2
View File
@@ -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>
);
}
+200 -33
View File
@@ -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>
);
}
@@ -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>
);
}
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
}
@@ -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>
);
}
+58 -19
View File
@@ -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
]
},
+13 -1
View File
@@ -10,4 +10,16 @@ export function getTimestamp() {
const SS = String(now.getSeconds()).padStart(2, "0");
return `${YYYY}${MM}${DD}_${HH}${mm}${SS}`;
}
}
export function formatDuration (secs) {
if (!secs) return "0 min";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
const parts = [];
if (h) parts.push(`${h} hr`);
if (m) parts.push(`${m} min${m !== 1 ? "s" : ""}`);
if (!h && !m && s) parts.push(`${s} sec${s !== 1 ? "s" : ""}`);
return parts.join(" ");
};