feat: enhance Activity Builder with block reordering, description preview, quiz type selection, and updated documentation
This commit is contained in:
@@ -8,7 +8,8 @@ OpenCCB is a high-performance, microservices-based Learning Management System (L
|
|||||||
- **LMS Service (Port 3002)**: Student experience, course consumption, and enrollment.
|
- **LMS Service (Port 3002)**: Student experience, course consumption, and enrollment.
|
||||||
- **Shared Library**: Core models and authentication logic.
|
- **Shared Library**: Core models and authentication logic.
|
||||||
- **Database**: PostgreSQL (shared/isolated schemas).
|
- **Database**: PostgreSQL (shared/isolated schemas).
|
||||||
- **Studio (Frontend)**: Next.js application for instructors and admins.
|
- **Studio (Frontend)**: Next.js application with a block-based **Activity Builder** for instructors.
|
||||||
|
- **Asset Storage**: Persistent local storage for native video/audio uploads.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -80,5 +81,10 @@ curl -X POST http://localhost:3002/enroll \
|
|||||||
-d '{"course_id": "YOUR_COURSE_ID"}'
|
-d '{"course_id": "YOUR_COURSE_ID"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Audit Logging
|
|
||||||
Every mutation in the CMS (Create Course/Module/Lesson) is automatically recorded in the `audit_logs` table for compliance and debugging.
|
Every mutation in the CMS (Create Course/Module/Lesson) is automatically recorded in the `audit_logs` table for compliance and debugging.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Block-Based Activity Builder**: Create lessons using text, media, and interactive quiz blocks.
|
||||||
|
- **Native File Uploads**: Drag-and-drop video/audio uploads with persistence.
|
||||||
|
- **Playback Constraints**: Limit how many times students can view specific media items.
|
||||||
|
- **Dynamic Reordering**: (Coming Soon) Organize content blocks with a single click.
|
||||||
|
|||||||
+6
-5
@@ -10,12 +10,13 @@
|
|||||||
|
|
||||||
## Phase 2: Core CMS Features (Current Focus)
|
## Phase 2: Core CMS Features (Current Focus)
|
||||||
- [/] Course Outline Editor (Modules & Lessons).
|
- [/] Course Outline Editor (Modules & Lessons).
|
||||||
- [ ] File Upload System (Video/Images/Docs).
|
- [x] File Upload System (Video/Audio/Native Assets).
|
||||||
- [ ] Interactive Content (Quizzes/Rubrics).
|
- [/] Interactive Content (**Activity Builder Refinement**).
|
||||||
|
- [ ] Block Reordering (Move Up/Down).
|
||||||
|
- [ ] Rich Text Editor Integration.
|
||||||
|
- [ ] Quiz Refinements (True/False, Multi-Response).
|
||||||
- [ ] Service-to-Service Communication (CMS -> LMS sync).
|
- [ ] Service-to-Service Communication (CMS -> LMS sync).
|
||||||
integration for videos, documents, and images.
|
- [x] **Video Player**: Integrated premium video player with playback limits.
|
||||||
- [ ] **Video Player**: Integrated premium video player for lessons.
|
|
||||||
- [ ] **Interactivity**: Quizzes, activities, and rubrics implementation.
|
|
||||||
- [ ] **Full Studio UI**: Drag-and-drop course builder.
|
- [ ] **Full Studio UI**: Drag-and-drop course builder.
|
||||||
|
|
||||||
## Phase 3: Authentication & Security
|
## Phase 3: Authentication & Security
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
setBlocks(blocks.map(b => b.id === id ? { ...b, ...updates } : b));
|
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 (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>;
|
if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>;
|
||||||
|
|
||||||
@@ -113,9 +122,26 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
|
<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 && (
|
{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">
|
<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
|
<button
|
||||||
onClick={() => removeBlock(block.id)}
|
onClick={() => removeBlock(block.id)}
|
||||||
className="w-8 h-8 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center hover:bg-red-500 hover:text-white transition-all border border-red-500/20"
|
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"
|
title="Remove Block"
|
||||||
>
|
>
|
||||||
<span className="text-sm">×</span>
|
<span className="text-sm">×</span>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface DescriptionBlockProps {
|
interface DescriptionBlockProps {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -9,6 +11,7 @@ interface DescriptionBlockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DescriptionBlock({ title, content, editMode, onChange }: DescriptionBlockProps) {
|
export default function DescriptionBlock({ title, content, editMode, onChange }: DescriptionBlockProps) {
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Block Header */}
|
{/* Block Header */}
|
||||||
@@ -30,18 +33,45 @@ export default function DescriptionBlock({ title, content, editMode, onChange }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Text</label>
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Content</label>
|
||||||
|
<div className="flex bg-white/5 rounded-lg p-1 border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(false)}
|
||||||
|
className={`px-4 py-1.5 text-[10px] uppercase font-black tracking-widest rounded-md transition-all ${!showPreview ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
Write
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(true)}
|
||||||
|
className={`px-4 py-1.5 text-[10px] uppercase font-black tracking-widest rounded-md transition-all ${showPreview ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="min-h-[200px] p-6 rounded-xl glass border-white/5 bg-white/5">
|
||||||
|
<div className="prose prose-invert max-w-none">
|
||||||
|
<p className="text-gray-300 leading-relaxed text-lg whitespace-pre-wrap">
|
||||||
|
{content || "Nothing to preview..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => onChange({ content: e.target.value })}
|
onChange={(e) => onChange({ content: e.target.value })}
|
||||||
placeholder="Explain the activity to the students..."
|
placeholder="Explain the activity to the students (Markdown supported)..."
|
||||||
className="w-full h-40 bg-white/5 border border-white/10 rounded-xl p-4 text-sm focus:border-blue-500/50 focus:outline-none transition-all resize-none"
|
className="w-full h-60 bg-white/5 border border-white/10 rounded-xl p-6 text-lg tracking-tight focus:border-blue-500/50 focus:outline-none transition-all resize-none shadow-inner"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="prose prose-invert max-w-none">
|
<div className="prose prose-invert max-w-none">
|
||||||
<p className="text-gray-300 leading-relaxed text-lg">
|
<p className="text-gray-300 leading-relaxed text-lg whitespace-pre-wrap">
|
||||||
{content || "No description provided."}
|
{content || "No description provided."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface QuizQuestion {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
correct: number;
|
correct: number;
|
||||||
|
type?: 'multiple-choice' | 'true-false';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuizBlockProps {
|
interface QuizBlockProps {
|
||||||
@@ -30,7 +31,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
id: Math.random().toString(36).substr(2, 9),
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
question: "New Question?",
|
question: "New Question?",
|
||||||
options: ["Option 1", "Option 2"],
|
options: ["Option 1", "Option 2"],
|
||||||
correct: 0
|
correct: 0,
|
||||||
|
type: 'multiple-choice'
|
||||||
};
|
};
|
||||||
onChange({ questions: [...questions, newQuestion] });
|
onChange({ questions: [...questions, newQuestion] });
|
||||||
};
|
};
|
||||||
@@ -71,21 +73,61 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{questions.map((q, idx) => (
|
{questions.map((q, idx) => (
|
||||||
<div key={q.id} className="p-6 glass border-white/5 space-y-4 rounded-2xl">
|
<div key={q.id} className="p-8 glass border-white/5 space-y-6 rounded-3xl relative group/question animate-in slide-in-from-left-4 duration-500">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex bg-white/5 rounded-lg p-1 border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => updateQuestion(idx, { type: 'multiple-choice' })}
|
||||||
|
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type !== 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
MCQ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateQuestion(idx, { type: 'true-false', options: ["True", "False"], correct: 0 })}
|
||||||
|
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
T / F
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newQuestions = questions.filter((_, i) => i !== idx);
|
||||||
|
onChange({ questions: newQuestions });
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-xl">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={q.question}
|
value={q.question}
|
||||||
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
|
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-3 font-semibold focus:outline-none focus:border-blue-500/50 transition-all"
|
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 text-lg font-bold focus:outline-none focus:border-blue-500/50 transition-all placeholder:text-gray-600"
|
||||||
placeholder="Enter your question..."
|
placeholder="What is the question?"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{q.options.map((opt, oIdx) => (
|
{q.type === 'true-false' ? (
|
||||||
<div key={oIdx} className="flex gap-3 items-center">
|
<div className="flex gap-4">
|
||||||
|
{["True", "False"].map((opt, oIdx) => (
|
||||||
|
<button
|
||||||
|
key={oIdx}
|
||||||
|
onClick={() => updateQuestion(idx, { correct: oIdx })}
|
||||||
|
className={`flex-1 py-4 rounded-xl border-2 transition-all font-black text-xs uppercase tracking-widest ${q.correct === oIdx ? "border-blue-500 bg-blue-500/10 text-white" : "border-white/5 bg-white/5 text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
q.options.map((opt, oIdx) => (
|
||||||
|
<div key={oIdx} className="flex gap-3 items-center group/opt">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={q.correct === oIdx}
|
checked={q.correct === oIdx}
|
||||||
onChange={() => updateQuestion(idx, { correct: oIdx })}
|
onChange={() => updateQuestion(idx, { correct: oIdx })}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-5 h-5 accent-blue-500 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={opt}
|
value={opt}
|
||||||
@@ -94,11 +136,32 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
newOpts[oIdx] = e.target.value;
|
newOpts[oIdx] = e.target.value;
|
||||||
updateQuestion(idx, { options: newOpts });
|
updateQuestion(idx, { options: newOpts });
|
||||||
}}
|
}}
|
||||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30"
|
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm font-medium focus:outline-none focus:border-blue-500/30 transition-all"
|
||||||
placeholder={`Option ${oIdx + 1}`}
|
placeholder={`Option ${oIdx + 1}`}
|
||||||
/>
|
/>
|
||||||
|
{q.options.length > 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newOpts = q.options.filter((_, i) => i !== oIdx);
|
||||||
|
updateQuestion(idx, { options: newOpts, correct: q.correct >= newOpts.length ? 0 : q.correct });
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover/opt:opacity-100 p-2 text-gray-500 hover:text-red-400 transition-all"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.type !== 'true-false' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateQuestion(idx, { options: [...q.options, `Option ${q.options.length + 1}`] })}
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest text-blue-500/50 hover:text-blue-500 transition-colors pl-8 mt-2"
|
||||||
|
>
|
||||||
|
+ Add Option
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user