feat: update CMS service handlers and main application logic.
This commit is contained in:
Generated
+4
@@ -216,9 +216,11 @@ name = "cms-service"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"common",
|
"common",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"jsonwebtoken",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1061,9 +1063,11 @@ name = "lms-service"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"common",
|
"common",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"jsonwebtoken",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
||||||
|
|
||||||
|
experience:
|
||||||
|
build:
|
||||||
|
context: ./web/experience
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3003:3003"
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_LMS_API_URL: http://localhost:3002
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ tracing-subscriber.workspace = true
|
|||||||
dotenvy.workspace = true
|
dotenvy.workspace = true
|
||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
bcrypt.workspace = true
|
||||||
|
jsonwebtoken.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM rustlang/rust:nightly as builder
|
FROM rustlang/rust:nightly AS builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Create users table for Instructors
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add instructor_id foreign key constraint to courses if not already applied
|
||||||
|
-- (In the initial schema it was just a UUID, let's make it formal if possible)
|
||||||
|
-- ALTER TABLE courses ADD CONSTRAINT fk_instructor FOREIGN KEY (instructor_id) REFERENCES users(id);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Add grading categories table
|
||||||
|
CREATE TABLE grading_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
weight INTEGER NOT NULL CHECK (weight >= 0 AND weight <= 100),
|
||||||
|
drop_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update lessons with grading fields
|
||||||
|
ALTER TABLE lessons ADD COLUMN grading_category_id UUID REFERENCES grading_categories(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE lessons ADD COLUMN is_graded BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -3,11 +3,13 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule};
|
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse};
|
||||||
|
use common::auth::create_jwt;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
|
|
||||||
pub async fn publish_course(
|
pub async fn publish_course(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
@@ -29,7 +31,16 @@ pub async fn publish_course(
|
|||||||
|
|
||||||
let mut pub_modules = Vec::new();
|
let mut pub_modules = Vec::new();
|
||||||
|
|
||||||
// 3. Fetch Lessons for each Module
|
// 3. Fetch Grading Categories
|
||||||
|
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
|
"SELECT * FROM grading_categories WHERE course_id = $1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// 4. Fetch Lessons for each Module
|
||||||
for module in modules {
|
for module in modules {
|
||||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||||
.bind(module.id)
|
.bind(module.id)
|
||||||
@@ -45,6 +56,7 @@ pub async fn publish_course(
|
|||||||
|
|
||||||
let payload = PublishedCourse {
|
let payload = PublishedCourse {
|
||||||
course,
|
course,
|
||||||
|
grading_categories,
|
||||||
modules: pub_modules,
|
modules: pub_modules,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +92,14 @@ pub struct LessonQuery {
|
|||||||
pub module_id: Option<Uuid>,
|
pub module_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GradingPayload {
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub weight: i32,
|
||||||
|
pub drop_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_course(
|
pub async fn create_course(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
@@ -149,8 +169,11 @@ pub async fn create_lesson(
|
|||||||
let transcription = payload.get("transcription").cloned();
|
let transcription = payload.get("transcription").cloned();
|
||||||
let metadata = payload.get("metadata").cloned();
|
let metadata = payload.get("metadata").cloned();
|
||||||
|
|
||||||
|
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok());
|
||||||
|
|
||||||
let lesson = sqlx::query_as::<_, Lesson>(
|
let lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *"
|
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(module_id)
|
.bind(module_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
@@ -159,6 +182,8 @@ pub async fn create_lesson(
|
|||||||
.bind(position)
|
.bind(position)
|
||||||
.bind(transcription)
|
.bind(transcription)
|
||||||
.bind(metadata)
|
.bind(metadata)
|
||||||
|
.bind(is_graded)
|
||||||
|
.bind(grading_category_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -226,29 +251,95 @@ pub async fn update_lesson(
|
|||||||
let content_type = payload.get("content_type").and_then(|t| t.as_str());
|
let content_type = payload.get("content_type").and_then(|t| t.as_str());
|
||||||
let content_url = payload.get("content_url").and_then(|t| t.as_str());
|
let content_url = payload.get("content_url").and_then(|t| t.as_str());
|
||||||
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||||
|
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool());
|
||||||
|
let grading_category_id = payload.get("grading_category_id")
|
||||||
|
.and_then(|v| {
|
||||||
|
if v.is_null() {
|
||||||
|
Some(None)
|
||||||
|
} else {
|
||||||
|
v.as_str().and_then(|s| Uuid::parse_str(s).ok()).map(Some)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"UPDATE lessons
|
"UPDATE lessons
|
||||||
SET title = COALESCE($1, title),
|
SET title = COALESCE($1, title),
|
||||||
content_type = COALESCE($2, content_type),
|
content_type = COALESCE($2, content_type),
|
||||||
content_url = COALESCE($3, content_url),
|
content_url = COALESCE($3, content_url),
|
||||||
position = COALESCE($4, position)
|
position = COALESCE($4, position),
|
||||||
WHERE id = $5 RETURNING *"
|
is_graded = COALESCE($5, is_graded),
|
||||||
|
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END
|
||||||
|
WHERE id = $8 RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(content_type)
|
.bind(content_type)
|
||||||
.bind(content_url)
|
.bind(content_url)
|
||||||
.bind(position)
|
.bind(position)
|
||||||
|
.bind(is_graded)
|
||||||
|
.bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" })
|
||||||
|
.bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Update lesson failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
log_action(&pool, Uuid::new_v4(), "UPDATE", "Lesson", id, json!(payload)).await;
|
log_action(&pool, Uuid::new_v4(), "UPDATE", "Lesson", id, json!(payload)).await;
|
||||||
|
|
||||||
Ok(Json(updated_lesson))
|
Ok(Json(updated_lesson))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grading Policies
|
||||||
|
pub async fn get_grading_categories(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<common::models::GradingCategory>>, (StatusCode, String)> {
|
||||||
|
let categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
|
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at"
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_grading_category(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<GradingPayload>,
|
||||||
|
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
||||||
|
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
|
"INSERT INTO grading_categories (course_id, name, weight, drop_count)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(payload.course_id)
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.weight)
|
||||||
|
.bind(payload.drop_count)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(category))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_grading_category(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
sqlx::query("DELETE FROM grading_categories WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
async fn log_action(
|
async fn log_action(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -392,3 +483,69 @@ pub async fn upload_asset(
|
|||||||
url,
|
url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AuthPayload {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<AuthPayload>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||||
|
|
||||||
|
let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("User").to_string());
|
||||||
|
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
"INSERT INTO users (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(&payload.email)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(full_name)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
||||||
|
|
||||||
|
let token = create_jwt(user.id, "instructor")
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
user: UserResponse {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
full_name: user.full_name,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<AuthPayload>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||||
|
.bind(&payload.email)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||||
|
|
||||||
|
if !verify(payload.password, &user.password_hash).map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Verification failed".into()))? {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = create_jwt(user.id, "instructor")
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
user: UserResponse {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
full_name: user.full_name,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post, delete},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
@@ -41,6 +41,11 @@ async fn main() {
|
|||||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
||||||
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||||
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
||||||
|
.route("/auth/register", post(handlers::register))
|
||||||
|
.route("/auth/login", post(handlers::login))
|
||||||
|
.route("/grading", post(handlers::create_grading_category))
|
||||||
|
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
||||||
|
.route("/courses/{id}/grading", get(handlers::get_grading_categories))
|
||||||
.route("/assets/upload", post(handlers::upload_asset))
|
.route("/assets/upload", post(handlers::upload_asset))
|
||||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
dotenvy.workspace = true
|
dotenvy.workspace = true
|
||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
|
bcrypt.workspace = true
|
||||||
|
jsonwebtoken.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM rustlang/rust:nightly as builder
|
FROM rustlang/rust:nightly AS builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Create users table for Students
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: Enrollments already exist and use user_id.
|
||||||
|
-- We should ideally link them now.
|
||||||
|
ALTER TABLE enrollments ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Add grading categories table
|
||||||
|
CREATE TABLE grading_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
weight INTEGER NOT NULL CHECK (weight >= 0 AND weight <= 100),
|
||||||
|
drop_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update lessons with grading fields
|
||||||
|
ALTER TABLE lessons ADD COLUMN grading_category_id UUID REFERENCES grading_categories(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE lessons ADD COLUMN is_graded BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Create table to track individual student scores
|
||||||
|
CREATE TABLE user_grades (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||||
|
score FLOAT4 NOT NULL, -- 0.0 to 1.0 (percentage)
|
||||||
|
metadata JSONB, -- store specific answers or feedback
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, lesson_id)
|
||||||
|
);
|
||||||
@@ -3,9 +3,12 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use common::models::{Course, Enrollment, Module, Lesson};
|
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse};
|
||||||
|
use common::auth::create_jwt;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
|
|
||||||
pub async fn enroll_user(
|
pub async fn enroll_user(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
@@ -13,7 +16,8 @@ pub async fn enroll_user(
|
|||||||
) -> Result<Json<Enrollment>, StatusCode> {
|
) -> Result<Json<Enrollment>, StatusCode> {
|
||||||
let course_id_str = payload.get("course_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
let course_id_str = payload.get("course_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
let user_id = Uuid::new_v4(); // Placeholder for actual auth
|
let user_id_str = payload.get("user_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let user_id = Uuid::parse_str(user_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
let enrollment = sqlx::query_as::<_, Enrollment>(
|
let enrollment = sqlx::query_as::<_, Enrollment>(
|
||||||
"INSERT INTO enrollments (user_id, course_id) VALUES ($1, $2) RETURNING *"
|
"INSERT INTO enrollments (user_id, course_id) VALUES ($1, $2) RETURNING *"
|
||||||
@@ -22,11 +26,89 @@ pub async fn enroll_user(
|
|||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Enrollment failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(enrollment))
|
Ok(Json(enrollment))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AuthPayload {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GradeSubmissionPayload {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub lesson_id: Uuid,
|
||||||
|
pub score: f32,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<AuthPayload>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||||
|
|
||||||
|
let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("Student").to_string());
|
||||||
|
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
"INSERT INTO users (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(&payload.email)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(full_name)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
||||||
|
|
||||||
|
let token = create_jwt(user.id, "student")
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
user: UserResponse {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
full_name: user.full_name,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<AuthPayload>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||||
|
.bind(&payload.email)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?;
|
||||||
|
|
||||||
|
if !verify(payload.password, &user.password_hash).map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Verification failed".into()))? {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = create_jwt(user.id, "student")
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
user: UserResponse {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
full_name: user.full_name,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_course_catalog(
|
pub async fn get_course_catalog(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||||
@@ -70,15 +152,37 @@ pub async fn ingest_course(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 2. Clear existing modules and lessons for this course to ensure perfect sync
|
// 2. Clear existing grading categories, modules and lessons (cascading handles lessons/categories)
|
||||||
// Cascading delete on courses(id) handles lessons too
|
sqlx::query("DELETE FROM grading_categories WHERE course_id = $1")
|
||||||
|
.bind(payload.course.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
sqlx::query("DELETE FROM modules WHERE course_id = $1")
|
sqlx::query("DELETE FROM modules WHERE course_id = $1")
|
||||||
.bind(payload.course.id)
|
.bind(payload.course.id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 3. Insert Modules and Lessons
|
// 3. Insert Grading Categories
|
||||||
|
for cat in payload.grading_categories {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO grading_categories (id, course_id, name, weight, drop_count, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||||
|
)
|
||||||
|
.bind(cat.id)
|
||||||
|
.bind(payload.course.id)
|
||||||
|
.bind(&cat.name)
|
||||||
|
.bind(cat.weight)
|
||||||
|
.bind(cat.drop_count)
|
||||||
|
.bind(cat.created_at)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Insert Modules and Lessons
|
||||||
for pub_module in payload.modules {
|
for pub_module in payload.modules {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO modules (id, course_id, title, position, created_at)
|
"INSERT INTO modules (id, course_id, title, position, created_at)
|
||||||
@@ -95,8 +199,8 @@ pub async fn ingest_course(
|
|||||||
|
|
||||||
for lesson in pub_module.lessons {
|
for lesson in pub_module.lessons {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at)
|
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"
|
||||||
)
|
)
|
||||||
.bind(lesson.id)
|
.bind(lesson.id)
|
||||||
.bind(pub_module.module.id)
|
.bind(pub_module.module.id)
|
||||||
@@ -107,9 +211,14 @@ pub async fn ingest_course(
|
|||||||
.bind(&lesson.metadata)
|
.bind(&lesson.metadata)
|
||||||
.bind(lesson.position)
|
.bind(lesson.position)
|
||||||
.bind(lesson.created_at)
|
.bind(lesson.created_at)
|
||||||
|
.bind(lesson.is_graded)
|
||||||
|
.bind(lesson.grading_category_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to insert lesson: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +245,17 @@ pub async fn get_course_outline(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let mut pub_modules = Vec::new();
|
// 3. Fetch Grading Categories
|
||||||
|
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
|
"SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 3. Fetch Lessons
|
// 4. Fetch Lessons
|
||||||
|
let mut pub_modules = Vec::new();
|
||||||
for module in modules {
|
for module in modules {
|
||||||
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||||
.bind(module.id)
|
.bind(module.id)
|
||||||
@@ -154,6 +271,7 @@ pub async fn get_course_outline(
|
|||||||
|
|
||||||
Ok(Json(common::models::PublishedCourse {
|
Ok(Json(common::models::PublishedCourse {
|
||||||
course,
|
course,
|
||||||
|
grading_categories,
|
||||||
modules: pub_modules,
|
modules: pub_modules,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -170,3 +288,57 @@ pub async fn get_lesson_content(
|
|||||||
|
|
||||||
Ok(Json(lesson))
|
Ok(Json(lesson))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_enrollments(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
|
||||||
|
let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(enrollments))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn submit_lesson_score(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<GradeSubmissionPayload>,
|
||||||
|
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
||||||
|
let grade = sqlx::query_as::<_, common::models::UserGrade>(
|
||||||
|
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, lesson_id) DO UPDATE SET
|
||||||
|
score = EXCLUDED.score,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
created_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(payload.user_id)
|
||||||
|
.bind(payload.course_id)
|
||||||
|
.bind(payload.lesson_id)
|
||||||
|
.bind(payload.score)
|
||||||
|
.bind(payload.metadata)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(grade))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_course_grades(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
|
||||||
|
let grades = sqlx::query_as::<_, common::models::UserGrade>(
|
||||||
|
"SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(course_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(grades))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
@@ -27,12 +28,23 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to run migrations");
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/catalog", get(handlers::get_course_catalog))
|
.route("/catalog", get(handlers::get_course_catalog))
|
||||||
.route("/enroll", post(handlers::enroll_user))
|
.route("/enroll", post(handlers::enroll_user))
|
||||||
.route("/ingest", post(handlers::ingest_course))
|
.route("/ingest", post(handlers::ingest_course))
|
||||||
|
.route("/auth/register", post(handlers::register))
|
||||||
|
.route("/auth/login", post(handlers::login))
|
||||||
|
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
||||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||||
|
.route("/grades", post(handlers::submit_lesson_score))
|
||||||
|
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
|
||||||
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||||
|
|||||||
@@ -32,10 +32,33 @@ pub struct Lesson {
|
|||||||
pub content_url: Option<String>,
|
pub content_url: Option<String>,
|
||||||
pub transcription: Option<serde_json::Value>,
|
pub transcription: Option<serde_json::Value>,
|
||||||
pub metadata: Option<serde_json::Value>,
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
pub grading_category_id: Option<Uuid>,
|
||||||
|
pub is_graded: bool,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct GradingCategory {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub weight: i32, // 0-100
|
||||||
|
pub drop_count: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct UserGrade {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub lesson_id: Uuid,
|
||||||
|
pub score: f32, // 0.0 to 1.0
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct AuditLog {
|
pub struct AuditLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -64,9 +87,32 @@ pub struct Asset {
|
|||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub full_name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub user: UserResponse,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct PublishedCourse {
|
pub struct PublishedCourse {
|
||||||
pub course: Course,
|
pub course: Course,
|
||||||
|
pub grading_categories: Vec<GradingCategory>,
|
||||||
pub modules: Vec<PublishedModule>,
|
pub modules: Vec<PublishedModule>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +154,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
})),
|
})),
|
||||||
|
grading_category_id: None,
|
||||||
|
is_graded: false,
|
||||||
position: 1,
|
position: 1,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
@@ -134,6 +182,7 @@ mod tests {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
},
|
},
|
||||||
|
grading_categories: vec![],
|
||||||
modules: vec![pub_module],
|
modules: vec![pub_module],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV PORT 3003
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
EXPOSE 3003
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+5760
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "experience",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.21",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"lucide-react": "^0.395.0",
|
||||||
|
"framer-motion": "^11.2.10",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { lmsApi } 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 lmsApi.login({ email, password });
|
||||||
|
login(res.user, res.token);
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Login failed. Please check your credentials.";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-6 bg-[#050505]">
|
||||||
|
<div className="w-full max-w-md space-y-8 animate-in fade-in zoom-in duration-500">
|
||||||
|
<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">Student Login</h1>
|
||||||
|
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Welcome back to your learning journey</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card p-8 border-white/5 bg-white/[0.02]">
|
||||||
|
<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">Email Address</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="name@company.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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={loading}
|
||||||
|
type="submit"
|
||||||
|
className="btn-premium w-full !py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Authenticating..." : "Continue to Dashboard"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
|
||||||
|
Don't have an account? <Link href="/auth/register" className="text-blue-500 hover:text-blue-400">Sign up here</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { lmsApi } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserPlus, Mail, Lock, User } from "lucide-react";
|
||||||
|
|
||||||
|
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 lmsApi.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-64px)] flex items-center justify-center p-6 bg-[#050505]">
|
||||||
|
<div className="w-full max-w-md space-y-8 animate-in fade-in zoom-in duration-500">
|
||||||
|
<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">Create Account</h1>
|
||||||
|
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Join the next generation of learners</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card p-8 border-white/5 bg-white/[0.02]">
|
||||||
|
<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="John Doe"
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Email Address</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="name@company.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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={loading}
|
||||||
|
type="submit"
|
||||||
|
className="btn-premium w-full !py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Creating..." : "Start Learning"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
|
||||||
|
Already have an account? <Link href="/auth/login" className="text-blue-500 hover:text-blue-400">Login here</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { lmsApi, Lesson, Course, Module } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, ChevronRight, Menu, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
import DescriptionPlayer from "@/components/blocks/DescriptionPlayer";
|
||||||
|
import MediaPlayer from "@/components/blocks/MediaPlayer";
|
||||||
|
import QuizPlayer from "@/components/blocks/QuizPlayer";
|
||||||
|
import FillInTheBlanksPlayer from "@/components/blocks/FillInTheBlanksPlayer";
|
||||||
|
import MatchingPlayer from "@/components/blocks/MatchingPlayer";
|
||||||
|
import OrderingPlayer from "@/components/blocks/OrderingPlayer";
|
||||||
|
import ShortAnswerPlayer from "@/components/blocks/ShortAnswerPlayer";
|
||||||
|
|
||||||
|
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||||
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
|
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAll = async () => {
|
||||||
|
try {
|
||||||
|
const [lessonData, courseData] = await Promise.all([
|
||||||
|
lmsApi.getLesson(params.lessonId),
|
||||||
|
lmsApi.getCourseOutline(params.id)
|
||||||
|
]);
|
||||||
|
setLesson(lessonData);
|
||||||
|
setCourse(courseData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAll();
|
||||||
|
}, [params.id, params.lessonId]);
|
||||||
|
|
||||||
|
if (loading) return <div className="p-20 text-center animate-pulse text-gray-500 font-bold uppercase tracking-widest">Loading Experience...</div>;
|
||||||
|
if (!lesson || !course) return <div className="p-20 text-center text-red-400">Content not found.</div>;
|
||||||
|
|
||||||
|
const allLessons = course.modules.flatMap(m => m.lessons);
|
||||||
|
const currentIndex = allLessons.findIndex(l => l.id === params.lessonId);
|
||||||
|
const prevLesson = allLessons[currentIndex - 1];
|
||||||
|
const nextLesson = allLessons[currentIndex + 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
||||||
|
{/* Navigation Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`glass border-r border-white/5 transition-all duration-500 bg-black/40 flex flex-col ${sidebarOpen ? 'w-80' : 'w-0 overflow-hidden border-none'}`}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-white/5">
|
||||||
|
<h2 className="text-xs font-black uppercase tracking-widest text-blue-500 mb-1">Course Content</h2>
|
||||||
|
<p className="text-sm font-bold text-white truncate">{course.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
|
||||||
|
{course.modules.map((module) => (
|
||||||
|
<div key={module.id} className="space-y-2">
|
||||||
|
<h4 className="px-3 text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">{module.title}</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{module.lessons.map((l) => (
|
||||||
|
<Link
|
||||||
|
key={l.id}
|
||||||
|
href={`/courses/${params.id}/lessons/${l.id}`}
|
||||||
|
className={`sidebar-link ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 truncate">{l.title}</div>
|
||||||
|
{/* Placeholder for progress checkmark */}
|
||||||
|
<div className="w-4 h-4 rounded-full border border-white/10" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 flex flex-col relative">
|
||||||
|
<div className="absolute top-4 left-4 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
className="p-3 rounded-xl glass border-white/10 text-gray-400 hover:text-white transition-all bg-black/40"
|
||||||
|
>
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-12">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-20 pb-40">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-400">
|
||||||
|
<span>{lesson.content_type === 'activity' ? 'Interactive Activity' : 'Video Lesson'}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-black tracking-tighter text-white">{lesson.title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render Blocks */}
|
||||||
|
{(lesson.metadata?.blocks || []).length > 0 ? (
|
||||||
|
<div className="space-y-24">
|
||||||
|
{lesson.metadata?.blocks?.map((block) => (
|
||||||
|
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
|
||||||
|
{block.type === 'description' && (
|
||||||
|
<DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />
|
||||||
|
)}
|
||||||
|
{block.type === 'media' && (
|
||||||
|
<MediaPlayer
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
url={block.url || ""}
|
||||||
|
media_type={block.media_type || 'video'}
|
||||||
|
config={block.config}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{block.type === 'quiz' && (
|
||||||
|
<QuizPlayer id={block.id} title={block.title} quizData={block.quiz_data || { questions: [] }} />
|
||||||
|
)}
|
||||||
|
{block.type === 'fill-in-the-blanks' && (
|
||||||
|
<FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} />
|
||||||
|
)}
|
||||||
|
{block.type === 'matching' && (
|
||||||
|
<MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} />
|
||||||
|
)}
|
||||||
|
{block.type === 'ordering' && (
|
||||||
|
<OrderingPlayer id={block.id} title={block.title} items={block.items || []} />
|
||||||
|
)}
|
||||||
|
{block.type === 'short-answer' && (
|
||||||
|
<ShortAnswerPlayer
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
prompt={block.prompt || ""}
|
||||||
|
correctAnswers={block.correctAnswers || []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-20 text-center glass-card border-dashed border-white/10">
|
||||||
|
<p className="text-gray-500 font-bold uppercase tracking-widest">This lesson currently has no content.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lesson.is_graded && (
|
||||||
|
<div className="pt-20 border-t border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||||
|
<div className="bg-blue-600/10 border border-blue-500/20 rounded-[2rem] p-12 text-center relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2 group-hover:bg-blue-500/20 transition-all duration-700"></div>
|
||||||
|
<h3 className="text-2xl font-black mb-4">Complete & Submit</h3>
|
||||||
|
<p className="text-gray-400 max-w-sm mx-auto mb-8 text-sm">
|
||||||
|
This activity is graded. Make sure you've completed all blocks before submitting your work for evaluation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const userData = localStorage.getItem("user");
|
||||||
|
if (userData) {
|
||||||
|
const u = JSON.parse(userData);
|
||||||
|
try {
|
||||||
|
// In a real scenario, we'd calculate the actual score from blocks
|
||||||
|
await lmsApi.submitScore(u.id, params.id, params.lessonId, 100);
|
||||||
|
alert("Score submitted successfully!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Submission failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-black italic">
|
||||||
|
SUBMIT FOR GRADING <CheckCircle2 className="w-5 h-5 group-hover/btn:scale-110 transition-transform" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Controls */}
|
||||||
|
<footer className="h-20 glass border-t border-white/5 px-6 flex items-center justify-between bg-black/60 backdrop-blur-3xl">
|
||||||
|
{prevLesson ? (
|
||||||
|
<Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl glass border-white/10 flex items-center justify-center group-hover:bg-white/5 transition-all text-gray-400 group-hover:text-white">
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Previous</p>
|
||||||
|
<p className="text-xs font-bold text-gray-300 group-hover:text-white truncate max-w-[120px]">{prevLesson.title}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : <div />}
|
||||||
|
|
||||||
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{allLessons.map((l, i) => (
|
||||||
|
<div key={l.id} className={`w-8 h-1 rounded-full ${i <= currentIndex ? 'bg-blue-500' : 'bg-white/10'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-4">
|
||||||
|
{currentIndex + 1} OF {allLessons.length} COMPLETED
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextLesson ? (
|
||||||
|
<Link href={`/courses/${params.id}/lessons/${nextLesson.id}`} className="btn-premium !py-3 !px-6 text-xs !shadow-none">
|
||||||
|
Next Lesson <ChevronRight size={18} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/" className="btn-premium !bg-green-600 !py-3 !px-6 text-xs !shadow-none">
|
||||||
|
Finish Course <CheckCircle2 size={18} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { lmsApi, Course, Module } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BookOpen, ChevronRight, PlayCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
||||||
|
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lmsApi.getCourseOutline(params.id)
|
||||||
|
.then(setCourseData)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-20 animate-pulse">
|
||||||
|
<div className="h-12 w-2/3 bg-white/5 rounded-xl mb-6"></div>
|
||||||
|
<div className="h-6 w-1/3 bg-white/5 rounded-xl mb-12"></div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-32 glass-card bg-white/5 border-white/5"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!courseData) return <div className="text-center py-20 text-gray-500">Course not found.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-20">
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="flex items-center gap-2 mb-6 text-blue-500 font-bold text-xs uppercase tracking-widest">
|
||||||
|
<Link href="/" className="hover:text-white transition-colors">Catalog</Link>
|
||||||
|
<ChevronRight size={14} className="text-gray-600" />
|
||||||
|
<span>Course Details</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-black tracking-tighter mb-6">{courseData.title}</h1>
|
||||||
|
<p className="text-gray-400 text-lg leading-relaxed max-w-2xl mb-10">
|
||||||
|
{courseData.description || "Master the core principles and advanced techniques in this structured curriculum. Each module is designed to provide actionable insights and hands-on experience."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-1">Modules</span>
|
||||||
|
<span className="text-xl font-bold text-white">{courseData.modules.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-white/10" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-1">Total Lessons</span>
|
||||||
|
<span className="text-xl font-bold text-white">
|
||||||
|
{courseData.modules.reduce((acc, m) => acc + m.lessons.length, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={`/courses/${params.id}/progress`}>
|
||||||
|
<button className="px-8 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
|
||||||
|
📊 View Progress
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-12">
|
||||||
|
{courseData.modules.map((module, idx) => (
|
||||||
|
<div key={module.id} className="relative">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-10 h-10 rounded-xl glass border-blue-500/20 bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<span className="text-blue-400 font-black text-xs">{idx + 1}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">{module.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 pl-14">
|
||||||
|
{module.lessons.map((lesson) => (
|
||||||
|
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
||||||
|
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||||||
|
{lesson.content_type === 'video' ? (
|
||||||
|
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
|
{lesson.content_type === 'activity' ? 'Interactive Activity' : 'Video Lesson'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<ChevronRight size={18} className="text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { lmsApi, GradingCategory, UserGrade, Course, Module } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
Award,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronRight,
|
||||||
|
Target,
|
||||||
|
BookOpen,
|
||||||
|
ArrowLeft,
|
||||||
|
TrendingUp
|
||||||
|
} 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 StudentProgressPage() {
|
||||||
|
const { id } = useParams() as { id: string };
|
||||||
|
const router = useRouter();
|
||||||
|
const [course, setCourse] = useState<(Course & { modules: Module[], grading_categories?: GradingCategory[] }) | null>(null);
|
||||||
|
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [user, setUser] = useState<any>(null);
|
||||||
|
|
||||||
|
const loadData = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const courseData = await lmsApi.getCourseOutline(id);
|
||||||
|
setCourse(courseData as any);
|
||||||
|
|
||||||
|
const userData = localStorage.getItem("user");
|
||||||
|
if (userData) {
|
||||||
|
const u = JSON.parse(userData);
|
||||||
|
const grades = await lmsApi.getUserGrades(u.id, id);
|
||||||
|
setUserGrades(grades);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load progress data", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userData = localStorage.getItem("user");
|
||||||
|
if (userData) setUser(JSON.parse(userData));
|
||||||
|
loadData();
|
||||||
|
}, [id, loadData]);
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="min-h-screen bg-slate-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>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!course) return <div className="p-20 text-center text-white">Course not found.</div>;
|
||||||
|
|
||||||
|
const gradingCategories = course.grading_categories || [];
|
||||||
|
|
||||||
|
// Calculate progress
|
||||||
|
const categoryStats = gradingCategories.map(cat => {
|
||||||
|
const catLessons = course.modules.flatMap(m => m.lessons).filter(l => l.grading_category_id === cat.id);
|
||||||
|
const catGrades = userGrades.filter(g => catLessons.some(l => l.id === g.lesson_id));
|
||||||
|
|
||||||
|
const count = catLessons.length;
|
||||||
|
const completedCount = catGrades.length;
|
||||||
|
const avgScore = completedCount > 0
|
||||||
|
? catGrades.reduce((sum, g) => sum + g.score, 0) / completedCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const weightedScore = (avgScore * cat.weight) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
count,
|
||||||
|
completedCount,
|
||||||
|
avgScore,
|
||||||
|
weightedScore
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalWeightedGrade = categoryStats.reduce((sum, s) => sum + s.weightedScore, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-white pb-20">
|
||||||
|
{/* Nav */}
|
||||||
|
<div className="sticky top-0 z-50 bg-slate-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8">
|
||||||
|
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => router.back()} className="p-2 hover:bg-white/5 rounded-full transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold">{course.title}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto px-8 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||||
|
{/* Left: Overall Progress */}
|
||||||
|
<div className="lg:col-span-1 space-y-8">
|
||||||
|
<div className="bg-gradient-to-br from-blue-600/20 to-indigo-600/20 rounded-[2.5rem] p-12 border border-blue-500/20 text-center relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2 group-hover:bg-blue-500/20 transition-all duration-700"></div>
|
||||||
|
<h2 className="text-gray-400 font-bold uppercase tracking-widest text-xs mb-8">Overall Standing</h2>
|
||||||
|
|
||||||
|
<div className="relative inline-flex items-center justify-center mb-8">
|
||||||
|
<svg className="w-48 h-48 -rotate-90">
|
||||||
|
<circle
|
||||||
|
className="text-white/5"
|
||||||
|
strokeWidth="8"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
r="88"
|
||||||
|
cx="96"
|
||||||
|
cy="96"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="text-blue-500 transition-all duration-1000 ease-out"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeDasharray={88 * 2 * Math.PI}
|
||||||
|
strokeDashoffset={88 * 2 * Math.PI * (1 - totalWeightedGrade / 100)}
|
||||||
|
strokeLinecap="round"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
r="88"
|
||||||
|
cx="96"
|
||||||
|
cy="96"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-6xl font-black">{Math.round(totalWeightedGrade)}%</span>
|
||||||
|
<span className="text-xs text-blue-400 font-bold uppercase tracking-widest mt-1">Current Grade</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-green-400 bg-green-400/10 py-2 px-4 rounded-full w-fit mx-auto border border-green-400/20">
|
||||||
|
<Award className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-bold">Passing Standing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-3xl p-8 border border-white/10 space-y-6">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-[0.2em] text-gray-500 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4" /> Assessment Summary
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{categoryStats.map(stat => (
|
||||||
|
<div key={stat.id} className="flex items-center justify-between group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.5)]"></div>
|
||||||
|
<span className="text-gray-400 group-hover:text-white transition-colors">{stat.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">{Math.round(stat.weightedScore)} / {stat.weight}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Detailed Breakdown */}
|
||||||
|
<div className="lg:col-span-2 space-y-12">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-black mb-8 flex items-center gap-3">
|
||||||
|
<Target className="w-8 h-8 text-blue-500" />
|
||||||
|
Detailed Breakdown
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{categoryStats.map(cat => (
|
||||||
|
<div key={cat.id} className="bg-white/5 border border-white/10 rounded-3xl p-8 hover:bg-white/[0.07] transition-all group">
|
||||||
|
<div className="flex items-start justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold">{cat.name}</h3>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">
|
||||||
|
Weight: {cat.weight}% of total course grade
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-4xl font-black text-blue-500">{Math.round(cat.avgScore)}%</div>
|
||||||
|
<div className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mt-1">Average Score</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="relative h-2 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full transition-all duration-1000 ease-out"
|
||||||
|
style={{ width: `${cat.avgScore}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-gray-400">{cat.completedCount} / {cat.count} assessments completed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cat.completedCount === cat.count && (
|
||||||
|
<div className="flex items-center gap-2 text-green-400 font-bold">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
Category Finished
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-indigo-600/10 border border-indigo-500/20 rounded-[2rem] p-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-indigo-500/20 flex items-center justify-center text-indigo-400">
|
||||||
|
<TrendingUp className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold">Certification Track</h3>
|
||||||
|
<p className="text-sm text-indigo-300/60 mt-0.5">Maintain 60% or higher to earn your verified certificate.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-6 h-6 text-indigo-500/50" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 255, 255, 255;
|
||||||
|
--background-start-rgb: 10, 10, 20;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
|
||||||
|
--accent-primary: #3b82f6;
|
||||||
|
--accent-secondary: #8b5cf6;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-blur: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: radial-gradient(circle at top left, #1a1a2e, #000000);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
@apply glass rounded-2xl p-6 transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
@apply border-white/20 bg-white/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium {
|
||||||
|
@apply relative px-8 py-3 rounded-xl font-bold transition-all duration-300 flex items-center justify-center gap-2;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium:hover {
|
||||||
|
@apply scale-[1.02] -translate-y-0.5 shadow-blue-500/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium:active {
|
||||||
|
@apply scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
@apply flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-active {
|
||||||
|
@apply bg-blue-500/10 text-blue-400 border border-blue-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-inactive {
|
||||||
|
@apply text-gray-400 hover:text-white hover:bg-white/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OpenCCB | Learning Experience",
|
||||||
|
description: "Consume high-fidelity educational content with OpenCCB",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen flex flex-col`}>
|
||||||
|
<AuthProvider>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
|
||||||
|
<Link href="/" className="flex items-center gap-2 group">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
<span className="font-black text-xl tracking-tighter text-white">LEARN<span className="text-blue-500">EXPERIENCE</span></span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
<Link href="/" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">Catalog</Link>
|
||||||
|
<Link href="#" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">My Learning</Link>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-12 px-6 border-t border-white/5 text-center bg-black/20">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600">
|
||||||
|
Powered by OpenCCB © 2023. Advanced Agentic Coding.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { lmsApi, Course } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Rocket, CheckCircle2, ArrowRight, Star } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CatalogPage() {
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [enrollments, setEnrollments] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const coursesData = await lmsApi.getCatalog();
|
||||||
|
setCourses(coursesData);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const enrollmentData = await lmsApi.getEnrollments(user.id);
|
||||||
|
setEnrollments(enrollmentData.map(e => e.course_id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleEnroll = async (courseId: string) => {
|
||||||
|
if (!user) {
|
||||||
|
router.push("/auth/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await lmsApi.enroll(courseId, user.id);
|
||||||
|
setEnrollments(prev => [...prev, courseId]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Enrollment failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-20">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-80 glass-card animate-pulse bg-white/5 border-white/5 rounded-3xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-20">
|
||||||
|
<div className="mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
|
||||||
|
<Star size={14} className="fill-blue-500" />
|
||||||
|
<span>Premier Curriculum</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-6xl font-black tracking-tighter leading-none">
|
||||||
|
Explore <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Courses</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 font-medium max-w-xl text-lg">
|
||||||
|
Master the skills of the future with our high-fidelity educational content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!user && (
|
||||||
|
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8">
|
||||||
|
Get Started Free
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courses.length === 0 ? (
|
||||||
|
<div className="py-20 text-center glass-card border-dashed border-white/10 rounded-3xl bg-white/[0.01]">
|
||||||
|
<p className="text-gray-500 font-bold uppercase tracking-widest">No courses published yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{courses.map((course) => {
|
||||||
|
const isEnrolled = enrollments.includes(course.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={course.id} className="glass-card group relative overflow-hidden h-full flex flex-col p-8 border-white/5 bg-white/[0.02] hover:bg-white/[0.04] transition-all duration-500 rounded-3xl">
|
||||||
|
<div className="mb-8 flex items-start justify-between">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-2xl shadow-blue-500/20 group-hover:scale-110 transition-transform duration-500">
|
||||||
|
<Rocket size={24} className="text-white fill-white/10" />
|
||||||
|
</div>
|
||||||
|
{isEnrolled && (
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-full bg-green-500/10 text-green-400 border border-green-500/20">
|
||||||
|
Enrolled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-black text-white mb-4 leading-tight group-hover:text-blue-400 transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-gray-500 text-sm font-medium line-clamp-3 mb-10 leading-relaxed">
|
||||||
|
{course.description || "In-depth curriculum covering foundational principles to advanced mastery, crafted by industry veterans."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 border-t border-white/5 flex items-center justify-between mt-auto">
|
||||||
|
{isEnrolled ? (
|
||||||
|
<Link href={`/courses/${course.id}`} className="btn-premium w-full !bg-blue-600/10 !text-blue-400 border border-blue-500/20 hover:!bg-blue-600/20 !shadow-none gap-2">
|
||||||
|
Continue Learning <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEnroll(course.id)}
|
||||||
|
className="btn-premium w-full group-hover:scale-[1.02] transition-transform flex items-center justify-center gap-2 group/btn"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="text-white/50 group-hover/btn:text-white transition-colors" />
|
||||||
|
Enroll for Free
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface DescriptionPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DescriptionPlayer({ id, title, content }: DescriptionPlayerProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
|
{title || "Overview"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-invert prose-lg max-w-none">
|
||||||
|
{/* We can use a markdown parser here later if desired, for now simple multiline text */}
|
||||||
|
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap font-medium">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
interface FillInTheBlanksPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheBlanksPlayerProps) {
|
||||||
|
const [userAnswers, setUserAnswers] = useState<string[]>([]);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// Parse content to find blanks
|
||||||
|
const parsed = useMemo(() => {
|
||||||
|
const parts: { type: 'text' | 'blank'; value?: string; index?: number; answer?: string }[] = [];
|
||||||
|
const answers: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
const regex = /\[\[(.*?)\]\]/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
parts.push({ type: 'text', value: content.substring(lastIndex, match.index) });
|
||||||
|
const answer = match[1];
|
||||||
|
parts.push({ type: 'blank', index: answers.length, answer });
|
||||||
|
answers.push(answer);
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
parts.push({ type: 'text', value: content.substring(lastIndex) });
|
||||||
|
|
||||||
|
return { parts, answers };
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSubmitted(false);
|
||||||
|
setUserAnswers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCorrect = (index: number) => {
|
||||||
|
return userAnswers[index]?.trim().toLowerCase() === parsed.answers[index]?.trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
|
{title || "Fill in the Blanks"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
|
||||||
|
<div className="text-lg leading-loose text-gray-100">
|
||||||
|
{parsed.parts.map((part, i) => (
|
||||||
|
part.type === 'text' ? (
|
||||||
|
<span key={i}>{part.value}</span>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
type="text"
|
||||||
|
value={userAnswers[part.index!] || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newAnswers = [...userAnswers];
|
||||||
|
newAnswers[part.index!] = e.target.value;
|
||||||
|
setUserAnswers(newAnswers);
|
||||||
|
}}
|
||||||
|
disabled={submitted}
|
||||||
|
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
|
||||||
|
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
|
||||||
|
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
|
||||||
|
placeholder="..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!submitted && parsed.answers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmitted(true)}
|
||||||
|
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
Validate Answers
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitted && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
interface MatchingPair {
|
||||||
|
left: string;
|
||||||
|
right: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchingPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
pairs: MatchingPair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps) {
|
||||||
|
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
|
||||||
|
const [matches, setMatches] = useState<Record<number, number>>({});
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const shuffledRight = useMemo(() => {
|
||||||
|
return (pairs || [])
|
||||||
|
.map((p, i) => ({ value: p.right, originalIdx: i }))
|
||||||
|
.sort(() => Math.random() - 0.5);
|
||||||
|
}, [pairs]);
|
||||||
|
|
||||||
|
const handleMatch = (leftIdx: number, rightIdx: number) => {
|
||||||
|
if (submitted) return;
|
||||||
|
setMatches(prev => ({ ...prev, [leftIdx]: rightIdx }));
|
||||||
|
setSelectedLeft(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSubmitted(false);
|
||||||
|
setMatches({});
|
||||||
|
setSelectedLeft(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
|
{title || "Concept Matching"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 p-8 glass border-white/5 rounded-3xl relative">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Term</label>
|
||||||
|
{(pairs || []).map((pair, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => !submitted && setSelectedLeft(i)}
|
||||||
|
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
|
||||||
|
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
|
||||||
|
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pair.left}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Definition</label>
|
||||||
|
{shuffledRight.map((item, i) => {
|
||||||
|
const matchedLeftIdx = Object.keys(matches).find(k => matches[parseInt(k)] === item.originalIdx);
|
||||||
|
const isCorrect = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) === item.originalIdx;
|
||||||
|
const isWrong = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) !== item.originalIdx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
disabled={selectedLeft === null || submitted}
|
||||||
|
onClick={() => handleMatch(selectedLeft!, item.originalIdx)}
|
||||||
|
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft !== null && matchedLeftIdx === undefined ? "hover:border-blue-500/50 hover:bg-white/5" : ""
|
||||||
|
} ${isCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
|
||||||
|
isWrong ? "border-red-500 bg-red-500/20 text-red-100" :
|
||||||
|
matchedLeftIdx !== undefined ? "border-blue-500/30 bg-blue-500/5 text-blue-400" :
|
||||||
|
"border-white/5 bg-white/5 text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{item.value}</span>
|
||||||
|
{isCorrect && <span>✅</span>}
|
||||||
|
{isWrong && <span>❌</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 pt-8 border-t border-white/5">
|
||||||
|
{!submitted && Object.keys(matches).length === (pairs || []).length && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmitted(true)}
|
||||||
|
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
Validate Matching
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{submitted && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Play, Lock, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface MediaPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
url: string;
|
||||||
|
media_type: 'video' | 'audio';
|
||||||
|
config?: {
|
||||||
|
maxPlays?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaPlayer({ id, title, url, media_type, config }: MediaPlayerProps) {
|
||||||
|
const [playCount, setPlayCount] = useState(0);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
|
const maxPlays = config?.maxPlays || 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (maxPlays > 0 && playCount >= maxPlays) {
|
||||||
|
setLocked(true);
|
||||||
|
}
|
||||||
|
}, [playCount, maxPlays]);
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
if (locked) return;
|
||||||
|
setPlayCount(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" id={id}>
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">{title || "Multimedia Content"}</h3>
|
||||||
|
<div className="glass-card aspect-video flex flex-col items-center justify-center gap-6 border-red-500/20 bg-red-500/5">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center text-red-500">
|
||||||
|
<Lock size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xl font-bold text-white mb-2">Content Locked</p>
|
||||||
|
<p className="text-sm text-gray-500 max-w-xs uppercase tracking-widest font-black">
|
||||||
|
You have reached the limit of {maxPlays} plays for this content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format URL (handles YouTube embeds)
|
||||||
|
const getEmbedUrl = (rawUrl: string) => {
|
||||||
|
if (rawUrl.includes("youtube.com/watch?v=")) {
|
||||||
|
return rawUrl.replace("watch?v=", "embed/");
|
||||||
|
}
|
||||||
|
if (rawUrl.includes("youtu.be/")) {
|
||||||
|
return rawUrl.replace("youtu.be/", "youtube.com/embed/");
|
||||||
|
}
|
||||||
|
return rawUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" id={id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">{title || "Multimedia Content"}</h3>
|
||||||
|
{maxPlays > 0 && (
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full bg-white/5 border border-white/5 text-gray-500">
|
||||||
|
{playCount} / {maxPlays} PLAYS
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card !p-2 overflow-hidden aspect-video relative group">
|
||||||
|
<iframe
|
||||||
|
src={getEmbedUrl(url)}
|
||||||
|
className="w-full h-full rounded-xl"
|
||||||
|
allowFullScreen
|
||||||
|
onLoad={() => {
|
||||||
|
// In a real app, we'd detect play events from the player API
|
||||||
|
// For this demo, we'll increment when the iframe loads or specific interaction
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Simulated play tracker overlay (invisible but catches first click) */}
|
||||||
|
{playCount === 0 && (
|
||||||
|
<div
|
||||||
|
onClick={handlePlay}
|
||||||
|
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-black/20 transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 rounded-full bg-blue-500 flex items-center justify-center shadow-2xl shadow-blue-500/40 group-hover:scale-110 transition-transform">
|
||||||
|
<Play size={32} className="text-white fill-white ml-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{maxPlays > 0 && playCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-orange-500/70 p-4 rounded-xl bg-orange-500/5 border border-orange-500/10">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>Watch carefully. Content will lock after {maxPlays} plays.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
interface OrderingPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps) {
|
||||||
|
const [userOrder, setUserOrder] = useState<number[]>([]);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const shuffledItems = useMemo(() => {
|
||||||
|
return (items || [])
|
||||||
|
.map((item, i) => ({ value: item, originalIdx: i }))
|
||||||
|
.sort(() => Math.random() - 0.5);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handlePick = (originalIdx: number) => {
|
||||||
|
if (submitted) return;
|
||||||
|
if (userOrder.includes(originalIdx)) {
|
||||||
|
setUserOrder(userOrder.filter(i => i !== originalIdx));
|
||||||
|
} else {
|
||||||
|
setUserOrder([...userOrder, originalIdx]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSubmitted(false);
|
||||||
|
setUserOrder([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
|
{title || "Sequence Ordering"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 p-8 glass border-white/5 rounded-3xl">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Available Items</label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{shuffledItems.map((item, i) => {
|
||||||
|
const isPicked = userOrder.includes(item.originalIdx);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
disabled={isPicked || submitted}
|
||||||
|
onClick={() => handlePick(item.originalIdx)}
|
||||||
|
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
|
||||||
|
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Your Sequence</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{userOrder.length === 0 && <p className="text-xs text-gray-600 italic py-4">Click items to build the sequence...</p>}
|
||||||
|
{userOrder.map((idx, i) => {
|
||||||
|
const isItemCorrect = submitted && idx === i;
|
||||||
|
const isItemWrong = submitted && idx !== i;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => !submitted && handlePick(idx)}
|
||||||
|
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
|
||||||
|
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
|
||||||
|
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="opacity-50 text-xs">{i + 1}.</span>
|
||||||
|
<span className="flex-1">{(items || [])[idx]}</span>
|
||||||
|
{!submitted && <span className="text-xs opacity-50">×</span>}
|
||||||
|
{isItemCorrect && <span>✅</span>}
|
||||||
|
{isItemWrong && <span>❌</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 border-t border-white/5">
|
||||||
|
{!submitted && userOrder.length === (items || []).length && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmitted(true)}
|
||||||
|
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
Validate Sequence
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{submitted && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correct: number[];
|
||||||
|
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
quizData: {
|
||||||
|
questions: QuizQuestion[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuizPlayer({ id, title, quizData }: QuizPlayerProps) {
|
||||||
|
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const questions = quizData?.questions || [];
|
||||||
|
|
||||||
|
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
|
||||||
|
if (submitted) return;
|
||||||
|
setUserAnswers(prev => {
|
||||||
|
const current = prev[qId] || [];
|
||||||
|
if (isMulti) {
|
||||||
|
const next = current.includes(optionIndex)
|
||||||
|
? current.filter(i => i !== optionIndex)
|
||||||
|
: [...current, optionIndex].sort((a, b) => a - b);
|
||||||
|
return { ...prev, [qId]: next };
|
||||||
|
} else {
|
||||||
|
return { ...prev, [qId]: [optionIndex] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
|
{title || "Knowledge Check"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{questions.map((q) => (
|
||||||
|
<div key={q.id} className="space-y-4 p-8 glass border-white/5 rounded-3xl">
|
||||||
|
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{q.options.map((opt, oIdx) => {
|
||||||
|
const isSelected = userAnswers[q.id]?.includes(oIdx);
|
||||||
|
const isCorrect = q.correct?.includes(oIdx);
|
||||||
|
const isActuallyCorrect = isCorrect && isSelected;
|
||||||
|
const isWrongSelection = !isCorrect && isSelected;
|
||||||
|
const missedCorrect = isCorrect && !isSelected;
|
||||||
|
|
||||||
|
let style = "glass border-white/10 hover:bg-white/5";
|
||||||
|
if (submitted) {
|
||||||
|
if (isActuallyCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
|
||||||
|
else if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100";
|
||||||
|
else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse";
|
||||||
|
else style = "opacity-50 grayscale border-white/5";
|
||||||
|
} else if (isSelected) {
|
||||||
|
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={oIdx}
|
||||||
|
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
|
||||||
|
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{opt}</span>
|
||||||
|
{submitted && isActuallyCorrect && <span>✅</span>}
|
||||||
|
{submitted && isWrongSelection && <span>❌</span>}
|
||||||
|
{submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Correct Answer</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!submitted && questions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmitted(true)}
|
||||||
|
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
Validate Answers
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{submitted && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSubmitted(false); setUserAnswers({}); }}
|
||||||
|
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-3xl border-white/5"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ShortAnswerPlayerProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
prompt: string;
|
||||||
|
correctAnswers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }: ShortAnswerPlayerProps) {
|
||||||
|
const [userAnswer, setUserAnswer] = useState("");
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSubmitted(false);
|
||||||
|
setUserAnswer("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCorrect = (correctAnswers || []).some(ans => ans.trim().toLowerCase() === userAnswer.trim().toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
|
{title || "Short Answer"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
|
||||||
|
<p className="text-xl font-bold text-gray-100">{prompt || "Please enter your answer below:"}</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userAnswer}
|
||||||
|
onChange={(e) => setUserAnswer(e.target.value)}
|
||||||
|
disabled={submitted}
|
||||||
|
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
|
||||||
|
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
|
||||||
|
: "border-white/10 focus:border-blue-500 text-white"
|
||||||
|
}`}
|
||||||
|
placeholder="Type your answer..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{submitted && !isCorrect && (
|
||||||
|
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl animate-in fade-in duration-500">
|
||||||
|
<p className="text-[10px] text-orange-400 uppercase font-black tracking-widest">Suggested Answer(s):</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">{(correctAnswers || [])[0]}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!submitted && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmitted(true)}
|
||||||
|
disabled={!userAnswer.trim()}
|
||||||
|
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:grayscale"
|
||||||
|
>
|
||||||
|
Submit Answer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitted && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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('experience_user');
|
||||||
|
const savedToken = localStorage.getItem('experience_token');
|
||||||
|
if (savedUser && savedToken) {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
setToken(savedToken);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = (newUser: User, newToken: string) => {
|
||||||
|
setUser(newUser);
|
||||||
|
setToken(newToken);
|
||||||
|
localStorage.setItem('experience_user', JSON.stringify(newUser));
|
||||||
|
localStorage.setItem('experience_token', newToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem('experience_user');
|
||||||
|
localStorage.removeItem('experience_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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
|
||||||
|
|
||||||
|
export interface Course {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
instructor_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
id: string;
|
||||||
|
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer';
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
url?: string;
|
||||||
|
media_type?: 'video' | 'audio';
|
||||||
|
config?: any;
|
||||||
|
quiz_data?: {
|
||||||
|
questions: {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correct: number[];
|
||||||
|
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
pairs?: { left: string; right: string }[];
|
||||||
|
items?: string[];
|
||||||
|
prompt?: string;
|
||||||
|
correctAnswers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lesson {
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
title: string;
|
||||||
|
content_type: string;
|
||||||
|
content_url?: string;
|
||||||
|
transcription?: any;
|
||||||
|
metadata?: {
|
||||||
|
blocks: Block[];
|
||||||
|
};
|
||||||
|
is_graded: boolean;
|
||||||
|
grading_category_id: string | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradingCategory {
|
||||||
|
id: string;
|
||||||
|
course_id: string;
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
drop_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserGrade {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
course_id: string;
|
||||||
|
lesson_id: string;
|
||||||
|
score: number;
|
||||||
|
metadata?: any;
|
||||||
|
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 interface Enrollment {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
course_id: string;
|
||||||
|
enroled_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Module {
|
||||||
|
id: string;
|
||||||
|
course_id: string;
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
lessons: Lesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lmsApi = {
|
||||||
|
async getCatalog(): Promise<Course[]> {
|
||||||
|
// LMS service uses /catalog for the published courses list
|
||||||
|
const response = await fetch(`${API_BASE_URL}/catalog`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch catalog');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseOutline(courseId: string): Promise<Course & { modules: Module[] }> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/outline`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch course outline');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLesson(id: string): Promise<Lesson> {
|
||||||
|
return fetch(`${API_BASE_URL}/lessons/${id}`).then(res => res.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(payload: AuthPayload): Promise<AuthResponse> {
|
||||||
|
return fetch(`${API_BASE_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e)));
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(payload: AuthPayload): Promise<AuthResponse> {
|
||||||
|
return fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e)));
|
||||||
|
},
|
||||||
|
|
||||||
|
async enroll(courseId: string, userId: string): Promise<any> {
|
||||||
|
return fetch(`${API_BASE_URL}/enroll`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ course_id: courseId, user_id: userId })
|
||||||
|
}).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e)));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEnrollments(userId: string): Promise<Enrollment[]> {
|
||||||
|
return fetch(`${API_BASE_URL}/enrollments/${userId}`).then(res => res.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitScore(userId: string, courseId: string, lessonId: string, score: number): Promise<UserGrade> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/grades`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: userId, course_id: courseId, lesson_id: lessonId, score })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to submit score');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserGrades(userId: string, courseId: string): Promise<UserGrade[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/users/${userId}/courses/${courseId}/grades`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch user grades');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "var(--background)",
|
||||||
|
foreground: "var(--foreground)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+69
-1
@@ -8,9 +8,13 @@
|
|||||||
"name": "studio",
|
"name": "studio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^11.2.10",
|
||||||
|
"lucide-react": "^0.395.0",
|
||||||
"next": "14.2.21",
|
"next": "14.2.21",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18"
|
"react-dom": "^18",
|
||||||
|
"tailwind-merge": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -1534,6 +1538,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2514,6 +2526,32 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
@@ -3531,6 +3569,14 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -3592,6 +3638,19 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -4984,6 +5043,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 Link from "next/link";
|
||||||
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
||||||
import MediaBlock from "@/components/blocks/MediaBlock";
|
import MediaBlock from "@/components/blocks/MediaBlock";
|
||||||
@@ -19,12 +19,18 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
|
|
||||||
// Activity State (Blocks)
|
// Activity State (Blocks)
|
||||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||||
|
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
|
||||||
|
const [isGraded, setIsGraded] = useState(false);
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
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);
|
setLesson(lessonData);
|
||||||
|
setIsGraded(lessonData.is_graded);
|
||||||
|
setSelectedCategoryId(lessonData.grading_category_id || "");
|
||||||
|
|
||||||
if (lessonData.metadata?.blocks) {
|
if (lessonData.metadata?.blocks) {
|
||||||
setBlocks(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 {
|
} catch {
|
||||||
console.error("Failed to load lesson");
|
console.error("Failed to load lesson or categories");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,7 +61,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await cmsApi.updateLesson(lesson.id, {
|
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);
|
setLesson(updated);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
@@ -125,6 +137,56 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-16">
|
||||||
{blocks.map((block, index) => (
|
{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` }}>
|
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
|
||||||
|
|||||||
@@ -18,20 +18,11 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// 1. Fetch course details
|
// Use cmsApi for consistent, typed data fetching
|
||||||
const courseData = await fetch(`http://localhost:3001/courses/${params.id}`).then(res => res.json());
|
const data = await cmsApi.getCourseWithFullOutline(params.id);
|
||||||
setCourse(courseData);
|
|
||||||
|
|
||||||
// 2. Fetch modules
|
setCourse(data);
|
||||||
const modulesData: Module[] = await fetch(`http://localhost:3001/modules?course_id=${params.id}`).then(res => res.json());
|
setModules(data.modules as FullModule[]);
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load course data:", err);
|
console.error("Failed to load course data:", err);
|
||||||
setError("Failed to load course details. Is the backend running?");
|
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="glass p-1">
|
||||||
<div className="flex border-b border-white/10">
|
<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">Settings</button>
|
||||||
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
|
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<body className={inter.className}>
|
<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" />
|
<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">
|
<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">
|
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
@@ -33,6 +35,7 @@ export default function RootLayout({
|
|||||||
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
|
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -57,10 +57,37 @@ export interface Lesson {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
blocks?: Block[];
|
blocks?: Block[];
|
||||||
} | null;
|
} | null;
|
||||||
|
is_graded: boolean;
|
||||||
|
grading_category_id: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
created_at: string;
|
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 = {
|
export const cmsApi = {
|
||||||
async getCourses(): Promise<Course[]> {
|
async getCourses(): Promise<Course[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/courses`);
|
const response = await fetch(`${API_BASE_URL}/courses`);
|
||||||
@@ -128,6 +155,35 @@ export const cmsApi = {
|
|||||||
return response.json();
|
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 }> {
|
async uploadAsset(file: File): Promise<{ id: string; filename: string; url: string }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -146,5 +202,25 @@ export const cmsApi = {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to publish course');
|
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
Reference in New Issue
Block a user