Files
starr-philproperties/src/modules/admin/pages/courses/lessons/LessonPageBuilder.jsx
T
kennethobsequio 71f758fe0b merged
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-11 12:12:40 +08:00

259 lines
9.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pages/admin/LessonPageBuilder.jsx
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Save, Pencil, Monitor, Eye, EyeOff, X } from "lucide-react";
import { nanoid } from "nanoid";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
import { cn } from "@/lib/utils";
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
function makeBlock(type) {
return {
id: nanoid(),
type,
content: { ...DEFAULT_CONTENT[type] },
};
}
export default function LessonPageBuilder() {
const navigate = useNavigate();
const { courseId, unitId, lessonId } = useParams();
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
const { user } = useAuth();
const [blocks, setBlocks] = useState([]);
const [initializing, setInitializing] = useState(true);
const [previewVisible, setPreviewVisible] = useState(true);
const [previewOpen, setPreviewOpen] = useState(false);
const headerRef = useRef(null);
const editorPaneRef = useRef(null);
const blocksSeeded = useRef(false);
useEffect(() => {
// The builder stays mounted across lesson navigations (route param change
// only), so local editor state must be reset by hand here — otherwise the
// previous lesson's blocks leak into the next lesson's editor.
(async () => {
setInitializing(true);
setBlocks([]);
blocksSeeded.current = false;
await fetchLesson(courseId, unitId, lessonId);
setInitializing(false);
})();
}, [courseId, unitId, lessonId]);
useEffect(() => {
if (lessonPage?.blocks?.length && !blocksSeeded.current) {
setBlocks(lessonPage.blocks);
blocksSeeded.current = true;
}
}, [lessonPage]);
useEffect(() => {
if (!headerRef.current) return;
const update = () => {
document.documentElement.style.setProperty(
"--builder-h",
`${headerRef.current.offsetHeight}px`
);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
useEffect(() => {
const el = editorPaneRef.current;
if (!el) return;
const applyHeight = () => {
if (window.innerWidth >= 1024 && previewVisible) {
el.style.height = `calc(100vh - var(--navbar-h) - var(--builder-h, 0px))`;
} else {
el.style.height = "auto";
}
};
applyHeight();
window.addEventListener("resize", applyHeight);
return () => window.removeEventListener("resize", applyHeight);
}, [previewVisible]);
const addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
const deleteBlock = (id) => setBlocks((prev) => prev.filter((b) => b.id !== id));
const moveBlock = (id, direction) => {
setBlocks((prev) => {
const index = prev.findIndex((b) => b.id === id);
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
const next = [...prev];
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
return next;
});
};
const handleSave = async () => {
const result = await saveLessonPage(courseId, unitId, lessonId, {
blocks,
updatedBy: user?.user_id,
});
if (!result) return;
navigate(-1);
};
const editorStyle = previewVisible
? { top: "calc(var(--navbar-h) + var(--builder-h, 0px))" }
: undefined;
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={lesson ? `Page Builder – ${lesson.title} - STARR` : undefined} />
{/* ── Sticky builder header ── */}
<div
ref={headerRef}
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight">Page Builder</h1>
<p className="text-sm text-muted-foreground truncate">{lesson?.title}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="outline"
onClick={() => setPreviewVisible((v) => !v)}
className="hidden lg:inline-flex gap-2"
>
{previewVisible
? <><EyeOff className="h-4 w-4" /> Hide Preview</>
: <><Eye className="h-4 w-4" /> Show Preview</>
}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="lg:hidden gap-2"
>
<Eye className="h-4 w-4" />
Preview
</Button>
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Page
</Button>
</div>
</div>
</div>
</div>
{/* ── Split pane ── */}
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
{/* LEFT — Editor */}
<div
className={cn(
"w-full border-r",
previewVisible ? "lg:w-[40%] lg:shrink-0 lg:sticky" : "lg:w-full"
)}
style={editorStyle}
>
<div
className={cn(
"flex flex-col gap-3 p-4 pb-10",
previewVisible && "lg:overflow-y-auto"
)}
ref={editorPaneRef}
>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
<Pencil className="h-4 w-4" />
Editor
{blocks.length > 0 && (
<span className="text-xs font-normal">
· {blocks.length} block{blocks.length !== 1 ? "s" : ""}
</span>
)}
</div>
{initializing ? (
<div className="flex items-center justify-center py-20">
<Spinner className="h-6 w-6" />
</div>
) : (
<>
<BlockList blocks={blocks} onUpdate={updateBlock} onMove={moveBlock} onDelete={deleteBlock} />
<AddBlockMenu onAdd={addBlock} />
</>
)}
</div>
</div>
{/* RIGHT — Live Preview (desktop only) */}
{previewVisible && (
<div className="hidden lg:flex flex-1 flex-col gap-3 p-4 pb-10">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
<Monitor className="h-4 w-4" />
Live Preview
</div>
<PreviewChrome title={lesson?.title}>
<div className="p-6 space-y-5 min-h-[300px]">
<PreviewContent
lesson={lesson}
blocks={blocks}
empty="Your content will appear here as you build."
/>
</div>
</PreviewChrome>
</div>
)}
</div>
{/* ── Mobile preview bottom sheet ── */}
{previewOpen && (
<div className="lg:hidden fixed inset-0 z-40 flex flex-col">
<div className="flex-1 bg-black/50" onClick={() => setPreviewOpen(false)} />
<div className="bg-background rounded-t-2xl border-t shadow-xl flex flex-col max-h-[85dvh]">
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b shrink-0">
<div className="flex items-center gap-2 text-sm font-medium">
<Monitor className="h-4 w-4 text-muted-foreground" />
Live Preview
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => setPreviewOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="overflow-y-auto flex-1 p-4">
<PreviewChrome title={lesson?.title}>
<div className="p-4 space-y-4 min-h-[200px]">
<PreviewContent
lesson={lesson}
blocks={blocks}
empty="No content yet."
/>
</div>
</PreviewChrome>
</div>
</div>
</div>
)}
</div>
);
}