ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
@@ -0,0 +1,167 @@
import { useState, useEffect, useCallback } from "react";
import { UserPlus, Check, ChevronsUpDown, Loader2 } from "lucide-react";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
const ROLE_COLORS = {
admin: "bg-red-100 text-red-700 border-red-200",
staff: "bg-green-100 text-green-700 border-green-200",
};
function getFullName(u) {
return u?.personal_info?.name?.full_name?.trim() || u?.email || "Unknown";
}
/**
* Props:
* added — current instructor list [{ user_id, display_name, ... }]
* onAdd(inst) — called with { user_id, display_name } to add one instructor
* onToggleLinked(user_id) — remove a linked user by their user_id
*/
export default function CourseInstructorPicker({ added = [], onAdd, onToggleLinked }) {
const [open, setOpen] = useState(false);
const [users, setUsers] = useState([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [externalName, setExternalName] = useState("");
const linkedIds = new Set(added.filter(i => i.user_id).map(i => String(i.user_id)));
const loadUsers = useCallback(async () => {
if (users.length) return;
setLoadingUsers(true);
try {
const { data } = await api.get("/admin/users", {
params: { limit: 200 },
});
const rows = data?.data?.data ?? [];
setUsers(rows.filter(u => u.acc_type === "staff" || u.acc_type === "admin"));
} catch {
// silently ignore — user can still add external
} finally {
setLoadingUsers(false);
}
}, [users.length]);
useEffect(() => {
if (open) loadUsers();
}, [open, loadUsers]);
const toggleUser = (u) => {
const uid = String(u.user_id);
if (linkedIds.has(uid)) {
onToggleLinked(u.user_id);
} else {
onAdd({ user_id: u.user_id, display_name: getFullName(u) });
}
};
const addExternal = () => {
const name = externalName.trim();
if (!name) return;
onAdd({ user_id: null, display_name: name });
setExternalName("");
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild className="w-full">
<Button type="button" variant="outline" size="sm" className="w-full justify-between">
<span className="flex items-center gap-1.5">
<UserPlus className="h-3.5 w-3.5" />
Add Instructor
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or email…" />
<CommandList>
{/* ── Staff / Admin accounts ── */}
<CommandGroup heading="Staff & Admin Accounts">
{loadingUsers && (
<CommandItem disabled>
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
Loading users…
</CommandItem>
)}
{!loadingUsers && users.length === 0 && (
<CommandEmpty>No staff or admin accounts found.</CommandEmpty>
)}
{users.map((u) => {
const uid = String(u.user_id);
const checked = linkedIds.has(uid);
const name = getFullName(u);
return (
<CommandItem
key={uid}
value={`${name} ${u.email}`}
onSelect={() => toggleUser(u)}
className="gap-2"
>
<Checkbox checked={checked} className="pointer-events-none" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{name}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${ROLE_COLORS[u.acc_type] ?? ""}`}
>
{u.acc_type}
</Badge>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
{/* ── External (no account) ── */}
<CommandGroup heading="External (no account)">
<div className="px-2 py-1.5 flex gap-2">
<Input
placeholder="Display name"
value={externalName}
onChange={(e) => setExternalName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addExternal())}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
className="h-8 px-3 shrink-0"
disabled={!externalName.trim()}
onClick={addExternal}
>
Add
</Button>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,388 @@
import { useEffect, useState, useMemo } from 'react';
import {
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from '@/components/ui/dialog';
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from '@/components/ui/pagination';
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
const PAGE_SIZE = 10;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
if (!status) return <Badge variant="outline" className="text-muted-foreground text-xs">Not started</Badge>;
return status === 'completed'
? <Badge variant="outline" className="text-emerald-600 border-emerald-400 text-xs">Completed</Badge>
: <Badge><RefreshCcw />In Progress</Badge>;
}
function ProgressBar({ value, total, className = '' }) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{value} / {total}
</span>
</div>
);
}
function UserAvatar({ name, email, avatarUrl }) {
const initials = name
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
: (email?.[0] ?? '?').toUpperCase();
return (
<Avatar className="size-9 shrink-0">
<AvatarImage src={avatarUrl ?? undefined} alt={name ?? email} />
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
{initials}
</AvatarFallback>
</Avatar>
);
}
// ─── Dialog: full breakdown for one user ─────────────────────────────────────
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
const breakdown = entry ? detailCache[entry.user_id] : null;
useEffect(() => {
if (open && entry && !breakdown) {
fetchUserReadingProgress(courseId, entry.user_id);
}
}, [open, entry]);
const lastSeen = entry?.last_accessed_at
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
<DialogHeader>
<div className="flex items-center gap-3">
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
<div className="min-w-0">
<DialogTitle className="truncate">
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
</DialogTitle>
<DialogDescription className="truncate">{entry?.user.email}</DialogDescription>
</div>
</div>
</DialogHeader>
{/* ── Meta strip ── */}
{entry && (
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge status={entry.course_status} />
<span className="text-xs">Last seen {lastSeen}</span>
<span className="text-xs text-muted-foreground ml-auto">
{entry.lessons_completed} / {entry.lessons_total} lessons
</span>
</div>
)}
{/* ── Progress bar ── */}
{entry && (
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
)}
<Separator />
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
<ScrollArea className="flex-1 min-h-0 pr-2">
{detailLoading && !breakdown ? (
<div className="space-y-3 py-1">
{[...Array(4)].map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-36 ml-5" />
<Skeleton className="h-3 w-40 ml-5" />
</div>
))}
</div>
) : breakdown?.length ? (
<div className="space-y-4 py-1">
{breakdown.map((unit, ui) => (
<div key={unit.unit_id} className="space-y-2">
{/* Unit header */}
<div className="flex items-center gap-2">
{unit.status === 'completed'
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: unit.status === 'in_progress'
? <Circle className="size-4 text-amber-400 shrink-0" />
: <Circle className="size-4 text-muted-foreground/30 shrink-0" />
}
<span className="text-sm font-semibold truncate flex-1">
Unit {ui + 1}: {unit.title}
</span>
{unit.status && <StatusBadge status={unit.status} />}
</div>
{/* Lesson rows */}
<div className="ml-6 space-y-1.5 border-l pl-3">
{unit.lessons.map((lesson) => (
<div key={lesson.lesson_id} className="flex items-center gap-2">
{lesson.status === 'completed'
? <CheckCircle2 className="size-3 text-emerald-500 shrink-0" />
: lesson.status === 'in_progress'
? <Circle className="size-3 text-amber-400 shrink-0" />
: <Circle className="size-3 text-muted-foreground/25 shrink-0" />
}
<span className={`text-xs truncate flex-1 ${lesson.status ? 'text-foreground' : 'text-muted-foreground'}`}>
{lesson.title}
</span>
{lesson.status === 'completed' && lesson.completed_at && (
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">No lesson data available.</p>
)}
</ScrollArea>
<DialogFooter showCloseButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── User summary card ────────────────────────────────────────────────────────
function UserCard({ entry, onOpen }) {
const lastSeen = entry.last_accessed_at
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<button
type="button"
onClick={() => onOpen(entry)}
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate flex-1">
{entry.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
</span>
<StatusBadge status={entry.course_status} />
</div>
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
<p className="text-xs">Last seen {lastSeen}</p>
</div>
</button>
);
}
// ─── Pagination controls ──────────────────────────────────────────────────────
function PaginationControls({ page, totalPages, onPage }) {
if (totalPages <= 1) return null;
const pages = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
// Show at most 5 page numbers around current
const getVisible = () => {
if (totalPages <= 5) return pages;
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, null, page - 1, page, page + 1, null, totalPages];
};
return (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
className={page === 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{getVisible().map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPage(p); }}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function CourseReadingProgressList({ courseId }) {
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [dialogEntry, setDialogEntry] = useState(null);
useEffect(() => {
fetchCourseReadingProgress(courseId);
}, [courseId]);
// Reset to page 1 when search changes
useEffect(() => { setPage(1); }, [search]);
// ── Filter ────────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return progressList;
return progressList.filter((e) =>
e.user.full_name?.toLowerCase().includes(q) ||
e.user.email?.toLowerCase().includes(q)
);
}, [progressList, search]);
// ── Paginate ──────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const completedCount = progressList.filter((e) => e.course_status === 'completed').length;
const inProgressCount = progressList.length - completedCount;
// ── Loading skeleton ──────────────────────────────────────────────────────
if (listLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-9 w-full rounded-md" />
{[...Array(4)].map((_, i) => (
<div key={i} className="border rounded-lg p-4 flex items-center gap-3">
<Skeleton className="size-9 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-56" />
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
<Skeleton className="h-5 w-20 rounded-full shrink-0" />
</div>
))}
</div>
);
}
// ── Empty state ───────────────────────────────────────────────────────────
if (!progressList.length) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center gap-2">
<BookOpen className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No students have started reading this course yet.</p>
</div>
);
}
return (
<>
{/* ── Summary strip ── */}
<div className="flex items-center gap-4 flex-wrap text-sm">
<span className="flex items-center gap-1.5">
<Users className="size-3.5" />
{progressList.length} enrolled
</span>
<span className="flex items-center gap-1.5">
<CheckCircle2 className="size-3.5 text-emerald-500" />
{completedCount} completed
</span>
<span className="flex items-center gap-1.5">
<Circle className="size-3.5 text-amber-400" />
{inProgressCount} in progress
</span>
</div>
{/* ── Search ── */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
maxLength={50}
className="pl-9 pr-16"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
{search.length}/50
</span>
</div>
{/* ── List ── */}
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No results for "{search}".</p>
) : (
<div className="space-y-2">
{paginated.map((entry) => (
<UserCard
key={entry.user_id}
entry={entry}
onOpen={setDialogEntry}
/>
))}
</div>
)}
{/* ── Pagination ── */}
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
{/* ── Detail Dialog ── */}
<UserDetailDialog
open={!!dialogEntry}
onOpenChange={(v) => { if (!v) setDialogEntry(null); }}
entry={dialogEntry}
courseId={courseId}
/>
</>
);
}
@@ -42,6 +42,7 @@ export default function CoursesTable() {
};
const rowActions = useMemo(() => buildRowActions({
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
@@ -1,29 +1,40 @@
import { Eye, ImageIcon, VideoIcon } from "lucide-react";
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/TextBlock";
/* A similar strategy to BlockList.jsx so this will applies
to Client side */
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
import { TextImageBlock } from "@/components/generic/Blocks/Client/TextImageBlock";
import { ImageBlock } from "@/components/generic/Blocks/Client/ImageBlock";
import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
export function LessonHeader({ lesson }) {
if (!lesson) return null;
return (
<div className="space-y-4 pb-2">
<div className="space-y-3 pb-2 sm:space-y-4">
<div>
<h1 style={{ fontSize: "1.875rem", fontWeight: 700, lineHeight: 1.2, margin: "0 0 0.4rem 0" }}>
<h1 className="text-2xl font-bold leading-tight mb-1 sm:text-3xl sm:leading-[1.2]">
{lesson.title}
</h1>
{lesson.description && (
<p style={{ margin: "0.2rem 0", lineHeight: 1.75, textAlign: "justify" }}
className="text-muted-foreground">
<p className="mt-1 leading-relaxed text-sm text-muted-foreground sm:text-base sm:leading-[1.75] sm:text-justify">
{lesson.description}
</p>
)}
</div>
{lesson.objectives?.length > 0 && (
<div className="rounded-lg border p-4 space-y-3">
<div className="rounded-lg border p-3 space-y-2 sm:p-4 sm:space-y-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-md bg-green-100 flex items-center justify-center shrink-0">
<div className="h-7 w-7 rounded-md bg-green-100 flex items-center justify-center shrink-0 sm:h-8 sm:w-8">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 text-green-600"
className="h-3.5 w-3.5 text-green-600 sm:h-4 sm:w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -36,11 +47,11 @@ export function LessonHeader({ lesson }) {
<circle cx="12" cy="12" r="2" />
</svg>
</div>
<p className="text-sm font-semibold">Objective</p>
<p className="text-xs font-semibold sm:text-sm">Objective</p>
</div>
<ul className="space-y-1.5 list-disc list-inside">
<ul className="space-y-1 list-disc list-inside sm:space-y-1.5">
{lesson.objectives.map((o) => (
<li key={o.objective_id} className="text-sm text-muted-foreground">
<li key={o.objective_id} className="text-xs text-muted-foreground sm:text-sm">
{o.text}
</li>
))}
@@ -51,6 +62,32 @@ export function LessonHeader({ lesson }) {
);
}
// ─── Zoomable image wrapper ───────────────────────────────────────────────────
// Must be rendered inside a <PhotoProvider>. Shows a subtle zoom hint on hover.
export function ZoomableImage({ url, alt }) {
if (!url) return null;
return (
<PhotoView src={url}>
<div className="relative group cursor-zoom-in">
<img
src={url}
alt={alt ?? ""}
className="w-full rounded-md object-cover aspect-video"
draggable={false}
/>
{/* Zoom hint badge — fades in on hover */}
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div className="flex items-center gap-1 bg-secondary text-sm px-2 py-1 rounded-full border">
<ZoomIn className="size-4" />
<span>Zoom</span>
</div>
</div>
</div>
</PhotoView>
);
}
export function PreviewImage({ url, alt }) {
if (!url) {
return (
@@ -60,9 +97,7 @@ export function PreviewImage({ url, alt }) {
</div>
);
}
return (
<img src={url} alt={alt ?? ""} className="w-full rounded-md object-cover aspect-video" />
);
return <ZoomableImage url={url} alt={alt} />;
}
export function PreviewVideo({ url, thumb }) {
@@ -80,129 +115,107 @@ export function PreviewVideo({ url, thumb }) {
<img src={thumb} alt="Video thumbnail" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<VideoIcon className="h-10 w-10 text-muted-foreground/40" />
<VideoIcon className="h-8 w-8 text-muted-foreground/40 sm:h-10 sm:w-10" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="h-10 w-10 rounded-full bg-black/50 flex items-center justify-center">
<VideoIcon className="h-5 w-5 text-white" />
<div className="h-8 w-8 rounded-full bg-black/50 flex items-center justify-center sm:h-10 sm:w-10">
<VideoIcon className="h-4 w-4 text-white sm:h-5 sm:w-5" />
</div>
</div>
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<p className="text-white text-[10px] truncate">{url}</p>
<p className="text-white text-[9px] truncate sm:text-[10px]">{url}</p>
</div>
</div>
);
}
export function PreviewBlock({ block }) {
const { type, content } = block;
const { id, type, content } = block;
if (type === "text") {
if (!content.body) {
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
}
return (
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
);
switch (type) {
case "text":
return <TextBlock blockId={id} content={content} readOnly />;
case "image":
return <ImageBlock content={content} readOnly />;
case "text-image":
return <TextImageBlock blockId={id} content={content} readOnly />;
case "video":
return <VideoBlock content={content} readOnly />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} readOnly />;
case "audio":
return <AudioBlock content={content} />;
case "code":
return <CodeBlock content={content} />;
case "markdown":
return <MarkdownBlock content={content} />;
default:
return null;
}
if (type === "image") {
if (!content.url) {
return (
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<ImageIcon className="h-4 w-4" />
No image selected
</div>
);
}
return (
<figure>
<img src={content.url} alt={content.alt ?? ""} className="w-full rounded-md object-cover" />
</figure>
);
}
if (type === "text-image") {
const imgLeft = content.image_position === "left";
return (
<div className="grid grid-cols-2 gap-4 items-start">
{imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
/>
{!imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
</div>
);
}
if (type === "video") {
if (!content.url) {
return (
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<VideoIcon className="h-4 w-4" />
No video selected
</div>
);
}
return <PreviewVideo url={content.url} thumb={content.thumbnail_url} />;
}
if (type === "text-video") {
const vidLeft = content.video_position === "left";
return (
<div className="grid grid-cols-2 gap-4 items-start">
{vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
/>
{!vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
</div>
);
}
return null;
}
// ─── PreviewContent ───────────────────────────────────────────────────────────
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
return (
<>
<PhotoProvider
speed={() => 300}
easing={(type) => (type === 2 ? "cubic-bezier(0.36, 0, 0.66, -0.56)" : "cubic-bezier(0.34, 1.56, 0.64, 1)")}
toolbarRender={({ onScale, scale, rotate, onRotate }) => (
<div className="flex items-center gap-3 px-2">
<button
onClick={() => onScale(scale + 0.5)}
className="text-white/80 hover:text-white transition-colors"
title="Zoom in"
>
<ZoomIn className="h-5 w-5" />
</button>
</div>
)}
>
<style>{WYSIWYG_STYLES}</style>
<LessonHeader lesson={lesson} />
{blocks.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Eye className="h-8 w-8 opacity-20" />
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
<p>{empty}</p>
</div>
) : (
blocks.map((block) => (
<div key={block.id}>
<PreviewBlock block={block} />
</div>
))
<div className="space-y-4 sm:space-y-5">
{blocks.map((block) => (
<div key={block.id}>
<PreviewBlock block={block} />
</div>
))}
</div>
)}
</>
</PhotoProvider>
);
}
export function PreviewChrome({ title, children }) {
export function PreviewChrome({ title, children, showChrome = true }) {
if (!showChrome) {
return <>{children}</>;
}
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<style>{WYSIWYG_STYLES}</style>
<div className="flex items-center gap-1.5 px-3 py-2 bg-muted/60 border-b">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
<span className="h-2.5 w-2.5 rounded-full bg-green-400" />
<div className="flex-1 mx-3 h-5 rounded bg-background/60 border text-[10px] flex items-center px-2 text-muted-foreground/60 truncate">
<span className="hidden h-2.5 w-2.5 rounded-full bg-red-400 sm:inline-block" />
<span className="hidden h-2.5 w-2.5 rounded-full bg-yellow-400 sm:inline-block" />
<span className="hidden h-2.5 w-2.5 rounded-full bg-green-400 sm:inline-block" />
<div className="flex-1 sm:mx-3 h-5 rounded bg-background/60 border text-[10px]
flex items-center px-2 text-muted-foreground/60 truncate">
{title ?? "Lesson Preview"}
</div>
<Eye className="h-3.5 w-3.5 text-muted-foreground/60" />
<Eye className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
</div>
<div className="p-3 sm:p-5">
{children}
</div>
{children}
</div>
);
}
@@ -50,6 +50,7 @@ export default function UnitsTable({ courseId }) {
);
const rowActions = useMemo(() => buildRowActions({
onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`),
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz`),
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),