mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
wip: course layout and func to context
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
// components/generic/CMS/AddBlockMenu.jsx
|
||||||
|
|
||||||
|
import { Plus, Type, Image, ImagePlay, Video, VideoIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
const BLOCK_TYPES = [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Text",
|
||||||
|
description: "A rich text block",
|
||||||
|
icon: <Type className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
label: "Image",
|
||||||
|
description: "A single image",
|
||||||
|
icon: <Image className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-image",
|
||||||
|
label: "Text + Image",
|
||||||
|
description: "Text beside an image",
|
||||||
|
icon: <ImagePlay className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "video",
|
||||||
|
label: "Video",
|
||||||
|
description: "A single video",
|
||||||
|
icon: <VideoIcon className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-video",
|
||||||
|
label: "Text + Video",
|
||||||
|
description: "Text beside a video",
|
||||||
|
icon: <Video className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AddBlockMenu({ onAdd }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" className="w-full border-dashed gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Block
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-56">
|
||||||
|
<DropdownMenuLabel>Choose a block type</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{BLOCK_TYPES.map(({ type, label, description, icon }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={type}
|
||||||
|
onClick={() => onAdd(type)}
|
||||||
|
className="flex items-start gap-3 py-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 text-muted-foreground">{icon}</span>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Search, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
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">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No {fileType} assets found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Asset Card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AssetCard({ asset, selected, onSelect }) {
|
||||||
|
const thumb = asset.thumbnail_url ?? asset.file_url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(asset)}
|
||||||
|
className={[
|
||||||
|
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
|
||||||
|
"hover:border-primary/60 hover:shadow-sm",
|
||||||
|
selected
|
||||||
|
? "border-primary ring-2 ring-primary/20"
|
||||||
|
: "border-border",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="aspect-video bg-muted w-full overflow-hidden">
|
||||||
|
{thumb ? (
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt={asset.display_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<span className="text-xs text-muted-foreground">No preview</span>
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 LIMIT = 12;
|
||||||
|
|
||||||
|
// ── Fetch on open / search / page change ──────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
fetchAssets({
|
||||||
|
page,
|
||||||
|
limit: LIMIT,
|
||||||
|
filters: [
|
||||||
|
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
|
||||||
|
...(search ? [{ id: "display_name", value: [search] }] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, [open, page, search, fileType]);
|
||||||
|
|
||||||
|
// ── Reset on close ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch("");
|
||||||
|
setPage(1);
|
||||||
|
setSelected(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSelect = (asset) => {
|
||||||
|
setSelected(asset.asset_id);
|
||||||
|
onSelect(asset);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = fileType
|
||||||
|
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
|
||||||
|
: "Assets";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b">
|
||||||
|
<SheetTitle>Select {label}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Click an asset to attach it.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* ── 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Grid ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<Spinner className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
) : !assets.length ? (
|
||||||
|
<EmptyState fileType={fileType ?? "asset"} />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<AssetCard
|
||||||
|
key={asset.asset_id}
|
||||||
|
asset={asset}
|
||||||
|
selected={selected === asset.asset_id}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Pagination ── */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!pagination.hasPrevPage || loading}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!pagination.hasNextPage || loading}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// components/generic/CMS/BlockList.jsx
|
||||||
|
|
||||||
|
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 { TextVideoBlock } from "./Blocks/TextVideoBlock";
|
||||||
|
|
||||||
|
// ─── Block renderer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// components/generic/CMS/BlockWrapper.jsx
|
||||||
|
|
||||||
|
import { GripVertical, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const BLOCK_LABELS = {
|
||||||
|
"text": "Text",
|
||||||
|
"image": "Image",
|
||||||
|
"text-image": "Text + Image",
|
||||||
|
"video": "Video",
|
||||||
|
"text-video": "Text + Video",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BlockWrapper({
|
||||||
|
type,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onDelete,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="group relative rounded-lg border bg-card transition-shadow hover:shadow-sm">
|
||||||
|
|
||||||
|
{/* ── Top toolbar ── */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 rounded-t-lg">
|
||||||
|
|
||||||
|
{/* Left — drag handle + block type */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{BLOCK_LABELS[type] ?? type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Block {index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — move + delete */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={onMoveUp}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={index === total - 1}
|
||||||
|
onClick={onMoveDown}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Block content ── */}
|
||||||
|
<div className="p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ImageIcon } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||||
|
|
||||||
|
|
||||||
|
function MediaPlaceholder({ onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click to select an image
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageBlock({ content, onUpdate }) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Image</Label>
|
||||||
|
|
||||||
|
{content.url ? (
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg overflow-hidden cursor-pointer group"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={content.url}
|
||||||
|
alt={content.alt ?? ""}
|
||||||
|
className="w-full aspect-video object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<p className="text-white text-sm font-medium">Change Image</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MediaPlaceholder onClick={() => setPickerOpen(true)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.url && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Alt Text</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Describe the image..."
|
||||||
|
value={content.alt ?? ""}
|
||||||
|
onChange={(e) => onUpdate({ ...content, alt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
fileType="image"
|
||||||
|
onSelect={(asset) => onUpdate({
|
||||||
|
...content,
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
url: asset.file_url,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// components/generic/CMS/blocks/TextBlock.jsx
|
||||||
|
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
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 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// 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,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||||
|
|
||||||
|
export function TextImageBlock({ content, onUpdate }) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* ── Layout ── */}
|
||||||
|
<div className="space-y-1.5 max-w-[200px]">
|
||||||
|
<Label>Image Position</Label>
|
||||||
|
<Select
|
||||||
|
value={content.image_position ?? "right"}
|
||||||
|
onValueChange={(v) => onUpdate({ ...content, image_position: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">Left</SelectItem>
|
||||||
|
<SelectItem value="right">Right</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={[
|
||||||
|
"grid gap-4",
|
||||||
|
"grid-cols-1 md:grid-cols-2",
|
||||||
|
].join(" ")}>
|
||||||
|
|
||||||
|
{/* ── Text side ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Content</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter text content..."
|
||||||
|
rows={5}
|
||||||
|
value={content.body ?? ""}
|
||||||
|
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Image side ── */}
|
||||||
|
<div className={[
|
||||||
|
"space-y-1.5",
|
||||||
|
content.image_position === "left" ? "md:order-first" : "",
|
||||||
|
].join(" ")}>
|
||||||
|
<Label>Image</Label>
|
||||||
|
|
||||||
|
{content.url ? (
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg overflow-hidden cursor-pointer group"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={content.url}
|
||||||
|
alt={content.alt ?? ""}
|
||||||
|
className="w-full aspect-video object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<p className="text-white text-sm font-medium">Change Image</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">Click to select an image</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.url && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Alt Text</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Describe the image..."
|
||||||
|
value={content.alt ?? ""}
|
||||||
|
onChange={(e) => onUpdate({ ...content, alt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
fileType="image"
|
||||||
|
onSelect={(asset) => onUpdate({
|
||||||
|
...content,
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
url: asset.file_url,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { VideoIcon } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||||
|
|
||||||
|
export function TextVideoBlock({ content, onUpdate }) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const thumb = content.thumbnail_url ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* ── Layout ── */}
|
||||||
|
<div className="space-y-1.5 max-w-[200px]">
|
||||||
|
<Label>Video Position</Label>
|
||||||
|
<Select
|
||||||
|
value={content.video_position ?? "right"}
|
||||||
|
onValueChange={(v) => onUpdate({ ...content, video_position: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">Left</SelectItem>
|
||||||
|
<SelectItem value="right">Right</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{/* ── Text side ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Content</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter text content..."
|
||||||
|
rows={5}
|
||||||
|
value={content.body ?? ""}
|
||||||
|
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Video side ── */}
|
||||||
|
<div className={[
|
||||||
|
"space-y-1.5",
|
||||||
|
content.video_position === "left" ? "md:order-first" : "",
|
||||||
|
].join(" ")}>
|
||||||
|
<Label>Video</Label>
|
||||||
|
|
||||||
|
{content.url ? (
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg overflow-hidden cursor-pointer group"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
{thumb ? (
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt="Video thumbnail"
|
||||||
|
className="w-full aspect-video object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-video bg-muted flex items-center justify-center">
|
||||||
|
<VideoIcon className="h-10 w-10 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<p className="text-white text-sm font-medium">Change Video</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<VideoIcon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">Click to select a video</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
fileType="video"
|
||||||
|
onSelect={(asset) => onUpdate({
|
||||||
|
...content,
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
url: asset.file_url,
|
||||||
|
thumbnail_url: asset.thumbnail_url ?? null,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { VideoIcon } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||||
|
|
||||||
|
export function VideoBlock({ content, onUpdate }) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const thumb = content.thumbnail_url ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Video</Label>
|
||||||
|
|
||||||
|
{content.url ? (
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg overflow-hidden cursor-pointer group"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
{thumb ? (
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt="Video thumbnail"
|
||||||
|
className="w-full aspect-video object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-video bg-muted flex items-center justify-center">
|
||||||
|
<VideoIcon className="h-10 w-10 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<p className="text-white text-sm font-medium">Change Video</p>
|
||||||
|
</div>
|
||||||
|
{/* Play icon overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<VideoIcon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">Click to select a video</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
fileType="video"
|
||||||
|
onSelect={(asset) => onUpdate({
|
||||||
|
...content,
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
url: asset.file_url,
|
||||||
|
thumbnail_url: asset.thumbnail_url ?? null,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const CoursesContext = createContext(null);
|
||||||
|
|
||||||
|
export function useCourses() {
|
||||||
|
const ctx = useContext(CoursesContext);
|
||||||
|
if (!ctx) throw new Error("useCourses must be used within a CoursesProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initial States ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PAGINATION_INIT = {
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalRecords: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasPrevPage: false,
|
||||||
|
hasNextPage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE = "/admin/courses";
|
||||||
|
|
||||||
|
export function CoursesProvider({ children }) {
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────
|
||||||
|
const [courses, setCourses] = useState([]);
|
||||||
|
const [course, setCourse] = useState(null);
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
|
const [unit, setUnit] = useState(null);
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [lesson, setLesson] = useState(null);
|
||||||
|
const [lessonPage, setLessonPage] = useState(null);
|
||||||
|
const [attributes, setAttributes] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// ─── Request wrapper ──────────────────────────────────────────────────────
|
||||||
|
const request = useCallback(async (fn) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
|
toast.error(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ─── COURSES ─────────────────────────────────────────────────────────────
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// ─── GET /admin/courses ───────────────────────────────────────────────────
|
||||||
|
const fetchCourses = useCallback(
|
||||||
|
(params = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const { filters, sort, ...rest } = params;
|
||||||
|
const { data } = await api.get(BASE, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
...(filters?.length ? { filters: JSON.stringify(filters) } : {}),
|
||||||
|
...(sort?.length ? { sort: JSON.stringify(sort) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const final_data = data?.data;
|
||||||
|
setCourses(final_data?.data ?? []);
|
||||||
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
|
if (final_data?.attributes?.length) setAttributes(final_data.attributes);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /admin/courses/:courseId ─────────────────────────────────────────
|
||||||
|
const fetchCourse = useCallback(
|
||||||
|
(courseId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get(`${BASE}/${courseId}`);
|
||||||
|
const result = data?.data?.data ?? null;
|
||||||
|
setCourse(result);
|
||||||
|
setUnits(result?.units ?? []);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /admin/courses ──────────────────────────────────────────────────
|
||||||
|
const createCourse = useCallback(
|
||||||
|
(payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.post(BASE, payload);
|
||||||
|
const course = data?.data?.data ?? null;
|
||||||
|
if (course) {
|
||||||
|
setCourses((prev) => [course, ...prev]);
|
||||||
|
toast.success("Course created successfully.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /admin/courses/:courseId ───────────────────────────────────────
|
||||||
|
const updateCourse = useCallback(
|
||||||
|
(courseId, payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.patch(`${BASE}/${courseId}`, payload);
|
||||||
|
const course = data?.data?.data ?? null;
|
||||||
|
if (course) {
|
||||||
|
setCourses((prev) => prev.map((c) => (c.course_id === courseId ? course : c)));
|
||||||
|
setCourse(course);
|
||||||
|
toast.success("Course updated successfully.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /admin/courses/:courseId ──────────────────────────────────────
|
||||||
|
const deleteCourse = useCallback(
|
||||||
|
(courseId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.delete(`${BASE}/${courseId}`);
|
||||||
|
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
||||||
|
setCourse((prev) => (prev?.course_id === courseId ? null : prev));
|
||||||
|
toast.success("Course deleted.");
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ─── UNITS ───────────────────────────────────────────────────────────────
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// ─── GET /admin/courses/:courseId/units ───────────────────────────────────
|
||||||
|
const fetchUnits = useCallback(
|
||||||
|
(courseId, params = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const { filters, sort, ...rest } = params;
|
||||||
|
const { data } = await api.get(`${BASE}/${courseId}/units`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
...(filters?.length ? { filters: JSON.stringify(filters) } : {}),
|
||||||
|
...(sort?.length ? { sort: JSON.stringify(sort) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const final_data = data?.data;
|
||||||
|
setUnits(final_data?.data ?? []);
|
||||||
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
|
if (final_data?.attributes?.length) setAttributes(final_data.attributes);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /admin/courses/:courseId/units/:unitId ───────────────────────────
|
||||||
|
const fetchUnit = useCallback(
|
||||||
|
(courseId, unitId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}`);
|
||||||
|
const result = data?.data?.data ?? null;
|
||||||
|
setUnit(result);
|
||||||
|
setLessons(result?.lessons ?? []);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /admin/courses/:courseId/units ──────────────────────────────────
|
||||||
|
const createUnit = useCallback(
|
||||||
|
(courseId, payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.post(`${BASE}/${courseId}/units`, payload);
|
||||||
|
const unit = data?.data?.data ?? null;
|
||||||
|
if (unit) {
|
||||||
|
setUnits((prev) => [...prev, unit]);
|
||||||
|
toast.success("Unit created successfully.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /admin/courses/:courseId/units/:unitId ─────────────────────────
|
||||||
|
const updateUnit = useCallback(
|
||||||
|
(courseId, unitId, payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}`, payload);
|
||||||
|
const unit = data?.data?.data ?? null;
|
||||||
|
if (unit) {
|
||||||
|
setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u)));
|
||||||
|
setUnit(unit);
|
||||||
|
toast.success("Unit updated successfully.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /admin/courses/:courseId/units/:unitId ────────────────────────
|
||||||
|
const deleteUnit = useCallback(
|
||||||
|
(courseId, unitId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}`);
|
||||||
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
|
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
||||||
|
toast.success("Unit deleted.");
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ─── LESSONS ─────────────────────────────────────────────────────────────
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// ─── GET /admin/courses/:courseId/units/:unitId/lessons ───────────────────
|
||||||
|
const fetchLessons = useCallback(
|
||||||
|
(courseId, unitId, params = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const { filters, sort, ...rest } = params;
|
||||||
|
const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/lessons`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
...(filters?.length ? { filters: JSON.stringify(filters) } : {}),
|
||||||
|
...(sort?.length ? { sort: JSON.stringify(sort) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const final_data = data?.data;
|
||||||
|
setLessons(final_data?.data ?? []);
|
||||||
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
|
if (final_data?.attributes?.length) setAttributes(final_data.attributes);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /admin/courses/:courseId/units/:unitId/lessons/:lessonId ─────────
|
||||||
|
const fetchLesson = useCallback(
|
||||||
|
(courseId, unitId, lessonId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get(
|
||||||
|
`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`
|
||||||
|
);
|
||||||
|
const result = data?.data?.data ?? null;
|
||||||
|
setLesson(result);
|
||||||
|
setLessonPage(result?.page ?? null);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /admin/courses/:courseId/units/:unitId/lessons ──────────────────
|
||||||
|
const createLesson = useCallback(
|
||||||
|
(courseId, unitId, payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.post(
|
||||||
|
`${BASE}/${courseId}/units/${unitId}/lessons`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
const lesson = data?.data?.data ?? null;
|
||||||
|
if (lesson) {
|
||||||
|
setLessons((prev) => [...prev, lesson]);
|
||||||
|
toast.success("Lesson created successfully.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /admin/courses/:courseId/units/:unitId/lessons/:lessonId ───────
|
||||||
|
const updateLesson = useCallback(
|
||||||
|
(courseId, unitId, lessonId, payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.patch(
|
||||||
|
`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
const lesson = data?.data?.data ?? null;
|
||||||
|
if (lesson) {
|
||||||
|
setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l)));
|
||||||
|
setLesson(lesson);
|
||||||
|
toast.success("Lesson updated successfully.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /admin/courses/:courseId/units/:unitId/lessons/:lessonId ──────
|
||||||
|
const deleteLesson = useCallback(
|
||||||
|
(courseId, unitId, lessonId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.delete(
|
||||||
|
`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`
|
||||||
|
);
|
||||||
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
|
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
||||||
|
toast.success("Lesson deleted.");
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ─── LESSON PAGE ─────────────────────────────────────────────────────────
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// ─── GET .../lessons/:lessonId/page ───────────────────────────────────────
|
||||||
|
const fetchLessonPage = useCallback(
|
||||||
|
(courseId, unitId, lessonId) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get(
|
||||||
|
`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
||||||
|
);
|
||||||
|
const page = data?.data?.data ?? null;
|
||||||
|
setLessonPage(page);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PUT .../lessons/:lessonId/page ───────────────────────────────────────
|
||||||
|
const saveLessonPage = useCallback(
|
||||||
|
(courseId, unitId, lessonId, payload) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.put(
|
||||||
|
`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/page`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
const page = data?.data?.data ?? null;
|
||||||
|
if (page) {
|
||||||
|
setLessonPage(page);
|
||||||
|
toast.success("Lesson page saved.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Provider ─────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<CoursesContext.Provider value={{
|
||||||
|
// state
|
||||||
|
courses, course,
|
||||||
|
units, unit,
|
||||||
|
lessons, lesson,
|
||||||
|
lessonPage,
|
||||||
|
attributes,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
|
||||||
|
// setters
|
||||||
|
setPagination,
|
||||||
|
setCourse,
|
||||||
|
setUnit,
|
||||||
|
setLesson,
|
||||||
|
setLessonPage,
|
||||||
|
|
||||||
|
// course actions
|
||||||
|
fetchCourses,
|
||||||
|
fetchCourse,
|
||||||
|
createCourse,
|
||||||
|
updateCourse,
|
||||||
|
deleteCourse,
|
||||||
|
|
||||||
|
// unit actions
|
||||||
|
fetchUnits,
|
||||||
|
fetchUnit,
|
||||||
|
createUnit,
|
||||||
|
updateUnit,
|
||||||
|
deleteUnit,
|
||||||
|
|
||||||
|
// lesson actions
|
||||||
|
fetchLessons,
|
||||||
|
fetchLesson,
|
||||||
|
createLesson,
|
||||||
|
updateLesson,
|
||||||
|
deleteLesson,
|
||||||
|
|
||||||
|
// lesson page actions
|
||||||
|
fetchLessonPage,
|
||||||
|
saveLessonPage,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</CoursesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import { AssetsProvider } from "../AdminAssetsContext";
|
|||||||
import { AdminDashboardProvider } from "../AdminDashboardContext"
|
import { AdminDashboardProvider } from "../AdminDashboardContext"
|
||||||
import { UserProvider } from "../AdminUserContext";
|
import { UserProvider } from "../AdminUserContext";
|
||||||
import { UserGroupProvider } from "../AdminUserGroupContext";
|
import { UserGroupProvider } from "../AdminUserGroupContext";
|
||||||
|
import { CoursesProvider } from "../AdminCoursesContext";
|
||||||
|
|
||||||
export const AdminProvider = ({ children }) => {
|
export const AdminProvider = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
@@ -10,7 +11,9 @@ export const AdminProvider = ({ children }) => {
|
|||||||
<AssetsProvider>
|
<AssetsProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<UserGroupProvider>
|
<UserGroupProvider>
|
||||||
|
<CoursesProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</CoursesProvider>
|
||||||
</UserGroupProvider>
|
</UserGroupProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</AssetsProvider>
|
</AssetsProvider>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// modules/admin/data/dashboard.data.jsx
|
|
||||||
//
|
|
||||||
// Centralizes all icon maps, link maps, and chart link maps for the
|
// Centralizes all icon maps, link maps, and chart link maps for the
|
||||||
// admin dashboard. To add a new section (e.g. Tasks), just add a new
|
// admin dashboard. To add a new section (e.g. Tasks), just add a new
|
||||||
// export block here and import it in UsersDashboard.jsx.
|
// export block here and import it in UsersDashboard.jsx.
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// data/adminTiles.data.js
|
import { Users, GitFork, FolderOpen, BookText, ListCheck } from "lucide-react";
|
||||||
|
|
||||||
import { Users, GitFork, FolderOpen } from "lucide-react";
|
|
||||||
|
|
||||||
export const ADMIN_SECTIONS = [
|
export const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -22,6 +20,16 @@ export const ADMIN_SECTIONS = [
|
|||||||
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
|
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "section-courses",
|
||||||
|
tab: "Content Management",
|
||||||
|
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: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "section-site",
|
id: "section-site",
|
||||||
tab: "Site Content",
|
tab: "Site Content",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/courses/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/courses/toolbar.config";
|
||||||
|
import { buildSelectionActions } from "../../config/courses/selection.config";
|
||||||
|
import { buildRowActions } from "../../config/courses/rowActions.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
export default function CoursesTable() {
|
||||||
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => { },
|
||||||
|
setFilters: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const { courses, attributes, pagination, setPagination, loading, fetchCourses, deleteCourse, } = useCourses();
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => {
|
||||||
|
tableRefsRef.current = refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConfig = {
|
||||||
|
allData: courses,
|
||||||
|
attributes,
|
||||||
|
filename: `${getTimestamp()}_Courses`,
|
||||||
|
sheetName: "Courses",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
|
onView: (row) => navigate(`/admin/courses/${row.course_id}`),
|
||||||
|
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
|
||||||
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchCourses,
|
||||||
|
pagination,
|
||||||
|
exportConfig,
|
||||||
|
navigate,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionActions = buildSelectionActions({
|
||||||
|
exportConfig,
|
||||||
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(attributes, rowActions),
|
||||||
|
[attributes, rowActions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchiveSuccess = () => {
|
||||||
|
setArchiveTarget(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Courses"
|
||||||
|
data={courses}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchCourses}
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveDialog
|
||||||
|
open={!!archiveTarget}
|
||||||
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
|
entity={archiveTarget}
|
||||||
|
entityLabel="Course"
|
||||||
|
getName={(c) => c?.title}
|
||||||
|
onArchive={(c) => deleteCourse(c?.course_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleArchiveSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
|
|
||||||
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/courses/lessons/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/courses/lessons/toolbar.config";
|
||||||
|
import { buildRowActions } from "../../config/courses/lessons/rowActions.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
export default function LessonsTable({ courseId, unitId }) {
|
||||||
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => { },
|
||||||
|
setFilters: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
lessons, attributes, pagination, setPagination, loading,
|
||||||
|
fetchLessons, deleteLesson,
|
||||||
|
} = useCourses();
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => {
|
||||||
|
tableRefsRef.current = refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConfig = {
|
||||||
|
allData: lessons,
|
||||||
|
attributes,
|
||||||
|
filename: `${getTimestamp()}_Lessons`,
|
||||||
|
sheetName: "Lessons",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetch = useCallback(
|
||||||
|
(params) => fetchLessons(courseId, unitId, params),
|
||||||
|
[courseId, unitId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
|
onView: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}`),
|
||||||
|
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/edit`),
|
||||||
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
}), [courseId, unitId]);
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchLessons: (params) => fetchLessons(courseId, unitId, params),
|
||||||
|
pagination,
|
||||||
|
exportConfig,
|
||||||
|
navigate,
|
||||||
|
courseId,
|
||||||
|
unitId,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(attributes, rowActions),
|
||||||
|
[attributes, rowActions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchiveSuccess = () => {
|
||||||
|
setArchiveTarget(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Lessons"
|
||||||
|
data={lessons}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={handleFetch}
|
||||||
|
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}
|
||||||
|
recordLabel="lesson"
|
||||||
|
emptyMessage="No lessons found."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveDialog
|
||||||
|
open={!!archiveTarget}
|
||||||
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
|
entity={archiveTarget}
|
||||||
|
entityLabel="Lesson"
|
||||||
|
getName={(l) => l?.title}
|
||||||
|
onArchive={(l) => deleteLesson(courseId, unitId, l?.lesson_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleArchiveSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useMemo, useRef, useState, useCallback } 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 { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/courses/units/toolbar.config";
|
||||||
|
import { buildRowActions } from "../../config/courses/units/rowActions.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
export default function UnitsTable({ courseId }) {
|
||||||
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => { },
|
||||||
|
setFilters: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
units, attributes, pagination, setPagination, loading,
|
||||||
|
fetchUnits, deleteUnit,
|
||||||
|
} = useCourses();
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => {
|
||||||
|
tableRefsRef.current = refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConfig = {
|
||||||
|
allData: units,
|
||||||
|
attributes,
|
||||||
|
filename: `${getTimestamp()}_Units`,
|
||||||
|
sheetName: "Units",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetch = useCallback(
|
||||||
|
(params) => fetchUnits(courseId, params),
|
||||||
|
[courseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
|
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}`),
|
||||||
|
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/edit`),
|
||||||
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
}), [courseId]);
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchUnits: (params) => fetchUnits(courseId, params),
|
||||||
|
pagination,
|
||||||
|
exportConfig,
|
||||||
|
navigate,
|
||||||
|
courseId,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(attributes, rowActions),
|
||||||
|
[attributes, rowActions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchiveSuccess = () => {
|
||||||
|
setArchiveTarget(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Units"
|
||||||
|
data={units}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={handleFetch}
|
||||||
|
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}
|
||||||
|
recordLabel="unit"
|
||||||
|
emptyMessage="No units found."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveDialog
|
||||||
|
open={!!archiveTarget}
|
||||||
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
|
entity={archiveTarget}
|
||||||
|
entityLabel="Unit"
|
||||||
|
getName={(u) => u?.title}
|
||||||
|
onArchive={(u) => deleteUnit(courseId, u?.unit_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleArchiveSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
left: ["select", "title"],
|
||||||
|
right: ["actions"],
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
left: ["select", "title"],
|
||||||
|
right: ["actions"],
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return rowActions ? [...base, rowActions] : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// config/courses/lessons/rowActions.config.jsx
|
||||||
|
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// config/courses/lessons/toolbar.config.jsx
|
||||||
|
|
||||||
|
import { Plus, RefreshCw, Download } 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/create`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
export function buildSelectionActions({ exportConfig, 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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Plus, RefreshCw, Download } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
export function buildToolbarActions({
|
||||||
|
fetchCourses,
|
||||||
|
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: () => fetchCourses({
|
||||||
|
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 Course",
|
||||||
|
icon: <Plus className="h-3.5 w-3.5" />,
|
||||||
|
variant: "default",
|
||||||
|
onClick: () => navigate("/admin/courses/create"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// config/courses/units/columns.config.jsx
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
left: ["select", "title"],
|
||||||
|
right: ["actions"],
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Plus, RefreshCw, Download } 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/create`),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { House } from "lucide-react";
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import CoursesTable from "../../components/courses/CourseTable";
|
||||||
|
|
||||||
|
export default function CourseList() {
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
|
{ label: "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">
|
||||||
|
<CoursesTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, 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 CreateLesson() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { courseId, unitId } = useParams();
|
||||||
|
const { createLesson, course, unit, 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: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||||
|
{ label: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||||
|
{ label: "Create Lesson" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
const result = await createLesson(courseId, unitId, { ...data, createdBy: user?.user_id });
|
||||||
|
if (!result) return;
|
||||||
|
navigate(`/admin/courses/${courseId}/units/${unitId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Lesson</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Add a new lesson to this unit.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
||||||
|
<FieldError message={errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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 Lesson
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, 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 CreateUnit() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { courseId } = useParams();
|
||||||
|
const { createUnit, course, 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: course?.title ?? "Course", to: `/admin/courses/${courseId}` },
|
||||||
|
{ label: "Create Unit" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
|
||||||
|
if (!result) return;
|
||||||
|
navigate(`/admin/courses/${courseId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Unit</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Add a new unit to this 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="Unit title" {...register("title")} />
|
||||||
|
<FieldError message={errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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 Unit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, 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 EditCourse() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { courseId } = useParams();
|
||||||
|
const { fetchCourse, updateCourse, loading } = useCourses();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
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: "Edit" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await fetchCourse(courseId);
|
||||||
|
const course = res?.data?.data ?? null;
|
||||||
|
if (!course) return;
|
||||||
|
reset({
|
||||||
|
title: course.title ?? "",
|
||||||
|
description: course.description ?? "",
|
||||||
|
order: course.order ?? 0,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
if (!isDirty) return navigate(-1);
|
||||||
|
const result = await updateCourse(courseId, { ...data, updatedBy: user?.user_id });
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">Edit Course</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Update course details.</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 || !isDirty}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, 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 EditLesson() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { courseId, unitId, lessonId } = useParams();
|
||||||
|
const { fetchLesson, updateLesson, course, unit, loading } = useCourses();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
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: unit?.title ?? "Unit", to: `/admin/courses/${courseId}/units/${unitId}` },
|
||||||
|
{ label: "Edit Lesson" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await fetchLesson(courseId, unitId, lessonId);
|
||||||
|
const lesson = res?.data?.data ?? null;
|
||||||
|
if (!lesson) return;
|
||||||
|
reset({
|
||||||
|
title: lesson.title ?? "",
|
||||||
|
description: lesson.description ?? "",
|
||||||
|
order: lesson.order ?? 0,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [courseId, unitId, lessonId]);
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
if (!isDirty) return navigate(-1);
|
||||||
|
const result = await updateLesson(courseId, unitId, lessonId, {
|
||||||
|
...data,
|
||||||
|
updatedBy: user?.user_id,
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">Edit Lesson</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Update lesson details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
||||||
|
<FieldError message={errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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 || !isDirty}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, 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 EditUnit() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { courseId, unitId } = useParams();
|
||||||
|
const { fetchUnit, updateUnit, course, loading } = useCourses();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
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);
|
||||||
|
const unit = res?.data?.data ?? null;
|
||||||
|
if (!unit) return;
|
||||||
|
reset({
|
||||||
|
title: unit.title ?? "",
|
||||||
|
description: unit.description ?? "",
|
||||||
|
order: unit.order ?? 0,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [courseId, unitId]);
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
if (!isDirty) return navigate(-1);
|
||||||
|
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">Edit Unit</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Update unit details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="title" placeholder="Unit title" {...register("title")} />
|
||||||
|
<FieldError message={errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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 || !isDirty}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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,74 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ArrowLeft, House } 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 LessonsTable from "../../components/courses/LessonsTable";
|
||||||
|
|
||||||
|
export default function UnitDetail() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { courseId, unitId } = useParams();
|
||||||
|
const { fetchUnit, course, unit, loading } = useCourses();
|
||||||
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await fetchUnit(courseId, unitId);
|
||||||
|
setInitializing(false);
|
||||||
|
})();
|
||||||
|
}, [courseId, unitId]);
|
||||||
|
|
||||||
|
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 ?? "..." },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
|
||||||
|
{/* ── 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 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{unit.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}>
|
||||||
|
Edit Unit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lessons ── */}
|
||||||
|
<LessonsTable courseId={courseId} unitId={unitId} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,17 @@ import ViewVideoAsset from "../pages/assets/ViewVideoAsset";
|
|||||||
import ViewDocumentAsset from "../pages/assets/ViewDocumentAsset";
|
import ViewDocumentAsset from "../pages/assets/ViewDocumentAsset";
|
||||||
import AssetList from '../pages/assets/AssetList'
|
import AssetList from '../pages/assets/AssetList'
|
||||||
import EditAsset from '../pages/assets/EditAsset'
|
import EditAsset from '../pages/assets/EditAsset'
|
||||||
|
import CourseList from '../pages/courses/CourseList'
|
||||||
|
import CreateCourse from '../pages/courses/CreateCourse'
|
||||||
|
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'
|
||||||
|
|
||||||
export const AdminRoutes = {
|
export const AdminRoutes = {
|
||||||
element: <ProtectedRoute allowedRoles={['admin']} />,
|
element: <ProtectedRoute allowedRoles={['admin']} />,
|
||||||
@@ -74,7 +85,27 @@ export const AdminRoutes = {
|
|||||||
{ path: 'view/document/:assetId', element: <ViewDocumentAsset /> },
|
{ path: 'view/document/:assetId', element: <ViewDocumentAsset /> },
|
||||||
{ path: 'edit/:assetId', element: <EditAsset /> },
|
{ path: 'edit/:assetId', element: <EditAsset /> },
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// Courses
|
||||||
|
{
|
||||||
|
path: 'courses',
|
||||||
|
element: <Outlet />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <CourseList /> },
|
||||||
|
{ path: 'create', element: <CreateCourse /> },
|
||||||
|
{ path: ':courseId', element: <CourseDetail /> },
|
||||||
|
{ 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 /> },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// Add here
|
// Add here
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user