feat: update CMS service handlers and main application logic.

This commit is contained in:
2025-12-22 13:54:35 -03:00
parent 57b8d7c0a1
commit 32f71852d9
59 changed files with 9125 additions and 59 deletions
+69 -1
View File
@@ -8,9 +8,13 @@
"name": "studio",
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^11.2.10",
"lucide-react": "^0.395.0",
"next": "14.2.21",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^20",
@@ -1534,6 +1538,14 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2514,6 +2526,32 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"dependencies": {
"motion-dom": "^11.18.1",
"motion-utils": "^11.18.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -3531,6 +3569,14 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/lucide-react": {
"version": "0.395.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.395.0.tgz",
"integrity": "sha512-6hzdNH5723A4FLaYZWpK50iyZH8iS2Jq5zuPRRotOFkhu6kxxJiebVdJ72tCR5XkiIeYFOU5NUawFZOac+VeYw==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3592,6 +3638,19 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
"dependencies": {
"motion-utils": "^11.18.1"
}
},
"node_modules/motion-utils": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4984,6 +5043,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+6 -2
View File
@@ -11,7 +11,11 @@
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.2.21"
"next": "14.2.21",
"lucide-react": "^0.395.0",
"framer-motion": "^11.2.10",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"typescript": "^5",
@@ -23,4 +27,4 @@
"eslint": "^8",
"eslint-config-next": "14.2.21"
}
}
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { cmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LogIn, Mail, Lock } from "lucide-react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await cmsApi.login({ email, password });
login(res.user, res.token);
router.push("/");
} catch (err) {
const message = err instanceof Error ? err.message : "Authentication failed. Please verify your credentials.";
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-160px)] flex items-center justify-center p-6">
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-blue-600/10 border border-blue-500/20 flex items-center justify-center mx-auto text-blue-500 mb-6">
<LogIn size={32} />
</div>
<h1 className="text-3xl font-black tracking-tighter text-white">Studio Login</h1>
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Access your educational dashboard</p>
</div>
<div className="glass-card p-8 border-white/5 bg-white/[0.02] rounded-3xl">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Instructor Email</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="instructor@openccb.com"
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>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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>
</div>
<button
disabled={loading}
type="submit"
className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 transition-all active:scale-[0.98]"
>
{loading ? "Verifying..." : "Enter Studio"}
</button>
</form>
</div>
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
New to Studio? <Link href="/auth/register" className="text-blue-500 hover:text-blue-400">Create an account</Link>
</p>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
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";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await cmsApi.register({ email, password, full_name: fullName });
login(res.user, res.token);
router.push("/");
} catch (err) {
const message = err instanceof Error ? err.message : "Registration failed. Please try again.";
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-160px)] flex items-center justify-center p-6">
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-blue-600/10 border border-blue-500/20 flex items-center justify-center mx-auto text-blue-500 mb-6">
<UserPlus size={32} />
</div>
<h1 className="text-3xl font-black tracking-tighter text-white">Instructor Studio</h1>
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Create your professional teaching workspace</p>
</div>
<div className="glass-card p-8 border-white/5 bg-white/[0.02] rounded-3xl">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Full Name</label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
required
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Instructor Name"
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>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Studio Email</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="instructor@openccb.com"
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>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Secure Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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>
</div>
<button
disabled={loading}
type="submit"
className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 transition-all active:scale-[0.98]"
>
{loading ? "Initializing..." : "Create Studio Workspace"}
</button>
</form>
</div>
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
Already registered? <Link href="/auth/login" className="text-blue-500 hover:text-blue-400">Login to Studio</Link>
</p>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { cmsApi, GradingCategory } from "@/lib/api";
import {
Plus,
Trash2,
Percent,
AlertCircle,
CheckCircle2,
ArrowLeft,
TrendingUp,
Settings
} from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export default function GradingPolicyPage() {
const { id } = useParams() as { id: string };
const router = useRouter();
const [categories, setCategories] = useState<GradingCategory[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newWeight, setNewWeight] = useState<number>(0);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadCategories();
}, [id]);
async function loadCategories() {
try {
const data = await cmsApi.getGradingCategories(id);
setCategories(data);
} catch (err) {
console.error("Failed to load categories", err);
} finally {
setLoading(false);
}
}
async function handleAdd() {
if (!newName || newWeight <= 0) return;
setSubmitting(true);
try {
await cmsApi.createGradingCategory(id, newName, newWeight);
setNewName("");
setNewWeight(0);
await loadCategories();
} catch (err) {
console.error("Failed to create category", err);
} finally {
setSubmitting(false);
}
}
async function handleDelete(catId: string) {
if (!confirm("Are you sure you want to delete this category?")) return;
try {
await cmsApi.deleteGradingCategory(catId);
await loadCategories();
} catch (err) {
console.error("Failed to delete category", err);
}
}
const totalWeight = categories.reduce((sum, c) => sum + c.weight, 0);
const isBalanced = totalWeight === 100;
if (loading) return (
<div className="min-h-screen bg-[#0f1115] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
return (
<div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-4">
<button
onClick={() => router.back()}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
Grading Policy
</h1>
<p className="text-gray-400 mt-1">Configure assessment types and weight distribution</p>
</div>
</div>
<div className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl border transition-all duration-500",
isBalanced ? "bg-green-500/10 border-green-500/30 text-green-400 shadow-lg shadow-green-500/5"
: "bg-amber-500/10 border-amber-500/30 text-amber-400"
)}>
{isBalanced ? <CheckCircle2 className="w-5 h-5" /> : <AlertCircle className="w-5 h-5" />}
<span className="font-bold text-lg">{totalWeight}%</span>
<span className="text-sm opacity-70">Total Weight</span>
</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">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-6 flex items-center gap-2">
<Settings className="w-4 h-4" /> Assessment Categories
</h2>
{categories.length === 0 ? (
<div className="bg-white/5 border border-white/10 rounded-2xl p-12 text-center">
<TrendingUp className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 italic">No grading categories defined yet.</p>
</div>
) : (
categories.map((cat) => (
<div
key={cat.id}
className="group bg-white/5 border border-white/10 p-6 rounded-2xl flex items-center justify-between hover:border-blue-500/50 hover:bg-white/[0.07] transition-all duration-300"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center text-blue-400 font-bold group-hover:scale-110 transition-transform">
{cat.weight}%
</div>
<div>
<h3 className="text-lg font-semibold text-gray-100">{cat.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500 bg-white/5 px-2 py-0.5 rounded-full capitalize">
Weight: {cat.weight}%
</span>
</div>
</div>
</div>
<button
onClick={() => handleDelete(cat.id)}
className="p-3 bg-red-500/10 text-red-400 rounded-xl opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white transition-all duration-300"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))
)}
</div>
{/* Add New Category Form */}
<div className="space-y-6">
<div className="bg-gradient-to-br from-gray-800/50 to-gray-900/50 p-8 rounded-3xl border border-white/10 sticky top-8">
<h2 className="text-xl font-bold mb-6 flex items-center gap-2">
<Plus className="w-5 h-5 text-blue-400" /> New Format
</h2>
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Type Name</label>
<input
type="text"
placeholder="e.g. Quizzes, Final Exam"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 mt-1.5 focus:outline-none focus:border-blue-500 transition-all text-gray-100"
/>
</div>
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Weight (%)</label>
<div className="relative mt-1.5">
<input
type="number"
placeholder="20"
value={newWeight || ""}
onChange={(e) => setNewWeight(parseInt(e.target.value) || 0)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500 transition-all text-gray-100 pl-10"
/>
<Percent className="w-4 h-4 text-gray-500 absolute left-4 top-1/2 -translate-y-1/2" />
</div>
</div>
<button
onClick={handleAdd}
disabled={submitting || !newName || newWeight <= 0}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white font-bold py-4 rounded-2xl mt-4 transition-all shadow-lg shadow-blue-500/20 active:scale-95 flex items-center justify-center gap-2"
>
{submitting ? "Adding..." : (
<>
<Plus className="w-5 h-5" />
Add Category
</>
)}
</button>
{!isBalanced && (
<div className="mt-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 flex gap-3">
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-amber-200/80 leading-relaxed">
The total weight of all categories must be exactly 100% for the course to be valid for certification. Currently: <strong>{totalWeight}%</strong>
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { cmsApi, Lesson, Block } from "@/lib/api";
import { cmsApi, Lesson, Block, GradingCategory } from "@/lib/api";
import Link from "next/link";
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
import MediaBlock from "@/components/blocks/MediaBlock";
@@ -19,12 +19,18 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
// Activity State (Blocks)
const [blocks, setBlocks] = useState<Block[]>([]);
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
const [isGraded, setIsGraded] = useState(false);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">("");
useEffect(() => {
const loadData = async () => {
try {
const lessonData: Lesson = await fetch(`http://localhost:3001/lessons/${params.lessonId}`).then(res => res.json());
// Use cmsApi for consistency
const lessonData = await cmsApi.getLesson(params.lessonId);
setLesson(lessonData);
setIsGraded(lessonData.is_graded);
setSelectedCategoryId(lessonData.grading_category_id || "");
if (lessonData.metadata?.blocks) {
setBlocks(lessonData.metadata.blocks);
@@ -37,8 +43,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
}
]);
}
// Load grading categories
const categories = await cmsApi.getGradingCategories(params.id);
setGradingCategories(categories);
} catch {
console.error("Failed to load lesson");
console.error("Failed to load lesson or categories");
} finally {
setLoading(false);
}
@@ -51,7 +61,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setIsSaving(true);
try {
const updated = await cmsApi.updateLesson(lesson.id, {
metadata: { ...lesson.metadata, blocks }
metadata: { ...lesson.metadata, blocks },
is_graded: isGraded,
grading_category_id: selectedCategoryId || null
});
setLesson(updated);
setEditMode(false);
@@ -125,6 +137,56 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div>
</div>
{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 justify-between">
<div>
<h3 className="text-xl font-bold flex items-center gap-2">
<span className="text-blue-500"></span> Grading Configuration
</h3>
<p className="text-sm text-gray-500 mt-1">Determine if this activity contributes to the final grade</p>
</div>
<label className="relative inline-flex items-center cursor-pointer group">
<input
type="checkbox"
checked={isGraded}
onChange={(e) => setIsGraded(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-8 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:start-[4px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-blue-600 group-hover:after:scale-110 transition-all"></div>
<span className="ms-3 text-sm font-bold uppercase tracking-widest text-gray-400 peer-checked:text-blue-400 transition-colors">
{isGraded ? "Graded" : "Not Graded"}
</span>
</label>
</div>
{isGraded && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in zoom-in-95 duration-300">
{gradingCategories.length === 0 ? (
<div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic">
No grading categories defined. <Link href={`/courses/${params.id}/grading`} className="text-blue-400 underline ml-1">Go to Grading Policy</Link>
</div>
) : (
gradingCategories.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategoryId(cat.id)}
className={`p-4 rounded-2xl border transition-all text-left group ${selectedCategoryId === cat.id
? "bg-blue-500/10 border-blue-500 text-blue-400"
: "bg-white/5 border-white/10 text-gray-500 hover:border-white/20"
}`}
>
<div className="text-[10px] font-bold uppercase tracking-widest opacity-60 mb-1">Category</div>
<div className="font-bold truncate">{cat.name}</div>
<div className="text-xs mt-2 font-medium opacity-80">{cat.weight}% Weight</div>
</button>
))
)}
</div>
)}
</div>
)}
<div className="space-y-16">
{blocks.map((block, index) => (
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
+6 -14
View File
@@ -18,20 +18,11 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
const loadData = async () => {
try {
setLoading(true);
// 1. Fetch course details
const courseData = await fetch(`http://localhost:3001/courses/${params.id}`).then(res => res.json());
setCourse(courseData);
// Use cmsApi for consistent, typed data fetching
const data = await cmsApi.getCourseWithFullOutline(params.id);
// 2. Fetch modules
const modulesData: Module[] = await fetch(`http://localhost:3001/modules?course_id=${params.id}`).then(res => res.json());
// 3. Fetch lessons for each module
const fullModules = await Promise.all(modulesData.map(async (mod) => {
const lessonsData: Lesson[] = await fetch(`http://localhost:3001/lessons?module_id=${mod.id}`).then(res => res.json());
return { ...mod, lessons: lessonsData };
}));
setModules(fullModules);
setCourse(data);
setModules(data.modules as FullModule[]);
} catch (err) {
console.error("Failed to load course data:", err);
setError("Failed to load course details. Is the backend running?");
@@ -126,7 +117,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
<div className="glass p-1">
<div className="flex border-b border-white/10">
<button className="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 bg-white/5">Outline</button>
<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>
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</button>
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
</div>
+20 -17
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
const inter = Inter({ subsets: ["latin"] });
@@ -15,24 +16,26 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<h1 className="text-xl font-bold tracking-tight">
Open<span className="gradient-text">CCB</span> Studio
</h1>
<div className="flex gap-4">
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Courses</button>
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Settings</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border border-white/20" />
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen`}>
<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" />
</div>
</div>
</div>
</nav>
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
{children}
</main>
</nav>
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
{children}
</main>
</AuthProvider>
</body>
</html>
);
+58
View File
@@ -0,0 +1,58 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from 'react';
import { User } from '@/lib/api';
interface AuthContextType {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const savedUser = localStorage.getItem('studio_user');
const savedToken = localStorage.getItem('studio_token');
if (savedUser && savedToken) {
setUser(JSON.parse(savedUser));
setToken(savedToken);
}
setLoading(false);
}, []);
const login = (newUser: User, newToken: string) => {
setUser(newUser);
setToken(newToken);
localStorage.setItem('studio_user', JSON.stringify(newUser));
localStorage.setItem('studio_token', newToken);
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('studio_user');
localStorage.removeItem('studio_token');
};
return (
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+76
View File
@@ -57,10 +57,37 @@ export interface Lesson {
metadata?: {
blocks?: Block[];
} | null;
is_graded: boolean;
grading_category_id: string | null;
position: number;
created_at: string;
}
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;
full_name: string;
}
export interface AuthResponse {
user: User;
token: string;
}
export interface AuthPayload {
email: string;
password?: string;
full_name?: string;
}
export const cmsApi = {
async getCourses(): Promise<Course[]> {
const response = await fetch(`${API_BASE_URL}/courses`);
@@ -128,6 +155,35 @@ export const cmsApi = {
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 }> {
const formData = new FormData();
formData.append('file', file);
@@ -146,5 +202,25 @@ export const cmsApi = {
method: 'POST'
});
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();
}
};
File diff suppressed because one or more lines are too long