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:
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration: Add organization_id to content tables
|
||||||
|
-- Scope: modules, lessons, grading_categories
|
||||||
|
|
||||||
|
-- 1. Add organization_id to modules
|
||||||
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill modules based on course
|
||||||
|
UPDATE modules m SET organization_id = c.organization_id
|
||||||
|
FROM courses c WHERE m.course_id = c.id AND m.organization_id IS NULL;
|
||||||
|
|
||||||
|
-- Make organization_id NOT NULL after backfill
|
||||||
|
ALTER TABLE modules ALTER COLUMN organization_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Add organization_id to lessons
|
||||||
|
-- Note: lessons are children of modules, which now have organization_id
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill lessons based on module
|
||||||
|
UPDATE lessons l SET organization_id = m.organization_id
|
||||||
|
FROM modules m WHERE l.module_id = m.id AND l.organization_id IS NULL;
|
||||||
|
|
||||||
|
-- Make organization_id NOT NULL after backfill
|
||||||
|
ALTER TABLE lessons ALTER COLUMN organization_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- 3. Add organization_id to grading_categories
|
||||||
|
ALTER TABLE grading_categories ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill grading_categories based on course
|
||||||
|
UPDATE grading_categories g SET organization_id = c.organization_id
|
||||||
|
FROM courses c WHERE g.course_id = c.id AND g.organization_id IS NULL;
|
||||||
|
|
||||||
|
-- Make organization_id NOT NULL after backfill
|
||||||
|
ALTER TABLE grading_categories ALTER COLUMN organization_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- 4. Re-create triggers to ensure they pick up the new columns for audit logs
|
||||||
|
-- (Triggers were already defined in 20260111000002_advanced_auditing.sql)
|
||||||
|
-- No changes needed to the triggers themselves as they use NEW.organization_id dynamically.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Migration: Fix Audit Logs Constraint and Trigger
|
||||||
|
-- 1. Make changes column nullable (legacy compatibility)
|
||||||
|
ALTER TABLE audit_logs ALTER COLUMN changes DROP NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Update fn_trigger_audit_log to populate changes if needed or just handle NULLs
|
||||||
|
CREATE OR REPLACE FUNCTION fn_trigger_audit_log()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_org_id UUID;
|
||||||
|
v_ip INET;
|
||||||
|
v_user_agent TEXT;
|
||||||
|
v_event_type VARCHAR(50);
|
||||||
|
v_old_data JSONB := NULL;
|
||||||
|
v_new_data JSONB := NULL;
|
||||||
|
v_action VARCHAR(50);
|
||||||
|
BEGIN
|
||||||
|
-- Try to get context from session variables
|
||||||
|
BEGIN
|
||||||
|
v_user_id := current_setting('app.current_user_id', true)::UUID;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_user_id := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
v_org_id := current_setting('app.current_org_id', true)::UUID;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_org_id := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
v_ip := current_setting('app.client_ip', true)::INET;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_ip := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
v_user_agent := current_setting('app.user_agent', true);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_user_agent := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
v_event_type := current_setting('app.event_type', true);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_event_type := 'USER_EVENT';
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Handle different operations
|
||||||
|
IF (TG_OP = 'DELETE') THEN
|
||||||
|
v_old_data := to_jsonb(OLD);
|
||||||
|
v_action := 'DELETE';
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
v_old_data := to_jsonb(OLD);
|
||||||
|
v_new_data := to_jsonb(NEW);
|
||||||
|
v_action := 'UPDATE';
|
||||||
|
ELSIF (TG_OP = 'INSERT') THEN
|
||||||
|
v_new_data := to_jsonb(NEW);
|
||||||
|
v_action := 'INSERT';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Insert into audit_logs
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
organization_id,
|
||||||
|
user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
event_type,
|
||||||
|
old_data,
|
||||||
|
new_data,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
changes -- Populate legacy column with new_data or old_data for compatibility
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
COALESCE(v_org_id, (CASE WHEN TG_OP = 'DELETE' THEN OLD.organization_id ELSE NEW.organization_id END)),
|
||||||
|
v_user_id,
|
||||||
|
v_action,
|
||||||
|
TG_TABLE_NAME,
|
||||||
|
CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END,
|
||||||
|
COALESCE(v_event_type, 'USER_EVENT'),
|
||||||
|
v_old_data,
|
||||||
|
v_new_data,
|
||||||
|
v_ip,
|
||||||
|
v_user_agent,
|
||||||
|
COALESCE(v_new_data, v_old_data)
|
||||||
|
);
|
||||||
|
|
||||||
|
IF (TG_OP = 'DELETE') THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Migration: Final Content Schema Alignment
|
||||||
|
-- Scope: lessons, modules
|
||||||
|
|
||||||
|
-- 1. Add missing columns to lessons
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS content_blocks JSONB DEFAULT '[]';
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
|
|
||||||
|
-- 2. Add missing columns to modules
|
||||||
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
|
|
||||||
|
-- 3. Create Update Trigger for updated_at (if not already exists)
|
||||||
|
-- Function update_updated_at_column was defined in initial_schema.sql
|
||||||
|
|
||||||
|
-- Attach triggers to lessons and modules
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_lessons_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_lessons_updated_at
|
||||||
|
BEFORE UPDATE ON lessons
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_modules_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_modules_updated_at
|
||||||
|
BEFORE UPDATE ON modules
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
-- Migration: Seed Tutorials
|
||||||
|
-- Description: Creates default courses for Admin, Instructor, and Student roles using system functions.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_admin_id UUID;
|
||||||
|
v_org_id UUID;
|
||||||
|
v_course_id UUID;
|
||||||
|
v_module_id UUID;
|
||||||
|
v_lesson_id UUID;
|
||||||
|
v_blocks JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Get an Author (First Admin)
|
||||||
|
SELECT id, organization_id INTO v_admin_id, v_org_id FROM users WHERE role = 'admin' LIMIT 1;
|
||||||
|
|
||||||
|
IF v_admin_id IS NULL THEN
|
||||||
|
RAISE NOTICE 'No admin user found. Skipping tutorial seeding.';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- SIMULATE SESSION for Audit Logs
|
||||||
|
-- This ensures fn_trigger_audit_log finds a user_id
|
||||||
|
PERFORM set_config('app.current_user_id', v_admin_id::TEXT, true);
|
||||||
|
PERFORM set_config('app.org_id', v_org_id::TEXT, true);
|
||||||
|
PERFORM set_config('app.event_type', 'SYSTEM_SEED', true);
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- COURSE 1: SYSTEM ADMINISTRATION
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- Check if exists to avoid duplicates (simple check by title)
|
||||||
|
PERFORM id FROM courses WHERE title = 'System Administration 101' AND organization_id = v_org_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'System Administration 101', 'self_paced');
|
||||||
|
|
||||||
|
-- Module 1: Organization & Users
|
||||||
|
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Organization & User Management', 1);
|
||||||
|
|
||||||
|
-- Lesson 1.1: Managing Users
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "Learn how to invite, manage, and remove users from your organization."},
|
||||||
|
{"id": "2", "type": "media", "media_type": "video", "title": "User Management Demo", "url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Managing Users & Roles', 'video', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
|
||||||
|
-- Lesson 1.2: Audit Logs
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "OpenCCB automatically logs all critical actions. Access the Audit Log from the settings menu to view a timeline of all system changes."}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Using the Audit Log', 'article', NULL, 2, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
|
||||||
|
-- Module 2: System Settings
|
||||||
|
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'System Integrations', 2);
|
||||||
|
|
||||||
|
-- Lesson 2.1: Webhooks
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "Configure webhooks to receive real-time updates for student enrollments and completions."}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Configuring Webhooks', 'article', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- COURSE 2: INSTRUCTOR ESSENTIALS
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
PERFORM id FROM courses WHERE title = 'Instructor Essentials' AND organization_id = v_org_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'Instructor Essentials', 'self_paced');
|
||||||
|
|
||||||
|
-- Module 1: The Lesson Builder
|
||||||
|
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Mastering the Lesson Builder', 1);
|
||||||
|
|
||||||
|
-- Lesson 1.1: Content Blocks
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "The Lesson Builder uses a block-based approach. You can mix and match text, video, and quizzes."},
|
||||||
|
{"id": "2", "type": "fill-in-the-blanks", "title": "Knowledge Check", "content": "The [[Lesson]] Builder allows you to create [[engaging]] content."}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Content Blocks Deep Dive', 'activity', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
|
||||||
|
-- Module 2: Grading
|
||||||
|
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Grading & Analytics', 2);
|
||||||
|
|
||||||
|
-- Lesson 2.1: Grading Policy
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "Set up grading categories like Exams and Assignments to weight student performance."}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Setting up Grading Policies', 'article', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- COURSE 3: STUDENT EXPERIENCE
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
PERFORM id FROM courses WHERE title = 'Student Onboarding' AND organization_id = v_org_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'Student Onboarding', 'self_paced');
|
||||||
|
|
||||||
|
-- Module 1: Getting Started
|
||||||
|
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Welcome to OpenCCB', 1);
|
||||||
|
|
||||||
|
-- Lesson 1.1: Navigation
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "Navigate through your courses using the sidebar. Track your progress on the dashboard."}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Platform Navigation', 'video', 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
|
||||||
|
-- Lesson 1.2: Gamification
|
||||||
|
v_blocks := '[
|
||||||
|
{"id": "1", "type": "description", "content": "Earn XP and badges as you complete lessons. Check the leaderboard to see how you stack up!"}
|
||||||
|
]';
|
||||||
|
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Understanding XP & Badges', 'article', NULL, 2, NULL, jsonb_build_object('blocks', v_blocks));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
@@ -262,6 +262,22 @@ pub async fn update_course(
|
|||||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
.or(existing.end_date);
|
.or(existing.end_date);
|
||||||
|
|
||||||
|
// BEGIN TRANSACTION
|
||||||
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Set auditing context
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)",
|
||||||
|
)
|
||||||
|
.bind(claims.sub.to_string())
|
||||||
|
.bind(org_ctx.id.to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
let course = sqlx::query_as::<_, Course>(
|
||||||
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||||
)
|
)
|
||||||
@@ -274,7 +290,7 @@ pub async fn update_course(
|
|||||||
.bind(start_date)
|
.bind(start_date)
|
||||||
.bind(end_date)
|
.bind(end_date)
|
||||||
.bind(certificate_template)
|
.bind(certificate_template)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
(
|
(
|
||||||
@@ -283,6 +299,10 @@ pub async fn update_course(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(course))
|
Ok(Json(course))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,13 +964,15 @@ pub async fn get_grading_categories(
|
|||||||
|
|
||||||
pub async fn create_grading_category(
|
pub async fn create_grading_category(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
Org(org_ctx): Org,
|
||||||
Json(payload): Json<GradingPayload>,
|
Json(payload): Json<GradingPayload>,
|
||||||
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
||||||
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
"INSERT INTO grading_categories (course_id, name, weight, drop_count)
|
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING *",
|
RETURNING *",
|
||||||
)
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.bind(payload.course_id)
|
.bind(payload.course_id)
|
||||||
.bind(payload.name)
|
.bind(payload.name)
|
||||||
.bind(payload.weight)
|
.bind(payload.weight)
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ COPY web/experience/ .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Final stage
|
# Final stage
|
||||||
FROM node:18-alpine AS runner
|
FROM node:18-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
# Install system dependencies for Rust binary
|
# 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 LMS binary
|
# Copy LMS binary
|
||||||
COPY --from=rust-builder /usr/src/app/target/release/lms-service ./
|
COPY --from=rust-builder /usr/src/app/target/release/lms-service ./
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { lmsApi } from "@/lib/api";
|
import { lmsApi } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react";
|
import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react";
|
||||||
|
|
||||||
export default function ExperienceLoginPage() {
|
export default function ExperienceLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -31,8 +33,7 @@ export default function ExperienceLoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("experience_token", response.token);
|
login(response.user, response.token);
|
||||||
localStorage.setItem("experience_user", JSON.stringify(response.user));
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} else {
|
} else {
|
||||||
const response = await lmsApi.register({
|
const response = await lmsApi.register({
|
||||||
@@ -42,8 +43,7 @@ export default function ExperienceLoginPage() {
|
|||||||
organization_name: organizationName,
|
organization_name: organizationName,
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem("experience_token", response.token);
|
login(response.user, response.token);
|
||||||
localStorage.setItem("experience_user", JSON.stringify(response.user));
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "./globals.css";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { BrandingProvider } from "@/context/BrandingContext";
|
import { BrandingProvider } from "@/context/BrandingContext";
|
||||||
|
import AuthGuard from "@/components/AuthGuard";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export default function RootLayout({
|
|||||||
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen flex flex-col`}>
|
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen flex flex-col`}>
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<AuthGuard>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
@@ -33,6 +35,7 @@ export default function RootLayout({
|
|||||||
Powered by OpenCCB © 2023. Advanced Agentic Coding.
|
Powered by OpenCCB © 2023. Advanced Agentic Coding.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
</AuthGuard>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrandingProvider>
|
</BrandingProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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-[#050505] flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-500/20 border-t-indigo-500 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthPage = pathname?.startsWith("/auth");
|
||||||
|
if (!user && !isAuthPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -14,12 +14,15 @@ COPY web/studio/ .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Final stage
|
# Final stage
|
||||||
FROM node:18-alpine AS runner
|
FROM node:18-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
# Install system dependencies for Rust binary
|
# 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 CMS binary
|
||||||
COPY --from=rust-builder /usr/src/app/target/release/cms-service ./
|
COPY --from=rust-builder /usr/src/app/target/release/cms-service ./
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { cmsApi, Organization } from '@/lib/api';
|
import { cmsApi, Organization, getImageUrl } from '@/lib/api';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X } from 'lucide-react';
|
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="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">
|
<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 ? (
|
{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" />
|
<Building2 className="w-6 h-6" />
|
||||||
)}
|
)}
|
||||||
@@ -259,7 +259,7 @@ export default function OrganizationsPage() {
|
|||||||
<div className="flex items-center gap-4">
|
<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">
|
<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 ? (
|
{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" />
|
<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="flex items-center gap-2">
|
||||||
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
|
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
|
||||||
{selectedOrg.logo_url ? (
|
{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 className="w-3 h-3 bg-white" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-16 h-2 bg-white/30 rounded" />
|
<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 { cmsApi, Course, AdvancedAnalytics } from "@/lib/api";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
|
||||||
BarChart3,
|
|
||||||
Users,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -35,9 +32,10 @@ export default function AdvancedAnalyticsPage() {
|
|||||||
]);
|
]);
|
||||||
setCourse(courseData);
|
setCourse(courseData);
|
||||||
setAnalytics(advancedData);
|
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);
|
console.error("Failed to load advanced analytics", err);
|
||||||
setError(err.message || "Failed to load data");
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { cmsApi, Course, Lesson } from "@/lib/api";
|
import { cmsApi, Course, Lesson } from "@/lib/api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Plus,
|
|
||||||
Calendar,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
|
||||||
AlertCircle
|
AlertCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||||
@@ -55,12 +52,12 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
|
|||||||
|
|
||||||
// Padding for first week
|
// Padding for first week
|
||||||
for (let i = 0; i < firstDay; i++) {
|
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
|
// Days of month
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
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));
|
const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
|
||||||
|
|
||||||
days.push(
|
days.push(
|
||||||
@@ -70,11 +67,11 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
|
|||||||
{dayLessons.map(lesson => (
|
{dayLessons.map(lesson => (
|
||||||
<div
|
<div
|
||||||
key={lesson.id}
|
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' :
|
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 === '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' :
|
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'
|
'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>
|
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||||
{lesson.title}
|
{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 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 className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className={`text - [10px] font - black uppercase tracking - widest mb - 1 ${lesson.important_date_type === 'exam' ? 'text-red-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' :
|
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
|
||||||
'text-green-400'
|
'text-green-400'
|
||||||
} `}>
|
}`}>
|
||||||
{lesson.important_date_type || 'Activity'}
|
{lesson.important_date_type || 'Activity'}
|
||||||
</div>
|
</div>
|
||||||
<h5 className="font-bold group-hover:text-blue-400 transition-colors">{lesson.title}</h5>
|
<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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Type Name</label>
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Assessment Type</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Quizzes, Final Exam"
|
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
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>
|
||||||
|
|
||||||
<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="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="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-[10px] text-blue-500 font-bold uppercase tracking-[0.2em]">
|
<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 className="text-gray-700">/</span>
|
||||||
<span>Activity</span>
|
<span>Activity</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,28 +265,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
|
|
||||||
{isGraded && (
|
{isGraded && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in zoom-in-95 duration-300">
|
<div className="col-span-full space-y-2">
|
||||||
{gradingCategories.length === 0 ? (
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
|
||||||
<div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic">
|
<select
|
||||||
No grading categories defined. <Link href={`/ courses / ${params.id} /grading`} className="text-blue-400 underline ml-1">Go to Grading Policy</Link >
|
value={selectedCategoryId}
|
||||||
</div >
|
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"
|
||||||
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>
|
<option value="" className="bg-gray-900 border-0">Select Category...</option>
|
||||||
<div className="font-bold truncate">{cat.name}</div>
|
{gradingCategories.map((cat) => (
|
||||||
<div className="text-xs mt-2 font-medium opacity-80">{cat.weight}% Weight</div>
|
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
|
||||||
</button>
|
{cat.name} ({cat.weight}%)
|
||||||
))
|
</option>
|
||||||
)}
|
))}
|
||||||
</div >
|
</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="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">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "./globals.css";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
|
import AuthGuard from "@/components/AuthGuard";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export default function RootLayout({
|
|||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
|
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<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">
|
<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">
|
<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">
|
<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">
|
||||||
@@ -33,6 +35,7 @@ export default function RootLayout({
|
|||||||
<AuthHeader />
|
<AuthHeader />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
</AuthGuard>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export default function WebhooksPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await cmsApi.getWebhooks();
|
const data = await cmsApi.getWebhooks();
|
||||||
setWebhooks(data);
|
setWebhooks(data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,8 @@ export default function WebhooksPage() {
|
|||||||
setNewWebhook({ url: '', events: ['course.published'], secret: '' });
|
setNewWebhook({ url: '', events: ['course.published'], secret: '' });
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
fetchWebhooks();
|
fetchWebhooks();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ export default function WebhooksPage() {
|
|||||||
try {
|
try {
|
||||||
await cmsApi.deleteWebhook(id);
|
await cmsApi.deleteWebhook(id);
|
||||||
fetchWebhooks();
|
fetchWebhooks();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 {
|
export interface Course {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user