Initial commit: Clean workspace without heavy binaries

This commit is contained in:
2025-12-19 15:36:54 -03:00
commit c71fae7dbc
51 changed files with 10725 additions and 0 deletions
@@ -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>
);
}
+149
View File
@@ -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.
+50
View File
@@ -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);
}
+39
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useState, useRef } from "react";
import { cmsApi } from "@/lib/api";
interface FileUploadProps {
onUploadComplete: (url: string) => void;
currentUrl?: string;
accept?: string;
}
export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (file: File) => {
setIsUploading(true);
try {
const result = await cmsApi.uploadAsset(file);
onUploadComplete(result.url);
} catch (err) {
alert("Upload failed. Please try again.");
console.error(err);
} finally {
setIsUploading(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
handleUpload(e.target.files[0]);
}
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleUpload(e.dataTransfer.files[0]);
}
};
return (
<div className="space-y-4">
<div
className={`relative group cursor-pointer border-2 border-dashed rounded-xl p-8 transition-all flex flex-col items-center justify-center gap-4 ${dragActive ? "border-blue-500 bg-blue-500/10 scale-[1.02]" : "border-white/10 hover:border-white/20 bg-white/5"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={accept}
onChange={handleFileChange}
/>
{isUploading ? (
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span className="text-xs font-bold uppercase tracking-widest text-blue-400">Uploading Asset...</span>
</div>
) : (
<>
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
</div>
<div className="text-center">
<p className="text-sm font-bold text-gray-300">Drag & drop or <span className="text-blue-400 underline decoration-blue-500/30">browse</span></p>
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Native video, audio files supported</p>
</div>
</>
)}
</div>
{currentUrl && !isUploading && (
<div className="flex items-center justify-between px-4 py-3 glass bg-green-500/5 border-green-500/20 rounded-lg">
<div className="flex items-center gap-3 overflow-hidden">
<span className="text-sm"></span>
<span className="text-xs text-green-400 truncate font-medium">{currentUrl}</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onUploadComplete(""); }}
className="text-[10px] uppercase font-black text-gray-500 hover:text-red-400 transition-colors"
>
Remove
</button>
</div>
)}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface MediaPlayerProps {
src: string | null;
type: string; // "video" | "audio"
transcription?: {
en?: string;
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
locked?: boolean;
onEnded?: () => void;
}
export default function MediaPlayer({ src, type, transcription, locked, onEnded }: MediaPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [currentCaption, setCurrentCaption] = useState("");
const [language, setLanguage] = useState<"en" | "es">("en");
useEffect(() => {
const media = type === "video" ? videoRef.current : audioRef.current;
if (!media) return;
const handleTimeUpdate = () => {
if (transcription?.cues) {
const activeCue = transcription.cues.find(cue =>
media.currentTime >= cue.start && media.currentTime <= cue.end
);
setCurrentCaption(activeCue?.text || "");
}
};
const handleEnded = () => {
if (onEnded) onEnded();
};
media.addEventListener("timeupdate", handleTimeUpdate);
media.addEventListener("ended", handleEnded);
return () => {
media.removeEventListener("timeupdate", handleTimeUpdate);
media.removeEventListener("ended", handleEnded);
};
}, [type, transcription, onEnded]);
if (!src) {
return (
<div className="glass aspect-video flex flex-col items-center justify-center border-dashed border-2 border-white/10 text-gray-500">
<span className="text-4xl mb-2">🎞</span>
<p>No media file linked yet.</p>
</div>
);
}
const isYouTube = src.includes("youtube.com") || src.includes("youtu.be");
const isVimeo = src.includes("vimeo.com");
const getYouTubeId = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const getVimeoId = (url: string) => {
const match = url.match(/vimeo.com\/(\d+)/);
return match ? match[1] : null;
};
if (isYouTube || isVimeo) {
let embedUrl = "";
if (isYouTube) {
const id = getYouTubeId(src);
embedUrl = `https://www.youtube.com/embed/${id}`;
} else {
const id = getVimeoId(src);
embedUrl = `https://player.vimeo.com/video/${id}`;
}
return (
<div className="space-y-4 relative group">
<div className={`glass overflow-hidden border border-white/10 aspect-video ${locked ? 'blur-xl grayscale' : ''}`}>
<iframe
src={embedUrl}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
{locked && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
<span className="text-3xl">🔒</span>
</div>
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
</div>
)}
{transcription && !locked && (
<div className="glass p-6 space-y-4 border border-blue-500/20">
<div className="flex justify-between items-center">
<h4 className="text-lg font-semibold flex items-center gap-2">
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
</h4>
<div className="flex bg-white/5 rounded-lg p-1">
<button
onClick={() => setLanguage("en")}
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
>EN</button>
<button
onClick={() => setLanguage("es")}
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
>ES</button>
</div>
</div>
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
&quot;{transcription[language] || "Transcription not available."}&quot;
</div>
</div>
)}
</div>
);
}
return (
<div className="space-y-4 relative group">
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
{type === "video" ? (
<video
ref={videoRef}
src={src}
className="w-full aspect-video object-cover"
controls={!locked}
/>
) : (
<div className="p-12 flex flex-col items-center justify-center bg-gradient-to-br from-blue-500/20 to-purple-500/20">
<audio ref={audioRef} src={src} controls={!locked} className="w-full max-w-md" />
<span className="text-xs text-gray-400 mt-6 uppercase tracking-[0.2em] font-medium">Audio Experience</span>
</div>
)}
{/* Caption Overlay */}
{currentCaption && type === "video" && !locked && (
<div className="absolute bottom-16 left-0 right-0 text-center px-8 pointer-events-none animate-in fade-in slide-in-from-bottom-2 duration-300">
<span className="bg-black/80 text-white px-4 py-2 rounded-xl text-lg font-medium backdrop-blur-md border border-white/20 shadow-2xl">
{currentCaption}
</span>
</div>
)}
</div>
{locked && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
<span className="text-3xl">🔒</span>
</div>
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
</div>
)}
{transcription && !locked && (
<div className="glass p-6 space-y-4 border border-blue-500/20">
<div className="flex justify-between items-center">
<h4 className="text-lg font-semibold flex items-center gap-2">
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
</h4>
<div className="flex bg-white/5 rounded-lg p-1">
<button
onClick={() => setLanguage("en")}
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
>EN</button>
<button
onClick={() => setLanguage("es")}
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
>ES</button>
</div>
</div>
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
&quot;{transcription[language] || "Transcription not available."}&quot;
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
interface DescriptionBlockProps {
id: string;
title?: string;
content: string;
editMode: boolean;
onChange: (updates: { title?: string; content?: string }) => void;
}
export default function DescriptionBlock({ title, content, editMode, onChange }: DescriptionBlockProps) {
return (
<div className="space-y-6">
{/* Block Header */}
<div className="space-y-2">
{editMode ? (
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Introduction, Context..."
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
/>
</div>
) : (
title && <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">{title}</h3>
)}
</div>
{editMode ? (
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Text</label>
<textarea
value={content}
onChange={(e) => onChange({ content: e.target.value })}
placeholder="Explain the activity to the students..."
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"
/>
</div>
) : (
<div className="prose prose-invert max-w-none">
<p className="text-gray-300 leading-relaxed text-lg">
{content || "No description provided."}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import MediaPlayer from "../MediaPlayer";
import FileUpload from "../FileUpload";
interface MediaBlockProps {
id: string;
title?: string;
url: string;
type: 'video' | 'audio';
config: {
maxPlays?: number;
currentPlays?: number;
};
editMode: boolean;
onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number } }) => void;
transcription?: {
en?: string;
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
}
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription }: MediaBlockProps) {
const [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
const maxPlays = config.maxPlays || 0;
const isLocked = maxPlays > 0 && localPlays >= maxPlays;
const handleEnded = () => {
if (maxPlays > 0) {
const nextPlays = localPlays + 1;
setLocalPlays(nextPlays);
onChange({ config: { ...config, currentPlays: nextPlays } });
}
};
// Full URL for display (handles relative paths from server)
const displayUrl = url.startsWith("/") ? `http://localhost:3001${url}` : url;
return (
<div className="space-y-6">
{/* Block Header */}
<div className="space-y-2">
{editMode ? (
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Explainer Video, Audio Guide..."
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
/>
</div>
) : (
title && <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">{title}</h3>
)}
</div>
{editMode && (
<div className="space-y-6 p-6 glass border-blue-500/10 mb-8 bg-blue-500/5">
<div className="flex items-center gap-4 mb-2">
<button
onClick={() => setSourceType("url")}
className={`px-4 py-2 text-[10px] uppercase font-black tracking-widest rounded-lg transition-all ${sourceType === "url" ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
>
External URL
</button>
<button
onClick={() => setSourceType("upload")}
className={`px-4 py-2 text-[10px] uppercase font-black tracking-widest rounded-lg transition-all ${sourceType === "upload" ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
>
Upload File
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sourceType === "url" ? (
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Media URL</label>
<input
type="text"
value={url.startsWith("/") ? "" : url}
onChange={(e) => onChange({ url: e.target.value })}
placeholder="YouTube, Vimeo or static link"
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:border-blue-500/50 focus:outline-none"
/>
</div>
) : (
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">File Manager</label>
<FileUpload
currentUrl={url.startsWith("/") ? url : undefined}
onUploadComplete={(newUrl) => onChange({ url: newUrl })}
/>
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Playback Limit (0 = Unlimited)</label>
<input
type="number"
value={maxPlays}
onChange={(e) => onChange({ config: { ...config, maxPlays: parseInt(e.target.value) || 0 } })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:border-blue-500/50 focus:outline-none h-11"
/>
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Prevent content fatigue by limiting how many times a student can watch/listen.</p>
</div>
</div>
</div>
)}
<div className="relative">
<MediaPlayer
src={displayUrl}
type={type}
transcription={transcription}
locked={isLocked}
onEnded={handleEnded}
/>
{!editMode && maxPlays > 0 && (
<div className="mt-4 flex items-center justify-between px-4 py-2 glass bg-white/5 border-white/5 rounded-lg">
<span className="text-xs text-gray-500 uppercase font-medium">Plays Remaining</span>
<span className={`text-sm font-bold ${maxPlays - localPlays <= 1 ? 'text-orange-400' : 'text-blue-400'}`}>
{Math.max(0, maxPlays - localPlays)} / {maxPlays}
</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
interface QuizQuestion {
id: string;
question: string;
options: string[];
correct: number;
}
interface QuizBlockProps {
id: string;
title?: string;
quizData: {
questions: QuizQuestion[];
};
editMode: boolean;
onChange: (data: { title?: string; questions?: QuizQuestion[] }) => void;
}
export default function QuizBlock({ id, title, quizData, editMode, onChange }: QuizBlockProps) {
const [userAnswers, setUserAnswers] = useState<Record<string, number>>({});
const [submitted, setSubmitted] = useState(false);
const questions = quizData.questions || [];
const addQuestion = () => {
const newQuestion: QuizQuestion = {
id: Math.random().toString(36).substr(2, 9),
question: "New Question?",
options: ["Option 1", "Option 2"],
correct: 0
};
onChange({ questions: [...questions, newQuestion] });
};
const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => {
const newQuestions = [...questions];
newQuestions[index] = { ...newQuestions[index], ...updates };
onChange({ questions: newQuestions });
};
const handleAnswer = (qId: string, optionIndex: number) => {
if (submitted) return;
setUserAnswers(prev => ({ ...prev, [qId]: optionIndex }));
};
return (
<div className="space-y-8" id={id}>
{/* Block Header */}
<div className="space-y-2">
{editMode ? (
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Final Evaluation, Knowledge Check..."
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
/>
</div>
) : (
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">
{title || "Knowledge Check"}
</h3>
)}
</div>
{editMode ? (
<div className="space-y-6">
{questions.map((q, idx) => (
<div key={q.id} className="p-6 glass border-white/5 space-y-4 rounded-2xl">
<input
value={q.question}
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"
placeholder="Enter your question..."
/>
<div className="space-y-3">
{q.options.map((opt, oIdx) => (
<div key={oIdx} className="flex gap-3 items-center">
<input
type="radio"
checked={q.correct === oIdx}
onChange={() => updateQuestion(idx, { correct: oIdx })}
className="w-4 h-4 accent-blue-500"
/>
<input
value={opt}
onChange={(e) => {
const newOpts = [...q.options];
newOpts[oIdx] = e.target.value;
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"
placeholder={`Option ${oIdx + 1}`}
/>
</div>
))}
</div>
</div>
))}
<button
onClick={addQuestion}
className="w-full py-4 glass border-dashed border-2 border-white/10 text-gray-500 hover:text-white hover:border-blue-500/30 hover:bg-blue-500/5 transition-all font-bold text-xs uppercase tracking-widest rounded-2xl"
>
+ Add Question
</button>
</div>
) : (
<div className="space-y-8">
{questions.map((q) => (
<div key={q.id} className="space-y-4 p-6 glass border-white/5 rounded-2xl">
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
<div className="grid gap-3">
{q.options.map((opt, oIdx) => {
const isSelected = userAnswers[q.id] === oIdx;
const isCorrect = q.correct === oIdx;
let style = "glass border-white/10 hover:bg-white/5";
if (submitted) {
if (isCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
else if (isSelected && !isCorrect) style = "bg-red-500/20 border-red-500 text-red-100";
else style = "opacity-50 grayscale border-white/5";
} else if (isSelected) {
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
}
return (
<button
key={oIdx}
onClick={() => handleAnswer(q.id, oIdx)}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
>
{opt}
</button>
);
})}
</div>
</div>
))}
{!submitted && questions.length > 0 && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Answers
</button>
)}
{submitted && (
<button
onClick={() => { setSubmitted(false); setUserAnswers({}); }}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl"
>
Try Again
</button>
)}
</div>
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
export const API_BASE_URL = "http://localhost:3001";
export interface Course {
id: string;
title: string;
instructor_id: string;
created_at: string;
}
export interface Module {
id: string;
course_id: string;
title: string;
position: number;
created_at: string;
lessons: Lesson[];
}
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz';
title?: string;
content?: string;
url?: string;
media_type?: 'video' | 'audio';
config?: {
maxPlays?: number;
currentPlays?: number;
allowDownload?: boolean;
};
quiz_data?: {
questions: {
id: string;
question: string;
options: string[];
correct: number;
}[];
};
}
export interface Lesson {
id: string;
module_id: string;
title: string;
content_type: string;
content_url: string | null;
transcription?: {
en?: string;
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
metadata?: {
blocks?: Block[];
} | null;
position: number;
created_at: string;
}
export const cmsApi = {
async getCourses(): Promise<Course[]> {
const response = await fetch(`${API_BASE_URL}/courses`);
if (!response.ok) throw new Error('Failed to fetch courses');
return response.json();
},
async createCourse(title: string): Promise<Course> {
const response = await fetch(`${API_BASE_URL}/courses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!response.ok) throw new Error('Failed to create course');
return response.json();
},
async getCourseWithFullOutline(courseId: string): Promise<Course & { modules: Module[] }> {
const course = await fetch(`${API_BASE_URL}/courses/${courseId}`).then(res => res.json());
const modules = await fetch(`${API_BASE_URL}/modules?course_id=${courseId}`).then(res => res.json());
const modulesWithLessons = await Promise.all(modules.map(async (m: Module) => {
const lessons = await fetch(`${API_BASE_URL}/lessons?module_id=${m.id}`).then(res => res.json());
return { ...m, lessons };
}));
return { ...course, modules: modulesWithLessons };
},
async createModule(courseId: string, title: string, position: number): Promise<Module> {
const response = await fetch(`${API_BASE_URL}/modules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ course_id: courseId, title, position }),
});
if (!response.ok) throw new Error('Failed to create module');
return response.json();
},
async createLesson(moduleId: string, title: string, contentType: string, position: number): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module_id: moduleId, title, content_type: contentType, position }),
});
if (!response.ok) throw new Error('Failed to create lesson');
return response.json();
},
async transcribeLesson(lessonId: string): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}/transcribe`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to transcribe lesson');
return response.json();
},
async updateLesson(lessonId: string, updates: Partial<Lesson>): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Failed to update lesson');
return response.json();
},
async uploadAsset(file: File): Promise<{ id: string; filename: string; url: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/assets/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
return response.json();
}
};