feat: implement structured grading system with predefined assessment types
- Add structured grading policy with predefined types (Continuous Assessment, Midterm, Final Test, Exam) - Replace free-text category input with combobox selection in Grading Policy page - Update Lesson Editor to use dropdown selector for grading category assignment - Fix create_grading_category handler to capture organization context - Fix update_course handler to set audit context in database transaction - Implement getImageUrl helper for proper asset path resolution - Add unoptimized prop to organization logo images to bypass Next.js optimization - Add database migrations for organization_id in content tables - Seed default tutorial courses for Admin, Instructor, and Student roles - Fix audit log constraints and content schema issues
This commit is contained in:
@@ -14,12 +14,15 @@ COPY web/studio/ .
|
||||
RUN npm run build
|
||||
|
||||
# Final stage
|
||||
FROM node:18-alpine AS runner
|
||||
FROM node:18-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Install system dependencies for Rust binary
|
||||
RUN apk add --no-cache openssl libgcc libstdc++
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install sharp for Next.js image optimization
|
||||
RUN npm install sharp
|
||||
|
||||
# Copy CMS binary
|
||||
COPY --from=rust-builder /usr/src/app/target/release/cms-service ./
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cmsApi, Organization } from '@/lib/api';
|
||||
import { cmsApi, Organization, getImageUrl } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Image from 'next/image';
|
||||
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X } from 'lucide-react';
|
||||
@@ -144,7 +144,7 @@ export default function OrganizationsPage() {
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center relative">
|
||||
{org.logo_url ? (
|
||||
<Image src={org.logo_url} alt={org.name} fill className="object-contain" />
|
||||
<Image src={getImageUrl(org.logo_url)} alt={org.name} fill className="object-contain" unoptimized />
|
||||
) : (
|
||||
<Building2 className="w-6 h-6" />
|
||||
)}
|
||||
@@ -259,7 +259,7 @@ export default function OrganizationsPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-xl bg-black/40 border border-white/10 flex items-center justify-center overflow-hidden relative">
|
||||
{selectedOrg.logo_url ? (
|
||||
<Image src={selectedOrg.logo_url} alt="Preview" fill className="object-contain" />
|
||||
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Preview" fill className="object-contain" unoptimized />
|
||||
) : (
|
||||
<Building2 className="w-8 h-8 text-gray-600" />
|
||||
)}
|
||||
@@ -323,7 +323,7 @@ export default function OrganizationsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
|
||||
{selectedOrg.logo_url ? (
|
||||
<Image src={selectedOrg.logo_url} alt="Logo" fill className="object-contain" />
|
||||
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Logo" fill className="object-contain" unoptimized />
|
||||
) : <div className="w-3 h-3 bg-white" />}
|
||||
</div>
|
||||
<div className="w-16 h-2 bg-white/30 rounded" />
|
||||
|
||||
@@ -5,9 +5,6 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import { cmsApi, Course, AdvancedAnalytics } from "@/lib/api";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
LineChart,
|
||||
BarChart3,
|
||||
Users,
|
||||
TrendingUp,
|
||||
ArrowLeft,
|
||||
Layers,
|
||||
@@ -35,9 +32,10 @@ export default function AdvancedAnalyticsPage() {
|
||||
]);
|
||||
setCourse(courseData);
|
||||
setAnalytics(advancedData);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load data";
|
||||
console.error("Failed to load advanced analytics", err);
|
||||
setError(err.message || "Failed to load data");
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -170,8 +168,8 @@ export default function AdvancedAnalyticsPage() {
|
||||
<div className="h-4 w-full bg-white/5 rounded-lg overflow-hidden border border-white/5">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ${percentage > 80 ? 'bg-indigo-500' :
|
||||
percentage > 50 ? 'bg-indigo-600/70' :
|
||||
'bg-indigo-700/40'
|
||||
percentage > 50 ? 'bg-indigo-600/70' :
|
||||
'bg-indigo-700/40'
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,9 @@ import { useEffect, useState } from "react";
|
||||
import { cmsApi, Course, Lesson } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Plus,
|
||||
Calendar,
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||
@@ -55,12 +52,12 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
|
||||
|
||||
// Padding for first week
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(<div key={`empty - ${i} `} className="h-32 border border-white/5 bg-white/2"></div>);
|
||||
days.push(<div key={`empty-${i}`} className="h-32 border border-white/5 bg-white/2"></div>);
|
||||
}
|
||||
|
||||
// Days of month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year} -${String(month + 1).padStart(2, '0')} -${String(day).padStart(2, '0')} `;
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
|
||||
|
||||
days.push(
|
||||
@@ -70,11 +67,11 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
|
||||
{dayLessons.map(lesson => (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className={`text - [10px] p - 1 rounded truncate flex items - center gap - 1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' :
|
||||
lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
|
||||
lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' :
|
||||
'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
} `}
|
||||
className={`text-[10px] p-1 rounded truncate flex items-center gap-1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' :
|
||||
lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
|
||||
lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' :
|
||||
'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
{lesson.title}
|
||||
@@ -172,10 +169,10 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
|
||||
<div key={lesson.id} className="glass p-4 border-white/5 hover:border-blue-500/30 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className={`text - [10px] font - black uppercase tracking - widest mb - 1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
|
||||
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
|
||||
'text-green-400'
|
||||
} `}>
|
||||
<div className={`text-[10px] font-black uppercase tracking-widest mb-1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
|
||||
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
|
||||
'text-green-400'
|
||||
}`}>
|
||||
{lesson.important_date_type || 'Activity'}
|
||||
</div>
|
||||
<h5 className="font-bold group-hover:text-blue-400 transition-colors">{lesson.title}</h5>
|
||||
|
||||
@@ -163,14 +163,18 @@ export default function GradingPolicyPage() {
|
||||
|
||||
<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"
|
||||
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Assessment Type</label>
|
||||
<select
|
||||
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"
|
||||
/>
|
||||
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 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">Select a type...</option>
|
||||
<option value="Continuous Assessment" className="bg-gray-900">Continuous Assessment (Min 4)</option>
|
||||
<option value="Midterm" className="bg-gray-900">Midterm</option>
|
||||
<option value="Final Test" className="bg-gray-900">Final Test</option>
|
||||
<option value="Exam" className="bg-gray-900">Exam</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 border-b border-white/5 pb-8">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] text-blue-500 font-bold uppercase tracking-[0.2em]">
|
||||
<Link href={`/ courses / ${params.id} `} className="hover:text-white transition-colors">Outline</Link>
|
||||
<Link href={`/courses/${params.id}`} className="hover:text-white transition-colors">Outline</Link>
|
||||
<span className="text-gray-700">/</span>
|
||||
<span>Activity</span>
|
||||
</div>
|
||||
@@ -265,28 +265,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
|
||||
{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 className="col-span-full space-y-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
|
||||
>
|
||||
<option value="" className="bg-gray-900 border-0">Select Category...</option>
|
||||
{gradingCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
|
||||
{cat.name} ({cat.weight}%)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
|
||||
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-white/5 animate-in fade-in duration-500">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./globals.css";
|
||||
import Link from "next/link";
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import AuthGuard from "@/components/AuthGuard";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -23,16 +24,18 @@ export default function RootLayout({
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
|
||||
<AuthProvider>
|
||||
<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>
|
||||
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
|
||||
</Link>
|
||||
<AuthHeader />
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<AuthGuard>
|
||||
<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>
|
||||
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
|
||||
</Link>
|
||||
<AuthHeader />
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function WebhooksPage() {
|
||||
try {
|
||||
const data = await cmsApi.getWebhooks();
|
||||
setWebhooks(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,8 +58,8 @@ export default function WebhooksPage() {
|
||||
setNewWebhook({ url: '', events: ['course.published'], secret: '' });
|
||||
setIsAdding(false);
|
||||
fetchWebhooks();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,8 +68,8 @@ export default function WebhooksPage() {
|
||||
try {
|
||||
await cmsApi.deleteWebhook(id);
|
||||
fetchWebhooks();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,8 +164,8 @@ export default function WebhooksPage() {
|
||||
id={`event-${event.id}`}
|
||||
onClick={() => toggleEvent(event.id)}
|
||||
className={`p-4 rounded-2xl border transition-all cursor-pointer ${newWebhook.events.includes(event.id)
|
||||
? 'bg-blue-500/20 border-blue-500 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]'
|
||||
: 'bg-black/20 border-white/10 text-gray-400 hover:bg-white/5'
|
||||
? 'bg-blue-500/20 border-blue-500 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]'
|
||||
: 'bg-black/20 border-white/10 text-gray-400 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
const isAuthPage = pathname?.startsWith("/auth");
|
||||
if (!user && !isAuthPage) {
|
||||
router.push("/auth/login");
|
||||
} else if (user && isAuthPage) {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
}, [user, loading, pathname, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAuthPage = pathname?.startsWith("/auth");
|
||||
if (!user && !isAuthPage) {
|
||||
return null; // Prevents flashing protected content
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
// Map /uploads to /assets if backend stores relative paths
|
||||
// The main.rs serves "uploads" dir at "/assets" route
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${API_BASE_URL}${finalPath}`;
|
||||
};
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user