Initial commit: Clean workspace without heavy binaries
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Database URLs for local development (outsite Docker)
|
||||||
|
# Change 'db' to 'localhost' if running the services natively
|
||||||
|
CMS_DATABASE_URL=postgresql://user:password@localhost:5432/openccb_cms
|
||||||
|
LMS_DATABASE_URL=postgresql://user:password@localhost:5432/openccb_lms
|
||||||
|
|
||||||
|
# General fallback
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/openccb_cms
|
||||||
|
|
||||||
|
# JWT Secret
|
||||||
|
JWT_SECRET=supersecret
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG=info
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# --- Rust / Cargo ---
|
||||||
|
# Ignora la carpeta de compilación (es muy pesada)
|
||||||
|
target/
|
||||||
|
# Si usas herramientas de profiling o tests adicionales
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# --- Frontend (web/studio y web/lms) ---
|
||||||
|
# Ignora las librerías de Node
|
||||||
|
node_modules/
|
||||||
|
# Ignora las carpetas de compilación de frontend (dist, build, .next, etc.)
|
||||||
|
dist/
|
||||||
|
.cache/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
|
||||||
|
# --- Variables de Entorno (SEGURIDAD) ---
|
||||||
|
# Nunca subas tus llaves secretas o credenciales
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# --- Archivos de Sistema y Editores ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# --- Logs ---
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
Generated
+2664
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"services/cms-service",
|
||||||
|
"services/lms-service",
|
||||||
|
"shared/common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["Juan Enrique Allende Cifuentes <juan.allende@gmail.com>"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
jsonwebtoken = "9.3"
|
||||||
|
bcrypt = "0.17"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# OpenCCB - Open Comprehensive Course Backbone
|
||||||
|
|
||||||
|
OpenCCB is a high-performance, microservices-based Learning Management System (LMS) and Content Management System (CMS) built with Rust (Edition 2024) and Next.js. The name stands for **Open Comprehensive Course Backbone**, representing the solid foundation for modern educational platforms.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **CMS Service (Port 3001)**: Course management, content creation, and administrative configurations.
|
||||||
|
- **LMS Service (Port 3002)**: Student experience, course consumption, and enrollment.
|
||||||
|
- **Shared Library**: Core models and authentication logic.
|
||||||
|
- **Database**: PostgreSQL (shared/isolated schemas).
|
||||||
|
- **Studio (Frontend)**: Next.js application for instructors and admins.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Rust (Edition 2024)
|
||||||
|
- Node.js (v18+)
|
||||||
|
|
||||||
|
### Running with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### CMS Service (`:3001`)
|
||||||
|
|
||||||
|
#### Create a Course
|
||||||
|
- **URL**: `/courses`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Example**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/courses \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title": "Advanced Rust 2024"}'
|
||||||
|
```
|
||||||
|
- **Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid-v4",
|
||||||
|
"title": "Advanced Rust 2024",
|
||||||
|
"description": null,
|
||||||
|
"instructor_id": "uuid-v4",
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"created_at": "2023-12-19T10:00:00Z",
|
||||||
|
"updated_at": "2023-12-19T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create a Module
|
||||||
|
- **URL**: `/modules`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Example**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/modules \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title": "Introduction", "course_id": "YOUR_COURSE_ID", "position": 1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### LMS Service (`:3002`)
|
||||||
|
|
||||||
|
#### Get Course Catalog
|
||||||
|
- **URL**: `/catalog`
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Example**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3002/catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Enroll in a Course
|
||||||
|
- **URL**: `/enroll`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Example**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3002/enroll \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"course_id": "YOUR_COURSE_ID"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audit Logging
|
||||||
|
Every mutation in the CMS (Create Course/Module/Lesson) is automatically recorded in the `audit_logs` table for compliance and debugging.
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# OpenCCB Database Management Script
|
||||||
|
# This script handles creation, migrations and sqlx preparation for both microservices.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Load environment variables if .env exists
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to DATABASE_URL if specific ones aren't set
|
||||||
|
# Note: For running locally against Docker Postgres, use localhost instead of db
|
||||||
|
CMS_URL=${CMS_DATABASE_URL:-$(echo $DATABASE_URL | sed 's/@db:/@localhost:/')}
|
||||||
|
LMS_URL=${LMS_DATABASE_URL:-$(echo $DATABASE_URL | sed 's/@db:/@localhost:/')}
|
||||||
|
|
||||||
|
if [ -z "$CMS_URL" ] || [ -z "$LMS_URL" ]; then
|
||||||
|
echo "Error: CMS_DATABASE_URL or LMS_DATABASE_URL is not set."
|
||||||
|
echo "Please check your .env file."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTION=$1
|
||||||
|
|
||||||
|
case $ACTION in
|
||||||
|
"setup")
|
||||||
|
echo "--- Creating Databases ---"
|
||||||
|
DATABASE_URL=$CMS_URL sqlx database create
|
||||||
|
DATABASE_URL=$LMS_URL sqlx database create
|
||||||
|
echo "Databases created (if they didn't exist)."
|
||||||
|
$0 migrate
|
||||||
|
;;
|
||||||
|
|
||||||
|
"migrate")
|
||||||
|
echo "--- Running CMS Migrations ---"
|
||||||
|
DATABASE_URL=$CMS_URL sqlx migrate run --source services/cms-service/migrations
|
||||||
|
|
||||||
|
echo "--- Running LMS Migrations ---"
|
||||||
|
DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations
|
||||||
|
|
||||||
|
echo "All migrations completed successfully."
|
||||||
|
;;
|
||||||
|
|
||||||
|
"prepare")
|
||||||
|
echo "--- Preparing SQLx queries for CMS ---"
|
||||||
|
cd services/cms-service && DATABASE_URL=$CMS_URL cargo sqlx prepare -- --all-targets --all-features && cd ../..
|
||||||
|
|
||||||
|
echo "--- Preparing SQLx queries for LMS ---"
|
||||||
|
cd services/lms-service && DATABASE_URL=$LMS_URL cargo sqlx prepare -- --all-targets --all-features && cd ../..
|
||||||
|
|
||||||
|
echo "SQLx preparation completed."
|
||||||
|
;;
|
||||||
|
|
||||||
|
"all")
|
||||||
|
$0 setup
|
||||||
|
$0 prepare
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {setup|migrate|prepare|all}"
|
||||||
|
echo " setup: Creates databases and runs migrations"
|
||||||
|
echo " migrate: Runs database migrations for all services"
|
||||||
|
echo " prepare: Runs cargo sqlx prepare for offline compilation"
|
||||||
|
echo " all: Runs setup and prepare"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: openccb
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
cms-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: services/cms-service/Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
lms-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: services/lms-service/Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://user:password@db:5432/openccb_lms
|
||||||
|
ports:
|
||||||
|
- "3002:3002"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
studio:
|
||||||
|
build:
|
||||||
|
context: ./web/studio
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
uploads_data:
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# OpenCCB: Open Comprehensive Course Backbone - Roadmap
|
||||||
|
|
||||||
|
## Phase 1: Foundation (Current)
|
||||||
|
- [x] Rust Workspace Setup (Edition 2024).
|
||||||
|
- [x] Microservices Scaffolding (CMS & LMS).
|
||||||
|
- [x] Multi-Database Infrastructure (Postgres with separate DBs).
|
||||||
|
- [x] Frontend Initialization (Next.js Studio).
|
||||||
|
- [x] Dockerization of all services.
|
||||||
|
- [x] API Integration (Dashboard <-> CMS Service).
|
||||||
|
|
||||||
|
## Phase 2: Core CMS Features (Current Focus)
|
||||||
|
- [/] Course Outline Editor (Modules & Lessons).
|
||||||
|
- [ ] File Upload System (Video/Images/Docs).
|
||||||
|
- [ ] Interactive Content (Quizzes/Rubrics).
|
||||||
|
- [ ] Service-to-Service Communication (CMS -> LMS sync).
|
||||||
|
integration for videos, documents, and images.
|
||||||
|
- [ ] **Video Player**: Integrated premium video player for lessons.
|
||||||
|
- [ ] **Interactivity**: Quizzes, activities, and rubrics implementation.
|
||||||
|
- [ ] **Full Studio UI**: Drag-and-drop course builder.
|
||||||
|
|
||||||
|
## Phase 3: Authentication & Security
|
||||||
|
- [ ] **Auth Service**: Integrated OIDC/OAuth2 or custom JWT provider.
|
||||||
|
- [ ] **RBAC**: Role-Based Access Control (Admin, Instructor, Student).
|
||||||
|
- [ ] **Audit UI**: Admin interface to view audit logs.
|
||||||
|
|
||||||
|
## Phase 4: LMS Experience
|
||||||
|
- [ ] **Progress Tracking**: Track student completion of lessons and modules.
|
||||||
|
- [ ] **Certificates**: Automated certificate generation upon completion.
|
||||||
|
- [ ] **Mobile Responsive**: Optimize student interface for mobile devices.
|
||||||
|
|
||||||
|
## Phase 5: Advanced Features
|
||||||
|
- [ ] **Multi-tenancy**: Support for multiple organizations.
|
||||||
|
- [ ] **Analytics**: Insight dashboards for instructors.
|
||||||
|
- [ ] **AI Integration**: AI-driven lesson summaries and quiz generation.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "cms-service"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../../shared/common" }
|
||||||
|
axum.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM rustlang/rust:nightly as builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install necessary build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Build the specific service
|
||||||
|
RUN cargo build --release -p cms-service
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /usr/local/bin
|
||||||
|
COPY --from=builder /usr/src/app/target/release/cms-service .
|
||||||
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["./cms-service"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- Initial schema for courses, modules, and lessons
|
||||||
|
CREATE TABLE courses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
instructor_id UUID NOT NULL,
|
||||||
|
start_date TIMESTAMPTZ,
|
||||||
|
end_date TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE modules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
position INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE lessons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
content_url TEXT,
|
||||||
|
position INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
changes JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigger to update updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
CREATE TRIGGER update_courses_updated_at BEFORE UPDATE ON courses FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add transcription and metadata columns to lessons table
|
||||||
|
ALTER TABLE lessons ADD COLUMN transcription JSONB;
|
||||||
|
ALTER TABLE lessons ADD COLUMN metadata JSONB;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE assets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
mimetype TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{State, Path, Query},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use common::models::{Course, Module, Lesson};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use serde_json::json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ModuleQuery {
|
||||||
|
pub course_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LessonQuery {
|
||||||
|
pub module_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_course(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<Json<Course>, StatusCode> {
|
||||||
|
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let instructor_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let course = sqlx::query_as::<_, Course>(
|
||||||
|
"INSERT INTO courses (title, instructor_id) VALUES ($1, $2) RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(title)
|
||||||
|
.bind(instructor_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(&pool, instructor_id, "CREATE", "Course", course.id, json!({ "title": title })).await;
|
||||||
|
|
||||||
|
Ok(Json(course))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_courses(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||||
|
let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(courses))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_module(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<Json<Module>, StatusCode> {
|
||||||
|
let title = payload.get("title").and_then(|t| t.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 position = payload.get("position").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||||
|
|
||||||
|
let module = sqlx::query_as::<_, Module>(
|
||||||
|
"INSERT INTO modules (course_id, title, position) VALUES ($1, $2, $3) RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(title)
|
||||||
|
.bind(position)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(&pool, Uuid::new_v4(), "CREATE", "Module", module.id, json!({ "title": title, "course_id": course_id })).await;
|
||||||
|
|
||||||
|
Ok(Json(module))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_lesson(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let module_id_str = payload.get("module_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let module_id = Uuid::parse_str(module_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
let content_type = payload.get("content_type").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let content_url = payload.get("content_url").and_then(|v| v.as_str());
|
||||||
|
let position = payload.get("position").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||||
|
let transcription = payload.get("transcription").cloned();
|
||||||
|
let metadata = payload.get("metadata").cloned();
|
||||||
|
|
||||||
|
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 *"
|
||||||
|
)
|
||||||
|
.bind(module_id)
|
||||||
|
.bind(title)
|
||||||
|
.bind(content_type)
|
||||||
|
.bind(content_url)
|
||||||
|
.bind(position)
|
||||||
|
.bind(transcription)
|
||||||
|
.bind(metadata)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(&pool, Uuid::new_v4(), "CREATE", "Lesson", lesson.id, json!({ "title": title, "module_id": module_id })).await;
|
||||||
|
|
||||||
|
Ok(Json(lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_transcription(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
// 1. Fetch lesson
|
||||||
|
let _lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
// 2. Simulate AI Processing
|
||||||
|
let mock_transcription = json!({
|
||||||
|
"en": "This is a simulated transcription of the video content in English.",
|
||||||
|
"es": "Esta es una transcripción simulada del contenido del video en español.",
|
||||||
|
"cues": [
|
||||||
|
{ "start": 0.0, "end": 2.0, "text": "Hello world!" },
|
||||||
|
{ "start": 2.1, "end": 5.0, "text": "Welcome to OpenCCB." }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Update lesson
|
||||||
|
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||||
|
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(mock_transcription)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(&pool, Uuid::new_v4(), "TRANSCRIPTION_PROCESSED", "Lesson", id, json!({})).await;
|
||||||
|
|
||||||
|
Ok(Json(updated_lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_lesson(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(Json(lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_lesson(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
|
let title = payload.get("title").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 position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||||
|
|
||||||
|
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||||
|
"UPDATE lessons
|
||||||
|
SET title = COALESCE($1, title),
|
||||||
|
content_type = COALESCE($2, content_type),
|
||||||
|
content_url = COALESCE($3, content_url),
|
||||||
|
position = COALESCE($4, position)
|
||||||
|
WHERE id = $5 RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(title)
|
||||||
|
.bind(content_type)
|
||||||
|
.bind(content_url)
|
||||||
|
.bind(position)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(&pool, Uuid::new_v4(), "UPDATE", "Lesson", id, json!(payload)).await;
|
||||||
|
|
||||||
|
Ok(Json(updated_lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_action(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
action: &str,
|
||||||
|
entity_type: &str,
|
||||||
|
entity_id: Uuid,
|
||||||
|
changes: serde_json::Value,
|
||||||
|
) {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO audit_logs (user_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5)"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(action)
|
||||||
|
.bind(entity_type)
|
||||||
|
.bind(entity_id)
|
||||||
|
.bind(changes)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_course(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Course>, StatusCode> {
|
||||||
|
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(Json(course))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_modules(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(query): Query<ModuleQuery>,
|
||||||
|
) -> Result<Json<Vec<Module>>, StatusCode> {
|
||||||
|
let modules = match query.course_id {
|
||||||
|
Some(course_id) => {
|
||||||
|
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
|
||||||
|
.bind(course_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, Module>("SELECT * FROM modules ORDER BY position")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(modules))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_lessons(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(query): Query<LessonQuery>,
|
||||||
|
) -> Result<Json<Vec<Lesson>>, StatusCode> {
|
||||||
|
let lessons = match query.module_id {
|
||||||
|
Some(module_id) => {
|
||||||
|
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
|
||||||
|
.bind(module_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons ORDER BY position")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(lessons))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UploadResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub filename: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_asset(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
mut multipart: axum::extract::Multipart,
|
||||||
|
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
||||||
|
let mut filename = String::new();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut mimetype = String::new();
|
||||||
|
|
||||||
|
while let Some(field) = multipart.next_field().await.map_err(|e: axum::extract::multipart::MultipartError| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
if name == "file" {
|
||||||
|
filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||||
|
mimetype = field.content_type().unwrap_or("application/octet-stream").to_string();
|
||||||
|
data = field.bytes().await.map_err(|e: axum::extract::multipart::MultipartError| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?.to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = Uuid::new_v4();
|
||||||
|
let extension = std::path::Path::new(&filename)
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let storage_filename = format!("{}.{}", asset_id, extension);
|
||||||
|
let storage_path = format!("uploads/{}", storage_filename);
|
||||||
|
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
tokio::fs::create_dir_all("uploads").await.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
tokio::fs::write(&storage_path, data).await.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Record in DB
|
||||||
|
let size_bytes = tokio::fs::metadata(&storage_path).await.map(|m| m.len() as i64).unwrap_or(0);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes) VALUES ($1, $2, $3, $4, $5)"
|
||||||
|
)
|
||||||
|
.bind(asset_id)
|
||||||
|
.bind(&filename)
|
||||||
|
.bind(storage_path)
|
||||||
|
.bind(mimetype)
|
||||||
|
.bind(size_bytes)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let url = format!("/assets/{}", storage_filename);
|
||||||
|
|
||||||
|
Ok(Json(UploadResponse {
|
||||||
|
id: asset_id,
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenv().ok();
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&db_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
|
||||||
|
.route("/courses/{id}", get(handlers::get_course))
|
||||||
|
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
|
||||||
|
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
||||||
|
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||||
|
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
|
||||||
|
.route("/assets/upload", post(handlers::upload_asset))
|
||||||
|
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(pool);
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
|
||||||
|
tracing::info!("CMS Service listening on {}", addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "lms-service"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../../shared/common" }
|
||||||
|
axum.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM rustlang/rust:nightly as builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install necessary build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Build the specific service
|
||||||
|
RUN cargo build --release -p lms-service
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /usr/local/bin
|
||||||
|
COPY --from=builder /usr/src/app/target/release/lms-service .
|
||||||
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
CMD ["./lms-service"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- LMS specific schema
|
||||||
|
CREATE TABLE enrollments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
course_id UUID NOT NULL, -- Referenced by ID from CMS service
|
||||||
|
enroled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: In a real microservices scenario, courses might be synced from CMS or shared DB.
|
||||||
|
-- Here we are using a shared DB for simplicity in this initial implementation.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use common::models::{Course, Enrollment};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn enroll_user(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> 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 = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
let user_id = Uuid::new_v4(); // Placeholder for actual auth
|
||||||
|
|
||||||
|
let enrollment = sqlx::query_as::<_, Enrollment>(
|
||||||
|
"INSERT INTO enrollments (user_id, course_id) VALUES ($1, $2) RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(course_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(enrollment))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_course_catalog(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||||
|
let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(courses))
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenv().ok();
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&db_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/catalog", get(handlers::get_course_catalog))
|
||||||
|
.route("/enroll", post(handlers::enroll_user))
|
||||||
|
.with_state(pool);
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||||
|
tracing::info!("LMS Service listening on {}", addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "common"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
jsonwebtoken.workspace = true
|
||||||
|
bcrypt.workspace = true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use jsonwebtoken::{encode, Header, EncodingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use chrono::{Utc, Duration};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: Uuid,
|
||||||
|
pub exp: i64,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_jwt(user_id: Uuid, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
|
let expiration = Utc::now()
|
||||||
|
.checked_add_signed(Duration::hours(24))
|
||||||
|
.expect("valid timestamp")
|
||||||
|
.timestamp();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
exp: expiration,
|
||||||
|
role: role.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(&Header::default(), &claims, &EncodingKey::from_secret("secret".as_ref()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod models;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod utils;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Course {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub instructor_id: Uuid,
|
||||||
|
pub start_date: Option<DateTime<Utc>>,
|
||||||
|
pub end_date: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Module {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub position: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Lesson {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub module_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub content_url: Option<String>,
|
||||||
|
pub transcription: Option<serde_json::Value>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
pub position: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct AuditLog {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub action: String,
|
||||||
|
pub entity_type: String,
|
||||||
|
pub entity_id: Uuid,
|
||||||
|
pub changes: serde_json::Value,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Enrollment {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub enroled_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Asset {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub filename: String,
|
||||||
|
pub storage_path: String,
|
||||||
|
pub mimetype: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Utility functions placeholder
|
||||||
|
pub fn format_error() {
|
||||||
|
// TODO: Implement error formatting
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
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.
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+5549
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"next": "14.2.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
This folder is for static assets.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cmsApi, Lesson, Block } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
||||||
|
import MediaBlock from "@/components/blocks/MediaBlock";
|
||||||
|
import QuizBlock from "@/components/blocks/QuizBlock";
|
||||||
|
|
||||||
|
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
||||||
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
// Activity State (Blocks)
|
||||||
|
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const lessonData: Lesson = await fetch(`http://localhost:3001/lessons/${params.lessonId}`).then(res => res.json());
|
||||||
|
setLesson(lessonData);
|
||||||
|
|
||||||
|
if (lessonData.metadata?.blocks) {
|
||||||
|
setBlocks(lessonData.metadata.blocks);
|
||||||
|
} else {
|
||||||
|
setBlocks([
|
||||||
|
{
|
||||||
|
id: 'initial-desc',
|
||||||
|
type: 'description',
|
||||||
|
content: `Welcome to ${lessonData.title}. Please follow the instructions below.`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to load lesson");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, [params.id, params.lessonId]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!lesson) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await cmsApi.updateLesson(lesson.id, {
|
||||||
|
metadata: { ...lesson.metadata, blocks }
|
||||||
|
});
|
||||||
|
setLesson(updated);
|
||||||
|
setEditMode(false);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to save activity.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBlock = (type: 'description' | 'media' | 'quiz') => {
|
||||||
|
const newBlock: Block = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
type,
|
||||||
|
...(type === 'description' && { content: "" }),
|
||||||
|
...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }),
|
||||||
|
...(type === 'quiz' && { quiz_data: { questions: [] } }),
|
||||||
|
};
|
||||||
|
setBlocks([...blocks, newBlock]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBlock = (id: string) => {
|
||||||
|
setBlocks(blocks.filter(b => b.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBlock = (id: string, updates: Partial<Block>) => {
|
||||||
|
setBlocks(blocks.map(b => b.id === id ? { ...b, ...updates } : b));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="py-20 text-center text-gray-500 animate-pulse font-medium">Initializing Activity Builder...</div>;
|
||||||
|
if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-12 pb-40 px-4">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 border-b border-white/5 pb-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-blue-500 font-bold uppercase tracking-[0.2em]">
|
||||||
|
<Link href={`/courses/${params.id}`} className="hover:text-white transition-colors">Outline</Link>
|
||||||
|
<span className="text-gray-700">/</span>
|
||||||
|
<span>Activity</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setEditMode(false)} className="px-6 py-2.5 glass text-xs font-bold uppercase tracking-widest hover:bg-white/5 transition-all">Discard</button>
|
||||||
|
<button onClick={handleSave} disabled={isSaving} className="btn-premium px-8 py-2.5 min-w-[140px] text-xs font-bold uppercase tracking-widest shadow-blue-500/20 shadow-lg">
|
||||||
|
{isSaving ? "Saving..." : "Publish Changes"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setEditMode(true)} className="px-8 py-3 glass text-xs font-bold uppercase tracking-widest hover:border-blue-500/50 transition-all flex items-center gap-2 group">
|
||||||
|
<span className="group-hover:rotate-12 transition-transform">✏️</span> Edit Activity
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-16">
|
||||||
|
{blocks.map((block, index) => (
|
||||||
|
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
|
||||||
|
{editMode && (
|
||||||
|
<div className="absolute -left-12 top-0 h-full flex flex-col items-center gap-2 opacity-0 group-hover/block:opacity-100 transition-all">
|
||||||
|
<button
|
||||||
|
onClick={() => removeBlock(block.id)}
|
||||||
|
className="w-8 h-8 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center hover:bg-red-500 hover:text-white transition-all border border-red-500/20"
|
||||||
|
title="Remove Block"
|
||||||
|
>
|
||||||
|
<span className="text-sm">×</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-0.5 flex-1 bg-white/5"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{block.type === 'description' && (
|
||||||
|
<DescriptionBlock
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
content={block.content || ""}
|
||||||
|
editMode={editMode}
|
||||||
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{block.type === 'media' && (
|
||||||
|
<MediaBlock
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
url={block.url || ""}
|
||||||
|
type={block.media_type || 'video'}
|
||||||
|
config={block.config || {}}
|
||||||
|
editMode={editMode}
|
||||||
|
transcription={lesson.transcription}
|
||||||
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{block.type === 'quiz' && (
|
||||||
|
<QuizBlock
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
quizData={block.quiz_data || { questions: [] }}
|
||||||
|
editMode={editMode}
|
||||||
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{editMode && (
|
||||||
|
<div className="pt-12 border-t border-white/5">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-[0.3em]">Add Content Block</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => addBlock('description')}
|
||||||
|
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
|
||||||
|
>
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform">📄</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Text</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addBlock('media')}
|
||||||
|
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
|
||||||
|
>
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform">🎬</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Media</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addBlock('quiz')}
|
||||||
|
className="flex flex-col items-center gap-2 p-6 glass hover:border-blue-500/50 transition-all group w-32"
|
||||||
|
>
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform">💡</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Quiz</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cmsApi, Course, Module, Lesson } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface FullModule extends Module {
|
||||||
|
lessons: Lesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||||
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
|
const [modules, setModules] = useState<FullModule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// 1. Fetch course details
|
||||||
|
const courseData = await fetch(`http://localhost:3001/courses/${params.id}`).then(res => res.json());
|
||||||
|
setCourse(courseData);
|
||||||
|
|
||||||
|
// 2. Fetch modules
|
||||||
|
const modulesData: Module[] = await fetch(`http://localhost:3001/modules?course_id=${params.id}`).then(res => res.json());
|
||||||
|
|
||||||
|
// 3. Fetch lessons for each module
|
||||||
|
const fullModules = await Promise.all(modulesData.map(async (mod) => {
|
||||||
|
const lessonsData: Lesson[] = await fetch(`http://localhost:3001/lessons?module_id=${mod.id}`).then(res => res.json());
|
||||||
|
return { ...mod, lessons: lessonsData };
|
||||||
|
}));
|
||||||
|
|
||||||
|
setModules(fullModules);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load course data:", err);
|
||||||
|
setError("Failed to load course details. Is the backend running?");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const handleAddModule = async () => {
|
||||||
|
const title = prompt("Module Title:");
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newMod = await cmsApi.createModule(params.id, title, modules.length + 1);
|
||||||
|
setModules([...modules, { ...newMod, lessons: [] }]);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to create module");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLesson = async (moduleId: string) => {
|
||||||
|
const title = prompt("Lesson Title:");
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Default to 'video' for now as a content type
|
||||||
|
const newLesson = await cmsApi.createLesson(moduleId, title, "video", 1);
|
||||||
|
setModules(modules.map(mod =>
|
||||||
|
mod.id === moduleId
|
||||||
|
? { ...mod, lessons: [...mod.lessons, newLesson] }
|
||||||
|
: mod
|
||||||
|
));
|
||||||
|
} catch {
|
||||||
|
alert("Failed to create lesson");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="py-20 text-center">Loading editor...</div>;
|
||||||
|
if (error) return <div className="py-20 text-center text-red-400">{error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||||
|
<Link href="/" className="hover:text-white cursor-pointer underline">Courses</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white">{course?.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold">{course?.title}</h2>
|
||||||
|
<p className="text-gray-400">Editor - Outline (ID: {params.id})</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">Preview</button>
|
||||||
|
<button className="btn-premium">Publish</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass p-1">
|
||||||
|
<div className="flex border-b border-white/10">
|
||||||
|
<button className="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 bg-white/5">Outline</button>
|
||||||
|
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</button>
|
||||||
|
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<div key={module.id} className="glass overflow-hidden">
|
||||||
|
<div className="bg-white/5 px-4 py-3 flex justify-between items-center border-b border-white/5">
|
||||||
|
<span className="font-medium text-blue-400">Module {module.position}: {module.title}</span>
|
||||||
|
<button className="text-xs text-gray-400 hover:text-white">Options</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
{module.lessons.map(lesson => (
|
||||||
|
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} key={lesson.id}>
|
||||||
|
<div className="glass border-white/5 p-3 flex items-center justify-between text-sm hover:bg-white/10 hover:border-blue-500/30 transition-all cursor-pointer group/lesson">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-blue-400 text-lg group-hover/lesson:scale-110 transition-transform">
|
||||||
|
{lesson.content_type === 'video' ? '🎬' : '📄'}
|
||||||
|
</span>
|
||||||
|
<span>{lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lesson.transcription && <span className="text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded">CC</span>}
|
||||||
|
<span className="text-xs text-gray-500 capitalize">{lesson.content_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAddLesson(module.id)}
|
||||||
|
className="w-full py-2 border border-dashed border-white/10 rounded-lg text-xs text-gray-500 hover:text-white hover:border-white/20 transition-all mt-2"
|
||||||
|
>
|
||||||
|
+ New Lesson
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddModule}
|
||||||
|
className="w-full py-4 border-2 border-dashed border-white/10 rounded-xl font-medium text-gray-500 hover:text-white hover:border-white/20 transition-all"
|
||||||
|
>
|
||||||
|
+ Add Module
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
|||||||
|
@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.05);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgb(var(--background-end-rgb))
|
||||||
|
)
|
||||||
|
rgb(var(--background-start-rgb));
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium {
|
||||||
|
@apply relative px-6 py-2 rounded-full font-medium transition-all duration-300;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium:hover {
|
||||||
|
@apply scale-105;
|
||||||
|
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OpenCCB Studio | modern Course Management",
|
||||||
|
description: "Advanced LMS Content Management System inspired by Open edX",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
|
||||||
|
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">
|
||||||
|
Open<span className="gradient-text">CCB</span> Studio
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Courses</button>
|
||||||
|
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Settings</button>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border border-white/20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cmsApi, Course } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
fetchCourses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await cmsApi.getCourses();
|
||||||
|
setCourses(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch courses:", err);
|
||||||
|
setError("Could not connect to CMS service. showing offline mode.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCourse = async () => {
|
||||||
|
const title = prompt("Enter course title:");
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newCourse = await cmsApi.createCourse(title);
|
||||||
|
setCourses([...courses, newCourse]);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to create course. Is the backend running?");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderCourses: Course[] = [
|
||||||
|
{ id: "p1", title: "Introduction to Rust (Demo)", instructor_id: "demo", created_at: new Date().toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayCourses = courses.length > 0 ? courses : (loading ? [] : placeholderCourses);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold">My Courses</h2>
|
||||||
|
<p className="text-gray-400">Manage and create your learning content</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCreateCourse} className="btn-premium">
|
||||||
|
+ New Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/50 p-4 rounded-lg text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="col-span-full py-20 text-center text-gray-500">
|
||||||
|
<div className="animate-spin inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mb-4"></div>
|
||||||
|
<p>Loading your courses...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{displayCourses.map((course) => (
|
||||||
|
<Link href={`/courses/${course.id}`} key={course.id}>
|
||||||
|
<div className="glass p-6 hover:border-blue-500/50 transition-all group cursor-pointer h-full">
|
||||||
|
<div className="h-32 bg-gradient-to-br from-blue-900/50 to-purple-900/50 rounded-lg mb-4 flex items-center justify-center border border-white/5">
|
||||||
|
<span className="text-4xl group-hover:scale-110 transition-transform">📚</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2 group-hover:text-blue-400">{course.title}</h3>
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-white/5">
|
||||||
|
<span suppressHydrationWarning className="text-xs text-gray-500">
|
||||||
|
Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-blue-400">View Details →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={handleCreateCourse}
|
||||||
|
className="glass p-6 border-dashed border-2 border-white/10 flex flex-col items-center justify-center text-gray-500 hover:border-white/20 transition-all cursor-pointer min-h-[300px]"
|
||||||
|
>
|
||||||
|
<span className="text-3xl mb-2">➕</span>
|
||||||
|
<span className="text-sm">Add New Course</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { cmsApi } from "@/lib/api";
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
onUploadComplete: (url: string) => void;
|
||||||
|
currentUrl?: string;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const result = await cmsApi.uploadAsset(file);
|
||||||
|
onUploadComplete(result.url);
|
||||||
|
} catch (err) {
|
||||||
|
alert("Upload failed. Please try again.");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
handleUpload(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
handleUpload(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div
|
||||||
|
className={`relative group cursor-pointer border-2 border-dashed rounded-xl p-8 transition-all flex flex-col items-center justify-center gap-4 ${dragActive ? "border-blue-500 bg-blue-500/10 scale-[1.02]" : "border-white/10 hover:border-white/20 bg-white/5"
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-blue-400">Uploading Asset...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-bold text-gray-300">Drag & drop or <span className="text-blue-400 underline decoration-blue-500/30">browse</span></p>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Native video, audio files supported</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentUrl && !isUploading && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 glass bg-green-500/5 border-green-500/20 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<span className="text-sm">✅</span>
|
||||||
|
<span className="text-xs text-green-400 truncate font-medium">{currentUrl}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onUploadComplete(""); }}
|
||||||
|
className="text-[10px] uppercase font-black text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface MediaPlayerProps {
|
||||||
|
src: string | null;
|
||||||
|
type: string; // "video" | "audio"
|
||||||
|
transcription?: {
|
||||||
|
en?: string;
|
||||||
|
es?: string;
|
||||||
|
cues?: { start: number; end: number; text: string }[];
|
||||||
|
} | null;
|
||||||
|
locked?: boolean;
|
||||||
|
onEnded?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaPlayer({ src, type, transcription, locked, onEnded }: MediaPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [currentCaption, setCurrentCaption] = useState("");
|
||||||
|
const [language, setLanguage] = useState<"en" | "es">("en");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = type === "video" ? videoRef.current : audioRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (transcription?.cues) {
|
||||||
|
const activeCue = transcription.cues.find(cue =>
|
||||||
|
media.currentTime >= cue.start && media.currentTime <= cue.end
|
||||||
|
);
|
||||||
|
setCurrentCaption(activeCue?.text || "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
if (onEnded) onEnded();
|
||||||
|
};
|
||||||
|
|
||||||
|
media.addEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
media.addEventListener("ended", handleEnded);
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
media.removeEventListener("ended", handleEnded);
|
||||||
|
};
|
||||||
|
}, [type, transcription, onEnded]);
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className="glass aspect-video flex flex-col items-center justify-center border-dashed border-2 border-white/10 text-gray-500">
|
||||||
|
<span className="text-4xl mb-2">🎞️</span>
|
||||||
|
<p>No media file linked yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isYouTube = src.includes("youtube.com") || src.includes("youtu.be");
|
||||||
|
const isVimeo = src.includes("vimeo.com");
|
||||||
|
|
||||||
|
const getYouTubeId = (url: string) => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVimeoId = (url: string) => {
|
||||||
|
const match = url.match(/vimeo.com\/(\d+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isYouTube || isVimeo) {
|
||||||
|
let embedUrl = "";
|
||||||
|
if (isYouTube) {
|
||||||
|
const id = getYouTubeId(src);
|
||||||
|
embedUrl = `https://www.youtube.com/embed/${id}`;
|
||||||
|
} else {
|
||||||
|
const id = getVimeoId(src);
|
||||||
|
embedUrl = `https://player.vimeo.com/video/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 relative group">
|
||||||
|
<div className={`glass overflow-hidden border border-white/10 aspect-video ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
className="w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locked && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||||
|
<span className="text-3xl">🔒</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||||
|
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transcription && !locked && (
|
||||||
|
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||||
|
</h4>
|
||||||
|
<div className="flex bg-white/5 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguage("en")}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||||
|
>EN</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguage("es")}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||||
|
>ES</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||||
|
"{transcription[language] || "Transcription not available."}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 relative group">
|
||||||
|
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||||
|
{type === "video" ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className="w-full aspect-video object-cover"
|
||||||
|
controls={!locked}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 flex flex-col items-center justify-center bg-gradient-to-br from-blue-500/20 to-purple-500/20">
|
||||||
|
<audio ref={audioRef} src={src} controls={!locked} className="w-full max-w-md" />
|
||||||
|
<span className="text-xs text-gray-400 mt-6 uppercase tracking-[0.2em] font-medium">Audio Experience</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Caption Overlay */}
|
||||||
|
{currentCaption && type === "video" && !locked && (
|
||||||
|
<div className="absolute bottom-16 left-0 right-0 text-center px-8 pointer-events-none animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<span className="bg-black/80 text-white px-4 py-2 rounded-xl text-lg font-medium backdrop-blur-md border border-white/20 shadow-2xl">
|
||||||
|
{currentCaption}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locked && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||||
|
<span className="text-3xl">🔒</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||||
|
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transcription && !locked && (
|
||||||
|
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||||
|
</h4>
|
||||||
|
<div className="flex bg-white/5 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguage("en")}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||||
|
>EN</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguage("es")}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||||
|
>ES</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||||
|
"{transcription[language] || "Transcription not available."}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface DescriptionBlockProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
content: string;
|
||||||
|
editMode: boolean;
|
||||||
|
onChange: (updates: { title?: string; content?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DescriptionBlock({ title, content, editMode, onChange }: DescriptionBlockProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Block Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{editMode ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title || ""}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
placeholder="e.g. Introduction, Context..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
title && <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">{title}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editMode ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Text</label>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => onChange({ content: e.target.value })}
|
||||||
|
placeholder="Explain the activity to the students..."
|
||||||
|
className="w-full h-40 bg-white/5 border border-white/10 rounded-xl p-4 text-sm focus:border-blue-500/50 focus:outline-none transition-all resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-invert max-w-none">
|
||||||
|
<p className="text-gray-300 leading-relaxed text-lg">
|
||||||
|
{content || "No description provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import MediaPlayer from "../MediaPlayer";
|
||||||
|
import FileUpload from "../FileUpload";
|
||||||
|
|
||||||
|
interface MediaBlockProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
url: string;
|
||||||
|
type: 'video' | 'audio';
|
||||||
|
config: {
|
||||||
|
maxPlays?: number;
|
||||||
|
currentPlays?: number;
|
||||||
|
};
|
||||||
|
editMode: boolean;
|
||||||
|
onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number } }) => void;
|
||||||
|
transcription?: {
|
||||||
|
en?: string;
|
||||||
|
es?: string;
|
||||||
|
cues?: { start: number; end: number; text: string }[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription }: MediaBlockProps) {
|
||||||
|
const [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
|
||||||
|
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
|
||||||
|
const maxPlays = config.maxPlays || 0;
|
||||||
|
const isLocked = maxPlays > 0 && localPlays >= maxPlays;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
if (maxPlays > 0) {
|
||||||
|
const nextPlays = localPlays + 1;
|
||||||
|
setLocalPlays(nextPlays);
|
||||||
|
onChange({ config: { ...config, currentPlays: nextPlays } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full URL for display (handles relative paths from server)
|
||||||
|
const displayUrl = url.startsWith("/") ? `http://localhost:3001${url}` : url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Block Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{editMode ? (
|
||||||
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title || ""}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
placeholder="e.g. Explainer Video, Audio Guide..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
title && <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">{title}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editMode && (
|
||||||
|
<div className="space-y-6 p-6 glass border-blue-500/10 mb-8 bg-blue-500/5">
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceType("url")}
|
||||||
|
className={`px-4 py-2 text-[10px] uppercase font-black tracking-widest rounded-lg transition-all ${sourceType === "url" ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
External URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceType("upload")}
|
||||||
|
className={`px-4 py-2 text-[10px] uppercase font-black tracking-widest rounded-lg transition-all ${sourceType === "upload" ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{sourceType === "url" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Media URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url.startsWith("/") ? "" : url}
|
||||||
|
onChange={(e) => onChange({ url: e.target.value })}
|
||||||
|
placeholder="YouTube, Vimeo or static link"
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:border-blue-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">File Manager</label>
|
||||||
|
<FileUpload
|
||||||
|
currentUrl={url.startsWith("/") ? url : undefined}
|
||||||
|
onUploadComplete={(newUrl) => onChange({ url: newUrl })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Playback Limit (0 = Unlimited)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxPlays}
|
||||||
|
onChange={(e) => onChange({ config: { ...config, maxPlays: parseInt(e.target.value) || 0 } })}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:border-blue-500/50 focus:outline-none h-11"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Prevent content fatigue by limiting how many times a student can watch/listen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<MediaPlayer
|
||||||
|
src={displayUrl}
|
||||||
|
type={type}
|
||||||
|
transcription={transcription}
|
||||||
|
locked={isLocked}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!editMode && maxPlays > 0 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between px-4 py-2 glass bg-white/5 border-white/5 rounded-lg">
|
||||||
|
<span className="text-xs text-gray-500 uppercase font-medium">Plays Remaining</span>
|
||||||
|
<span className={`text-sm font-bold ${maxPlays - localPlays <= 1 ? 'text-orange-400' : 'text-blue-400'}`}>
|
||||||
|
{Math.max(0, maxPlays - localPlays)} / {maxPlays}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizBlockProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
quizData: {
|
||||||
|
questions: QuizQuestion[];
|
||||||
|
};
|
||||||
|
editMode: boolean;
|
||||||
|
onChange: (data: { title?: string; questions?: QuizQuestion[] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuizBlock({ id, title, quizData, editMode, onChange }: QuizBlockProps) {
|
||||||
|
const [userAnswers, setUserAnswers] = useState<Record<string, number>>({});
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const questions = quizData.questions || [];
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const newQuestion: QuizQuestion = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
question: "New Question?",
|
||||||
|
options: ["Option 1", "Option 2"],
|
||||||
|
correct: 0
|
||||||
|
};
|
||||||
|
onChange({ questions: [...questions, newQuestion] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => {
|
||||||
|
const newQuestions = [...questions];
|
||||||
|
newQuestions[index] = { ...newQuestions[index], ...updates };
|
||||||
|
onChange({ questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnswer = (qId: string, optionIndex: number) => {
|
||||||
|
if (submitted) return;
|
||||||
|
setUserAnswers(prev => ({ ...prev, [qId]: optionIndex }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8" id={id}>
|
||||||
|
{/* Block Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{editMode ? (
|
||||||
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title || ""}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
placeholder="e.g. Final Evaluation, Knowledge Check..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">
|
||||||
|
{title || "Knowledge Check"}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editMode ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{questions.map((q, idx) => (
|
||||||
|
<div key={q.id} className="p-6 glass border-white/5 space-y-4 rounded-2xl">
|
||||||
|
<input
|
||||||
|
value={q.question}
|
||||||
|
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl p-3 font-semibold focus:outline-none focus:border-blue-500/50 transition-all"
|
||||||
|
placeholder="Enter your question..."
|
||||||
|
/>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{q.options.map((opt, oIdx) => (
|
||||||
|
<div key={oIdx} className="flex gap-3 items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={q.correct === oIdx}
|
||||||
|
onChange={() => updateQuestion(idx, { correct: oIdx })}
|
||||||
|
className="w-4 h-4 accent-blue-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOpts = [...q.options];
|
||||||
|
newOpts[oIdx] = e.target.value;
|
||||||
|
updateQuestion(idx, { options: newOpts });
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30"
|
||||||
|
placeholder={`Option ${oIdx + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="w-full py-4 glass border-dashed border-2 border-white/10 text-gray-500 hover:text-white hover:border-blue-500/30 hover:bg-blue-500/5 transition-all font-bold text-xs uppercase tracking-widest rounded-2xl"
|
||||||
|
>
|
||||||
|
+ Add Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{questions.map((q) => (
|
||||||
|
<div key={q.id} className="space-y-4 p-6 glass border-white/5 rounded-2xl">
|
||||||
|
<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] === oIdx;
|
||||||
|
const isCorrect = q.correct === oIdx;
|
||||||
|
let style = "glass border-white/10 hover:bg-white/5";
|
||||||
|
if (submitted) {
|
||||||
|
if (isCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
|
||||||
|
else if (isSelected && !isCorrect) style = "bg-red-500/20 border-red-500 text-red-100";
|
||||||
|
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)}
|
||||||
|
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</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-2xl"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export const API_BASE_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
export interface Course {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
instructor_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Module {
|
||||||
|
id: string;
|
||||||
|
course_id: string;
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
created_at: string;
|
||||||
|
lessons: Lesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
id: string;
|
||||||
|
type: 'description' | 'media' | 'quiz';
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
url?: string;
|
||||||
|
media_type?: 'video' | 'audio';
|
||||||
|
config?: {
|
||||||
|
maxPlays?: number;
|
||||||
|
currentPlays?: number;
|
||||||
|
allowDownload?: boolean;
|
||||||
|
};
|
||||||
|
quiz_data?: {
|
||||||
|
questions: {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correct: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lesson {
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
title: string;
|
||||||
|
content_type: string;
|
||||||
|
content_url: string | null;
|
||||||
|
transcription?: {
|
||||||
|
en?: string;
|
||||||
|
es?: string;
|
||||||
|
cues?: { start: number; end: number; text: string }[];
|
||||||
|
} | null;
|
||||||
|
metadata?: {
|
||||||
|
blocks?: Block[];
|
||||||
|
} | null;
|
||||||
|
position: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cmsApi = {
|
||||||
|
async getCourses(): Promise<Course[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/courses`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch courses');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCourse(title: string): Promise<Course> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/courses`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create course');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseWithFullOutline(courseId: string): Promise<Course & { modules: Module[] }> {
|
||||||
|
const course = await fetch(`${API_BASE_URL}/courses/${courseId}`).then(res => res.json());
|
||||||
|
const modules = await fetch(`${API_BASE_URL}/modules?course_id=${courseId}`).then(res => res.json());
|
||||||
|
|
||||||
|
const modulesWithLessons = await Promise.all(modules.map(async (m: Module) => {
|
||||||
|
const lessons = await fetch(`${API_BASE_URL}/lessons?module_id=${m.id}`).then(res => res.json());
|
||||||
|
return { ...m, lessons };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ...course, modules: modulesWithLessons };
|
||||||
|
},
|
||||||
|
|
||||||
|
async createModule(courseId: string, title: string, position: number): Promise<Module> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ course_id: courseId, title, position }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create module');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLesson(moduleId: string, title: string, contentType: string, position: number): Promise<Lesson> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/lessons`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ module_id: moduleId, title, content_type: contentType, position }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create lesson');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async transcribeLesson(lessonId: string): Promise<Lesson> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}/transcribe`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to transcribe lesson');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateLesson(lessonId: string, updates: Partial<Lesson>): Promise<Lesson> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/lessons/${lessonId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to update lesson');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAsset(file: File): Promise<{ id: string; filename: string; url: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/assets/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
|
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,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"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"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user