Files
openccb/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx
T

295 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"use client";
import { useEffect, useState } from "react";
import { cmsApi, Lesson, Block } from "@/lib/api";
import Link from "next/link";
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
import MediaBlock from "@/components/blocks/MediaBlock";
import QuizBlock from "@/components/blocks/QuizBlock";
import FillInTheBlanksBlock from "@/components/blocks/FillInTheBlanksBlock";
import MatchingBlock from "@/components/blocks/MatchingBlock";
import OrderingBlock from "@/components/blocks/OrderingBlock";
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const [lesson, setLesson] = useState<Lesson | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
// Activity State (Blocks)
const [blocks, setBlocks] = useState<Block[]>([]);
useEffect(() => {
const loadData = async () => {
try {
const lessonData: Lesson = await fetch(`http://localhost:3001/lessons/${params.lessonId}`).then(res => res.json());
setLesson(lessonData);
if (lessonData.metadata?.blocks) {
setBlocks(lessonData.metadata.blocks);
} else {
setBlocks([
{
id: 'initial-desc',
type: 'description',
content: `Welcome to ${lessonData.title}. Please follow the instructions below.`
}
]);
}
} catch {
console.error("Failed to load lesson");
} finally {
setLoading(false);
}
};
loadData();
}, [params.id, params.lessonId]);
const handleSave = async () => {
if (!lesson) return;
setIsSaving(true);
try {
const updated = await cmsApi.updateLesson(lesson.id, {
metadata: { ...lesson.metadata, blocks }
});
setLesson(updated);
setEditMode(false);
} catch {
alert("Failed to save activity.");
} finally {
setIsSaving(false);
}
};
const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer') => {
const newBlock: Block = {
id: Math.random().toString(36).substr(2, 9),
type,
...(type === 'description' && { content: "" }),
...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }),
...(type === 'quiz' && { quiz_data: { questions: [] } }),
...(type === 'fill-in-the-blanks' && { content: "Type your text here with [[blanks]]." }),
...(type === 'matching' && { pairs: [{ left: "Item 1", right: "Match 1" }] }),
...(type === 'ordering' && { items: ["Item A", "Item B"] }),
...(type === 'short-answer' && { prompt: "Question?", correctAnswers: ["Answer"] }),
};
setBlocks([...blocks, newBlock]);
};
const removeBlock = (id: string) => {
setBlocks(blocks.filter(b => b.id !== id));
};
const updateBlock = (id: string, updates: Partial<Block>) => {
setBlocks(blocks.map(b => b.id === id ? { ...b, ...updates } : b));
};
const moveBlock = (index: number, direction: 'up' | 'down') => {
const newBlocks = [...blocks];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newBlocks.length) return;
[newBlocks[index], newBlocks[targetIndex]] = [newBlocks[targetIndex], newBlocks[index]];
setBlocks(newBlocks);
};
if (loading) return <div className="py-20 text-center text-gray-500 animate-pulse font-medium">Initializing Activity Builder...</div>;
if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>;
return (
<div className="max-w-4xl mx-auto space-y-12 pb-40 px-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 border-b border-white/5 pb-8">
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px] text-blue-500 font-bold uppercase tracking-[0.2em]">
<Link href={`/courses/${params.id}`} className="hover:text-white transition-colors">Outline</Link>
<span className="text-gray-700">/</span>
<span>Activity</span>
</div>
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2>
</div>
<div className="flex items-center gap-3">
{editMode ? (
<>
<button onClick={() => setEditMode(false)} className="px-6 py-2.5 glass text-xs font-bold uppercase tracking-widest hover:bg-white/5 transition-all">Discard</button>
<button onClick={handleSave} disabled={isSaving} className="btn-premium px-8 py-2.5 min-w-[140px] text-xs font-bold uppercase tracking-widest shadow-blue-500/20 shadow-lg">
{isSaving ? "Saving..." : "Publish Changes"}
</button>
</>
) : (
<button onClick={() => setEditMode(true)} className="px-8 py-3 glass text-xs font-bold uppercase tracking-widest hover:border-blue-500/50 transition-all flex items-center gap-2 group">
<span className="group-hover:rotate-12 transition-transform"></span> Edit Activity
</button>
)}
</div>
</div>
<div className="space-y-16">
{blocks.map((block, index) => (
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
{editMode && (
<div className="absolute -left-12 top-0 h-full flex flex-col items-center gap-2 opacity-0 group-hover/block:opacity-100 transition-all">
<button
onClick={() => moveBlock(index, 'up')}
disabled={index === 0}
className="w-8 h-8 rounded-lg bg-white/5 text-gray-400 flex items-center justify-center hover:bg-blue-500 hover:text-white transition-all border border-white/10 disabled:opacity-20 disabled:cursor-not-allowed"
title="Move Up"
>
<span className="text-xs"></span>
</button>
<button
onClick={() => moveBlock(index, 'down')}
disabled={index === blocks.length - 1}
className="w-8 h-8 rounded-lg bg-white/5 text-gray-400 flex items-center justify-center hover:bg-blue-500 hover:text-white transition-all border border-white/10 disabled:opacity-20 disabled:cursor-not-allowed"
title="Move Down"
>
<span className="text-xs"></span>
</button>
<div className="h-2"></div>
<button
onClick={() => removeBlock(block.id)}
className="w-8 h-8 rounded-lg bg-red-500/10 text-red-400 flex items-center justify-center hover:bg-red-500 hover:text-white transition-all border border-red-500/20"
title="Remove Block"
>
<span className="text-sm">×</span>
</button>
<div className="w-0.5 flex-1 bg-white/5"></div>
</div>
)}
<div className="space-y-6">
{block.type === 'description' && (
<DescriptionBlock
id={block.id}
title={block.title}
content={block.content || ""}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'media' && (
<MediaBlock
id={block.id}
title={block.title}
url={block.url || ""}
type={block.media_type || 'video'}
config={block.config || {}}
editMode={editMode}
transcription={lesson.transcription}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'quiz' && (
<QuizBlock
id={block.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'fill-in-the-blanks' && (
<FillInTheBlanksBlock
id={block.id}
title={block.title}
content={block.content || ""}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'matching' && (
<MatchingBlock
id={block.id}
title={block.title}
pairs={block.pairs || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'ordering' && (
<OrderingBlock
id={block.id}
title={block.title}
items={block.items || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'short-answer' && (
<ShortAnswerBlock
id={block.id}
title={block.title}
prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div>
</div>
))}
{editMode && (
<div className="pt-12 border-t border-white/5">
<div className="flex flex-col items-center gap-6">
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-[0.3em]">Add Content Block</span>
<div className="flex items-center gap-4">
<button
onClick={() => addBlock('description')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">📄</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Text</span>
</button>
<button
onClick={() => addBlock('media')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">🎬</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Media</span>
</button>
<button
onClick={() => addBlock('quiz')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">💡</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Quiz</span>
</button>
<button
onClick={() => addBlock('fill-in-the-blanks')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform"></span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Blanks</span>
</button>
<button
onClick={() => addBlock('matching')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">🔗</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Match</span>
</button>
<button
onClick={() => addBlock('ordering')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">🔢</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Order</span>
</button>
<button
onClick={() => addBlock('short-answer')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">💬</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Short</span>
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}