feat: Implement multi-tenancy with new database migrations, API updates across services, and refactor frontend API calls.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface AuthPayload {
|
||||
email: string;
|
||||
password?: string;
|
||||
full_name?: string;
|
||||
organization_name?: string;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user