feat: Implement multi-tenancy with new database migrations, API updates across services, and refactor frontend API calls.

This commit is contained in:
2025-12-26 10:59:07 -03:00
parent 8c440def23
commit e772d430b1
22 changed files with 819 additions and 745 deletions
+22 -2
View File
@@ -3,13 +3,14 @@
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { lmsApi } from "@/lib/api";
import { GraduationCap, Lock, Mail, User } from "lucide-react";
import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react";
export default function ExperienceLoginPage() {
const router = useRouter();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -37,7 +38,8 @@ export default function ExperienceLoginPage() {
const response = await lmsApi.register({
email,
password,
full_name: fullName
full_name: fullName,
organization_name: organizationName,
});
localStorage.setItem("experience_token", response.token);
@@ -101,6 +103,24 @@ export default function ExperienceLoginPage() {
</div>
</div>
)}
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Organization Name (Optional)
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Your School or Company"
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">If left blank, an organization will be created based on your email domain.</p>
</div>
)}
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
+18 -2
View File
@@ -5,12 +5,13 @@ import { lmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus, Mail, Lock, User } from "lucide-react";
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -22,7 +23,7 @@ export default function RegisterPage() {
setLoading(true);
setError("");
try {
const res = await lmsApi.register({ email, password, full_name: fullName });
const res = await lmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
login(res.user, res.token);
router.push("/");
} catch (err) {
@@ -97,6 +98,21 @@ export default function RegisterPage() {
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Organization Name (Optional)</label>
<div className="relative">
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Your School or Company"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
<p className="text-[10px] text-gray-600 px-1">If blank, we'll use your email domain.</p>
</div>
<button
disabled={loading}
type="submit"
+1
View File
@@ -86,6 +86,7 @@ export interface AuthPayload {
email: string;
password?: string;
full_name?: string;
organization_name?: string;
}
export interface Enrollment {
+43 -21
View File
@@ -3,13 +3,16 @@
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { cmsApi } from "@/lib/api";
import { BookOpen, Lock, Mail, User } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import { BookOpen, Lock, Mail, User, Building2 } from "lucide-react";
export default function StudioLoginPage() {
const router = useRouter();
const { login } = useAuth();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -30,19 +33,18 @@ export default function StudioLoginPage() {
return;
}
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
login(response.user, response.token);
router.push("/");
} else {
const response = await cmsApi.register({
email,
password,
full_name: fullName,
role: "instructor"
role: "instructor",
organization_name: organizationName,
});
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
login(response.user, response.token);
router.push("/");
}
} catch (err) {
@@ -85,22 +87,42 @@ export default function StudioLoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
<>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Organization Name (Optional)
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your School or Company"
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">
If left blank, an organization will be created based on your email domain.
</p>
</div>
</>
)}
<div>
+18 -2
View File
@@ -5,12 +5,13 @@ import { cmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus, Mail, Lock, User } from "lucide-react";
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -22,7 +23,7 @@ export default function RegisterPage() {
setLoading(true);
setError("");
try {
const res = await cmsApi.register({ email, password, full_name: fullName });
const res = await cmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
login(res.user, res.token);
router.push("/");
} catch (err) {
@@ -97,6 +98,21 @@ export default function RegisterPage() {
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Organization Name (Optional)</label>
<div className="relative">
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Your School or Company"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all placeholder:text-gray-700"
/>
</div>
<p className="text-[10px] text-gray-600 px-1">If blank, we'll use your email domain.</p>
</div>
<button
disabled={loading}
type="submit"
+40 -23
View File
@@ -3,48 +3,65 @@
@tailwind utilities;
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 10, 10, 20;
--foreground-rgb: 229, 231, 235;
/* text-gray-200 */
--background-start-rgb: 15, 17, 21;
/* #0f1115 */
--background-end-rgb: 0, 0, 0;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
/* blue-500 */
--accent-secondary: #6366f1;
/* indigo-500 */
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(16px);
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
min-height: 100vh;
background: var(--background-start-rgb);
}
.glass {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
.gradient-text {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
.glass-card {
@apply glass rounded-2xl p-6;
}
.btn-premium {
@apply relative px-6 py-2 rounded-full font-medium transition-all duration-300;
@apply relative px-6 py-2.5 rounded-xl font-bold transition-all duration-300;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
}
.btn-premium:hover {
@apply scale-105;
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
@apply scale-[1.02] -translate-y-0.5 shadow-blue-500/40;
}
.btn-premium:active {
@apply scale-95;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+40 -20
View File
@@ -1,15 +1,41 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
import Link from "next/link";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { BookOpen, LogOut, ShieldAlert } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "OpenCCB Studio | modern Course Management",
description: "Advanced LMS Content Management System inspired by Open edX",
title: "OpenCCB | Studio",
description: "Create and manage high-fidelity educational content.",
};
function AuthHeader() {
"use client";
const { user, logout } = useAuth();
return (
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<ShieldAlert size={16} /> Audit
</Link>
)}
{user && (
<>
<div className="w-8 h-8 rounded-full bg-white/10 border border-white/20 flex items-center justify-center font-bold text-xs">
{user.full_name.charAt(0)}
</div>
<button onClick={logout} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<LogOut size={16} />
</button>
</>
)}
</div>
);
}
export default function RootLayout({
children,
}: Readonly<{
@@ -17,26 +43,20 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen`}>
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
<AuthProvider>
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<h1 className="text-xl font-bold tracking-tight">
Open<span className="gradient-text">CCB</span> Studio
</h1>
<div className="flex gap-4">
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Courses</button>
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Settings</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border border-white/20" />
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<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-transform">
<BookOpen size={20} />
</div>
</div>
</nav>
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
{children}
</main>
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
</Link>
<AuthHeader />
</header>
<main className="flex-1">{children}</main>
</AuthProvider>
</body>
</html>
);
}
}
+54 -104
View File
@@ -1,137 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { cmsApi, Course } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { Plus, BookOpen } from "lucide-react";
export default function Home() {
const router = useRouter();
const { user } = useAuth();
export default function StudioDashboard() {
const [courses, setCourses] = useState<Course[]>([]);
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
useEffect(() => {
setMounted(true);
// Authentication handled by AuthContext or manual check
// We keep this manual check as a legacy safe-guard or rely on AuthContext if we trust it fully.
// Given previous edits, we know AuthContext works.
if (typeof window !== 'undefined' && !localStorage.getItem("studio_user")) {
router.push("/auth/login");
return;
}
// Fetch courses
const loadCourses = async () => {
if (!user) {
setLoading(false);
return;
};
try {
setLoading(true);
const data = await cmsApi.getCourses();
setCourses(data);
setError(null);
} catch (err) {
console.error("Failed to fetch courses:", err);
setError("Could not connect to CMS service. showing offline mode.");
console.error("Failed to load courses", err);
} finally {
setLoading(false);
}
};
loadCourses();
}, [router]);
}, [user]);
const handleCreateCourse = async () => {
const title = prompt("Enter course title:");
if (!title) return;
try {
const newCourse = await cmsApi.createCourse(title);
setCourses([...courses, newCourse]);
} catch {
alert("Failed to create course. Is the backend running?");
const title = prompt("Enter new course title:");
if (title) {
try {
const newCourse = await cmsApi.createCourse(title);
setCourses(prev => [...prev, newCourse]);
} catch (err) {
alert("Failed to create course. Please ensure the backend is running.");
}
}
};
const placeholderCourses: Course[] = [
{
id: "p1",
title: "Introduction to Rust (Demo)",
description: "A demo course to get started",
instructor_id: "demo",
passing_percentage: 70,
certificate_template: undefined,
created_at: new Date().toISOString()
},
];
const displayCourses = courses.length > 0 ? courses : (loading ? [] : placeholderCourses);
return (
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">My Courses</h2>
<p className="text-gray-400">Manage and create your learning content</p>
</div>
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<Link href="/admin/audit">
<button className="px-4 py-2 text-sm font-bold text-gray-400 hover:text-white border border-white/10 hover:border-white/30 rounded-lg transition-colors flex items-center gap-2">
🛡 Audit Logs
</button>
</Link>
)}
<button onClick={handleCreateCourse} className="btn-premium">
+ New Course
</button>
</div>
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">My Courses</h1>
<button onClick={handleCreateCourse} className="btn-premium flex items-center gap-2">
<Plus size={18} />
New Course
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/50 p-4 rounded-lg text-red-400 text-sm">
{error}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 glass-card animate-pulse bg-white/5 border-white/5"></div>
))}
</div>
) : courses.length === 0 ? (
<div className="text-center py-20 glass-card border-dashed border-white/10">
<p className="text-gray-500">You haven't created any courses yet.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
<div className="flex-1">
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
<BookOpen className="text-blue-400" />
</div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-white/5 text-xs text-gray-500">
<span>Last updated: {new Date(course.updated_at).toLocaleDateString()}</span>
<span>ID: {course.id.slice(0, 4)}...</span>
</div>
</div>
</Link>
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<div className="col-span-full py-20 text-center text-gray-500">
<div className="animate-spin inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mb-4"></div>
<p>Loading your courses...</p>
</div>
) : (
<>
{displayCourses.map((course) => (
<Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass p-6 hover:border-blue-500/50 transition-all group cursor-pointer h-full">
<div className="h-32 bg-gradient-to-br from-blue-900/50 to-purple-900/50 rounded-lg mb-4 flex items-center justify-center border border-white/5">
<span className="text-4xl group-hover:scale-110 transition-transform">📚</span>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-blue-400">{course.title}</h3>
<div className="flex justify-between items-center pt-4 border-t border-white/5">
<span suppressHydrationWarning className="text-xs text-gray-500">
Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"}
</span>
<span className="text-xs font-medium text-blue-400">View Details </span>
</div>
</div>
</Link>
))}
<div
onClick={handleCreateCourse}
className="glass p-6 border-dashed border-2 border-white/10 flex flex-col items-center justify-center text-gray-500 hover:border-white/20 transition-all cursor-pointer min-h-[300px]"
>
<span className="text-3xl mb-2"></span>
<span className="text-sm">Add New Course</span>
</div>
</>
)}
</div>
</div>
);
}
}
+95 -219
View File
@@ -1,27 +1,15 @@
export const API_BASE_URL = "http://localhost:3001";
export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
export interface Course {
id: string;
title: string;
description: string;
description?: string;
instructor_id: string;
passing_percentage: number;
certificate_template?: string;
created_at: string;
}
export interface CourseAnalytics {
course_id: string;
total_enrollments: number;
average_score: number;
lessons: LessonAnalytics[];
}
export interface LessonAnalytics {
lesson_id: string;
lesson_title: string;
average_score: number;
submission_count: number;
updated_at: string;
modules?: Module[];
}
export interface Module {
@@ -29,7 +17,6 @@ export interface Module {
course_id: string;
title: string;
position: number;
created_at: string;
lessons: Lesson[];
}
@@ -40,19 +27,9 @@ export interface Block {
content?: string;
url?: string;
media_type?: 'video' | 'audio';
config?: {
maxPlays?: number;
currentPlays?: number;
allowDownload?: boolean;
};
config?: any;
quiz_data?: {
questions: {
id: string;
question: string;
options: string[];
correct: number[];
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
}[];
questions: any[];
};
pairs?: { left: string; right: string }[];
items?: string[];
@@ -65,31 +42,16 @@ export interface Lesson {
module_id: string;
title: string;
content_type: string;
content_url: string | null;
transcription?: {
en?: string;
es?: string;
cues?: { start: number; end: number; text: string }[];
} | null;
metadata?: {
blocks?: Block[];
} | null;
blocks: Block[];
};
is_graded: boolean;
grading_category_id: string | null;
max_attempts: number | null;
allow_retry: boolean;
position: number;
created_at: string;
transcription?: any;
}
export interface GradingCategory {
id: string;
course_id: string;
name: string;
weight: number;
drop_count: number;
created_at: string;
}
export interface User {
id: string;
email: string;
@@ -106,200 +68,114 @@ export interface AuthPayload {
email: string;
password?: string;
full_name?: string;
role?: string;
role?: 'instructor' | 'admin';
organization_name?: string;
}
export interface UploadResponse {
id: string;
filename: string;
url: string;
}
export interface GradingCategory {
id: string;
course_id: string;
name: string;
weight: number;
drop_count: number;
}
export interface AuditLog {
id: string;
user_id: string;
user_full_name?: string;
user_full_name: string | null;
action: string;
entity_type: string;
entity_id: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
changes: any;
created_at: string;
}
export interface CourseAnalytics {
course_id: string;
total_enrollments: number;
average_score: number;
lessons: {
lesson_id: string;
lesson_title: string;
average_score: number;
submission_count: number;
}[];
}
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const apiFetch = (url: string, options: RequestInit = {}) => {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
...options.headers,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
return fetch(`${API_BASE_URL}${url}`, { ...options, headers }).then(res => {
if (!res.ok) {
return res.json().then(err => Promise.reject(err.message || 'An error occurred'));
}
// Handle no-content responses
if (res.status === 204) return;
return res.json();
});
};
export const cmsApi = {
async getCourses(): Promise<Course[]> {
const response = await fetch(`${API_BASE_URL}/courses`);
if (!response.ok) throw new Error('Failed to fetch courses');
return response.json();
},
// Auth
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
login: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(payload) }),
async createCourse(title: string): Promise<Course> {
const response = await fetch(`${API_BASE_URL}/courses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!response.ok) throw new Error('Failed to create course');
return response.json();
},
// Courses
getCourses: (): Promise<Course[]> => apiFetch('/courses'),
createCourse: (title: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }),
getCourse: (id: string): Promise<Course> => apiFetch(`/courses/${id}`),
getCourseWithFullOutline: (id: string): Promise<Course> => apiFetch(`/courses/${id}/outline`),
updateCourse: (id: string, payload: Partial<Course>): Promise<Course> => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
publishCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST' }),
async getCourseWithFullOutline(courseId: string): Promise<Course & { modules: Module[] }> {
const course = await fetch(`${API_BASE_URL}/courses/${courseId}`).then(res => res.json());
const modules = await fetch(`${API_BASE_URL}/modules?course_id=${courseId}`).then(res => res.json());
// Modules & Lessons
createModule: (course_id: string, title: string, position: number): Promise<Module> => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),
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) }),
const modulesWithLessons = await Promise.all(modules.map(async (m: Module) => {
const lessons = await fetch(`${API_BASE_URL}/lessons?module_id=${m.id}`).then(res => res.json());
return { ...m, lessons };
}));
// Grading
getGradingCategories: (courseId: string): Promise<GradingCategory[]> => apiFetch(`/courses/${courseId}/grading`),
createGradingCategory: (course_id: string, name: string, weight: number): Promise<GradingCategory> => apiFetch('/grading', { method: 'POST', body: JSON.stringify({ course_id, name, weight, drop_count: 0 }) }),
deleteGradingCategory: (id: string): Promise<void> => apiFetch(`/grading/${id}`, { method: 'DELETE' }),
return { ...course, modules: modulesWithLessons };
},
// Admin & Analytics
getAuditLogs: (): Promise<AuditLog[]> => apiFetch('/audit-logs'),
getCourseAnalytics: (id: string): Promise<CourseAnalytics> => apiFetch(`/courses/${id}/analytics`),
async createModule(courseId: string, title: string, position: number): Promise<Module> {
const response = await fetch(`${API_BASE_URL}/modules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ course_id: courseId, title, position }),
});
if (!response.ok) throw new Error('Failed to create module');
return response.json();
},
async createLesson(moduleId: string, title: string, contentType: string, position: number): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module_id: moduleId, title, content_type: contentType, position }),
});
if (!response.ok) throw new Error('Failed to create lesson');
return response.json();
},
async transcribeLesson(lessonId: string): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}/transcribe`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to transcribe lesson');
return response.json();
},
async updateLesson(lessonId: string, updates: Partial<Lesson>): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Failed to update lesson');
return response.json();
},
async getLesson(lessonId: string): Promise<Lesson> {
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}`);
if (!response.ok) throw new Error('Failed to fetch lesson');
return response.json();
},
async getGradingCategories(courseId: string): Promise<GradingCategory[]> {
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/grading`);
if (!response.ok) throw new Error('Failed to fetch grading categories');
return response.json();
},
async createGradingCategory(courseId: string, name: string, weight: number): Promise<GradingCategory> {
const response = await fetch(`${API_BASE_URL}/grading`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ course_id: courseId, name, weight, drop_count: 0 }),
});
if (!response.ok) throw new Error('Failed to create grading category');
return response.json();
},
async deleteGradingCategory(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/grading/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete grading category');
},
async uploadAsset(file: File): Promise<{ id: string; filename: string; url: string }> {
// Assets
uploadAsset: (file: File): Promise<UploadResponse> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/assets/upload`, {
const token = getToken();
const headers: Record<string, string> = {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
// Note: We don't set 'Content-Type' for multipart/form-data.
// The browser will set it automatically with the correct boundary.
return fetch(`${API_BASE_URL}/assets/upload`, {
method: 'POST',
headers,
body: formData,
}).then(res => {
if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Upload failed')));
return res.json();
});
if (!response.ok) throw new Error('Upload failed');
return response.json();
},
async publishCourse(courseId: string): Promise<void> {
const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/publish`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to publish course');
},
async register(payload: AuthPayload): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw await response.json();
return response.json();
},
async login(payload: AuthPayload): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw await response.json();
return response.json();
},
async getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/analytics`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to fetch course analytics');
return response.json();
},
async getCourse(id: string): Promise<Course> {
const response = await fetch(`${API_BASE_URL}/courses/${id}`);
if (!response.ok) throw new Error('Failed to fetch course');
return response.json();
},
async updateCourse(id: string, data: Partial<Course>): Promise<Course> {
const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const response = await fetch(`${API_BASE_URL}/courses/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to update course');
return response.json();
},
async getAuditLogs(page: number = 1, limit: number = 50): Promise<AuditLog[]> {
const token = typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const response = await fetch(`${API_BASE_URL}/audit-logs?page=${page}&limit=${limit}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to fetch audit logs');
return response.json();
}
};
};