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:
Generated
+387
@@ -219,6 +219,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"common",
|
||||
"dotenvy",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -257,6 +258,16 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -385,6 +396,16 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "etcetera"
|
||||
version = "0.8.0"
|
||||
@@ -407,6 +428,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.5"
|
||||
@@ -424,12 +451,33 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -546,6 +594,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -672,6 +739,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -681,6 +749,39 @@ dependencies = [
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"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]]
|
||||
@@ -689,14 +790,24 @@ version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -844,6 +955,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -917,6 +1044,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1031,6 +1164,23 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1108,6 +1258,50 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -1314,6 +1508,46 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -1348,6 +1582,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustls"
|
||||
version = "0.23.35"
|
||||
@@ -1394,12 +1641,44 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1815,6 +2094,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -1827,6 +2109,40 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "2.0.17"
|
||||
@@ -1940,6 +2256,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
@@ -1995,12 +2331,14 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -2080,6 +2418,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -2173,6 +2517,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -2207,6 +2560,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.106"
|
||||
@@ -2239,6 +2605,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -2308,6 +2684,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -25,3 +25,4 @@ jsonwebtoken = "9.3"
|
||||
bcrypt = "0.17"
|
||||
dotenvy = "0.15"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
@@ -17,3 +17,4 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
dotenvy.workspace = true
|
||||
tower-http.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
@@ -3,12 +3,73 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Module, Lesson};
|
||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
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)]
|
||||
pub struct ModuleQuery {
|
||||
pub course_id: Option<Uuid>,
|
||||
|
||||
@@ -22,6 +22,12 @@ async fn main() {
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
// Run migrations automatically
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
@@ -30,6 +36,7 @@ async fn main() {
|
||||
let app = Router::new()
|
||||
.route("/courses", get(handlers::get_courses).post(handlers::create_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("/lessons", get(handlers::get_lessons).post(handlers::create_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()
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
extract::{State, Path},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Enrollment};
|
||||
use common::models::{Course, Enrollment, Module, Lesson};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -37,3 +37,136 @@ pub async fn get_course_catalog(
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -21,9 +21,18 @@ async fn main() {
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
// Run migrations automatically
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/catalog", get(handlers::get_course_catalog))
|
||||
.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);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
|
||||
|
||||
@@ -64,3 +64,84 @@ pub struct Asset {
|
||||
pub size_bytes: i64,
|
||||
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 MediaBlock from "@/components/blocks/MediaBlock";
|
||||
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 } }) {
|
||||
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 = {
|
||||
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: [] } }),
|
||||
...(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]);
|
||||
};
|
||||
@@ -181,6 +189,43 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
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>
|
||||
))}
|
||||
@@ -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-[10px] font-bold uppercase tracking-widest text-gray-400">Quiz</span>
|
||||
</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>
|
||||
|
||||
@@ -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 (error) return <div className="py-20 text-center text-red-400">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* ... navigation ... */}
|
||||
<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>
|
||||
@@ -90,7 +107,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
||||
</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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ interface QuizQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
type?: 'multiple-choice' | 'true-false';
|
||||
correct: number[];
|
||||
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
|
||||
}
|
||||
|
||||
interface QuizBlockProps {
|
||||
@@ -17,11 +17,11 @@ interface QuizBlockProps {
|
||||
questions: QuizQuestion[];
|
||||
};
|
||||
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) {
|
||||
const [userAnswers, setUserAnswers] = useState<Record<string, number>>({});
|
||||
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
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),
|
||||
question: "New Question?",
|
||||
options: ["Option 1", "Option 2"],
|
||||
correct: 0,
|
||||
correct: [0],
|
||||
type: 'multiple-choice'
|
||||
};
|
||||
onChange({ questions: [...questions, newQuestion] });
|
||||
onChange({ quiz_data: { questions: [...questions, newQuestion] } });
|
||||
};
|
||||
|
||||
const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => {
|
||||
const newQuestions = [...questions];
|
||||
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;
|
||||
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 (
|
||||
@@ -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 bg-white/5 rounded-lg p-1 border border-white/5">
|
||||
<button
|
||||
onClick={() => updateQuestion(idx, { type: 'multiple-choice' })}
|
||||
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"}`}
|
||||
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 === 'multiple-choice' ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
>
|
||||
MCQ
|
||||
</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"}`}
|
||||
>
|
||||
T / F
|
||||
@@ -92,7 +120,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
@@ -113,8 +141,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
{["True", "False"].map((opt, oIdx) => (
|
||||
<button
|
||||
key={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"}`}
|
||||
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?.includes(oIdx) ? "border-blue-500 bg-blue-500/10 text-white" : "border-white/5 bg-white/5 text-gray-500"}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
@@ -124,9 +152,9 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
q.options.map((opt, oIdx) => (
|
||||
<div key={oIdx} className="flex gap-3 items-center group/opt">
|
||||
<input
|
||||
type="radio"
|
||||
checked={q.correct === oIdx}
|
||||
onChange={() => updateQuestion(idx, { correct: oIdx })}
|
||||
type={q.type === 'multiple-select' ? "checkbox" : "radio"}
|
||||
checked={q.correct?.includes(oIdx)}
|
||||
onChange={() => toggleCorrectOption(idx, oIdx, q.type === 'multiple-select')}
|
||||
className="w-5 h-5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
@@ -143,7 +171,8 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
@@ -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>
|
||||
<div className="grid gap-3">
|
||||
{q.options.map((opt, oIdx) => {
|
||||
const isSelected = userAnswers[q.id] === oIdx;
|
||||
const isCorrect = q.correct === oIdx;
|
||||
const isSelected = userAnswers[q.id]?.includes(oIdx);
|
||||
const isCorrect = q.correct?.includes(oIdx);
|
||||
const isActuallyCorrect = isCorrect && isSelected;
|
||||
const isWrongSelection = !isCorrect && isSelected;
|
||||
const missedCorrect = isCorrect && !isSelected;
|
||||
|
||||
let style = "glass border-white/10 hover:bg-white/5";
|
||||
if (submitted) {
|
||||
if (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";
|
||||
if (isActuallyCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
|
||||
else if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100";
|
||||
else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse";
|
||||
else style = "opacity-50 grayscale border-white/5";
|
||||
} else if (isSelected) {
|
||||
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
|
||||
@@ -193,10 +227,15 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
||||
return (
|
||||
<button
|
||||
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}`}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface Module {
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
type: 'description' | 'media' | 'quiz';
|
||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer';
|
||||
title?: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
@@ -33,9 +33,14 @@ export interface Block {
|
||||
id: string;
|
||||
question: 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 {
|
||||
@@ -134,5 +139,12 @@ export const cmsApi = {
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user