mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
259 lines
9.3 KiB
React
259 lines
9.3 KiB
React
// 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>
|
||
);
|
||
} |