feat: Introduce new interactive content blocks including Fill-in-the-Blanks, Short Answer, Ordering, and Matching, with corresponding API, database, and UI integration.

This commit is contained in:
2025-12-19 17:03:26 -03:00
parent 0988213eb7
commit 57b8d7c0a1
17 changed files with 1513 additions and 32 deletions
Generated
+387
View File
@@ -219,6 +219,7 @@ dependencies = [
"chrono", "chrono",
"common", "common",
"dotenvy", "dotenvy",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@@ -257,6 +258,16 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -385,6 +396,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "etcetera" name = "etcetera"
version = "0.8.0" version = "0.8.0"
@@ -407,6 +428,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.5" version = "0.1.5"
@@ -424,12 +451,33 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -546,6 +594,25 @@ dependencies = [
"wasip2", "wasip2",
] ]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -672,6 +739,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -681,6 +749,39 @@ dependencies = [
"pin-utils", "pin-utils",
"smallvec", "smallvec",
"tokio", "tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
] ]
[[package]] [[package]]
@@ -689,14 +790,24 @@ version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [ dependencies = [
"base64",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"hyper", "hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -844,6 +955,22 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -917,6 +1044,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -1031,6 +1164,23 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1108,6 +1258,50 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1314,6 +1508,46 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.12.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1348,6 +1582,19 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.35" version = "0.23.35"
@@ -1394,12 +1641,44 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -1815,6 +2094,9 @@ name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
@@ -1827,6 +2109,40 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.17" version = "2.0.17"
@@ -1940,6 +2256,26 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.17" version = "0.1.17"
@@ -1995,12 +2331,14 @@ dependencies = [
"http-body-util", "http-body-util",
"http-range-header", "http-range-header",
"httpdate", "httpdate",
"iri-string",
"mime", "mime",
"mime_guess", "mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -2080,6 +2418,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@@ -2173,6 +2517,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@@ -2207,6 +2560,19 @@ dependencies = [
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.106" version = "0.2.106"
@@ -2239,6 +2605,16 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "web-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@@ -2308,6 +2684,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
+1
View File
@@ -25,3 +25,4 @@ jsonwebtoken = "9.3"
bcrypt = "0.17" bcrypt = "0.17"
dotenvy = "0.15" dotenvy = "0.15"
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
reqwest = { version = "0.12", features = ["json"] }
+1
View File
@@ -17,3 +17,4 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
dotenvy.workspace = true dotenvy.workspace = true
tower-http.workspace = true tower-http.workspace = true
reqwest.workspace = true
+62 -1
View File
@@ -3,12 +3,73 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use common::models::{Course, Module, Lesson}; use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use serde_json::json; use serde_json::json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub async fn publish_course(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut pub_modules = Vec::new();
// 3. Fetch Lessons for each Module
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
pub_modules.push(PublishedModule {
module,
lessons,
});
}
let payload = PublishedCourse {
course,
modules: pub_modules,
};
// 4. Send to LMS
// Using service name for Docker compatibility
let client = reqwest::Client::new();
let res = client.post("http://lms-service:3002/ingest")
.json(&payload)
.send()
.await
.map_err(|e| {
tracing::error!("Failed to reach LMS service: {}", e);
StatusCode::BAD_GATEWAY
})?;
if !res.status().is_success() {
tracing::error!("LMS ingestion failed with status: {}", res.status());
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
log_action(&pool, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await;
Ok(StatusCode::OK)
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ModuleQuery { pub struct ModuleQuery {
pub course_id: Option<Uuid>, pub course_id: Option<Uuid>,
+7
View File
@@ -22,6 +22,12 @@ async fn main() {
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
// Run migrations automatically
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
.allow_methods(Any) .allow_methods(Any)
@@ -30,6 +36,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/courses", get(handlers::get_courses).post(handlers::create_course)) .route("/courses", get(handlers::get_courses).post(handlers::create_course))
.route("/courses/{id}", get(handlers::get_course)) .route("/courses/{id}", get(handlers::get_course))
.route("/courses/{id}/publish", post(handlers::publish_course))
.route("/modules", get(handlers::get_modules).post(handlers::create_module)) .route("/modules", get(handlers::get_modules).post(handlers::create_module))
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson)) .route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson)) .route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
@@ -0,0 +1,32 @@
-- Mirrored schema for courses, modules, and lessons in the LMS
-- This table stores the published version of the content
CREATE TABLE courses (
id UUID PRIMARY KEY, -- Using the same ID as CMS
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,
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,
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content_type TEXT NOT NULL,
content_url TEXT,
transcription JSONB,
metadata JSONB,
position INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+135 -2
View File
@@ -1,9 +1,9 @@
use axum::{ use axum::{
extract::State, extract::{State, Path},
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use common::models::{Course, Enrollment}; use common::models::{Course, Enrollment, Module, Lesson};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -37,3 +37,136 @@ pub async fn get_course_catalog(
Ok(Json(courses)) Ok(Json(courses))
} }
pub async fn ingest_course(
State(pool): State<PgPool>,
Json(payload): Json<common::models::PublishedCourse>,
) -> Result<StatusCode, StatusCode> {
let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 1. Upsert Course
sqlx::query(
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
instructor_id = EXCLUDED.instructor_id,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
updated_at = EXCLUDED.updated_at"
)
.bind(payload.course.id)
.bind(&payload.course.title)
.bind(&payload.course.description)
.bind(payload.course.instructor_id)
.bind(payload.course.start_date)
.bind(payload.course.end_date)
.bind(payload.course.updated_at)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to upsert course during ingestion: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 2. Clear existing modules and lessons for this course to ensure perfect sync
// Cascading delete on courses(id) handles lessons too
sqlx::query("DELETE FROM modules WHERE course_id = $1")
.bind(payload.course.id)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 3. Insert Modules and Lessons
for pub_module in payload.modules {
sqlx::query(
"INSERT INTO modules (id, course_id, title, position, created_at)
VALUES ($1, $2, $3, $4, $5)"
)
.bind(pub_module.module.id)
.bind(payload.course.id)
.bind(&pub_module.module.title)
.bind(pub_module.module.position)
.bind(pub_module.module.created_at)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for lesson in pub_module.lessons {
sqlx::query(
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
)
.bind(lesson.id)
.bind(pub_module.module.id)
.bind(&lesson.title)
.bind(&lesson.content_type)
.bind(&lesson.content_url)
.bind(&lesson.transcription)
.bind(&lesson.metadata)
.bind(lesson.position)
.bind(lesson.created_at)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
}
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}
pub async fn get_course_outline(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut pub_modules = Vec::new();
// 3. Fetch Lessons
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
pub_modules.push(common::models::PublishedModule {
module,
lessons,
});
}
Ok(Json(common::models::PublishedCourse {
course,
modules: pub_modules,
}))
}
pub async fn get_lesson_content(
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))
}
+9
View File
@@ -21,9 +21,18 @@ async fn main() {
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
// Run migrations automatically
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
let app = Router::new() let app = Router::new()
.route("/catalog", get(handlers::get_course_catalog)) .route("/catalog", get(handlers::get_course_catalog))
.route("/enroll", post(handlers::enroll_user)) .route("/enroll", post(handlers::enroll_user))
.route("/ingest", post(handlers::ingest_course))
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.with_state(pool); .with_state(pool);
let addr = SocketAddr::from(([0, 0, 0, 0], 3002)); let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
+81
View File
@@ -64,3 +64,84 @@ pub struct Asset {
pub size_bytes: i64, pub size_bytes: i64,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct PublishedCourse {
pub course: Course,
pub modules: Vec<PublishedModule>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PublishedModule {
pub module: Module,
pub lessons: Vec<Lesson>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_published_course_serialization() {
let lesson_id = Uuid::new_v4();
let module_id = Uuid::new_v4();
let course_id = Uuid::new_v4();
let lesson = Lesson {
id: lesson_id,
module_id,
title: "Test Lesson".to_string(),
content_type: "activity".to_string(),
content_url: None,
transcription: None,
metadata: Some(json!({
"blocks": [
{
"id": "b1",
"type": "fill-in-the-blanks",
"content": "The capital of France is [[Paris]]."
},
{
"id": "b2",
"type": "matching",
"pairs": [{"left": "Term", "right": "Definition"}]
}
]
})),
position: 1,
created_at: Utc::now(),
};
let pub_module = PublishedModule {
module: Module {
id: module_id,
course_id,
title: "Test Module".to_string(),
position: 1,
created_at: Utc::now(),
},
lessons: vec![lesson],
};
let pub_course = PublishedCourse {
course: Course {
id: course_id,
title: "Test Course".to_string(),
description: None,
instructor_id: Uuid::new_v4(),
start_date: None,
end_date: None,
created_at: Utc::now(),
updated_at: Utc::now(),
},
modules: vec![pub_module],
};
let serialized = serde_json::to_string(&pub_course).unwrap();
let deserialized: PublishedCourse = serde_json::from_str(&serialized).unwrap();
assert_eq!(pub_course.course.title, deserialized.course.title);
assert_eq!(pub_course.modules.len(), deserialized.modules.len());
assert_eq!(deserialized.modules[0].lessons[0].title, "Test Lesson");
}
}
@@ -6,6 +6,10 @@ import Link from "next/link";
import DescriptionBlock from "@/components/blocks/DescriptionBlock"; import DescriptionBlock from "@/components/blocks/DescriptionBlock";
import MediaBlock from "@/components/blocks/MediaBlock"; import MediaBlock from "@/components/blocks/MediaBlock";
import QuizBlock from "@/components/blocks/QuizBlock"; import QuizBlock from "@/components/blocks/QuizBlock";
import FillInTheBlanksBlock from "@/components/blocks/FillInTheBlanksBlock";
import MatchingBlock from "@/components/blocks/MatchingBlock";
import OrderingBlock from "@/components/blocks/OrderingBlock";
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) { export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const [lesson, setLesson] = useState<Lesson | null>(null); const [lesson, setLesson] = useState<Lesson | null>(null);
@@ -58,13 +62,17 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
} }
}; };
const addBlock = (type: 'description' | 'media' | 'quiz') => { const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer') => {
const newBlock: Block = { const newBlock: Block = {
id: Math.random().toString(36).substr(2, 9), id: Math.random().toString(36).substr(2, 9),
type, type,
...(type === 'description' && { content: "" }), ...(type === 'description' && { content: "" }),
...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }), ...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }),
...(type === 'quiz' && { quiz_data: { questions: [] } }), ...(type === 'quiz' && { quiz_data: { questions: [] } }),
...(type === 'fill-in-the-blanks' && { content: "Type your text here with [[blanks]]." }),
...(type === 'matching' && { pairs: [{ left: "Item 1", right: "Match 1" }] }),
...(type === 'ordering' && { items: ["Item A", "Item B"] }),
...(type === 'short-answer' && { prompt: "Question?", correctAnswers: ["Answer"] }),
}; };
setBlocks([...blocks, newBlock]); setBlocks([...blocks, newBlock]);
}; };
@@ -181,6 +189,43 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
onChange={(updates) => updateBlock(block.id, updates)} onChange={(updates) => updateBlock(block.id, updates)}
/> />
)} )}
{block.type === 'fill-in-the-blanks' && (
<FillInTheBlanksBlock
id={block.id}
title={block.title}
content={block.content || ""}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'matching' && (
<MatchingBlock
id={block.id}
title={block.title}
pairs={block.pairs || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'ordering' && (
<OrderingBlock
id={block.id}
title={block.title}
items={block.items || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'short-answer' && (
<ShortAnswerBlock
id={block.id}
title={block.title}
prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div> </div>
</div> </div>
))} ))}
@@ -211,6 +256,34 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<span className="text-2xl group-hover:scale-110 transition-transform">💡</span> <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> <span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Quiz</span>
</button> </button>
<button
onClick={() => addBlock('fill-in-the-blanks')}
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">Blanks</span>
</button>
<button
onClick={() => addBlock('matching')}
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">Match</span>
</button>
<button
onClick={() => addBlock('ordering')}
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">Order</span>
</button>
<button
onClick={() => addBlock('short-answer')}
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">Short</span>
</button>
</div> </div>
</div> </div>
</div> </div>
+31 -1
View File
@@ -72,11 +72,28 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
} }
}; };
const [isPublishing, setIsPublishing] = useState(false);
const handlePublish = async () => {
if (!course) return;
setIsPublishing(true);
try {
await cmsApi.publishCourse(params.id);
alert("Course published successfully to LMS!");
} catch (err) {
console.error("Publish failed:", err);
alert("Failed to publish course. Check if LMS service is reachable.");
} finally {
setIsPublishing(false);
}
};
if (loading) return <div className="py-20 text-center">Loading editor...</div>; 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>; if (error) return <div className="py-20 text-center text-red-400">{error}</div>;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* ... navigation ... */}
<div className="flex items-center gap-4 text-sm text-gray-400"> <div className="flex items-center gap-4 text-sm text-gray-400">
<Link href="/" className="hover:text-white cursor-pointer underline">Courses</Link> <Link href="/" className="hover:text-white cursor-pointer underline">Courses</Link>
<span>/</span> <span>/</span>
@@ -90,7 +107,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
</div> </div>
<div className="flex gap-3"> <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="px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">Preview</button>
<button className="btn-premium">Publish</button> <button
onClick={handlePublish}
disabled={isPublishing}
className={`btn-premium flex items-center gap-2 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
>
{isPublishing ? (
<>
<span className="animate-spin text-lg"></span>
Publishing...
</>
) : (
"Publish to LMS"
)}
</button>
</div> </div>
</div> </div>
@@ -0,0 +1,132 @@
"use client";
import { useState, useMemo } from "react";
interface FillInTheBlanksBlockProps {
id: string;
title?: string;
content: string;
editMode: boolean;
onChange: (updates: { title?: string; content?: string }) => void;
}
export default function FillInTheBlanksBlock({ id, title, content, editMode, onChange }: FillInTheBlanksBlockProps) {
const [userAnswers, setUserAnswers] = useState<string[]>([]);
const [submitted, setSubmitted] = useState(false);
// Parse content to find blanks
const parsed = useMemo(() => {
const parts: { type: 'text' | 'blank'; value?: string; index?: number; answer?: string }[] = [];
const answers: string[] = [];
let lastIndex = 0;
const regex = /\[\[(.*?)\]\]/g;
let match;
while ((match = regex.exec(content)) !== null) {
// Push text before the blank
parts.push({ type: 'text', value: content.substring(lastIndex, match.index) });
// Push the blank
const answer = match[1];
parts.push({ type: 'blank', index: answers.length, answer });
answers.push(answer);
lastIndex = regex.lastIndex;
}
// Push remaining text
parts.push({ type: 'text', value: content.substring(lastIndex) });
return { parts, answers };
}, [content]);
const handleReset = () => {
setSubmitted(false);
setUserAnswers([]);
};
const isCorrect = (index: number) => {
return userAnswers[index]?.trim().toLowerCase() === parsed.answers[index]?.trim().toLowerCase();
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
{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. Fill the gaps, Quote..."
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 || "Fill in the Blanks"}
</h3>
)}
</div>
{editMode ? (
<div className="space-y-4">
<div className="p-6 glass border-white/5 space-y-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Text with blanks (use [[answer]])</label>
<textarea
value={content}
onChange={(e) => onChange({ content: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[150px] text-lg font-medium focus:outline-none focus:border-blue-500/50 transition-all"
placeholder="Example: The [[capital]] of France is [[Paris]]."
/>
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Tip: Surround any word with double brackets to create a blank.</p>
</div>
</div>
) : (
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
<div className="text-lg leading-loose text-gray-100">
{parsed.parts.map((part, i) => (
part.type === 'text' ? (
<span key={i}>{part.value}</span>
) : (
<input
key={i}
type="text"
value={userAnswers[part.index!] || ""}
onChange={(e) => {
const newAnswers = [...userAnswers];
newAnswers[part.index!] = e.target.value;
setUserAnswers(newAnswers);
}}
disabled={submitted}
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
}`}
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
placeholder="..."
/>
)
))}
</div>
{!submitted && parsed.answers.length > 0 && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Answers
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
>
Try Again
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useState, useMemo } from "react";
interface MatchingPair {
left: string;
right: string;
}
interface MatchingBlockProps {
id: string;
title?: string;
pairs: MatchingPair[];
editMode: boolean;
onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void;
}
export default function MatchingBlock({ id, title, pairs, editMode, onChange }: MatchingBlockProps) {
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
const [matches, setMatches] = useState<Record<number, number>>({}); // leftIdx -> rightIdx
const [submitted, setSubmitted] = useState(false);
// Shuffled right items for the game
const shuffledRight = useMemo(() => {
return pairs
.map((p, i) => ({ value: p.right, originalIdx: i }))
.sort(() => Math.random() - 0.5);
}, [pairs]);
const handleMatch = (leftIdx: number, rightIdx: number) => {
if (submitted) return;
setMatches(prev => ({ ...prev, [leftIdx]: rightIdx }));
setSelectedLeft(null);
};
const handleReset = () => {
setSubmitted(false);
setMatches({});
setSelectedLeft(null);
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
{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. Match the concepts..."
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 || "Concept Matching"}
</h3>
)}
</div>
{editMode ? (
<div className="space-y-4">
{pairs.map((pair, idx) => (
<div key={idx} className="flex gap-4 items-center animate-in slide-in-from-left-4 duration-300">
<input
value={pair.left}
onChange={(e) => {
const newPairs = [...pairs];
newPairs[idx].left = e.target.value;
onChange({ pairs: newPairs });
}}
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:border-blue-500/50 focus:outline-none"
placeholder="Term A"
/>
<span className="text-gray-500 font-bold"></span>
<input
value={pair.right}
onChange={(e) => {
const newPairs = [...pairs];
newPairs[idx].right = e.target.value;
onChange({ pairs: newPairs });
}}
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:border-blue-500/50 focus:outline-none"
placeholder="Definition B"
/>
<button
onClick={() => {
const newPairs = pairs.filter((_, i) => i !== idx);
onChange({ pairs: newPairs });
}}
className="p-2 text-gray-500 hover:text-red-400"
>
×
</button>
</div>
))}
<button
onClick={() => onChange({ pairs: [...pairs, { left: "", right: "" }] })}
className="w-full py-4 border-dashed border-2 border-white/10 text-gray-400 hover:text-white hover:border-blue-500/30 transition-all font-bold text-xs uppercase tracking-widest rounded-xl"
>
+ Add Pair
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 p-8 glass border-white/5 rounded-3xl relative">
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Term</label>
{pairs.map((pair, i) => (
<button
key={i}
onClick={() => !submitted && setSelectedLeft(i)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
}`}
>
{pair.left}
</button>
))}
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Definition</label>
{shuffledRight.map((item, i) => {
const matchedLeftIdx = Object.keys(matches).find(k => matches[parseInt(k)] === item.originalIdx);
const isCorrect = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) === item.originalIdx;
const isWrong = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) !== item.originalIdx;
return (
<button
key={i}
disabled={selectedLeft === null || submitted}
onClick={() => handleMatch(selectedLeft!, item.originalIdx)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft !== null && matchedLeftIdx === undefined ? "hover:border-blue-500/50 hover:bg-white/5" : ""
} ${isCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isWrong ? "border-red-500 bg-red-500/20 text-red-100" :
matchedLeftIdx !== undefined ? "border-blue-500/30 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200"
}`}
>
<div className="flex items-center justify-between">
<span>{item.value}</span>
{isCorrect && <span></span>}
{isWrong && <span></span>}
</div>
</button>
);
})}
</div>
<div className="md:col-span-2 pt-8 border-t border-white/5">
{!submitted && Object.keys(matches).length === pairs.length && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Matching
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,192 @@
"use client";
import { useState, useMemo } from "react";
interface OrderingBlockProps {
id: string;
title?: string;
items: string[];
editMode: boolean;
onChange: (updates: { title?: string; items?: string[] }) => void;
}
export default function OrderingBlock({ id, title, items, editMode, onChange }: OrderingBlockProps) {
const [userOrder, setUserOrder] = useState<number[]>([]); // Array of original indices in user-selected order
const [submitted, setSubmitted] = useState(false);
// Shuffled items for the start
const shuffledItems = useMemo(() => {
return items
.map((item, i) => ({ value: item, originalIdx: i }))
.sort(() => Math.random() - 0.5);
}, [items]);
const handlePick = (originalIdx: number) => {
if (submitted) return;
if (userOrder.includes(originalIdx)) {
setUserOrder(userOrder.filter(i => i !== originalIdx));
} else {
setUserOrder([...userOrder, originalIdx]);
}
};
const handleReset = () => {
setSubmitted(false);
setUserOrder([]);
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
{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. Sequence of Events..."
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 || "Sequence Ordering"}
</h3>
)}
</div>
{editMode ? (
<div className="space-y-4">
<p className="text-[10px] text-gray-500 uppercase tracking-widest mb-2">Define items in their CORRECT order:</p>
{items.map((item, idx) => (
<div key={idx} className="flex gap-4 items-center animate-in slide-in-from-left-4 duration-300">
<span className="text-blue-500 font-black w-6">{idx + 1}.</span>
<input
value={item}
onChange={(e) => {
const newItems = [...items];
newItems[idx] = e.target.value;
onChange({ items: newItems });
}}
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:border-blue-500/50 focus:outline-none"
placeholder={`Step ${idx + 1}`}
/>
<div className="flex gap-2">
<button
disabled={idx === 0}
onClick={() => {
const newItems = [...items];
[newItems[idx], newItems[idx - 1]] = [newItems[idx - 1], newItems[idx]];
onChange({ items: newItems });
}}
className="p-2 text-gray-500 hover:text-white disabled:opacity-20"
>
</button>
<button
disabled={idx === items.length - 1}
onClick={() => {
const newItems = [...items];
[newItems[idx], newItems[idx + 1]] = [newItems[idx + 1], newItems[idx]];
onChange({ items: newItems });
}}
className="p-2 text-gray-500 hover:text-white disabled:opacity-20"
>
</button>
</div>
<button
onClick={() => {
const newItems = items.filter((_, i) => i !== idx);
onChange({ items: newItems });
}}
className="p-2 text-gray-500 hover:text-red-400"
>
×
</button>
</div>
))}
<button
onClick={() => onChange({ items: [...items, ""] })}
className="w-full py-4 border-dashed border-2 border-white/10 text-gray-400 hover:text-white hover:border-blue-500/30 transition-all font-bold text-xs uppercase tracking-widest rounded-xl"
>
+ Add Step
</button>
</div>
) : (
<div className="space-y-8 p-8 glass border-white/5 rounded-3xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Available Items</label>
<div className="flex flex-wrap gap-3">
{shuffledItems.map((item, i) => {
const isPicked = userOrder.includes(item.originalIdx);
return (
<button
key={i}
disabled={isPicked || submitted}
onClick={() => handlePick(item.originalIdx)}
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
}`}
>
{item.value}
</button>
);
})}
</div>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Your Sequence</label>
<div className="space-y-3">
{userOrder.length === 0 && <p className="text-xs text-gray-600 italic py-4">Click items to build the sequence...</p>}
{userOrder.map((idx, i) => {
const isItemCorrect = submitted && idx === i;
const isItemWrong = submitted && idx !== i;
return (
<div
key={i}
onClick={() => !submitted && handlePick(idx)}
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
}`}
>
<span className="opacity-50 text-xs">{i + 1}.</span>
<span className="flex-1">{items[idx]}</span>
{!submitted && <span className="text-xs opacity-50">×</span>}
{isItemCorrect && <span></span>}
{isItemWrong && <span></span>}
</div>
);
})}
</div>
</div>
</div>
<div className="pt-8 border-t border-white/5">
{!submitted && userOrder.length === items.length && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Sequence
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
)}
</div>
);
}
+64 -25
View File
@@ -6,8 +6,8 @@ interface QuizQuestion {
id: string; id: string;
question: string; question: string;
options: string[]; options: string[];
correct: number; correct: number[];
type?: 'multiple-choice' | 'true-false'; type?: 'multiple-choice' | 'true-false' | 'multiple-select';
} }
interface QuizBlockProps { interface QuizBlockProps {
@@ -17,11 +17,11 @@ interface QuizBlockProps {
questions: QuizQuestion[]; questions: QuizQuestion[];
}; };
editMode: boolean; editMode: boolean;
onChange: (data: { title?: string; questions?: QuizQuestion[] }) => void; onChange: (data: { title?: string; quiz_data?: { questions: QuizQuestion[] } }) => void;
} }
export default function QuizBlock({ id, title, quizData, editMode, onChange }: QuizBlockProps) { export default function QuizBlock({ id, title, quizData, editMode, onChange }: QuizBlockProps) {
const [userAnswers, setUserAnswers] = useState<Record<string, number>>({}); const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const questions = quizData.questions || []; const questions = quizData.questions || [];
@@ -31,21 +31,43 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
id: Math.random().toString(36).substr(2, 9), id: Math.random().toString(36).substr(2, 9),
question: "New Question?", question: "New Question?",
options: ["Option 1", "Option 2"], options: ["Option 1", "Option 2"],
correct: 0, correct: [0],
type: 'multiple-choice' type: 'multiple-choice'
}; };
onChange({ questions: [...questions, newQuestion] }); onChange({ quiz_data: { questions: [...questions, newQuestion] } });
}; };
const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => { const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => {
const newQuestions = [...questions]; const newQuestions = [...questions];
newQuestions[index] = { ...newQuestions[index], ...updates }; newQuestions[index] = { ...newQuestions[index], ...updates };
onChange({ questions: newQuestions }); onChange({ quiz_data: { questions: newQuestions } });
}; };
const handleAnswer = (qId: string, optionIndex: number) => { const toggleCorrectOption = (qIdx: number, optIdx: number, isMulti: boolean) => {
const current = questions[qIdx].correct || [];
if (isMulti) {
const next = current.includes(optIdx)
? current.filter(i => i !== optIdx)
: [...current, optIdx].sort((a, b) => a - b);
updateQuestion(qIdx, { correct: next.length ? next : [0] });
} else {
updateQuestion(qIdx, { correct: [optIdx] });
}
};
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
if (submitted) return; if (submitted) return;
setUserAnswers(prev => ({ ...prev, [qId]: optionIndex })); setUserAnswers(prev => {
const current = prev[qId] || [];
if (isMulti) {
const next = current.includes(optionIndex)
? current.filter(i => i !== optionIndex)
: [...current, optionIndex].sort((a, b) => a - b);
return { ...prev, [qId]: next };
} else {
return { ...prev, [qId]: [optionIndex] };
}
});
}; };
return ( return (
@@ -77,13 +99,19 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex bg-white/5 rounded-lg p-1 border border-white/5"> <div className="flex bg-white/5 rounded-lg p-1 border border-white/5">
<button <button
onClick={() => updateQuestion(idx, { type: 'multiple-choice' })} onClick={() => updateQuestion(idx, { type: 'multiple-choice', correct: [q.correct?.[0] || 0] })}
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type !== 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`} className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'multiple-choice' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
> >
MCQ MCQ
</button> </button>
<button <button
onClick={() => updateQuestion(idx, { type: 'true-false', options: ["True", "False"], correct: 0 })} onClick={() => updateQuestion(idx, { type: 'multiple-select', correct: [q.correct?.[0] || 0] })}
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'multiple-select' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
>
MSQ
</button>
<button
onClick={() => updateQuestion(idx, { type: 'true-false', options: ["True", "False"], correct: [0] })}
className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`} className={`px-3 py-1.5 text-[9px] uppercase font-black tracking-widest rounded-md transition-all ${q.type === 'true-false' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
> >
T / F T / F
@@ -92,7 +120,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<button <button
onClick={() => { onClick={() => {
const newQuestions = questions.filter((_, i) => i !== idx); const newQuestions = questions.filter((_, i) => i !== idx);
onChange({ questions: newQuestions }); onChange({ quiz_data: { questions: newQuestions } });
}} }}
className="p-2 text-gray-500 hover:text-red-400 transition-colors" className="p-2 text-gray-500 hover:text-red-400 transition-colors"
> >
@@ -113,8 +141,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
{["True", "False"].map((opt, oIdx) => ( {["True", "False"].map((opt, oIdx) => (
<button <button
key={oIdx} key={oIdx}
onClick={() => updateQuestion(idx, { correct: oIdx })} onClick={() => updateQuestion(idx, { correct: [oIdx] })}
className={`flex-1 py-4 rounded-xl border-2 transition-all font-black text-xs uppercase tracking-widest ${q.correct === oIdx ? "border-blue-500 bg-blue-500/10 text-white" : "border-white/5 bg-white/5 text-gray-500"}`} className={`flex-1 py-4 rounded-xl border-2 transition-all font-black text-xs uppercase tracking-widest ${q.correct?.includes(oIdx) ? "border-blue-500 bg-blue-500/10 text-white" : "border-white/5 bg-white/5 text-gray-500"}`}
> >
{opt} {opt}
</button> </button>
@@ -124,9 +152,9 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
q.options.map((opt, oIdx) => ( q.options.map((opt, oIdx) => (
<div key={oIdx} className="flex gap-3 items-center group/opt"> <div key={oIdx} className="flex gap-3 items-center group/opt">
<input <input
type="radio" type={q.type === 'multiple-select' ? "checkbox" : "radio"}
checked={q.correct === oIdx} checked={q.correct?.includes(oIdx)}
onChange={() => updateQuestion(idx, { correct: oIdx })} onChange={() => toggleCorrectOption(idx, oIdx, q.type === 'multiple-select')}
className="w-5 h-5 accent-blue-500 cursor-pointer" className="w-5 h-5 accent-blue-500 cursor-pointer"
/> />
<input <input
@@ -143,7 +171,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<button <button
onClick={() => { onClick={() => {
const newOpts = q.options.filter((_, i) => i !== oIdx); const newOpts = q.options.filter((_, i) => i !== oIdx);
updateQuestion(idx, { options: newOpts, correct: q.correct >= newOpts.length ? 0 : q.correct }); const newCorrect = q.correct?.filter(i => i !== oIdx).map(i => i > oIdx ? i - 1 : i);
updateQuestion(idx, { options: newOpts, correct: newCorrect?.length ? newCorrect : [0] });
}} }}
className="opacity-0 group-hover/opt:opacity-100 p-2 text-gray-500 hover:text-red-400 transition-all" className="opacity-0 group-hover/opt:opacity-100 p-2 text-gray-500 hover:text-red-400 transition-all"
> >
@@ -179,12 +208,17 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4> <h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
<div className="grid gap-3"> <div className="grid gap-3">
{q.options.map((opt, oIdx) => { {q.options.map((opt, oIdx) => {
const isSelected = userAnswers[q.id] === oIdx; const isSelected = userAnswers[q.id]?.includes(oIdx);
const isCorrect = q.correct === oIdx; const isCorrect = q.correct?.includes(oIdx);
const isActuallyCorrect = isCorrect && isSelected;
const isWrongSelection = !isCorrect && isSelected;
const missedCorrect = isCorrect && !isSelected;
let style = "glass border-white/10 hover:bg-white/5"; let style = "glass border-white/10 hover:bg-white/5";
if (submitted) { if (submitted) {
if (isCorrect) style = "bg-green-500/20 border-green-500 text-green-400"; if (isActuallyCorrect) 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 if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100";
else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse";
else style = "opacity-50 grayscale border-white/5"; else style = "opacity-50 grayscale border-white/5";
} else if (isSelected) { } else if (isSelected) {
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]"; style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
@@ -193,10 +227,15 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
return ( return (
<button <button
key={oIdx} key={oIdx}
onClick={() => handleAnswer(q.id, oIdx)} onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`} className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
> >
{opt} <div className="flex items-center justify-between">
<span>{opt}</span>
{submitted && isActuallyCorrect && <span></span>}
{submitted && isWrongSelection && <span></span>}
{submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Correct Answer</span>}
</div>
</button> </button>
); );
})} })}
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
interface ShortAnswerBlockProps {
id: string;
title?: string;
prompt: string;
correctAnswers: string[];
editMode: boolean;
onChange: (updates: { title?: string; prompt?: string; correctAnswers?: string[] }) => void;
}
export default function ShortAnswerBlock({ id, title, prompt, correctAnswers, editMode, onChange }: ShortAnswerBlockProps) {
const [userAnswer, setUserAnswer] = useState("");
const [submitted, setSubmitted] = useState(false);
const handleReset = () => {
setSubmitted(false);
setUserAnswer("");
};
const isCorrect = correctAnswers.some(ans => ans.trim().toLowerCase() === userAnswer.trim().toLowerCase());
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
{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. Critical Thinking, Quick Response..."
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 || "Short Answer"}
</h3>
)}
</div>
{editMode ? (
<div className="space-y-6">
<div className="p-6 glass border-white/5 space-y-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Question Prompt</label>
<textarea
value={prompt}
onChange={(e) => onChange({ prompt: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[100px] text-lg font-medium focus:outline-none focus:border-blue-500/50 transition-all"
placeholder="Type the question for the student..."
/>
</div>
<div className="p-6 glass border-white/5 space-y-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Correct Answers (One per line)</label>
<textarea
value={correctAnswers ? correctAnswers.join("\n") : ""}
onChange={(e) => onChange({ correctAnswers: e.target.value.split("\n").filter(a => a.trim() !== "") })}
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[100px] text-sm font-medium focus:outline-none focus:border-blue-500/50 transition-all"
placeholder="Answer 1&#10;Answer 2 (Alternative)"
/>
<p className="text-[10px] text-gray-500 uppercase tracking-wider">Validation is case-insensitive. Provide multiple alternatives if necessary.</p>
</div>
</div>
) : (
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
<p className="text-xl font-bold text-gray-100">{prompt || "Please enter your answer below:"}</p>
<div className="space-y-4">
<input
type="text"
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
disabled={submitted}
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
: "border-white/10 focus:border-blue-500 text-white"
}`}
placeholder="Type your answer..."
/>
{submitted && !isCorrect && (
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl">
<p className="text-[10px] text-orange-400 uppercase font-black tracking-widest">Suggested Answer(s):</p>
<p className="text-sm text-gray-400 mt-1">{correctAnswers && correctAnswers[0]}</p>
</div>
)}
</div>
{!submitted && (
<button
onClick={() => setSubmitted(true)}
disabled={!userAnswer.trim()}
className="btn-premium w-full py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:grayscale"
>
Submit Answer
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-4 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-xl border-white/5"
>
Try Again
</button>
)}
</div>
)}
</div>
);
}
+14 -2
View File
@@ -18,7 +18,7 @@ export interface Module {
export interface Block { export interface Block {
id: string; id: string;
type: 'description' | 'media' | 'quiz'; type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer';
title?: string; title?: string;
content?: string; content?: string;
url?: string; url?: string;
@@ -33,9 +33,14 @@ export interface Block {
id: string; id: string;
question: string; question: string;
options: string[]; options: string[];
correct: number; correct: number[];
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
}[]; }[];
}; };
pairs?: { left: string; right: string }[];
items?: string[];
prompt?: string;
correctAnswers?: string[];
} }
export interface Lesson { export interface Lesson {
@@ -134,5 +139,12 @@ export const cmsApi = {
if (!response.ok) throw new Error('Upload failed'); if (!response.ok) throw new Error('Upload failed');
return response.json(); return response.json();
},
async publishCourse(courseId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/publish`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to publish course');
} }
}; };