Initial commit: Clean workspace without heavy binaries
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"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";
|
||||
|
||||
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') => {
|
||||
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: [] } }),
|
||||
};
|
||||
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));
|
||||
};
|
||||
|
||||
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={() => 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"
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cmsApi, Course, Module, Lesson } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
interface FullModule extends Module {
|
||||
lessons: Lesson[];
|
||||
}
|
||||
|
||||
export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
const [course, setCourse] = useState<Course | null>(null);
|
||||
const [modules, setModules] = useState<FullModule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 1. Fetch course details
|
||||
const courseData = await fetch(`http://localhost:3001/courses/${params.id}`).then(res => res.json());
|
||||
setCourse(courseData);
|
||||
|
||||
// 2. Fetch modules
|
||||
const modulesData: Module[] = await fetch(`http://localhost:3001/modules?course_id=${params.id}`).then(res => res.json());
|
||||
|
||||
// 3. Fetch lessons for each module
|
||||
const fullModules = await Promise.all(modulesData.map(async (mod) => {
|
||||
const lessonsData: Lesson[] = await fetch(`http://localhost:3001/lessons?module_id=${mod.id}`).then(res => res.json());
|
||||
return { ...mod, lessons: lessonsData };
|
||||
}));
|
||||
|
||||
setModules(fullModules);
|
||||
} catch (err) {
|
||||
console.error("Failed to load course data:", err);
|
||||
setError("Failed to load course details. Is the backend running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [params.id]);
|
||||
|
||||
const handleAddModule = async () => {
|
||||
const title = prompt("Module Title:");
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const newMod = await cmsApi.createModule(params.id, title, modules.length + 1);
|
||||
setModules([...modules, { ...newMod, lessons: [] }]);
|
||||
} catch {
|
||||
alert("Failed to create module");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLesson = async (moduleId: string) => {
|
||||
const title = prompt("Lesson Title:");
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
// Default to 'video' for now as a content type
|
||||
const newLesson = await cmsApi.createLesson(moduleId, title, "video", 1);
|
||||
setModules(modules.map(mod =>
|
||||
mod.id === moduleId
|
||||
? { ...mod, lessons: [...mod.lessons, newLesson] }
|
||||
: mod
|
||||
));
|
||||
} catch {
|
||||
alert("Failed to create lesson");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="py-20 text-center">Loading editor...</div>;
|
||||
if (error) return <div className="py-20 text-center text-red-400">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<Link href="/" className="hover:text-white cursor-pointer underline">Courses</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">{course?.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">{course?.title}</h2>
|
||||
<p className="text-gray-400">Editor - Outline (ID: {params.id})</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">Preview</button>
|
||||
<button className="btn-premium">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass p-1">
|
||||
<div className="flex border-b border-white/10">
|
||||
<button className="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 bg-white/5">Outline</button>
|
||||
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</button>
|
||||
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id} className="glass overflow-hidden">
|
||||
<div className="bg-white/5 px-4 py-3 flex justify-between items-center border-b border-white/5">
|
||||
<span className="font-medium text-blue-400">Module {module.position}: {module.title}</span>
|
||||
<button className="text-xs text-gray-400 hover:text-white">Options</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{module.lessons.map(lesson => (
|
||||
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} key={lesson.id}>
|
||||
<div className="glass border-white/5 p-3 flex items-center justify-between text-sm hover:bg-white/10 hover:border-blue-500/30 transition-all cursor-pointer group/lesson">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-blue-400 text-lg group-hover/lesson:scale-110 transition-transform">
|
||||
{lesson.content_type === 'video' ? '🎬' : '📄'}
|
||||
</span>
|
||||
<span>{lesson.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lesson.transcription && <span className="text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded">CC</span>}
|
||||
<span className="text-xs text-gray-500 capitalize">{lesson.content_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => handleAddLesson(module.id)}
|
||||
className="w-full py-2 border border-dashed border-white/10 rounded-lg text-xs text-gray-500 hover:text-white hover:border-white/20 transition-all mt-2"
|
||||
>
|
||||
+ New Lesson
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleAddModule}
|
||||
className="w-full py-4 border-2 border-dashed border-white/10 rounded-xl font-medium text-gray-500 hover:text-white hover:border-white/20 transition-all"
|
||||
>
|
||||
+ Add Module
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 10, 10, 20;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
|
||||
--accent-primary: #3b82f6;
|
||||
--accent-secondary: #8b5cf6;
|
||||
--glass-bg: rgba(255, 255, 255, 0.05);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.btn-premium {
|
||||
@apply relative px-6 py-2 rounded-full font-medium transition-all duration-300;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium:hover {
|
||||
@apply scale-105;
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenCCB Studio | modern Course Management",
|
||||
description: "Advanced LMS Content Management System inspired by Open edX",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
|
||||
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
|
||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
Open<span className="gradient-text">CCB</span> Studio
|
||||
</h1>
|
||||
<div className="flex gap-4">
|
||||
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Courses</button>
|
||||
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Settings</button>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border border-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
|
||||
{children}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cmsApi, Course } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await cmsApi.getCourses();
|
||||
setCourses(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch courses:", err);
|
||||
setError("Could not connect to CMS service. showing offline mode.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
const title = prompt("Enter course title:");
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const newCourse = await cmsApi.createCourse(title);
|
||||
setCourses([...courses, newCourse]);
|
||||
} catch {
|
||||
alert("Failed to create course. Is the backend running?");
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderCourses: Course[] = [
|
||||
{ id: "p1", title: "Introduction to Rust (Demo)", instructor_id: "demo", created_at: new Date().toISOString() },
|
||||
];
|
||||
|
||||
const displayCourses = courses.length > 0 ? courses : (loading ? [] : placeholderCourses);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">My Courses</h2>
|
||||
<p className="text-gray-400">Manage and create your learning content</p>
|
||||
</div>
|
||||
<button onClick={handleCreateCourse} className="btn-premium">
|
||||
+ New Course
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/50 p-4 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{loading ? (
|
||||
<div className="col-span-full py-20 text-center text-gray-500">
|
||||
<div className="animate-spin inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mb-4"></div>
|
||||
<p>Loading your courses...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{displayCourses.map((course) => (
|
||||
<Link href={`/courses/${course.id}`} key={course.id}>
|
||||
<div className="glass p-6 hover:border-blue-500/50 transition-all group cursor-pointer h-full">
|
||||
<div className="h-32 bg-gradient-to-br from-blue-900/50 to-purple-900/50 rounded-lg mb-4 flex items-center justify-center border border-white/5">
|
||||
<span className="text-4xl group-hover:scale-110 transition-transform">📚</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-blue-400">{course.title}</h3>
|
||||
<div className="flex justify-between items-center pt-4 border-t border-white/5">
|
||||
<span suppressHydrationWarning className="text-xs text-gray-500">
|
||||
Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-blue-400">View Details →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div
|
||||
onClick={handleCreateCourse}
|
||||
className="glass p-6 border-dashed border-2 border-white/10 flex flex-col items-center justify-center text-gray-500 hover:border-white/20 transition-all cursor-pointer min-h-[300px]"
|
||||
>
|
||||
<span className="text-3xl mb-2">➕</span>
|
||||
<span className="text-sm">Add New Course</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user