feat: Implement organization branding, course pacing, and display upcoming deadlines in the experience portal.

This commit is contained in:
2025-12-29 01:30:48 -03:00
parent 1a2b9e473c
commit 158aa5b315
41 changed files with 2422 additions and 262 deletions
+12
View File
@@ -521,6 +521,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -577,6 +578,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1051,6 +1053,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1451,6 +1454,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2056,6 +2060,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2218,6 +2223,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3514,6 +3520,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -4226,6 +4233,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4416,6 +4424,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4427,6 +4436,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5267,6 +5277,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5424,6 +5435,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -0,0 +1,208 @@
"use client";
import { useEffect, useState } from "react";
import { lmsApi, Course, Lesson, Module } from "@/lib/api";
import Link from "next/link";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
ChevronRight as ChevronRightIcon,
AlertCircle,
Clock,
CheckCircle2
} from "lucide-react";
export default function StudentCalendarPage({ params }: { params: { id: string } }) {
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
const loadData = async () => {
try {
const courseData = await lmsApi.getCourseOutline(params.id);
setCourse(courseData);
// Flatten lessons from modules
const allLessons: Lesson[] = [];
courseData.modules?.forEach(mod => {
mod.lessons.forEach(lesson => {
allLessons.push(lesson);
});
});
setLessons(allLessons);
} catch (err) {
console.error("Failed to load course data", err);
} finally {
setLoading(false);
}
};
loadData();
}, [params.id]);
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const renderCalendar = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const days = [];
// Padding
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="h-28 border border-white/5 bg-white/[0.01]"></div>);
}
// Days
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
days.push(
<div key={day} className={`h-28 border border-white/5 p-2 relative hover:bg-white/5 transition-colors group ${isToday ? 'bg-blue-500/5' : ''}`}>
<span className={`text-sm font-black ${isToday ? 'text-blue-400' : 'text-gray-600'}`}>
{day}
{isToday && <span className="ml-2 text-[8px] uppercase tracking-widest px-1.5 py-0.5 bg-blue-500 text-white rounded">Today</span>}
</span>
<div className="mt-1 space-y-1 overflow-y-auto max-h-20">
{dayLessons.map(lesson => (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div
className={`text-[9px] p-1 rounded truncate flex items-center gap-1 mb-1 border transition-all hover:scale-[1.02] ${lesson.important_date_type === 'exam' ? 'bg-red-500/10 text-red-400 border-red-500/20' :
lesson.important_date_type === 'assignment' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20' :
'bg-green-500/10 text-green-400 border-green-500/20'
}`}
>
<span className="w-1 h-1 rounded-full bg-current"></span>
{lesson.title}
</div>
</Link>
))}
</div>
</div>
);
}
return days;
};
const nextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
const prevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
if (loading) return <div className="py-20 text-center animate-pulse text-gray-500 font-bold uppercase tracking-widest text-xs">Syncing your timeline...</div>;
if (!course) return <div className="text-center py-20 text-red-400">Course not found.</div>;
const monthName = currentDate.toLocaleString('default', { month: 'long' });
const year = currentDate.getFullYear();
return (
<div className="max-w-6xl mx-auto px-6 py-20">
<div className="mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<div className="flex items-center gap-2 mb-4 text-blue-500 font-bold text-xs uppercase tracking-widest">
<Link href={`/courses/${params.id}`} className="hover:text-white transition-colors">Outline</Link>
<ChevronRightIcon size={14} className="text-gray-600" />
<span>Timeline</span>
</div>
<h1 className="text-4xl font-black tracking-tight mb-2">Course <span className="text-blue-500">Timeline</span></h1>
<p className="text-gray-500 font-medium">{course.title}</p>
</div>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2 px-4 py-2 rounded-full border border-white/5 bg-white/2 text-[10px] font-black uppercase tracking-widest text-gray-400">
<div className="w-2 h-2 rounded-full bg-red-500"></div> Exam
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-full border border-white/5 bg-white/2 text-[10px] font-black uppercase tracking-widest text-gray-400">
<div className="w-2 h-2 rounded-full bg-blue-500"></div> Assignment
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-full border border-white/5 bg-white/2 text-[10px] font-black uppercase tracking-widest text-gray-400">
<div className="w-2 h-2 rounded-full bg-green-500"></div> Task
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3">
<div className="glass-card bg-white/[0.01] border-white/5 p-6 rounded-3xl overflow-hidden shadow-2xl">
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-black uppercase tracking-tight italic">{monthName} {year}</h3>
<div className="flex items-center gap-2 bg-white/5 rounded-2xl p-1 border border-white/10">
<button onClick={prevMonth} className="p-2 hover:bg-white/10 rounded-xl transition-colors"><ChevronLeft className="w-5 h-5" /></button>
<button onClick={() => setCurrentDate(new Date())} className="px-4 py-1 text-[10px] font-black uppercase tracking-widest hover:text-blue-400 transition-colors">Today</button>
<button onClick={nextMonth} className="p-2 hover:bg-white/10 rounded-xl transition-colors"><ChevronRight className="w-5 h-5" /></button>
</div>
</div>
<div className="grid grid-cols-7 border-t border-l border-white/5 rounded-2xl overflow-hidden">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="bg-white/5 py-4 text-center text-[10px] font-black uppercase tracking-widest text-gray-600 border-r border-b border-white/5">
{day}
</div>
))}
{renderCalendar()}
</div>
</div>
</div>
<div className="space-y-8">
<div className="glass-card p-8 border-blue-500/20 bg-blue-500/5 rounded-3xl relative overflow-hidden">
<div className="relative z-10">
<h4 className="text-xs font-black uppercase tracking-[0.2em] text-blue-400 mb-6 flex items-center gap-2">
<AlertCircle size={14} /> Upcoming Deadlines
</h4>
<div className="space-y-6">
{lessons
.filter(l => l.due_date && new Date(l.due_date) >= new Date())
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
.slice(0, 5)
.map(lesson => (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`} className="block group">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1 flex justify-between">
<span>{lesson.important_date_type || 'Activity'}</span>
<span className="text-blue-500 font-black">{new Date(lesson.due_date!).toLocaleDateString()}</span>
</div>
<div className="font-bold text-sm group-hover:text-blue-400 transition-colors">{lesson.title}</div>
</Link>
))
}
{lessons.filter(l => l.due_date && new Date(l.due_date) >= new Date()).length === 0 && (
<div className="text-xs text-gray-600 italic py-4">No upcoming deadlines. You are all caught up!</div>
)}
</div>
</div>
<div className="absolute -bottom-10 -right-10 w-40 h-40 bg-blue-500/10 blur-[60px] rounded-full"></div>
</div>
<div className="glass-card p-8 border-white/5 bg-white/[0.01] rounded-3xl">
<h4 className="text-xs font-black uppercase tracking-[0.2em] text-gray-500 mb-6 flex items-center gap-2">
<Clock size={14} /> Course Pace
</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">Mode</span>
<span className="text-xs font-black uppercase tracking-widest text-white">{course.pacing_mode.replace('_', '-')}</span>
</div>
{course.start_date && (
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">Start Date</span>
<span className="text-xs font-black text-white">{new Date(course.start_date).toLocaleDateString()}</span>
</div>
)}
{course.end_date && (
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">End Date</span>
<span className="text-xs font-black text-white">{new Date(course.end_date).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+44 -8
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { lmsApi, Course, Module } from "@/lib/api";
import Link from "next/link";
import { BookOpen, ChevronRight, PlayCircle } from "lucide-react";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
@@ -45,6 +45,25 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
{courseData.description || "Master the core principles and advanced techniques in this structured curriculum. Each module is designed to provide actionable insights and hands-on experience."}
</p>
<div className="flex flex-wrap items-center gap-4 mb-10">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border text-xs font-bold uppercase tracking-widest ${courseData.pacing_mode === 'instructor_led' ? 'bg-purple-500/10 border-purple-500/30 text-purple-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400'
}`}>
{courseData.pacing_mode === 'instructor_led' ? <Clock size={14} /> : <Info size={14} />}
{courseData.pacing_mode === 'instructor_led' ? 'Instructor-Led' : 'Self-Paced'}
</div>
{courseData.pacing_mode === 'instructor_led' && (courseData.start_date || courseData.end_date) && (
<div className="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase tracking-widest">
<Calendar size={14} />
<span>
{courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'TBD'}
<span className="mx-2 text-gray-700"></span>
{courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'TBD'}
</span>
</div>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<div className="flex flex-col">
@@ -60,11 +79,18 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
</div>
<Link href={`/courses/${params.id}/progress`}>
<button className="px-8 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
📊 View Progress
</button>
</Link>
<div className="flex gap-2">
<Link href={`/courses/${params.id}/calendar`}>
<button className="px-6 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
<Calendar size={16} /> Timeline
</button>
</Link>
<Link href={`/courses/${params.id}/progress`}>
<button className="px-8 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
📊 Progress
</button>
</Link>
</div>
</div>
</div>
@@ -98,8 +124,18 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</span>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
<div className="flex items-center gap-6">
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Deadline</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
</div>
</div>
</div>
+6 -2
View File
@@ -7,8 +7,12 @@
--background-start-rgb: 10, 10, 20;
--background-end-rgb: 0, 0, 0;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
/* Branding Defaults */
--primary-color: #3b82f6;
--secondary-color: #8b5cf6;
--accent-primary: var(--primary-color);
--accent-secondary: var(--secondary-color);
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(16px);
+44 -28
View File
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { AuthProvider } from "@/context/AuthContext";
import { BrandingProvider, useBranding } from "@/context/BrandingContext";
const inter = Inter({ subsets: ["latin"] });
@@ -11,6 +12,36 @@ export const metadata: Metadata = {
description: "Consume high-fidelity educational content with OpenCCB",
};
function AppHeader() {
const { branding } = useBranding();
return (
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
{branding?.logo_url ? (
<img src={branding.logo_url} alt={branding.name} className="w-full h-full object-contain" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700">L</div>
)}
</div>
<div className="flex flex-col -gap-1">
<span className="font-black text-lg tracking-tighter text-white leading-none">
{branding?.name?.toUpperCase() || 'LEARN'}
</span>
{!branding && <span className="text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCE</span>}
</div>
</Link>
<nav className="hidden md:flex items-center gap-8">
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">Catalog</Link>
<Link href="#" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">My Learning</Link>
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
</nav>
</header>
);
}
export default function RootLayout({
children,
}: Readonly<{
@@ -19,34 +50,19 @@ export default function RootLayout({
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen flex flex-col`}>
<AuthProvider>
{/* Header */}
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<Link href="/" className="flex items-center gap-2 group">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
L
</div>
<span className="font-black text-xl tracking-tighter text-white">LEARN<span className="text-blue-500">EXPERIENCE</span></span>
</Link>
<nav className="hidden md:flex items-center gap-8">
<Link href="/" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">Catalog</Link>
<Link href="#" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">My Learning</Link>
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
</nav>
</header>
<main className="flex-1">
{children}
</main>
{/* Footer */}
<footer className="py-12 px-6 border-t border-white/5 text-center bg-black/20">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600">
Powered by OpenCCB &copy; 2023. Advanced Agentic Coding.
</p>
</footer>
</AuthProvider>
<BrandingProvider>
<AuthProvider>
<AppHeader />
<main className="flex-1">
{children}
</main>
<footer className="py-12 px-6 border-t border-white/5 text-center bg-black/20">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600">
Powered by OpenCCB &copy; 2023. Advanced Agentic Coding.
</p>
</footer>
</AuthProvider>
</BrandingProvider>
</body>
</html>
);
+48 -3
View File
@@ -1,17 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { lmsApi, Course } from "@/lib/api";
import { lmsApi, Course, Lesson } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import { Rocket, CheckCircle2, ArrowRight, Star } from "lucide-react";
import { Rocket, CheckCircle2, ArrowRight, Star, Calendar, Clock, AlertCircle } from "lucide-react";
export default function CatalogPage() {
const [courses, setCourses] = useState<Course[]>([]);
const [enrollments, setEnrollments] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [gamification, setGamification] = useState<{ points: number, badges: any[] } | null>(null);
const [upcomingDeadlines, setUpcomingDeadlines] = useState<{ lesson: Lesson, courseTitle: string }[]>([]);
const { user } = useAuth();
const router = useRouter();
@@ -28,6 +29,24 @@ export default function CatalogPage() {
const gamificationData = await lmsApi.getGamification(user.id);
setGamification(gamificationData);
// Fetch deadlines for enrolled courses
const deadlines: { lesson: Lesson, courseTitle: string }[] = [];
for (const enrollment of enrollmentData) {
try {
const outline = await lmsApi.getCourseOutline(enrollment.course_id);
outline.modules.forEach(mod => {
mod.lessons.forEach(l => {
if (l.due_date && new Date(l.due_date) >= new Date()) {
deadlines.push({ lesson: l, courseTitle: outline.title });
}
});
});
} catch (err) {
console.error(`Failed to fetch outline for course ${enrollment.course_id}`, err);
}
}
setUpcomingDeadlines(deadlines.sort((a, b) => new Date(a.lesson.due_date!).getTime() - new Date(b.lesson.due_date!).getTime()).slice(0, 3));
}
} catch (err) {
console.error(err);
@@ -115,7 +134,33 @@ export default function CatalogPage() {
)}
</div>
{/* Visual Flair */}
<div className="absolute -bottom-10 -right-10 w-40 h-40 bg-blue-500/5 blur-[80px] rounded-full"></div>
</div>
</div>
)}
{user && upcomingDeadlines.length > 0 && (
<div className="mb-16 animate-in fade-in slide-in-from-top-4 duration-700 delay-200">
<h3 className="text-xs font-black uppercase tracking-[0.3em] text-gray-500 mb-6 flex items-center gap-2">
<Calendar size={14} /> Upcoming Deadlines
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{upcomingDeadlines.map(({ lesson, courseTitle }) => (
<Link key={lesson.id} href={`/courses/${lesson.module_id}/lessons/${lesson.id}`} className="group">
<div className="glass-card p-6 border-blue-500/10 bg-blue-500/2 rounded-3xl hover:border-blue-500/30 transition-all">
<div className="flex justify-between items-start mb-4">
<div className="text-[10px] font-black uppercase tracking-widest text-blue-400 group-hover:text-blue-300 transition-colors">
{lesson.important_date_type || 'Activity'}
</div>
<div className="text-right">
<div className="text-xs font-black text-white">{new Date(lesson.due_date!).toLocaleDateString()}</div>
<div className="text-[8px] font-bold text-gray-600 uppercase tracking-widest">Deadline</div>
</div>
</div>
<h4 className="font-bold text-sm text-gray-200 mb-1 group-hover:text-white transition-colors line-clamp-1">{lesson.title}</h4>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">{courseTitle}</p>
</div>
</Link>
))}
</div>
</div>
)}
@@ -0,0 +1,52 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { lmsApi, Organization } from '@/lib/api';
interface BrandingContextType {
branding: Organization | null;
loading: boolean;
}
const BrandingContext = createContext<BrandingContextType>({
branding: null,
loading: true,
});
export const useBranding = () => useContext(BrandingContext);
export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [branding, setBranding] = useState<Organization | null>(null);
const [loading, setLoading] = useState(true);
const orgId = process.env.NEXT_PUBLIC_ORG_ID || '00000000-0000-0000-0000-000000000001';
useEffect(() => {
const loadBranding = async () => {
try {
const data = await lmsApi.getBranding(orgId);
setBranding(data);
// Apply CSS variables
if (data.primary_color) {
document.documentElement.style.setProperty('--primary-color', data.primary_color);
}
if (data.secondary_color) {
document.documentElement.style.setProperty('--secondary-color', data.secondary_color);
}
} catch (error) {
console.error('Failed to load branding', error);
} finally {
setLoading(false);
}
};
loadBranding();
}, [orgId]);
return (
<BrandingContext.Provider value={{ branding, loading }}>
{children}
</BrandingContext.Provider>
);
};
+20
View File
@@ -1,4 +1,13 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
export interface Organization {
id: string;
name: string;
logo_url?: string;
primary_color?: string;
secondary_color?: string;
}
export interface Course {
id: string;
@@ -7,6 +16,9 @@ export interface Course {
instructor_id: string;
passing_percentage: number;
certificate_template?: string;
pacing_mode: string;
start_date?: string;
end_date?: string;
created_at: string;
}
@@ -55,6 +67,8 @@ export interface Lesson {
max_attempts: number | null;
allow_retry: boolean;
position: number;
due_date?: string;
important_date_type?: 'exam' | 'assignment' | 'milestone' | 'live-session';
created_at: string;
}
@@ -177,5 +191,11 @@ export const lmsApi = {
const response = await fetch(`${API_BASE_URL}/users/${userId}/gamification`);
if (!response.ok) throw new Error('Failed to fetch gamification data');
return response.json();
},
async getBranding(orgId: string): Promise<Organization> {
const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`);
if (!response.ok) throw new Error('Failed to fetch branding');
return response.json();
}
};
+13 -1
View File
@@ -545,6 +545,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -606,6 +607,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1080,6 +1082,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2000,6 +2003,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2162,6 +2166,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3445,6 +3450,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -4151,6 +4157,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4346,6 +4353,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4357,6 +4365,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4417,7 +4426,8 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"peer": true
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -5229,6 +5239,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5386,6 +5397,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+219 -8
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { cmsApi, Organization } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck } from 'lucide-react';
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Check } from 'lucide-react';
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -11,6 +11,15 @@ export default function OrganizationsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newDomain, setNewDomain] = useState('');
// Branding States
const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [primaryColor, setPrimaryColor] = useState('#3B82F6');
const [secondaryColor, setSecondaryColor] = useState('#8B5CF6');
const [isSavingBranding, setIsSavingBranding] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const { user } = useAuth();
useEffect(() => {
@@ -41,6 +50,51 @@ export default function OrganizationsPage() {
}
};
const openBranding = (org: Organization) => {
setSelectedOrg(org);
setPrimaryColor(org.primary_color || '#3B82F6');
setSecondaryColor(org.secondary_color || '#8B5CF6');
setIsBrandingModalOpen(true);
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedOrg) return;
setUploadingLogo(true);
try {
const resp = await cmsApi.uploadOrganizationLogo(selectedOrg.id, file);
setSelectedOrg({ ...selectedOrg, logo_url: resp.url });
// Update in list
setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, logo_url: resp.url } : o));
} catch (error) {
console.error('Failed to upload logo', error);
alert('Failed to upload logo. Please try again.');
} finally {
setUploadingLogo(false);
}
};
const handleBrandingSave = async () => {
if (!selectedOrg) return;
setIsSavingBranding(true);
try {
await cmsApi.updateOrganizationBranding(selectedOrg.id, {
primary_color: primaryColor,
secondary_color: secondaryColor
});
// Update in list
setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, primary_color: primaryColor, secondary_color: secondaryColor } : o));
setIsBrandingModalOpen(false);
} catch (error) {
console.error('Failed to update branding', error);
alert('Failed to update branding. Please try again.');
} finally {
setIsSavingBranding(false);
}
};
if (user?.role !== 'admin') {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
@@ -87,8 +141,12 @@ export default function OrganizationsPage() {
</div>
<div className="flex items-start gap-4 mb-4">
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Building2 className="w-6 h-6" />
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center">
{org.logo_url ? (
<img src={org.logo_url} alt={org.name} className="w-full h-full object-contain" />
) : (
<Building2 className="w-6 h-6" />
)}
</div>
<div>
<h3 className="font-semibold text-lg">{org.name}</h3>
@@ -99,7 +157,12 @@ export default function OrganizationsPage() {
</div>
</div>
<div className="space-y-3 mt-6">
<div className="flex gap-2 mt-4 mb-2">
<div className="flex-1 h-1 rounded-full" style={{ backgroundColor: org.primary_color || '#3B82F6' }} title="Primary Color" />
<div className="flex-1 h-1 rounded-full" style={{ backgroundColor: org.secondary_color || '#8B5CF6' }} title="Secondary Color" />
</div>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between text-xs text-gray-500 bg-black/20 p-2 rounded-lg">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
@@ -109,16 +172,24 @@ export default function OrganizationsPage() {
{org.id.split('-')[0]}...
</div>
</div>
<button className="w-full py-2 px-4 text-sm font-medium border border-white/5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-2">
Details <ExternalLink className="w-3 h-3" />
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => openBranding(org)}
className="py-2 px-4 text-sm font-medium border border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10 text-blue-400 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Palette className="w-3 h-3" /> Branding
</button>
<button className="py-2 px-4 text-sm font-medium border border-white/5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-2">
Details <ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{/* Create Organization Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md glass border border-white/10 rounded-2xl p-8 shadow-2xl">
@@ -164,6 +235,146 @@ export default function OrganizationsPage() {
</div>
</div>
)}
{/* Branding Management Modal */}
{isBrandingModalOpen && selectedOrg && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-2xl glass border border-white/10 rounded-2xl p-8 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-bold">Branding Management</h2>
<p className="text-sm text-gray-400">{selectedOrg.name}</p>
</div>
<button onClick={() => setIsBrandingModalOpen(false)} className="p-2 hover:bg-white/5 rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-3 text-brand">Organization Logo</label>
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-xl bg-black/40 border border-white/10 flex items-center justify-center overflow-hidden">
{selectedOrg.logo_url ? (
<img src={selectedOrg.logo_url} alt="Preview" className="w-full h-full object-contain" />
) : (
<Building2 className="w-8 h-8 text-gray-600" />
)}
</div>
<div className="flex-1">
<label className="relative flex items-center justify-center gap-2 px-4 py-2 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 rounded-lg cursor-pointer transition-all border border-blue-500/20">
<Upload className="w-4 h-4" />
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} disabled={uploadingLogo} />
</label>
<p className="text-[10px] text-gray-500 mt-2">PNG, JPG or SVG. Max 2MB.</p>
</div>
</div>
</div>
{/* Colors */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Primary Color</label>
<div className="flex gap-2">
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none"
/>
<input
type="text"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Secondary Color</label>
<div className="flex gap-2">
<input
type="color"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none"
/>
<input
type="text"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono"
/>
</div>
</div>
</div>
</div>
{/* Live Preview */}
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-400 mb-2">Experience Portal Preview</label>
<div className="rounded-xl border border-white/10 overflow-hidden bg-slate-900 shadow-inner">
{/* Mock Experience Header */}
<div className="h-10 px-4 flex items-center justify-between border-b border-white/5" style={{ backgroundColor: primaryColor }}>
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden">
{selectedOrg.logo_url ? (
<img src={selectedOrg.logo_url} className="w-full h-full object-contain" />
) : <div className="w-3 h-3 bg-white" />}
</div>
<div className="w-16 h-2 bg-white/30 rounded" />
</div>
<div className="flex gap-2">
<div className="w-6 h-2 bg-white/20 rounded" />
<div className="w-6 h-2 bg-white/20 rounded" />
</div>
</div>
{/* Mock Experience Content */}
<div className="p-4 space-y-3 bg-[#0a0c10]">
<div className="w-2/3 h-4 bg-white/10 rounded mb-2" />
<div className="w-full h-24 bg-white/5 rounded-lg border border-white/5 p-3">
<div className="w-1/3 h-3 rounded mb-2" style={{ backgroundColor: secondaryColor }} />
<div className="w-full h-2 bg-white/5 rounded mb-1" />
<div className="w-full h-2 bg-white/5 rounded mb-1" />
<div className="w-1/2 h-2 bg-white/5 rounded" />
<div className="mt-4 flex justify-end">
<div className="px-3 py-1.5 rounded text-[8px] font-bold text-white" style={{ backgroundColor: primaryColor }}>
GET STARTED
</div>
</div>
</div>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<p className="text-[10px] text-blue-400 leading-relaxed">
This is a real-time preview of how the brand identity will apply to the student's learning experience.
</p>
</div>
</div>
</div>
<div className="flex gap-3 mt-10">
<button
onClick={() => setIsBrandingModalOpen(false)}
className="flex-1 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all font-medium"
>
Cancel
</button>
<button
onClick={handleBrandingSave}
disabled={isSavingBranding}
className="flex-2 px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2"
>
{isSavingBranding ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
Save Branding
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import { useEffect, useState } from "react";
import { cmsApi, Course, Lesson } from "@/lib/api";
import Link from "next/link";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
Plus,
Layout,
CheckCircle2,
BarChart2,
Settings,
Clock,
AlertCircle
} from "lucide-react";
export default function CourseCalendarPage({ params }: { params: { id: string } }) {
const [course, setCourse] = useState<Course | null>(null);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
const loadData = async () => {
try {
const courseData = await cmsApi.getCourseWithFullOutline(params.id);
setCourse(courseData);
// Flatten lessons from modules
const allLessons: Lesson[] = [];
courseData.modules?.forEach(mod => {
mod.lessons.forEach(lesson => {
allLessons.push(lesson);
});
});
setLessons(allLessons);
} catch (err) {
console.error("Failed to load course data", err);
} finally {
setLoading(false);
}
};
loadData();
}, [params.id]);
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const renderCalendar = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const days = [];
// Padding for first week
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="h-32 border border-white/5 bg-white/2"></div>);
}
// Days of month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
days.push(
<div key={day} className="h-32 border border-white/5 p-2 relative hover:bg-white/5 transition-colors group">
<span className="text-sm font-bold text-gray-400">{day}</span>
<div className="mt-1 space-y-1 overflow-y-auto max-h-24">
{dayLessons.map(lesson => (
<div
key={lesson.id}
className={`text-[10px] p-1 rounded truncate flex items-center gap-1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' :
lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' :
'bg-green-500/20 text-green-400 border border-green-500/30'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
{lesson.title}
</div>
))}
</div>
</div>
);
}
return days;
};
const nextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
const prevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
if (loading) return <div className="py-20 text-center">Loading calendar...</div>;
const monthName = currentDate.toLocaleString('default', { month: 'long' });
const year = currentDate.getFullYear();
return (
<div className="space-y-8">
<div className="flex items-center gap-4 text-sm text-gray-400">
<Link href="/" className="hover:text-white transition-colors">Courses</Link>
<span>/</span>
<span className="text-white">{course?.title}</span>
</div>
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">{course?.title}</h2>
<div className="flex items-center gap-3 mt-1 text-gray-400 text-sm">
<CalendarIcon className="w-4 h-4" />
<span>Course Calendar</span>
</div>
</div>
<div className="flex gap-3">
<Link href={`/courses/${params.id}`} className="px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">
Back to Outline
</Link>
</div>
</div>
<div className="glass p-1">
<div className="flex border-b border-white/10">
<Link href={`/courses/${params.id}`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Layout className="w-4 h-4" /> Outline
</Link>
<Link href={`/courses/${params.id}/grading`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<CheckCircle2 className="w-4 h-4" /> Grading
</Link>
<Link href={`/courses/${params.id}/calendar`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
<CalendarIcon className="w-4 h-4" /> Calendar
</Link>
<Link href={`/courses/${params.id}/analytics`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<BarChart2 className="w-4 h-4" /> Analytics
</Link>
<Link href={`/courses/${params.id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Settings className="w-4 h-4" /> Settings
</Link>
</div>
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-6">
<h3 className="text-2xl font-black uppercase tracking-tight">{monthName} <span className="text-blue-500">{year}</span></h3>
<div className="flex items-center gap-2 bg-white/5 rounded-xl p-1 border border-white/10">
<button onClick={prevMonth} className="p-2 hover:bg-white/10 rounded-lg transition-colors"><ChevronLeft className="w-5 h-5" /></button>
<button onClick={() => setCurrentDate(new Date())} className="px-3 py-1 text-xs font-bold uppercase tracking-widest hover:text-blue-400 transition-colors">Today</button>
<button onClick={nextMonth} className="p-2 hover:bg-white/10 rounded-lg transition-colors"><ChevronRight className="w-5 h-5" /></button>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-red-500"></span> Exam
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-blue-500"></span> Assignment
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-purple-500"></span> Live
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-green-500"></span> Lesson
</div>
</div>
</div>
<div className="grid grid-cols-7 border-t border-l border-white/5 rounded-xl overflow-hidden shadow-2xl overflow-hidden">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="bg-white/5 py-4 text-center text-xs font-black uppercase tracking-widest text-gray-500 border-r border-b border-white/5">
{day}
</div>
))}
{renderCalendar()}
</div>
<div className="mt-12 space-y-4">
<h4 className="text-lg font-bold flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-blue-500" />
Upcoming Deadlines
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{lessons
.filter(l => l.due_date && new Date(l.due_date) >= new Date())
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
.slice(0, 6)
.map(lesson => (
<div key={lesson.id} className="glass p-4 border-white/5 hover:border-blue-500/30 transition-all group">
<div className="flex justify-between items-start">
<div>
<div className={`text-[10px] font-black uppercase tracking-widest mb-1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
'text-green-400'
}`}>
{lesson.important_date_type || 'Activity'}
</div>
<h5 className="font-bold group-hover:text-blue-400 transition-colors">{lesson.title}</h5>
</div>
<div className="text-right">
<div className="text-sm font-black">{new Date(lesson.due_date!).toLocaleDateString()}</div>
<div className="text-[10px] text-gray-500 uppercase font-bold">Due Date</div>
</div>
</div>
</div>
))
}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -2,7 +2,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { cmsApi, GradingCategory } from "@/lib/api";
import { cmsApi, GradingCategory, Course } from "@/lib/api";
import {
Plus,
Trash2,
@@ -11,8 +11,12 @@ import {
CheckCircle2,
ArrowLeft,
TrendingUp,
Settings
Settings,
Layout,
Calendar,
BarChart2
} from "lucide-react";
import Link from "next/link";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@@ -109,6 +113,26 @@ export default function GradingPolicyPage() {
</div>
</div>
<div className="glass p-1 mb-12">
<div className="flex border-b border-white/10">
<Link href={`/courses/${id}`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Layout className="w-4 h-4" /> Outline
</Link>
<Link href={`/courses/${id}/grading`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
<CheckCircle2 className="w-4 h-4" /> Grading
</Link>
<Link href={`/courses/${id}/calendar`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Calendar className="w-4 h-4" /> Calendar
</Link>
<Link href={`/courses/${id}/analytics`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<BarChart2 className="w-4 h-4" /> Analytics
</Link>
<Link href={`/courses/${id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Settings className="w-4 h-4" /> Settings
</Link>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Categories List */}
<div className="lg:col-span-2 space-y-4">
@@ -10,6 +10,20 @@ import FillInTheBlanksBlock from "@/components/blocks/FillInTheBlanksBlock";
import MatchingBlock from "@/components/blocks/MatchingBlock";
import OrderingBlock from "@/components/blocks/OrderingBlock";
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
import {
Save,
X,
Pencil,
ChevronUp,
ChevronDown,
Trash2,
PlayCircle,
FileText,
Calendar,
Settings,
Layout,
CheckCircle2
} from "lucide-react";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const [lesson, setLesson] = useState<Lesson | null>(null);
@@ -20,6 +34,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
// Activity State (Blocks)
const [blocks, setBlocks] = useState<Block[]>([]);
const [summary, setSummary] = useState<string>("");
const [isTranscribing, setIsTranscribing] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false);
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
@@ -27,6 +42,11 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">("");
const [maxAttempts, setMaxAttempts] = useState<number | null>(null);
const [allowRetry, setAllowRetry] = useState(true);
const [dueDate, setDueDate] = useState<string>("");
const [importantDateType, setImportantDateType] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
useEffect(() => {
const loadData = async () => {
@@ -39,6 +59,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setSelectedCategoryId(lessonData.grading_category_id || "");
setMaxAttempts(lessonData.max_attempts);
setAllowRetry(lessonData.allow_retry);
setDueDate(lessonData.due_date ? new Date(lessonData.due_date).toISOString().split('T')[0] : "");
setImportantDateType(lessonData.important_date_type || "");
if (lessonData.metadata?.blocks) {
setBlocks(lessonData.metadata.blocks);
@@ -64,6 +86,17 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
loadData();
}, [params.id, params.lessonId]);
const handleSaveLessonTitle = async () => {
if (!lesson || !editValue) return;
try {
const updated = await cmsApi.updateLesson(lesson.id, { title: editValue });
setLesson(updated);
setEditingId(null);
} catch {
alert("Failed to update title");
}
};
const handleSave = async () => {
if (!lesson) return;
setIsSaving(true);
@@ -74,7 +107,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
is_graded: isGraded,
grading_category_id: selectedCategoryId || null,
max_attempts: maxAttempts,
allow_retry: allowRetry
allow_retry: allowRetry,
due_date: dueDate ? new Date(dueDate).toISOString() : undefined,
important_date_type: (importantDateType || undefined) as any
});
setLesson(updated);
setEditMode(false);
@@ -117,6 +152,19 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setBlocks(newBlocks);
};
const handleTranscribe = async () => {
if (!lesson) return;
setIsTranscribing(true);
try {
const updated = await cmsApi.transcribeLesson(lesson.id);
setLesson(updated);
} catch {
alert("Failed to transcribe video.");
} finally {
setIsTranscribing(false);
}
};
const handleSummarize = async () => {
if (!lesson) return;
setIsGeneratingSummary(true);
@@ -155,7 +203,31 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<span className="text-gray-700">/</span>
<span>Activity</span>
</div>
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2>
<div className="flex items-center gap-4">
{editingId === 'lesson-title' ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveLessonTitle()}
className="text-4xl font-black bg-transparent border-b-2 border-blue-500 focus:outline-none"
/>
<button onClick={handleSaveLessonTitle} className="text-green-400"><Save className="w-6 h-6" /></button>
<button onClick={() => setEditingId(null)} className="text-gray-400"><X className="w-6 h-6" /></button>
</div>
) : (
<div className="flex items-center gap-4 group">
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2>
<button
onClick={() => { setEditingId('lesson-title'); setEditValue(lesson.title); }}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
@@ -259,7 +331,98 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div>
)}
{/* AI Summary Section */}
{editMode && (
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
<div>
<h3 className="text-xl font-bold flex items-center gap-2">
<span className="text-blue-500">📅</span> Scheduling & Deadlines
</h3>
<p className="text-sm text-gray-500 mt-1">Set deadlines and mark important dates for this activity</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<label className="block">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Due Date</span>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all font-bold"
/>
</label>
</div>
<div className="space-y-4">
<label className="block">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Date Type</span>
<select
value={importantDateType}
onChange={(e) => setImportantDateType(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
>
<option value="" className="bg-gray-900">Standard Activity</option>
<option value="exam" className="bg-gray-900">Exam</option>
<option value="assignment" className="bg-gray-900">Assignment</option>
<option value="milestone" className="bg-gray-900">Milestone</option>
<option value="live-session" className="bg-gray-900">Live Session</option>
</select>
</label>
</div>
</div>
</div>
)}
{/* AI Magic Section */}
{editMode && (
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-3">
<span className="text-2xl">🪄</span>
<div>
<h3 className="text-xl font-bold italic tracking-tight">AI Content Assistant</h3>
<p className="text-xs text-gray-400 mt-1 uppercase tracking-widest font-bold">Automate your content creation</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(lesson.content_type === 'video' || lesson.content_type === 'audio') && (
<button
onClick={handleTranscribe}
disabled={isTranscribing}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${lesson.transcription ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400 hover:border-blue-500/60'}`}
>
<span className="text-xl">{isTranscribing ? '⏳' : '🎤'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Video/Audio</div>
<div className="font-bold">{isTranscribing ? 'Transcribing...' : lesson.transcription ? 'Update Transcript' : 'Transcribe Video'}</div>
</button>
)}
<button
onClick={handleSummarize}
disabled={isGeneratingSummary || !lesson.transcription}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${summary ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400 hover:border-indigo-500/60 disabled:opacity-30 disabled:cursor-not-allowed'}`}
>
<span className="text-xl">{isGeneratingSummary ? '⏳' : '✍️'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Summarization</div>
<div className="font-bold">{isGeneratingSummary ? 'Generating...' : summary ? 'Update Summary' : 'Generate Summary'}</div>
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
</button>
<button
onClick={handleGenerateQuiz}
disabled={isGeneratingQuiz || !lesson.transcription}
className="p-6 bg-purple-500/10 border border-purple-500/30 hover:border-purple-500/60 rounded-2xl transition-all text-left flex flex-col gap-2 text-purple-400 disabled:opacity-30 disabled:cursor-not-allowed"
>
<span className="text-xl">{isGeneratingQuiz ? '⏳' : '💡'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
<div className="font-bold">{isGeneratingQuiz ? 'Building...' : 'Generate Quiz'}</div>
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
</button>
</div>
</div>
)}
{/* AI Summary Visualization */}
{(summary || editMode) && (
<div className="bg-gradient-to-br from-indigo-500/10 to-blue-500/10 border border-indigo-500/20 rounded-3xl p-8 space-y-6 animate-in fade-in duration-700">
<div className="flex items-center justify-between">
@@ -270,15 +433,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<p className="text-xs text-gray-400 mt-1 uppercase tracking-widest font-bold">Key insights generated by intelligence</p>
</div>
</div>
{editMode && (
<button
onClick={handleSummarize}
disabled={isGeneratingSummary}
className="px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-widest rounded-xl border border-blue-500/20 transition-all flex items-center gap-2"
>
{isGeneratingSummary ? "Generating..." : "Regenerate Summary"}
</button>
)}
</div>
{editMode ? (
@@ -300,32 +454,33 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{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">
<div className="absolute -left-16 top-0 h-full flex flex-col items-center gap-2 opacity-100 transition-all">
<span className="text-[10px] font-black text-gray-700 uppercase vertical-text mb-2">Move</span>
<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"
className="w-10 h-10 rounded-xl 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 group-hover/block:scale-110"
title="Move Up"
>
<span className="text-xs"></span>
<ChevronUp className="w-5 h-5" />
</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"
className="w-10 h-10 rounded-xl 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 group-hover/block:scale-110"
title="Move Down"
>
<span className="text-xs"></span>
<ChevronDown className="w-5 h-5" />
</button>
<div className="h-2"></div>
<div className="h-4"></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"
className="w-10 h-10 rounded-xl 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 group-hover/block:scale-110"
title="Remove Block"
>
<span className="text-sm">×</span>
<Trash2 className="w-5 h-5" />
</button>
<div className="w-0.5 flex-1 bg-white/5"></div>
<div className="w-0.5 flex-1 bg-white/5 mt-2"></div>
</div>
)}
+298 -60
View File
@@ -3,6 +3,23 @@
import { useEffect, useState } from "react";
import { cmsApi, Course, Module, Lesson } from "@/lib/api";
import Link from "next/link";
import {
Plus,
Pencil,
ChevronUp,
ChevronDown,
PlayCircle,
FileText,
Calendar,
CheckCircle2,
Settings,
BarChart2,
Layout,
Save,
X,
GripVertical,
Trash2
} from "lucide-react";
interface FullModule extends Module {
lessons: Lesson[];
@@ -13,14 +30,19 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
const [modules, setModules] = useState<FullModule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const startEditing = (id: string, currentTitle: string) => {
setEditingId(id);
setEditValue(currentTitle);
};
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
// Use cmsApi for consistent, typed data fetching
const data = await cmsApi.getCourseWithFullOutline(params.id);
setCourse(data);
setModules(data.modules as FullModule[]);
} catch (err) {
@@ -35,34 +57,120 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
}, [params.id]);
const handleAddModule = async () => {
const title = prompt("Module Title:");
if (!title) return;
const title = "New Module";
try {
const newMod = await cmsApi.createModule(params.id, title, modules.length + 1);
setModules([...modules, { ...newMod, lessons: [] }]);
const fullMod = { ...newMod, lessons: [] };
setModules([...modules, fullMod]);
setEditingId(newMod.id);
setEditValue(title);
} catch {
alert("Failed to create module");
}
};
const handleAddLesson = async (moduleId: string) => {
const title = prompt("Lesson Title:");
if (!title) return;
const mod = modules.find(m => m.id === moduleId);
if (!mod) return;
const title = "New Lesson";
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
const newLesson = await cmsApi.createLesson(moduleId, title, "video", mod.lessons.length + 1);
setModules(modules.map(m =>
m.id === moduleId
? { ...m, lessons: [...m.lessons, newLesson] }
: m
));
setEditingId(newLesson.id);
setEditValue(title);
} catch {
alert("Failed to create lesson");
}
};
const handleSaveTitle = async (id: string, type: 'module' | 'lesson') => {
if (!editValue) {
setEditingId(null);
return;
}
try {
if (type === 'module') {
await cmsApi.updateModule(id, { title: editValue });
setModules(modules.map(m => m.id === id ? { ...m, title: editValue } : m));
} else {
await cmsApi.updateLesson(id, { title: editValue });
setModules(modules.map(mod => ({
...mod,
lessons: mod.lessons.map(l => l.id === id ? { ...l, title: editValue } : l)
})));
}
setEditingId(null);
} catch {
alert("Failed to update title");
}
};
const handleDeleteModule = async (id: string) => {
if (!confirm("Are you sure you want to delete this module and all its lessons?")) return;
try {
await cmsApi.deleteModule(id);
setModules(modules.filter(m => m.id !== id));
} catch {
alert("Failed to delete module");
}
};
const handleDeleteLesson = async (moduleId: string, lessonId: string) => {
if (!confirm("Are you sure you want to delete this lesson?")) return;
try {
await cmsApi.deleteLesson(lessonId);
setModules(modules.map(m =>
m.id === moduleId
? { ...m, lessons: m.lessons.filter(l => l.id !== lessonId) }
: m
));
} catch {
alert("Failed to delete lesson");
}
};
const handleReorderModule = async (index: number, direction: 'up' | 'down') => {
const newModules = [...modules];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newModules.length) return;
[newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]];
const items = newModules.map((m, i) => ({ id: m.id, position: i + 1 }));
setModules(newModules.map((m, i) => ({ ...m, position: i + 1 })));
try {
await cmsApi.reorderModules({ items });
} catch {
alert("Failed to save module order");
}
};
const handleReorderLesson = async (moduleId: string, lessonIndex: number, direction: 'up' | 'down') => {
const mod = modules.find(m => m.id === moduleId);
if (!mod) return;
const newLessons = [...mod.lessons];
const targetIndex = direction === 'up' ? lessonIndex - 1 : lessonIndex + 1;
if (targetIndex < 0 || targetIndex >= newLessons.length) return;
[newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]];
const items = newLessons.map((l, i) => ({ id: l.id, position: i + 1 }));
setModules(modules.map(m => m.id === moduleId ? { ...m, lessons: newLessons.map((l, i) => ({ ...l, position: i + 1 })) } : m));
try {
await cmsApi.reorderLessons({ items });
} catch {
alert("Failed to save lesson order");
}
};
const [isPublishing, setIsPublishing] = useState(false);
const handlePublish = async () => {
@@ -73,7 +181,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
alert("Course published successfully to LMS!");
} catch (err) {
console.error("Publish failed:", err);
alert("Failed to publish course. Check if LMS service is reachable.");
alert("Failed to publish course.");
} finally {
setIsPublishing(false);
}
@@ -84,76 +192,206 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
return (
<div className="space-y-8">
{/* ... navigation ... */}
<div className="flex items-center gap-4 text-sm text-gray-400">
<Link href="/" className="hover:text-white cursor-pointer underline">Courses</Link>
<Link href="/" className="hover:text-white transition-colors">Courses</Link>
<span>/</span>
<span className="text-white">{course?.title}</span>
</div>
<div className="flex justify-between items-end">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">{course?.title}</h2>
<p className="text-gray-400">Editor - Outline (ID: {params.id})</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-gray-400 text-sm">Editor - Outline</span>
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded ${course?.pacing_mode === 'instructor_led' ? 'bg-purple-500/20 text-purple-400' : 'bg-green-500/20 text-green-400'}`}>
{course?.pacing_mode?.replace('_', ' ') || 'Self Paced'}
</span>
</div>
</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="flex items-center gap-2 px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">
Preview
</button>
<button
onClick={handlePublish}
disabled={isPublishing}
className={`btn-premium flex items-center gap-2 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
className={`btn-primary flex items-center gap-2 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
>
{isPublishing ? (
<>
<span className="animate-spin text-lg"></span>
Publishing...
</>
) : (
"Publish to LMS"
)}
{isPublishing ? "Publishing..." : "Publish to LMS"}
</button>
</div>
</div>
<div className="glass p-1">
<div className="flex border-b border-white/10">
<Link href={`/courses/${params.id}`} className="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 bg-white/5">Outline</Link>
<Link href={`/courses/${params.id}/grading`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Grading</Link>
<Link href={`/courses/${params.id}/analytics`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Analytics</Link>
<Link href={`/courses/${params.id}/settings`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</Link>
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
<Link href={`/courses/${params.id}`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
<Layout className="w-4 h-4" /> Outline
</Link>
<Link href={`/courses/${params.id}/grading`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<CheckCircle2 className="w-4 h-4" /> Grading
</Link>
<Link href={`/courses/${params.id}/calendar`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Calendar className="w-4 h-4" /> Calendar
</Link>
<Link href={`/courses/${params.id}/analytics`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<BarChart2 className="w-4 h-4" /> Analytics
</Link>
<Link href={`/courses/${params.id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Settings className="w-4 h-4" /> Settings
</Link>
</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 className="p-8 space-y-6">
{modules.map((module, mIndex) => (
<div key={module.id} className="glass rounded-xl overflow-hidden border-white/5">
<div className="bg-white/5 px-6 py-4 flex justify-between items-center border-b border-white/5">
<div className="flex items-center gap-4 flex-1">
<div className="flex flex-col">
<button
onClick={() => handleReorderModule(mIndex, 'up')}
disabled={mIndex === 0}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => handleReorderModule(mIndex, 'down')}
disabled={mIndex === modules.length - 1}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
<GripVertical className="text-gray-600 w-5 h-5 cursor-grab active:cursor-grabbing" />
{editingId === module.id ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(module.id, 'module')}
className="bg-black/40 border border-blue-500/50 rounded px-3 py-1 flex-1 text-white focus:outline-none"
/>
<button onClick={() => handleSaveTitle(module.id, 'module')} className="text-green-400 hover:text-green-300">
<Save className="w-5 h-5" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-red-400">
<X className="w-5 h-5" />
</button>
</div>
</Link>
) : (
<div className="flex items-center gap-3 group flex-1">
<span
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="font-semibold text-lg text-blue-400 cursor-pointer hover:text-blue-300 transition-colors"
>
Module {module.position}: {module.title}
</span>
<button
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={() => handleDeleteModule(module.id)}
className="text-gray-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-6 space-y-3">
{module.lessons.map((lesson, lIndex) => (
<div key={lesson.id} className="flex items-center gap-3 group/row">
<div className="flex flex-col opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
onClick={() => handleReorderLesson(module.id, lIndex, 'up')}
disabled={lIndex === 0}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0"
>
<ChevronUp className="w-3 h-3" />
</button>
<button
onClick={() => handleReorderLesson(module.id, lIndex, 'down')}
disabled={lIndex === module.lessons.length - 1}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0"
>
<ChevronDown className="w-3 h-3" />
</button>
</div>
<div className="flex-1">
{editingId === lesson.id ? (
<div className="flex items-center gap-2 glass border-blue-500/30 p-2 rounded-lg">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(lesson.id, 'lesson')}
className="bg-transparent border-none flex-1 text-white focus:outline-none"
/>
<button onClick={() => handleSaveTitle(lesson.id, 'lesson')} className="text-green-400">
<Save className="w-4 h-4" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center justify-between glass border-white/5 p-4 rounded-xl hover:bg-white/10 hover:border-blue-500/30 transition-all cursor-pointer group/lesson">
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} className="flex-1 flex items-center gap-4">
<div className="p-2 bg-blue-500/20 rounded-lg text-blue-400 group-hover/lesson:scale-110 transition-transform">
{lesson.content_type === 'video' ? <PlayCircle className="w-5 h-5" /> : <FileText className="w-5 h-5" />}
</div>
<div className="flex flex-col">
<span
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
className="font-medium hover:text-blue-400 transition-colors"
>
{lesson.title}
</span>
<div className="flex items-center gap-3 text-[10px] text-gray-500 uppercase mt-0.5 font-semibold">
<span>{lesson.content_type}</span>
{lesson.due_date && (
<div className="flex items-center gap-1 text-orange-400">
<Calendar className="w-3 h-3" />
{new Date(lesson.due_date).toLocaleDateString()}
</div>
)}
</div>
</div>
</Link>
<div className="flex items-center gap-4">
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
className="opacity-0 group-hover/lesson:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDeleteLesson(module.id, lesson.id); }}
className="opacity-0 group-hover/lesson:opacity-100 text-gray-500 hover:text-red-400 transition-opacity"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
))}
<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"
className="w-full py-3 border border-dashed border-white/10 rounded-xl text-sm text-gray-500 hover:text-white hover:border-white/20 hover:bg-white/5 transition-all mt-3 flex items-center justify-center gap-2"
>
+ New Lesson
<Plus className="w-4 h-4" /> New Lesson
</button>
</div>
</div>
@@ -161,9 +399,9 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
<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"
className="w-full py-6 border-2 border-dashed border-white/10 rounded-2xl font-medium text-gray-500 hover:text-white hover:border-white/20 hover:bg-white/5 transition-all flex items-center justify-center gap-3 text-lg"
>
+ Add Module
<Plus className="w-6 h-6" /> Add New Module
</button>
</div>
</div>
@@ -2,8 +2,9 @@
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { cmsApi, Course } from "@/lib/api";
import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen } from "lucide-react";
import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Layout, CheckCircle2 } from "lucide-react";
const DEFAULT_CERTIFICATE_TEMPLATE = `
<div style="width: 800px; height: 600px; padding: 40px; text-align: center; border: 10px solid #787878; font-family: 'Times New Roman', serif; background-color: #fff; color: #333;">
@@ -28,6 +29,9 @@ export default function CourseSettingsPage() {
const [certificateTemplate, setCertificateTemplate] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pacingMode, setPacingMode] = useState<'self_paced' | 'instructor_led'>("self_paced");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
useEffect(() => {
const fetchCourse = async () => {
@@ -36,6 +40,9 @@ export default function CourseSettingsPage() {
setCourse(data);
setPassingPercentage(data.passing_percentage || 70);
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
setPacingMode(data.pacing_mode || "self_paced");
setStartDate(data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : "");
setEndDate(data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : "");
} catch (err) {
console.error("Failed to load course", err);
} finally {
@@ -50,7 +57,10 @@ export default function CourseSettingsPage() {
try {
const updated = await cmsApi.updateCourse(id, {
passing_percentage: passingPercentage,
certificate_template: certificateTemplate
certificate_template: certificateTemplate,
pacing_mode: pacingMode,
start_date: startDate ? new Date(startDate).toISOString() : undefined,
end_date: endDate ? new Date(endDate).toISOString() : undefined
});
setCourse(updated);
alert("Course settings updated successfully!");
@@ -97,6 +107,23 @@ export default function CourseSettingsPage() {
</header>
<main className="max-w-5xl mx-auto px-8 mt-12 space-y-8">
<div className="glass p-1 mb-12">
<div className="flex border-b border-white/10">
<Link href={`/courses/${id}`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Layout className="w-4 h-4" /> Outline
</Link>
<Link href={`/courses/${id}/grading`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<CheckCircle2 className="w-4 h-4" /> Grading
</Link>
<Link href={`/courses/${id}/calendar`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Calendar className="w-4 h-4" /> Calendar
</Link>
<Link href={`/courses/${id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
<SettingsIcon className="w-4 h-4" /> Settings
</Link>
</div>
</div>
{/* Passing Percentage Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
@@ -163,6 +190,70 @@ export default function CourseSettingsPage() {
</div>
</section>
{/* Course Pacing Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-green-500/10 flex items-center justify-center text-green-400">
<Clock size={24} />
</div>
<h2 className="text-2xl font-black">Course Pacing & Schedule</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<label className="block text-sm font-bold text-gray-300">Pacing Mode</label>
<div className="flex gap-4">
<button
onClick={() => setPacingMode('self_paced')}
className={`flex-1 p-4 rounded-2xl border-2 transition-all text-left ${pacingMode === 'self_paced' ? 'border-blue-500 bg-blue-500/10' : 'border-white/5 bg-white/5 hover:border-white/10'}`}
>
<div className="font-bold">Self-Paced</div>
<div className="text-xs text-gray-500">Learners go at their own speed.</div>
</button>
<button
onClick={() => setPacingMode('instructor_led')}
className={`flex-1 p-4 rounded-2xl border-2 transition-all text-left ${pacingMode === 'instructor_led' ? 'border-purple-500 bg-purple-500/10' : 'border-white/5 bg-white/5 hover:border-white/10'}`}
>
<div className="font-bold">Instructor-Led</div>
<div className="text-xs text-gray-500">Cohort-based with specific dates.</div>
</button>
</div>
</div>
{pacingMode === 'instructor_led' && (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2">
<label className="block text-sm font-bold text-gray-300">Course Schedule</label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs text-gray-500">Start Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-gray-500">End Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
</div>
</section>
{/* Certificate Template Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
@@ -18,7 +18,7 @@ export default function DescriptionBlock({ title, content, editMode, onChange }:
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -51,7 +51,7 @@ export default function FillInTheBlanksBlock({ id, title, content, editMode, onC
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -98,8 +98,8 @@ export default function FillInTheBlanksBlock({ id, title, content, editMode, onC
}}
disabled={submitted}
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
}`}
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
placeholder="..."
@@ -44,7 +44,7 @@ export default function MatchingBlock({ id, title, pairs, editMode, onChange }:
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -112,8 +112,8 @@ export default function MatchingBlock({ id, title, pairs, editMode, onChange }:
key={i}
onClick={() => !submitted && setSelectedLeft(i)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
}`}
>
{pair.left}
@@ -45,7 +45,7 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -40,7 +40,7 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -128,7 +128,7 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
disabled={isPicked || submitted}
onClick={() => handlePick(item.originalIdx)}
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
}`}
>
{item.value}
@@ -151,8 +151,8 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
key={i}
onClick={() => !submitted && handlePick(idx)}
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
}`}
>
<span className="opacity-50 text-xs">{i + 1}.</span>
@@ -76,7 +76,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -27,7 +27,7 @@ export default function ShortAnswerBlock({ id, title, prompt, correctAnswers, ed
<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>
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
@@ -77,8 +77,8 @@ export default function ShortAnswerBlock({ id, title, prompt, correctAnswers, ed
onChange={(e) => setUserAnswer(e.target.value)}
disabled={submitted}
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
: "border-white/10 focus:border-blue-500 text-white"
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
: "border-white/10 focus:border-blue-500 text-white"
}`}
placeholder="Type your answer..."
/>
+47
View File
@@ -5,6 +5,9 @@ export interface Course {
title: string;
description?: string;
instructor_id: string;
pacing_mode: 'self_paced' | 'instructor_led';
start_date?: string;
end_date?: string;
passing_percentage: number;
certificate_template?: string;
created_at: string;
@@ -57,26 +60,40 @@ export interface Lesson {
grading_category_id: string | null;
max_attempts: number | null;
allow_retry: boolean;
due_date?: string;
important_date_type?: 'exam' | 'assignment' | 'milestone' | 'live-session';
summary?: string;
transcription?: {
en?: string;
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
created_at: string;
}
export interface Organization {
id: string;
name: string;
domain?: string;
logo_url?: string;
primary_color?: string;
secondary_color?: string;
certificate_template?: string;
created_at: string;
updated_at: string;
}
export interface BrandingPayload {
primary_color?: string;
secondary_color?: string;
}
export interface User {
id: string;
email: string;
full_name: string;
role: string;
organization_id?: string;
}
export interface AuthResponse {
@@ -152,6 +169,8 @@ const apiFetch = (url: string, options: RequestInit = {}) => {
export const cmsApi = {
// Organization
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
getOrganizations: (): Promise<Organization[]> => apiFetch('/organizations'),
createOrganization: (name: string, domain?: string): Promise<Organization> => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }),
// Auth
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
@@ -171,8 +190,13 @@ export const cmsApi = {
createLesson: (module_id: string, title: string, content_type: string, position: number): Promise<Lesson> => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }),
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
transcribeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/transcribe`, { method: 'POST' }),
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
reorderModules: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/modules/reorder', { method: 'POST', body: JSON.stringify(payload) }),
reorderLessons: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/lessons/reorder', { method: 'POST', body: JSON.stringify(payload) }),
// Grading
getGradingCategories: (courseId: string): Promise<GradingCategory[]> => apiFetch(`/courses/${courseId}/grading`),
@@ -183,6 +207,10 @@ export const cmsApi = {
getAuditLogs: (): Promise<AuditLog[]> => apiFetch('/audit-logs'),
getCourseAnalytics: (id: string): Promise<CourseAnalytics> => apiFetch(`/courses/${id}/analytics`),
// Users
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
updateUser: (id: string, role: string, organization_id: string): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }),
// Assets
uploadAsset: (file: File): Promise<UploadResponse> => {
const formData = new FormData();
@@ -204,4 +232,23 @@ export const cmsApi = {
return res.json();
});
},
// Organizations Branding
getOrganizationBranding: (id: string): Promise<Organization> => apiFetch(`/organizations/${id}/branding`),
updateOrganizationBranding: (id: string, payload: BrandingPayload): Promise<void> => apiFetch(`/organizations/${id}/branding`, { method: 'PUT', body: JSON.stringify(payload) }),
uploadOrganizationLogo: (id: string, file: File): Promise<UploadResponse> => {
const formData = new FormData();
formData.append('file', file);
const token = getToken();
const headers: Record<string, string> = {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
return fetch(`${API_BASE_URL}/organizations/${id}/logo`, {
method: 'POST',
headers,
body: formData,
}).then(res => {
if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Logo upload failed')));
return res.json();
});
},
};