Compare commits
3 commits
07d98b64b3
...
5e160ae11c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e160ae11c | ||
|
|
08b69851cd | ||
|
|
fbedcf97e3 |
148 changed files with 12432 additions and 629 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
/target
|
||||
config.yml
|
||||
config.yaml
|
||||
/storage
|
||||
|
|
|
|||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"frontend/src/locales"
|
||||
]
|
||||
}
|
||||
486
Cargo.lock
generated
486
Cargo.lock
generated
|
|
@ -226,6 +226,23 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
|
|
@ -281,7 +298,7 @@ version = "0.1.16"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
|
@ -310,6 +327,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
|
|
@ -512,6 +538,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -672,8 +704,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -834,6 +882,23 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -842,13 +907,21 @@ version = "0.1.20"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -996,6 +1069,12 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1067,6 +1146,12 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
|
|
@ -1088,6 +1173,12 @@ version = "0.4.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1166,7 +1257,7 @@ dependencies = [
|
|||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.7",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -1374,6 +1465,62 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
|
|
@ -1383,6 +1530,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.7"
|
||||
|
|
@ -1391,7 +1544,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
|||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1401,7 +1565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1410,7 +1574,22 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1468,6 +1647,58 @@ version = "0.8.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
|
|
@ -1495,7 +1726,7 @@ dependencies = [
|
|||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
|
|
@ -1512,6 +1743,60 @@ dependencies = [
|
|||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
|
|
@ -1675,7 +1960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -1686,7 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -1722,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1885,7 +2170,7 @@ dependencies = [
|
|||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.7",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
|
|
@ -1924,7 +2209,7 @@ dependencies = [
|
|||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -1993,10 +2278,12 @@ dependencies = [
|
|||
"axum",
|
||||
"chrono",
|
||||
"config",
|
||||
"reqwest",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
|
|
@ -2037,6 +2324,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"
|
||||
|
|
@ -2049,6 +2339,19 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
|
|
@ -2139,6 +2442,16 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
|
|
@ -2231,8 +2544,10 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2309,6 +2624,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 = "typeid"
|
||||
version = "1.0.3"
|
||||
|
|
@ -2366,6 +2687,12 @@ version = "1.13.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
|
|
@ -2402,6 +2729,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"
|
||||
|
|
@ -2427,6 +2763,16 @@ dependencies = [
|
|||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
|
|
@ -2459,6 +2805,35 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
|
|
@ -2534,7 +2909,16 @@ version = "0.48.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2552,13 +2936,29 @@ version = "0.48.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2567,42 +2967,90 @@ version = "0.48.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
|
|
|
|||
13
Cargo.toml
13
Cargo.toml
|
|
@ -16,6 +16,10 @@ axum = { version = "0.8.9", features = ["macros"] }
|
|||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
config = "0.15.23"
|
||||
schemars = { version = "0.9.0", features = ["chrono04"] }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
|
|
@ -25,7 +29,14 @@ sqlx = { version = "0.8.6", features = [
|
|||
"runtime-tokio",
|
||||
] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "signal"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", features = [
|
||||
"fs",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tower-http = { version = "0.6.10", features = ["fs"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
|
|
|
|||
BIN
LaTeX_pdf/assets/epfl.png
Normal file
BIN
LaTeX_pdf/assets/epfl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
60
LaTeX_pdf/assignment.cls
Normal file
60
LaTeX_pdf/assignment.cls
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
% https://github.com/Ziqi-Yang/latex_assignment_template
|
||||
\NeedsTeXFormat{LaTeX2e}
|
||||
\ProvidesClass{assignment}[a latex class for editing your assignment]
|
||||
|
||||
\LoadClass[11pt,letterpaper]{article}
|
||||
|
||||
\RequirePackage[left=2cm,right=2cm,top=1.5cm,bottom=2cm]{geometry}
|
||||
\RequirePackage[dvipsnames]{xcolor}
|
||||
\RequirePackage{listings}
|
||||
\RequirePackage{tcolorbox}
|
||||
|
||||
\setlength{\textwidth}{6.5in}
|
||||
\setlength{\textheight}{9.in}
|
||||
\setlength{\oddsidemargin}{0in}
|
||||
\setlength{\headheight}{0in}
|
||||
|
||||
% create a box to highlight word
|
||||
\newtcbox{\hword}[1][orange]{on line, arc = 0.3mm,auto outer arc,colback = #1!12!, colframe = #1!60!,colupper = black, boxsep =0mm, left =0.3mm, right = 0.3mm, top =0.4mm, bottom = 0.4mm,boxrule =0.2mm, bottomrule =0.2mm, toprule = 0.2mm}
|
||||
|
||||
% default code style
|
||||
\lstset{
|
||||
framerule=1pt,
|
||||
frame=tb,
|
||||
emphstyle={\small\ttfamily\bfseries\color{Orange}},
|
||||
numbers=left,
|
||||
numberstyle= \tiny\color{black},
|
||||
basicstyle = \small\ttfamily,
|
||||
keywordstyle = \bfseries\color{BrickRed},
|
||||
identifierstyle = \bfseries\color{black},
|
||||
stringstyle = \bfseries\color{ForestGreen},
|
||||
commentstyle = \bfseries\color{Violet},
|
||||
breaklines = true,
|
||||
columns = fixed,
|
||||
basewidth = .5em,
|
||||
backgroundcolor=\color{Gray!5},
|
||||
tabsize=2,
|
||||
showspaces=false,
|
||||
showstringspaces=false,
|
||||
}
|
||||
|
||||
\newcommand{\assignmentTitle}[5]{
|
||||
\begin{flushleft}
|
||||
\textbf{Student Name:} #1\\
|
||||
\textbf{Student ID:} #2
|
||||
\end{flushleft}
|
||||
|
||||
\begin{flushright}\vspace{-15mm}
|
||||
\includegraphics[height=1cm]{#3}
|
||||
\end{flushright}
|
||||
|
||||
\begin{flushleft}\vspace{0mm}
|
||||
\textbf{\large #4}
|
||||
\end{flushleft}
|
||||
|
||||
\begin{flushleft}\vspace{-3.5mm}
|
||||
\textbf{\large #5}
|
||||
\end{flushleft}
|
||||
|
||||
\noindent\rule{\linewidth}{.6pt}
|
||||
}
|
||||
27
LaTeX_pdf/box-filter.lua
Normal file
27
LaTeX_pdf/box-filter.lua
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- box-filter.lua
|
||||
-- Converts fenced divs like:
|
||||
-- ::: {.defbox title="Def. 1.1 -- Something"}
|
||||
-- content (normal Markdown: **bold**, lists, code blocks, math...)
|
||||
-- :::
|
||||
-- into raw LaTeX tcolorbox environments (defbox/theobox/exbox), while the
|
||||
-- content itself stays as ordinary Markdown blocks that pandoc converts
|
||||
-- normally (bold -> \textbf, code fences -> lstlisting, etc.).
|
||||
|
||||
local boxnames = { defbox = true, theobox = true, exbox = true }
|
||||
|
||||
function Div(el)
|
||||
for name, _ in pairs(boxnames) do
|
||||
if el.classes:includes(name) then
|
||||
local title = el.attributes["title"] or ""
|
||||
local open = pandoc.RawBlock("latex", "\\begin{" .. name .. "}[" .. title .. "]")
|
||||
local close = pandoc.RawBlock("latex", "\\end{" .. name .. "}")
|
||||
local blocks = { open }
|
||||
for _, b in ipairs(el.content) do
|
||||
table.insert(blocks, b)
|
||||
end
|
||||
table.insert(blocks, close)
|
||||
return blocks
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
78
LaTeX_pdf/modele.md
Normal file
78
LaTeX_pdf/modele.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
student-name: "Antoine Pelletier"
|
||||
student-id: "356029"
|
||||
logo: "/home/tibiscuit/LaTeX_pdf/assets/epfl.png"
|
||||
course: "CS473 -- System Programming for SOC's"
|
||||
subtitle: "Lecture 2 Notes: Memory subsystem"
|
||||
---
|
||||
|
||||
<!--
|
||||
MODELE DE COURS EN MARKDOWN
|
||||
============================
|
||||
- Chaque "\section{...}" du LaTeX devient un titre "# " en Markdown.
|
||||
- Chaque "\subsection{...}" devient "## ".
|
||||
- Les listes à puces \begin{itemize} deviennent simplement des "-".
|
||||
- Le gras \textbf{...} devient **...** et l'italique \textit{...} devient *...*.
|
||||
- Pour les encadrés Def./Theo./Ex. (tcolorbox), on garde du LaTeX brut :
|
||||
pandoc laisse passer tel quel tout bloc LaTeX qu'il ne reconnaît pas
|
||||
en Markdown, donc \begin{defbox}[titre] ... \end{defbox} fonctionne
|
||||
directement dans le .md.
|
||||
- Les blocs de code ```c ... ``` sont automatiquement transformés en
|
||||
\begin{lstlisting} grâce à l'option --listings de pandoc.
|
||||
-->
|
||||
|
||||
# Introduction
|
||||
|
||||
Texte d'introduction du cours. On peut utiliser du **gras**, de
|
||||
l'*italique*, des listes :
|
||||
|
||||
- premier point
|
||||
- second point
|
||||
- sous-point
|
||||
|
||||
et des énumérations numérotées :
|
||||
|
||||
1. étape une
|
||||
2. étape deux
|
||||
|
||||
## Sous-section
|
||||
|
||||
On peut librement imbriquer des sous-sections avec `##`, `###`, etc.
|
||||
|
||||
# Définitions, théorèmes et exemples
|
||||
|
||||
Pour respecter le rendu exact du LaTeX d'origine, les encadrés colorés
|
||||
sont insérés en LaTeX brut au milieu du Markdown :
|
||||
|
||||
\begin{defbox}[Nom de la définition]
|
||||
Contenu de la définition, en Markdown normal (gras, italique, etc.
|
||||
fonctionnent aussi à l'intérieur).
|
||||
\end{defbox}
|
||||
|
||||
\begin{theobox}[Nom du théorème]
|
||||
Énoncé du théorème ou de la propriété importante à retenir.
|
||||
\end{theobox}
|
||||
|
||||
\begin{exbox}[Nom de l'exemple]
|
||||
Illustration concrète, éventuellement avec un extrait de code :
|
||||
|
||||
```c
|
||||
int main() {
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
\end{exbox}
|
||||
|
||||
# Tableaux
|
||||
|
||||
Un tableau Markdown standard :
|
||||
|
||||
| Colonne A | Colonne B | Colonne C |
|
||||
|:----------|:---------:|----------:|
|
||||
| gauche | centré | droite |
|
||||
| ligne 2 | ligne 2 | ligne 2 |
|
||||
|
||||
# Conclusion
|
||||
|
||||
Résumé des points clés du cours, comme dans la dernière slide de chaque
|
||||
lecture.
|
||||
156
LaTeX_pdf/template.tex
Normal file
156
LaTeX_pdf/template.tex
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
\documentclass[11pt]{article}
|
||||
|
||||
% ---------- Encoding & fonts (pdflatex-compatible, no external fonts needed) ----------
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage{textcomp}
|
||||
% Unicode chars pdflatex doesn't know out of the box
|
||||
\DeclareUnicodeCharacter{2212}{\ensuremath{-}} % − minus sign
|
||||
\DeclareUnicodeCharacter{2192}{\ensuremath{\rightarrow}}
|
||||
\DeclareUnicodeCharacter{2190}{\ensuremath{\leftarrow}}
|
||||
\DeclareUnicodeCharacter{2264}{\ensuremath{\leq}}
|
||||
\DeclareUnicodeCharacter{2265}{\ensuremath{\geq}}
|
||||
\DeclareUnicodeCharacter{00D7}{\ensuremath{\times}}
|
||||
|
||||
% ---------- Page geometry (matches assignment.cls) ----------
|
||||
\usepackage[left=2cm,right=2cm,top=1.5cm,bottom=2cm]{geometry}
|
||||
\usepackage{setspace}
|
||||
\setstretch{1.08}
|
||||
|
||||
% ---------- Math ----------
|
||||
\usepackage{amsmath,amssymb}
|
||||
|
||||
% ---------- Colour & boxes ----------
|
||||
\usepackage[dvipsnames]{xcolor}
|
||||
\usepackage[most]{tcolorbox}
|
||||
\tcbuselibrary{skins,breakable}
|
||||
|
||||
\newtcolorbox{defbox}[1][]{%
|
||||
enhanced, breakable, colback=blue!5, colframe=blue!65!black,
|
||||
fonttitle=\bfseries, coltitle=white, title={#1},
|
||||
boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
|
||||
}
|
||||
\newtcolorbox{theobox}[1][]{%
|
||||
enhanced, breakable, colback=red!5, colframe=red!55!black,
|
||||
fonttitle=\bfseries, coltitle=white, title={#1},
|
||||
boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
|
||||
}
|
||||
\newtcolorbox{exbox}[1][]{%
|
||||
enhanced, breakable, colback=green!5, colframe=green!40!black,
|
||||
fonttitle=\bfseries, coltitle=white, title={#1},
|
||||
boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
|
||||
}
|
||||
|
||||
% Word-highlight box, matching assignment.cls's \hword
|
||||
\newtcbox{\hword}[1][orange]{on line, arc=0.3mm, auto outer arc,
|
||||
colback=#1!12!, colframe=#1!60!, colupper=black, boxsep=0mm,
|
||||
left=0.3mm, right=0.3mm, top=0.4mm, bottom=0.4mm,
|
||||
boxrule=0.2mm, bottomrule=0.2mm, toprule=0.2mm}
|
||||
|
||||
% ---------- Tables ----------
|
||||
\usepackage{calc}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{array}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
\providecommand{\passthrough}[1]{#1}
|
||||
\providecommand{\pandocbounded}[1]{#1}
|
||||
|
||||
% Fix for a real bug (see jgm/pandoc#11201): longtable >= 4.21 (shipped with
|
||||
% recent TeX Live, e.g. 2026) combined with the caption package's longtable
|
||||
% patch tries to reference a counter literally named "none" as a placeholder
|
||||
% for uncaptioned tables, which was never actually defined anywhere, crashing
|
||||
% with "LaTeX Error: No counter 'none' defined." Defining it as an unused
|
||||
% dummy counter satisfies that reference harmlessly.
|
||||
\newcounter{none}
|
||||
|
||||
% ---------- Code listings (pandoc --listings), matching assignment.cls ----------
|
||||
\usepackage{listings}
|
||||
\usepackage{pmboxdraw} % box-drawing glyphs (├ └ │ ─ ...) under pdflatex
|
||||
\lstset{
|
||||
% listings is not UTF-8 aware under pdflatex: map multibyte chars explicitly
|
||||
literate=
|
||||
{├}{{\textSFviii}}1 {└}{{\textSFii}}1 {┌}{{\textSFi}}1 {┐}{{\textSFiii}}1 {┘}{{\textSFiv}}1 {┤}{{\textSFix}}1
|
||||
{┬}{{\textSFvi}}1 {┴}{{\textSFvii}}1 {┼}{{\textSFv}}1 {│}{{\textSFxi}}1 {─}{{\textSFx}}1
|
||||
{é}{{\'e}}1 {è}{{\`e}}1 {ê}{{\^e}}1 {ë}{{\"e}}1 {à}{{\`a}}1 {â}{{\^a}}1
|
||||
{ù}{{\`u}}1 {û}{{\^u}}1 {ü}{{\"u}}1 {ô}{{\^o}}1 {ö}{{\"o}}1 {î}{{\^i}}1
|
||||
{ï}{{\"i}}1 {ç}{{\c{c}}}1 {É}{{\'E}}1 {È}{{\`E}}1 {À}{{\`A}}1 {Ç}{{\c{C}}}1
|
||||
{«}{{\guillemotleft}}1 {»}{{\guillemotright}}1 {’}{{'}}1 {…}{{...}}3 {−}{{-}}1 {–}{{--}}1 {—}{{---}}1
|
||||
{≤}{{$$\leq$$}}1 {≥}{{$$\geq$$}}1 {×}{{$$\times$$}}1
|
||||
{→}{{$$\rightarrow$$}}2 {←}{{$$\leftarrow$$}}2,
|
||||
framerule=1pt,
|
||||
frame=tb,
|
||||
emphstyle={\small\ttfamily\bfseries\color{Orange}},
|
||||
numbers=left,
|
||||
numberstyle=\tiny\color{black},
|
||||
basicstyle=\small\ttfamily,
|
||||
keywordstyle=\bfseries\color{BrickRed},
|
||||
identifierstyle=\bfseries\color{black},
|
||||
stringstyle=\bfseries\color{ForestGreen},
|
||||
commentstyle=\bfseries\color{Violet},
|
||||
breaklines=true,
|
||||
columns=fixed,
|
||||
basewidth=.5em,
|
||||
backgroundcolor=\color{Gray!5},
|
||||
tabsize=2,
|
||||
showspaces=false,
|
||||
showstringspaces=false,
|
||||
}
|
||||
|
||||
% ---------- Links ----------
|
||||
\usepackage[colorlinks=true,linkcolor=blue!60!black,urlcolor=blue!60!black,citecolor=blue!60!black]{hyperref}
|
||||
|
||||
% caption is loaded AFTER hyperref on purpose: it needs to detect hyperref's
|
||||
% presence at load time to patch longtable's anchor/bookmark generation
|
||||
% correctly. Loading it before hyperref (as an earlier version of this
|
||||
% template did) is a known cause of exactly the
|
||||
% "LaTeX Error: No counter 'none' defined" crash on \begin{longtable}.
|
||||
\usepackage{caption}
|
||||
\captionsetup{skip=6pt}
|
||||
|
||||
% Failsafe belt-and-braces: bypass hyperref's auto-computed \theHtable /
|
||||
% \theHfigure chain entirely.
|
||||
\providecommand{\theHtable}{\thetable}
|
||||
\providecommand{\theHfigure}{\thefigure}
|
||||
|
||||
% ---------- Section styling ----------
|
||||
\usepackage{sectsty}
|
||||
\sectionfont{\Large\bfseries}
|
||||
\subsectionfont{\large\bfseries\color{black!80}}
|
||||
|
||||
\usepackage{graphicx}
|
||||
|
||||
\begin{document}
|
||||
|
||||
% ---------- Header (matches assignment.cls's \assignmentTitle) ----------
|
||||
\begin{flushleft}
|
||||
$if(student-name)$\textbf{Student Name:} $student-name$\\$endif$
|
||||
$if(student-id)$\textbf{Student ID:} $student-id$$endif$
|
||||
\end{flushleft}
|
||||
|
||||
$if(logo)$
|
||||
\begin{flushright}\vspace{-15mm}
|
||||
\includegraphics[height=1cm]{$logo$}
|
||||
\end{flushright}
|
||||
$endif$
|
||||
|
||||
$if(course)$
|
||||
\begin{flushleft}\vspace{0mm}
|
||||
\textbf{\large $course$}
|
||||
\end{flushleft}
|
||||
$endif$
|
||||
|
||||
$if(subtitle)$
|
||||
\begin{flushleft}\vspace{-3.5mm}
|
||||
\textbf{\large $subtitle$}
|
||||
\end{flushleft}
|
||||
$endif$
|
||||
|
||||
\noindent\rule{\linewidth}{.6pt}
|
||||
\vspace{4mm}
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
138
LaTeX_pdf/template.tex.bak
Normal file
138
LaTeX_pdf/template.tex.bak
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
\documentclass[11pt]{article}
|
||||
|
||||
% ---------- Encoding & fonts (pdflatex-compatible, no external fonts needed) ----------
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage{textcomp}
|
||||
|
||||
% ---------- Page geometry (matches assignment.cls) ----------
|
||||
\usepackage[left=2cm,right=2cm,top=1.5cm,bottom=2cm]{geometry}
|
||||
\usepackage{setspace}
|
||||
\setstretch{1.08}
|
||||
|
||||
% ---------- Math ----------
|
||||
\usepackage{amsmath,amssymb}
|
||||
|
||||
% ---------- Colour & boxes ----------
|
||||
\usepackage[dvipsnames]{xcolor}
|
||||
\usepackage[most]{tcolorbox}
|
||||
\tcbuselibrary{skins,breakable}
|
||||
|
||||
\newtcolorbox{defbox}[1][]{%
|
||||
enhanced, breakable, colback=blue!5, colframe=blue!65!black,
|
||||
fonttitle=\bfseries, coltitle=white, title={#1},
|
||||
boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
|
||||
}
|
||||
\newtcolorbox{theobox}[1][]{%
|
||||
enhanced, breakable, colback=red!5, colframe=red!55!black,
|
||||
fonttitle=\bfseries, coltitle=white, title={#1},
|
||||
boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
|
||||
}
|
||||
\newtcolorbox{exbox}[1][]{%
|
||||
enhanced, breakable, colback=green!5, colframe=green!40!black,
|
||||
fonttitle=\bfseries, coltitle=white, title={#1},
|
||||
boxrule=0.8pt, arc=2pt, left=6pt, right=6pt, top=4pt, bottom=4pt
|
||||
}
|
||||
|
||||
% Word-highlight box, matching assignment.cls's \hword
|
||||
\newtcbox{\hword}[1][orange]{on line, arc=0.3mm, auto outer arc,
|
||||
colback=#1!12!, colframe=#1!60!, colupper=black, boxsep=0mm,
|
||||
left=0.3mm, right=0.3mm, top=0.4mm, bottom=0.4mm,
|
||||
boxrule=0.2mm, bottomrule=0.2mm, toprule=0.2mm}
|
||||
|
||||
% ---------- Tables ----------
|
||||
\usepackage{calc}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{array}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
\providecommand{\passthrough}[1]{#1}
|
||||
\providecommand{\pandocbounded}[1]{#1}
|
||||
|
||||
% Fix for a real bug (see jgm/pandoc#11201): longtable >= 4.21 (shipped with
|
||||
% recent TeX Live, e.g. 2026) combined with the caption package's longtable
|
||||
% patch tries to reference a counter literally named "none" as a placeholder
|
||||
% for uncaptioned tables, which was never actually defined anywhere, crashing
|
||||
% with "LaTeX Error: No counter 'none' defined." Defining it as an unused
|
||||
% dummy counter satisfies that reference harmlessly.
|
||||
\newcounter{none}
|
||||
|
||||
% ---------- Code listings (pandoc --listings), matching assignment.cls ----------
|
||||
\usepackage{listings}
|
||||
\lstset{
|
||||
framerule=1pt,
|
||||
frame=tb,
|
||||
emphstyle={\small\ttfamily\bfseries\color{Orange}},
|
||||
numbers=left,
|
||||
numberstyle=\tiny\color{black},
|
||||
basicstyle=\small\ttfamily,
|
||||
keywordstyle=\bfseries\color{BrickRed},
|
||||
identifierstyle=\bfseries\color{black},
|
||||
stringstyle=\bfseries\color{ForestGreen},
|
||||
commentstyle=\bfseries\color{Violet},
|
||||
breaklines=true,
|
||||
columns=fixed,
|
||||
basewidth=.5em,
|
||||
backgroundcolor=\color{Gray!5},
|
||||
tabsize=2,
|
||||
showspaces=false,
|
||||
showstringspaces=false,
|
||||
}
|
||||
|
||||
% ---------- Links ----------
|
||||
\usepackage[colorlinks=true,linkcolor=blue!60!black,urlcolor=blue!60!black,citecolor=blue!60!black]{hyperref}
|
||||
|
||||
% caption is loaded AFTER hyperref on purpose: it needs to detect hyperref's
|
||||
% presence at load time to patch longtable's anchor/bookmark generation
|
||||
% correctly. Loading it before hyperref (as an earlier version of this
|
||||
% template did) is a known cause of exactly the
|
||||
% "LaTeX Error: No counter 'none' defined" crash on \begin{longtable}.
|
||||
\usepackage{caption}
|
||||
\captionsetup{skip=6pt}
|
||||
|
||||
% Failsafe belt-and-braces: bypass hyperref's auto-computed \theHtable /
|
||||
% \theHfigure chain entirely.
|
||||
\providecommand{\theHtable}{\thetable}
|
||||
\providecommand{\theHfigure}{\thefigure}
|
||||
|
||||
% ---------- Section styling ----------
|
||||
\usepackage{sectsty}
|
||||
\sectionfont{\Large\bfseries}
|
||||
\subsectionfont{\large\bfseries\color{black!80}}
|
||||
|
||||
\usepackage{graphicx}
|
||||
|
||||
\begin{document}
|
||||
|
||||
% ---------- Header (matches assignment.cls's \assignmentTitle) ----------
|
||||
\begin{flushleft}
|
||||
$if(student-name)$\textbf{Student Name:} $student-name$\\$endif$
|
||||
$if(student-id)$\textbf{Student ID:} $student-id$$endif$
|
||||
\end{flushleft}
|
||||
|
||||
$if(logo)$
|
||||
\begin{flushright}\vspace{-15mm}
|
||||
\includegraphics[height=1cm]{$logo$}
|
||||
\end{flushright}
|
||||
$endif$
|
||||
|
||||
$if(course)$
|
||||
\begin{flushleft}\vspace{0mm}
|
||||
\textbf{\large $course$}
|
||||
\end{flushleft}
|
||||
$endif$
|
||||
|
||||
$if(subtitle)$
|
||||
\begin{flushleft}\vspace{-3.5mm}
|
||||
\textbf{\large $subtitle$}
|
||||
\end{flushleft}
|
||||
$endif$
|
||||
|
||||
\noindent\rule{\linewidth}{.6pt}
|
||||
\vspace{4mm}
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
|
|
@ -26,7 +26,7 @@ psql -h localhost -U postgres -c 'CREATE DATABASE studium'
|
|||
dbmate up
|
||||
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data
|
||||
|
||||
# 2. Configure the app
|
||||
# 2. Configure the app (the lecture notes also need pandoc and pdflatex)
|
||||
cp config.example.yml config.yml
|
||||
|
||||
# 3. Run the backend (port 3000)
|
||||
|
|
@ -53,6 +53,9 @@ key, keep it up to date. Every value can also come from the environment
|
|||
│ │ └── repositories/ traits describing what the core needs from the storage
|
||||
│ ├── services/ implementations of the repositories (postgres/sqlx)
|
||||
│ └── utils/ configuration
|
||||
├── LaTeX_pdf/ template of the lecture notes pdf (pandoc + pdflatex)
|
||||
├── storage/ files of the courses and associations, one folder each,
|
||||
│ stands in for the NAS (gitignored)
|
||||
├── db/
|
||||
│ ├── migrations/ dbmate migrations
|
||||
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
|
||||
|
|
|
|||
|
|
@ -13,3 +13,27 @@ postgres:
|
|||
|
||||
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
||||
frontend_dir: frontend/dist
|
||||
|
||||
# Where the files of the courses and associations are stored (lecture notes and their
|
||||
# pdf...), one folder each: Cours/CS-477 Advanced OS/Notes/..., Associations/...
|
||||
# A local directory standing in for the NAS for now.
|
||||
storage_dir: storage
|
||||
|
||||
# Rendering of the lecture notes to pdf (needs pandoc and pdflatex)
|
||||
notes:
|
||||
# Directory of template.tex, box-filter.lua and assets/
|
||||
template_dir: LaTeX_pdf
|
||||
# Relative to template_dir
|
||||
logo: assets/epfl.png
|
||||
# Shown in the header of the pdf, both optional
|
||||
student_name: Jane Doe
|
||||
student_id: "123456"
|
||||
|
||||
# Optional: the focus sessions of the pomodoro are also counted in TimeTagger, tagged
|
||||
# with the course code, the tags and the name of the task. Without it, the time is only
|
||||
# counted in the app.
|
||||
# timetagger:
|
||||
# # https://timetagger.app/api/v2 by default, or the api of your own server
|
||||
# url: https://timetagger.app/api/v2
|
||||
# # Api token, from the account page of TimeTagger
|
||||
# token: ...
|
||||
|
|
|
|||
70
db/migrations/20260928150000_course_page.sql
Normal file
70
db/migrations/20260928150000_course_page.sql
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
-- migrate:up
|
||||
|
||||
-- Tags are shared by the time slots and the tasks
|
||||
ALTER TABLE slot_tags RENAME TO tags;
|
||||
ALTER TABLE tags RENAME CONSTRAINT slot_tags_pkey TO tags_pkey;
|
||||
ALTER SEQUENCE slot_tags_id_seq RENAME TO tags_id_seq;
|
||||
|
||||
INSERT INTO tags ("name", color) VALUES
|
||||
('{"fr": "Devoir", "en": "Homework"}', '#9333ea'),
|
||||
('{"fr": "Examen", "en": "Exam"}', '#dc2626'),
|
||||
('{"fr": "Projet", "en": "Project"}', '#0891b2');
|
||||
|
||||
-- Grades of a course, on any scale: `score` out of `max_score`. The weights are relative
|
||||
-- to each other, the average of the course is weighted by them.
|
||||
CREATE TABLE grades (
|
||||
id SERIAL PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
|
||||
"name" TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL CHECK (score >= 0),
|
||||
max_score DOUBLE PRECISION NOT NULL CHECK (max_score > 0),
|
||||
weight DOUBLE PRECISION NOT NULL CHECK (weight > 0),
|
||||
CHECK (score <= max_score)
|
||||
);
|
||||
|
||||
CREATE INDEX grades_course_id_idx ON grades (course_id);
|
||||
|
||||
-- Homework, exams, or anything to do for a course. A task may have subtasks (one level).
|
||||
CREATE TABLE tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
|
||||
parent_id INTEGER REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
"name" TEXT NOT NULL,
|
||||
description TEXT,
|
||||
links TEXT[] NOT NULL DEFAULT '{}',
|
||||
deadline TIMESTAMPTZ,
|
||||
done BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX tasks_course_id_idx ON tasks (course_id);
|
||||
|
||||
CREATE TABLE task_tags (
|
||||
task_id INTEGER NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
|
||||
-- Lecture notes written in markdown. The content (.md) and the rendered pdf are files
|
||||
-- in the storage, not in the database.
|
||||
CREATE TABLE lecture_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
-- False when the last rendering to pdf failed
|
||||
has_pdf BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX lecture_notes_course_id_idx ON lecture_notes (course_id);
|
||||
|
||||
-- migrate:down
|
||||
DROP TABLE IF EXISTS lecture_notes;
|
||||
DROP TABLE IF EXISTS task_tags;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
DROP TABLE IF EXISTS grades;
|
||||
|
||||
DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM time_slots) AND id > 3;
|
||||
ALTER SEQUENCE tags_id_seq RENAME TO slot_tags_id_seq;
|
||||
ALTER TABLE tags RENAME CONSTRAINT tags_pkey TO slot_tags_pkey;
|
||||
ALTER TABLE tags RENAME TO slot_tags;
|
||||
51
db/migrations/20260929090000_course_files.sql
Normal file
51
db/migrations/20260929090000_course_files.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- migrate:up
|
||||
|
||||
-- What each tag can label: time slots, tasks and/or course files
|
||||
ALTER TABLE tags ADD COLUMN scopes TEXT[] NOT NULL DEFAULT '{}'
|
||||
CHECK (scopes <@ ARRAY['slot', 'task', 'file']);
|
||||
-- Stable name, for the tags the backend needs to find (e.g. `notes` for the lecture notes)
|
||||
ALTER TABLE tags ADD COLUMN slug TEXT UNIQUE;
|
||||
UPDATE tags SET scopes = '{slot,task}', slug = lower("name"->>'en')
|
||||
WHERE "name"->>'en' IN ('Lecture', 'Exercises', 'Lab');
|
||||
UPDATE tags SET scopes = '{task}', slug = lower("name"->>'en')
|
||||
WHERE "name"->>'en' IN ('Homework', 'Exam', 'Project');
|
||||
INSERT INTO tags ("name", color, scopes, slug) VALUES
|
||||
('{"fr": "Slides", "en": "Slides"}', '#0d9488', '{file}', 'slides'),
|
||||
('{"fr": "Notes", "en": "Notes"}', '#ca8a04', '{file}', 'notes');
|
||||
|
||||
-- The lecture notes become the first kind of course files. Uploaded files (slides...)
|
||||
-- will be the second one. Ids are kept: they are part of the paths in the storage.
|
||||
ALTER TABLE lecture_notes RENAME TO course_files;
|
||||
ALTER TABLE course_files RENAME CONSTRAINT lecture_notes_pkey TO course_files_pkey;
|
||||
ALTER TABLE course_files RENAME CONSTRAINT lecture_notes_course_id_fkey TO course_files_course_id_fkey;
|
||||
ALTER SEQUENCE lecture_notes_id_seq RENAME TO course_files_id_seq;
|
||||
ALTER INDEX lecture_notes_course_id_idx RENAME TO course_files_course_id_idx;
|
||||
ALTER TABLE course_files RENAME COLUMN title TO "name";
|
||||
-- `note`: markdown written in the app and rendered to pdf, `upload`: any file
|
||||
ALTER TABLE course_files ADD COLUMN kind TEXT NOT NULL DEFAULT 'note'
|
||||
CHECK (kind IN ('note', 'upload'));
|
||||
ALTER TABLE course_files ALTER COLUMN kind DROP DEFAULT;
|
||||
|
||||
CREATE TABLE file_tags (
|
||||
file_id INTEGER NOT NULL REFERENCES course_files (id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (file_id, tag_id)
|
||||
);
|
||||
|
||||
INSERT INTO file_tags (file_id, tag_id)
|
||||
SELECT f.id, t.id FROM course_files f, tags t WHERE t.slug = 'notes';
|
||||
|
||||
-- migrate:down
|
||||
DROP TABLE IF EXISTS file_tags;
|
||||
DELETE FROM course_files WHERE kind <> 'note';
|
||||
ALTER TABLE course_files DROP COLUMN kind;
|
||||
ALTER TABLE course_files RENAME COLUMN "name" TO title;
|
||||
ALTER INDEX course_files_course_id_idx RENAME TO lecture_notes_course_id_idx;
|
||||
ALTER SEQUENCE course_files_id_seq RENAME TO lecture_notes_id_seq;
|
||||
ALTER TABLE course_files RENAME CONSTRAINT course_files_course_id_fkey TO lecture_notes_course_id_fkey;
|
||||
ALTER TABLE course_files RENAME CONSTRAINT course_files_pkey TO lecture_notes_pkey;
|
||||
ALTER TABLE course_files RENAME TO lecture_notes;
|
||||
|
||||
DELETE FROM tags WHERE scopes = '{file}';
|
||||
ALTER TABLE tags DROP COLUMN slug;
|
||||
ALTER TABLE tags DROP COLUMN scopes;
|
||||
41
db/migrations/20260929180000_associations_focus.sql
Normal file
41
db/migrations/20260929180000_associations_focus.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- migrate:up
|
||||
|
||||
-- Associations are managed like courses: same tasks, files and slots, without grades.
|
||||
ALTER TABLE courses ADD COLUMN kind TEXT NOT NULL DEFAULT 'course'
|
||||
CHECK (kind IN ('course', 'association'));
|
||||
ALTER TABLE courses ALTER COLUMN kind DROP DEFAULT;
|
||||
|
||||
-- Folder of the course in the storage (the NAS), relative to its root, named after the
|
||||
-- course: `Cours/CS-477 Advanced operating systems`. Moved when the course is renamed.
|
||||
-- The existing folders were named after the ids and are not moved by this migration.
|
||||
ALTER TABLE courses ADD COLUMN folder TEXT;
|
||||
UPDATE courses SET folder = 'Cours/' || trim(regexp_replace(
|
||||
regexp_replace(COALESCE(code || ' ', '') || "name", '[/\\:*?"<>|]', '-', 'g'),
|
||||
'\s+', ' ', 'g'));
|
||||
ALTER TABLE courses ALTER COLUMN folder SET NOT NULL;
|
||||
ALTER TABLE courses ADD CONSTRAINT courses_folder_key UNIQUE (folder);
|
||||
|
||||
-- Path of a file in the folder of its course, without extension: `Notes/Lecture 1 Notes`
|
||||
-- (the note is `Notes/Lecture 1 Notes.md` and its pdf `Notes/Lecture 1 Notes.pdf`)
|
||||
ALTER TABLE course_files ADD COLUMN "path" TEXT;
|
||||
UPDATE course_files SET "path" = 'Notes/' || trim(regexp_replace(
|
||||
regexp_replace("name", '[/\\:*?"<>|]', '-', 'g'), '\s+', ' ', 'g'));
|
||||
ALTER TABLE course_files ALTER COLUMN "path" SET NOT NULL;
|
||||
ALTER TABLE course_files ADD CONSTRAINT course_files_course_id_path_key UNIQUE (course_id, "path");
|
||||
|
||||
-- Time spent working on a task, counted by the pomodoro
|
||||
ALTER TABLE tasks ADD COLUMN time_spent INTEGER NOT NULL DEFAULT 0 CHECK (time_spent >= 0);
|
||||
|
||||
-- Kinds of the slots and tasks of the associations
|
||||
INSERT INTO tags ("name", color, scopes, slug) VALUES
|
||||
('{"fr": "Réunion", "en": "Meeting"}', '#db2777', '{slot,task}', 'meeting'),
|
||||
('{"fr": "Événement", "en": "Event"}', '#4f46e5', '{slot,task}', 'event');
|
||||
|
||||
-- migrate:down
|
||||
DELETE FROM courses WHERE kind = 'association';
|
||||
DELETE FROM time_slots WHERE tag_id IN (SELECT id FROM tags WHERE slug IN ('meeting', 'event'));
|
||||
DELETE FROM tags WHERE slug IN ('meeting', 'event');
|
||||
ALTER TABLE tasks DROP COLUMN time_spent;
|
||||
ALTER TABLE course_files DROP COLUMN "path";
|
||||
ALTER TABLE courses DROP COLUMN folder;
|
||||
ALTER TABLE courses DROP COLUMN kind;
|
||||
350
db/schema.sql
350
db/schema.sql
|
|
@ -19,6 +19,43 @@ SET default_tablespace = '';
|
|||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: course_files; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.course_files (
|
||||
id integer CONSTRAINT lecture_notes_id_not_null NOT NULL,
|
||||
course_id integer CONSTRAINT lecture_notes_course_id_not_null NOT NULL,
|
||||
name text CONSTRAINT lecture_notes_title_not_null NOT NULL,
|
||||
has_pdf boolean DEFAULT false CONSTRAINT lecture_notes_has_pdf_not_null NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_created_at_not_null NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_updated_at_not_null NOT NULL,
|
||||
kind text NOT NULL,
|
||||
path text NOT NULL,
|
||||
CONSTRAINT course_files_kind_check CHECK ((kind = ANY (ARRAY['note'::text, 'upload'::text])))
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.course_files_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.course_files_id_seq OWNED BY public.course_files.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -28,7 +65,10 @@ CREATE TABLE public.courses (
|
|||
name text NOT NULL,
|
||||
code text,
|
||||
teachers text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
main_page_url text
|
||||
main_page_url text,
|
||||
kind text NOT NULL,
|
||||
folder text NOT NULL,
|
||||
CONSTRAINT courses_kind_check CHECK ((kind = ANY (ARRAY['course'::text, 'association'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -53,30 +93,38 @@ ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
|
|||
|
||||
|
||||
--
|
||||
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
|
||||
-- Name: file_tags; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.schema_migrations (
|
||||
version character varying NOT NULL
|
||||
CREATE TABLE public.file_tags (
|
||||
file_id integer NOT NULL,
|
||||
tag_id integer NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: slot_tags; Type: TABLE; Schema: public; Owner: -
|
||||
-- Name: grades; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.slot_tags (
|
||||
CREATE TABLE public.grades (
|
||||
id integer NOT NULL,
|
||||
name jsonb NOT NULL,
|
||||
color text NOT NULL
|
||||
course_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
score double precision NOT NULL,
|
||||
max_score double precision NOT NULL,
|
||||
weight double precision NOT NULL,
|
||||
CONSTRAINT grades_check CHECK ((score <= max_score)),
|
||||
CONSTRAINT grades_max_score_check CHECK ((max_score > (0)::double precision)),
|
||||
CONSTRAINT grades_score_check CHECK ((score >= (0)::double precision)),
|
||||
CONSTRAINT grades_weight_check CHECK ((weight > (0)::double precision))
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: slot_tags_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
-- Name: grades_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.slot_tags_id_seq
|
||||
CREATE SEQUENCE public.grades_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
|
|
@ -86,10 +134,101 @@ CREATE SEQUENCE public.slot_tags_id_seq
|
|||
|
||||
|
||||
--
|
||||
-- Name: slot_tags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
-- Name: grades_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.slot_tags_id_seq OWNED BY public.slot_tags.id;
|
||||
ALTER SEQUENCE public.grades_id_seq OWNED BY public.grades.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.schema_migrations (
|
||||
version character varying NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.tags (
|
||||
id integer CONSTRAINT slot_tags_id_not_null NOT NULL,
|
||||
name jsonb CONSTRAINT slot_tags_name_not_null NOT NULL,
|
||||
color text CONSTRAINT slot_tags_color_not_null NOT NULL,
|
||||
scopes text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
slug text,
|
||||
CONSTRAINT tags_scopes_check CHECK ((scopes <@ ARRAY['slot'::text, 'task'::text, 'file'::text]))
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.tags_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.tags_id_seq OWNED BY public.tags.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: task_tags; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.task_tags (
|
||||
task_id integer NOT NULL,
|
||||
tag_id integer NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.tasks (
|
||||
id integer NOT NULL,
|
||||
course_id integer NOT NULL,
|
||||
parent_id integer,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
links text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
deadline timestamp with time zone,
|
||||
done boolean DEFAULT false NOT NULL,
|
||||
time_spent integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT tasks_time_spent_check CHECK ((time_spent >= 0))
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.tasks_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.tasks_id_seq OWNED BY public.tasks.id;
|
||||
|
||||
|
||||
--
|
||||
|
|
@ -128,6 +267,13 @@ CREATE SEQUENCE public.time_slots_id_seq
|
|||
ALTER SEQUENCE public.time_slots_id_seq OWNED BY public.time_slots.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.course_files ALTER COLUMN id SET DEFAULT nextval('public.course_files_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -136,10 +282,24 @@ ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.cour
|
|||
|
||||
|
||||
--
|
||||
-- Name: slot_tags id; Type: DEFAULT; Schema: public; Owner: -
|
||||
-- Name: grades id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.slot_tags ALTER COLUMN id SET DEFAULT nextval('public.slot_tags_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public.grades ALTER COLUMN id SET DEFAULT nextval('public.grades_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tags ALTER COLUMN id SET DEFAULT nextval('public.tags_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tasks ALTER COLUMN id SET DEFAULT nextval('public.tasks_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
|
|
@ -149,6 +309,30 @@ ALTER TABLE ONLY public.slot_tags ALTER COLUMN id SET DEFAULT nextval('public.sl
|
|||
ALTER TABLE ONLY public.time_slots ALTER COLUMN id SET DEFAULT nextval('public.time_slots_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files course_files_course_id_path_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.course_files
|
||||
ADD CONSTRAINT course_files_course_id_path_key UNIQUE (course_id, path);
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files course_files_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.course_files
|
||||
ADD CONSTRAINT course_files_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses courses_folder_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.courses
|
||||
ADD CONSTRAINT courses_folder_key UNIQUE (folder);
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -157,6 +341,22 @@ ALTER TABLE ONLY public.courses
|
|||
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_tags file_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.file_tags
|
||||
ADD CONSTRAINT file_tags_pkey PRIMARY KEY (file_id, tag_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: grades grades_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.grades
|
||||
ADD CONSTRAINT grades_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -166,11 +366,35 @@ ALTER TABLE ONLY public.schema_migrations
|
|||
|
||||
|
||||
--
|
||||
-- Name: slot_tags slot_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.slot_tags
|
||||
ADD CONSTRAINT slot_tags_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.tags
|
||||
ADD CONSTRAINT tags_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags tags_slug_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tags
|
||||
ADD CONSTRAINT tags_slug_key UNIQUE (slug);
|
||||
|
||||
|
||||
--
|
||||
-- Name: task_tags task_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.task_tags
|
||||
ADD CONSTRAINT task_tags_pkey PRIMARY KEY (task_id, tag_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tasks
|
||||
ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
|
|
@ -181,6 +405,27 @@ ALTER TABLE ONLY public.time_slots
|
|||
ADD CONSTRAINT time_slots_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX course_files_course_id_idx ON public.course_files USING btree (course_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: grades_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX grades_course_id_idx ON public.grades USING btree (course_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX tasks_course_id_idx ON public.tasks USING btree (course_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: time_slots_course_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -188,6 +433,70 @@ ALTER TABLE ONLY public.time_slots
|
|||
CREATE INDEX time_slots_course_id_idx ON public.time_slots USING btree (course_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: course_files course_files_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.course_files
|
||||
ADD CONSTRAINT course_files_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_tags file_tags_file_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.file_tags
|
||||
ADD CONSTRAINT file_tags_file_id_fkey FOREIGN KEY (file_id) REFERENCES public.course_files(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_tags file_tags_tag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.file_tags
|
||||
ADD CONSTRAINT file_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: grades grades_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.grades
|
||||
ADD CONSTRAINT grades_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: task_tags task_tags_tag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.task_tags
|
||||
ADD CONSTRAINT task_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: task_tags task_tags_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.task_tags
|
||||
ADD CONSTRAINT task_tags_task_id_fkey FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks tasks_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tasks
|
||||
ADD CONSTRAINT tasks_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tasks tasks_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.tasks
|
||||
ADD CONSTRAINT tasks_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.tasks(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: time_slots time_slots_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -201,7 +510,7 @@ ALTER TABLE ONLY public.time_slots
|
|||
--
|
||||
|
||||
ALTER TABLE ONLY public.time_slots
|
||||
ADD CONSTRAINT time_slots_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.slot_tags(id);
|
||||
ADD CONSTRAINT time_slots_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id);
|
||||
|
||||
|
||||
--
|
||||
|
|
@ -216,4 +525,7 @@ ALTER TABLE ONLY public.time_slots
|
|||
--
|
||||
|
||||
INSERT INTO public.schema_migrations (version) VALUES
|
||||
('20260928120000');
|
||||
('20260928120000'),
|
||||
('20260928150000'),
|
||||
('20260929090000'),
|
||||
('20260929180000');
|
||||
|
|
|
|||
|
|
@ -11,14 +11,18 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import { formatDuration, useFocus } from '@/services/focus'
|
||||
import { locales, setLocale } from '@/services/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Pause, Timer } from '@lucide/vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { locale: currentLocale } = useI18n()
|
||||
const { session, elapsed, isRunning } = useFocus()
|
||||
|
||||
const selectedLangFlag = computed(
|
||||
() => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag,
|
||||
const selectedLocale = computed(
|
||||
() => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
@ -29,20 +33,36 @@ const selectedLangFlag = computed(
|
|||
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<h1 class="font-medium">{{ route.meta.title ? $t(route.meta.title) : '' }}</h1>
|
||||
|
||||
<!-- The session running, from any other page -->
|
||||
<RouterLink
|
||||
v-if="session && route.name !== 'pomodoro'"
|
||||
:to="{ name: 'pomodoro' }"
|
||||
class="ml-auto flex max-w-56 items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors hover:bg-accent"
|
||||
:title="$t('focus.back')"
|
||||
>
|
||||
<Timer v-if="isRunning" class="size-4 shrink-0 animate-pulse text-primary" />
|
||||
<Pause v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="font-mono tabular-nums">{{ formatDuration(elapsed) }}</span>
|
||||
<span class="hidden truncate text-muted-foreground sm:inline">
|
||||
{{ session.task?.name ?? $t('focus.free') }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<ThemeToggle :class="{ 'ml-auto': !session || route.name === 'pomodoro' }" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="ml-auto">
|
||||
<span :class="'fi fi-' + selectedLangFlag"></span>
|
||||
<Button variant="outline" class="rounded-lg">
|
||||
<span :class="'fi fi-' + selectedLocale.flag"></span>
|
||||
{{ selectedLocale.lang.toUpperCase() }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="min-w-0">
|
||||
<DropdownMenuItem
|
||||
v-for="locale in locales"
|
||||
:key="locale.lang"
|
||||
class="justify-center"
|
||||
@click="setLocale(locale.lang)"
|
||||
>
|
||||
<span :class="'fi fi-' + locale.flag"></span>
|
||||
{{ locale.lang.toUpperCase() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function onNavigate() {
|
|||
<SidebarMenuItem v-for="section in sections" :key="section.name">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="route.name === section.name"
|
||||
:is-active="(route.meta.section ?? route.name) === section.name"
|
||||
:tooltip="$t(section.title)"
|
||||
>
|
||||
<RouterLink :to="{ name: section.name }" @click="onNavigate">
|
||||
|
|
|
|||
37
frontend/src/components/ConfirmDeleteDialog.vue
Normal file
37
frontend/src/components/ConfirmDeleteDialog.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
/** "Do you really want to delete...?" confirmation, `confirm` is emitted on approval */
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const emit = defineEmits<{ confirm: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog v-model:open="open">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription v-if="description">{{ description }}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class="bg-destructive text-white hover:bg-destructive/90"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
25
frontend/src/components/ThemeToggle.vue
Normal file
25
frontend/src/components/ThemeToggle.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { Moon, Sun } from '@lucide/vue'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Adds the `dark` class on <html>, follows the system until the user picks a theme,
|
||||
// and remembers the choice in the local storage
|
||||
const isDark = useDark()
|
||||
const toggleDark = useToggle(isDark)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="rounded-lg"
|
||||
:title="isDark ? $t('theme.light') : $t('theme.dark')"
|
||||
@click="toggleDark()"
|
||||
>
|
||||
<Sun v-if="isDark" />
|
||||
<Moon v-else />
|
||||
<span class="sr-only">{{ isDark ? $t('theme.light') : $t('theme.dark') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
277
frontend/src/components/calendar/TasksCalendar.vue
Normal file
277
frontend/src/components/calendar/TasksCalendar.vue
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Month calendar of the deadlines of the courses and associations of `kinds`. On a phone
|
||||
* the days only show dots: the tasks of the selected day are listed under the calendar,
|
||||
* on every screen. `create` asks for a new task due on the selected day.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarCheck, ChevronLeft, ChevronRight, Plus, Timer } from '@lucide/vue'
|
||||
|
||||
import TagBadge from '@/components/course/TagBadge.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { useCalendarTasks } from '@/services/api/tasks'
|
||||
import { dayKey, monthWeeks, weeksPeriod } from '@/utils/calendar'
|
||||
import { isPast } from '@/utils/dates'
|
||||
import { coursePageRoute, focusRoute, tasksTab } from '@/utils/routes'
|
||||
import type { CalendarTask, CourseKind } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ kinds: CourseKind[] }>()
|
||||
const emit = defineEmits<{ create: [day: Date] }>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const { tagsById } = useTags()
|
||||
|
||||
/** Tasks shown in a day cell on large screens, the others are counted */
|
||||
const MAX_PER_DAY = 3
|
||||
|
||||
const today = new Date()
|
||||
const month = ref(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
const selectedDay = ref(dayKey(today))
|
||||
|
||||
const weeks = computed(() => monthWeeks(month.value.getFullYear(), month.value.getMonth()))
|
||||
const {
|
||||
data: tasks,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = useCalendarTasks(() => weeksPeriod(weeks.value))
|
||||
|
||||
/** Tasks of each day, already sorted by deadline by the backend */
|
||||
const tasksByDay = computed(() => {
|
||||
const byDay = new Map<string, CalendarTask[]>()
|
||||
for (const task of tasks.value ?? []) {
|
||||
if (!props.kinds.includes(task.course_kind)) continue
|
||||
const key = dayKey(new Date(task.deadline))
|
||||
byDay.set(key, [...(byDay.get(key) ?? []), task])
|
||||
}
|
||||
return byDay
|
||||
})
|
||||
|
||||
const selectedTasks = computed(() => tasksByDay.value.get(selectedDay.value) ?? [])
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
new Intl.DateTimeFormat(locale.value, { month: 'long', year: 'numeric' }).format(month.value),
|
||||
)
|
||||
const weekdayLabels = computed(() =>
|
||||
weeks.value[0]!.map((day) =>
|
||||
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(day),
|
||||
),
|
||||
)
|
||||
const selectedDate = computed(() => {
|
||||
const [year, m, d] = selectedDay.value.split('-').map(Number)
|
||||
return new Date(year!, m! - 1, d)
|
||||
})
|
||||
const selectedLabel = computed(() =>
|
||||
new Intl.DateTimeFormat(locale.value, { dateStyle: 'full' }).format(selectedDate.value),
|
||||
)
|
||||
|
||||
function moveMonth(offset: number) {
|
||||
const next = new Date(month.value.getFullYear(), month.value.getMonth() + offset, 1)
|
||||
month.value = next
|
||||
selectedDay.value = dayKey(
|
||||
next.getMonth() === today.getMonth() && next.getFullYear() === today.getFullYear()
|
||||
? today
|
||||
: next,
|
||||
)
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
month.value = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
selectedDay.value = dayKey(today)
|
||||
}
|
||||
|
||||
function taskColor(task: CalendarTask) {
|
||||
return tagsById.value.get(task.tag_ids[0] ?? -1)?.color ?? 'var(--primary)'
|
||||
}
|
||||
|
||||
function isLate(task: CalendarTask) {
|
||||
return !task.done && isPast(task.deadline)
|
||||
}
|
||||
|
||||
function time(task: CalendarTask) {
|
||||
return new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(task.deadline),
|
||||
)
|
||||
}
|
||||
|
||||
function taskLink(task: CalendarTask) {
|
||||
return coursePageRoute({ id: task.course_id, kind: task.course_kind }, tasksTab(task.course_kind))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="mr-auto text-lg font-semibold capitalize">{{ monthLabel }}</h2>
|
||||
<Button variant="outline" size="sm" @click="goToday">{{ $t('calendar.today') }}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
:title="$t('calendar.previous')"
|
||||
@click="moveMonth(-1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
<span class="sr-only">{{ $t('calendar.previous') }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon-sm" :title="$t('calendar.next')" @click="moveMonth(1)">
|
||||
<ChevronRight />
|
||||
<span class="sr-only">{{ $t('calendar.next') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border bg-card transition-opacity"
|
||||
:class="{ 'opacity-60': isPending }"
|
||||
>
|
||||
<div class="grid grid-cols-7 border-b">
|
||||
<div
|
||||
v-for="label in weekdayLabels"
|
||||
:key="label"
|
||||
class="py-2 text-center text-xs font-medium text-muted-foreground capitalize"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(week, index) in weeks"
|
||||
:key="index"
|
||||
class="grid grid-cols-7 [&:not(:last-child)]:border-b"
|
||||
>
|
||||
<!-- Not a <button>: it holds links -->
|
||||
<div
|
||||
v-for="day in week"
|
||||
:key="dayKey(day)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex min-h-14 cursor-pointer min-w-0 flex-col gap-1 border-l p-1 text-left transition-colors first:border-l-0 hover:bg-accent/40 md:min-h-28 md:p-1.5"
|
||||
:class="{
|
||||
'bg-muted/40 text-muted-foreground': day.getMonth() !== month.getMonth(),
|
||||
'ring-2 ring-primary ring-inset': dayKey(day) === selectedDay,
|
||||
}"
|
||||
:aria-pressed="dayKey(day) === selectedDay"
|
||||
@click="selectedDay = dayKey(day)"
|
||||
@keydown.enter.space.prevent="selectedDay = dayKey(day)"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 items-center justify-center self-center rounded-full text-xs tabular-nums md:self-start"
|
||||
:class="{
|
||||
'bg-primary font-semibold text-primary-foreground': dayKey(day) === dayKey(today),
|
||||
}"
|
||||
>
|
||||
{{ day.getDate() }}
|
||||
</span>
|
||||
|
||||
<!-- Phone: one dot per task -->
|
||||
<span class="flex flex-wrap justify-center gap-0.5 md:hidden">
|
||||
<span
|
||||
v-for="task in tasksByDay.get(dayKey(day)) ?? []"
|
||||
:key="task.task_id"
|
||||
class="size-1.5 rounded-full"
|
||||
:class="{ 'opacity-40': task.done }"
|
||||
:style="{ backgroundColor: taskColor(task) }"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<!-- Large screens: the first tasks of the day -->
|
||||
<span class="hidden min-w-0 flex-col gap-0.5 md:flex">
|
||||
<RouterLink
|
||||
v-for="task in (tasksByDay.get(dayKey(day)) ?? []).slice(0, MAX_PER_DAY)"
|
||||
:key="task.task_id"
|
||||
:to="taskLink(task)"
|
||||
class="truncate rounded border-l-2 px-1 py-0.5 text-xs hover:underline"
|
||||
:class="{
|
||||
'text-muted-foreground line-through': task.done,
|
||||
'font-medium text-destructive': isLate(task),
|
||||
}"
|
||||
:style="{
|
||||
borderLeftColor: taskColor(task),
|
||||
backgroundColor: `color-mix(in oklab, ${taskColor(task)} 12%, transparent)`,
|
||||
}"
|
||||
:title="`${task.course_code ?? task.course_name} · ${task.name}`"
|
||||
@click.stop
|
||||
>
|
||||
<span v-if="task.course_code" class="font-mono text-[0.625rem] opacity-70">
|
||||
{{ task.course_code }}
|
||||
</span>
|
||||
{{ task.name }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="(tasksByDay.get(dayKey(day))?.length ?? 0) > MAX_PER_DAY"
|
||||
class="px-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('calendar.more', tasksByDay.get(dayKey(day))!.length - MAX_PER_DAY) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="isError" class="text-sm text-destructive">
|
||||
{{ $t('calendar.load-error') }}
|
||||
<Button variant="link" size="sm" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle class="text-base first-letter:uppercase">{{ selectedLabel }}</CardTitle>
|
||||
<Button variant="outline" size="sm" @click="emit('create', selectedDate)">
|
||||
<Plus />
|
||||
{{ $t('calendar.add-that-day') }}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
v-if="!selectedTasks.length"
|
||||
class="flex flex-col items-center gap-2 py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<CalendarCheck class="size-6" />
|
||||
{{ $t('calendar.nothing') }}
|
||||
</div>
|
||||
<ul v-else class="flex flex-col gap-3">
|
||||
<li v-for="task in selectedTasks" :key="task.task_id" class="flex items-start gap-2">
|
||||
<RouterLink
|
||||
:to="taskLink(task)"
|
||||
class="flex min-w-0 flex-1 flex-col gap-1 border-l-2 pl-3 hover:underline"
|
||||
:style="{ borderLeftColor: taskColor(task) }"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="{ 'text-muted-foreground line-through': task.done }"
|
||||
>
|
||||
{{ task.name }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ time(task) }} · {{ task.course_name }}
|
||||
<template v-if="task.course_code">({{ task.course_code }})</template>
|
||||
<span v-if="isLate(task)" class="font-medium text-destructive">
|
||||
· {{ $t('tasks.late') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<Button
|
||||
v-if="!task.done"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
:title="$t('focus.start')"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="focusRoute(task.task_id)">
|
||||
<Timer />
|
||||
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
185
frontend/src/components/calendar/TodoList.vue
Normal file
185
frontend/src/components/calendar/TodoList.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Everything left to do in the courses and associations of `kinds`: the late tasks,
|
||||
* today's, this week's, the later ones and the ones without deadline. Each task (and
|
||||
* subtask) can be checked, or worked on with the pomodoro.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ListTodo, Timer } from '@lucide/vue'
|
||||
|
||||
import TagBadge from '@/components/course/TagBadge.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { toTaskBody, useAnyTaskMutations, useTodoTasks } from '@/services/api/tasks'
|
||||
import { useFocus } from '@/services/focus'
|
||||
import { dayKey } from '@/utils/calendar'
|
||||
import { formatDateTime, formatRelative, isPast } from '@/utils/dates'
|
||||
import { coursePageRoute, focusRoute, tasksTab } from '@/utils/routes'
|
||||
import type { CourseKind, Task, TodoTask } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ kinds: CourseKind[] }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { tagsById } = useTags()
|
||||
const { data, isPending, isError, refetch } = useTodoTasks()
|
||||
const { update } = useAnyTaskMutations()
|
||||
const { session } = useFocus()
|
||||
|
||||
const GROUPS = ['late', 'today', 'week', 'later', 'undated'] as const
|
||||
type Group = (typeof GROUPS)[number]
|
||||
|
||||
function groupOf(task: Task): Group {
|
||||
if (!task.deadline) return 'undated'
|
||||
if (isPast(task.deadline)) return 'late'
|
||||
const deadline = new Date(task.deadline)
|
||||
if (dayKey(deadline) === dayKey(new Date())) return 'today'
|
||||
return deadline.getTime() - Date.now() < 7 * 24 * 3600 * 1000 ? 'week' : 'later'
|
||||
}
|
||||
|
||||
/** Already sorted by deadline by the backend */
|
||||
const groups = computed(() => {
|
||||
const byGroup = new Map<Group, TodoTask[]>()
|
||||
for (const todo of data.value ?? []) {
|
||||
if (!props.kinds.includes(todo.course.kind)) continue
|
||||
const group = groupOf(todo.task)
|
||||
byGroup.set(group, [...(byGroup.get(group) ?? []), todo])
|
||||
}
|
||||
return GROUPS.filter((group) => byGroup.has(group)).map((group) => ({
|
||||
group,
|
||||
todos: byGroup.get(group)!,
|
||||
}))
|
||||
})
|
||||
|
||||
async function setDone(courseId: number, task: Task, done: boolean) {
|
||||
try {
|
||||
await update.mutateAsync({ courseId, id: task.id, body: toTaskBody(task, { done }) })
|
||||
} catch {
|
||||
toast.error(t('tasks.save-error'))
|
||||
}
|
||||
}
|
||||
|
||||
function inFocus(task: Task) {
|
||||
return session.value?.task?.task_id === task.id
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="isPending" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="i in 4" :key="i" class="h-14 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="isError" class="text-sm text-destructive">
|
||||
{{ $t('tasks.load-error') }}
|
||||
<Button variant="link" size="sm" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="!groups.length"
|
||||
class="flex flex-col items-center gap-2 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<ListTodo class="size-6" />
|
||||
{{ $t('calendar.todo.empty') }}
|
||||
</div>
|
||||
|
||||
<section v-for="{ group, todos } in groups" v-else :key="group" class="flex flex-col gap-2">
|
||||
<h3
|
||||
class="text-xs font-semibold tracking-wide uppercase"
|
||||
:class="group === 'late' ? 'text-destructive' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ $t(`calendar.todo.${group}`) }}
|
||||
<span class="font-normal">· {{ todos.length }}</span>
|
||||
</h3>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="{ course, task } in todos"
|
||||
:key="task.id"
|
||||
class="flex flex-col gap-1.5 rounded-lg border bg-card p-2.5"
|
||||
>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Checkbox
|
||||
class="mt-0.5"
|
||||
:model-value="task.done"
|
||||
:aria-label="$t('tasks.done')"
|
||||
@update:model-value="setDone(course.id, task, $event === true)"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<RouterLink
|
||||
:to="coursePageRoute(course, tasksTab(course.kind))"
|
||||
class="text-sm leading-snug font-medium hover:underline"
|
||||
>
|
||||
{{ task.name }}
|
||||
</RouterLink>
|
||||
<span class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span class="font-mono">{{ course.code ?? course.name }}</span>
|
||||
<template v-if="task.deadline">
|
||||
·
|
||||
<span
|
||||
:class="{ 'font-medium text-destructive': group === 'late' }"
|
||||
:title="formatDateTime(task.deadline, locale)"
|
||||
>
|
||||
{{ formatRelative(task.deadline, locale) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
:class="{ 'text-primary': inFocus(task) }"
|
||||
:title="$t('focus.start')"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="focusRoute(task.id)">
|
||||
<Timer :class="{ 'animate-pulse': inFocus(task) }" />
|
||||
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul v-if="task.subtasks.length" class="ml-6.5 flex flex-col gap-0.5 border-l pl-2.5">
|
||||
<li
|
||||
v-for="subtask in task.subtasks"
|
||||
:key="subtask.id"
|
||||
class="group flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="subtask.done"
|
||||
:aria-label="$t('tasks.done')"
|
||||
@update:model-value="setDone(course.id, subtask, $event === true)"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate"
|
||||
:class="{ 'text-muted-foreground line-through': subtask.done }"
|
||||
>
|
||||
{{ subtask.name }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="!subtask.done"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
:class="{ 'text-primary opacity-100': inFocus(subtask) }"
|
||||
:title="$t('focus.start')"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="focusRoute(subtask.id)">
|
||||
<Timer :class="{ 'animate-pulse': inFocus(subtask) }" />
|
||||
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
190
frontend/src/components/course/AssociationDeadlinesTab.vue
Normal file
190
frontend/src/components/course/AssociationDeadlinesTab.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Deadlines of an association: its week, with its slots (added by selecting a time range)
|
||||
* and the deadlines of each day, then the list of its tasks.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
|
||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||
import CourseTasksTab from '@/components/course/CourseTasksTab.vue'
|
||||
import SlotFormDialog from '@/components/schedule/SlotFormDialog.vue'
|
||||
import WeekTimetable from '@/components/timetable/WeekTimetable.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDeleteSlot } from '@/services/api/courses'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { useCalendarTasks } from '@/services/api/tasks'
|
||||
import { dayKey, startOfWeek, weeksPeriod } from '@/utils/calendar'
|
||||
import { isPast } from '@/utils/dates'
|
||||
import { toHourMinutes, type SlotSelection } from '@/utils/timetable'
|
||||
import type { CalendarTask, Course, TimeSlot } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ course: Course }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { tagsById } = useTags()
|
||||
const deleteSlot = useDeleteSlot()
|
||||
|
||||
const courses = computed(() => [props.course])
|
||||
|
||||
/** Monday of the week shown */
|
||||
const monday = ref(startOfWeek(new Date()))
|
||||
/** The 7 days of the week, the timetable shows the first 5 */
|
||||
const week = computed(() =>
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const day = new Date(monday.value)
|
||||
day.setDate(day.getDate() + i)
|
||||
return day
|
||||
}),
|
||||
)
|
||||
|
||||
function moveWeek(offset: number) {
|
||||
const next = new Date(monday.value)
|
||||
next.setDate(next.getDate() + offset * 7)
|
||||
monday.value = next
|
||||
}
|
||||
|
||||
const weekLabel = computed(() => {
|
||||
const format = new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'long' })
|
||||
return `${format.format(week.value[0]!)} – ${format.format(week.value[6]!)}`
|
||||
})
|
||||
|
||||
const { data: tasks } = useCalendarTasks(() => weeksPeriod([week.value]))
|
||||
|
||||
const tasksByDay = computed(() => {
|
||||
const byDay = new Map<string, CalendarTask[]>()
|
||||
for (const task of tasks.value ?? []) {
|
||||
if (task.course_id !== props.course.id) continue
|
||||
const key = dayKey(new Date(task.deadline))
|
||||
byDay.set(key, [...(byDay.get(key) ?? []), task])
|
||||
}
|
||||
return byDay
|
||||
})
|
||||
|
||||
/** Deadlines of the weekend, not shown by the timetable */
|
||||
const weekendTasks = computed(() =>
|
||||
week.value.slice(5).flatMap((day) => tasksByDay.value.get(dayKey(day)) ?? []),
|
||||
)
|
||||
|
||||
function taskColor(task: CalendarTask) {
|
||||
return tagsById.value.get(task.tag_ids[0] ?? -1)?.color ?? 'var(--primary)'
|
||||
}
|
||||
|
||||
function time(task: CalendarTask) {
|
||||
return new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(task.deadline),
|
||||
)
|
||||
}
|
||||
|
||||
const slotFormOpen = ref(false)
|
||||
const slotSelection = ref<SlotSelection>()
|
||||
|
||||
function onSelect(selection: SlotSelection) {
|
||||
slotSelection.value = selection
|
||||
slotFormOpen.value = true
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deletedSlot = ref<TimeSlot>()
|
||||
const deletedSlotLabel = computed(() => {
|
||||
const slot = deletedSlot.value
|
||||
if (!slot) return ''
|
||||
return `${t(`weekdays.${slot.weekday}`)} ${toHourMinutes(slot.start_time)}–${toHourMinutes(slot.end_time)}`
|
||||
})
|
||||
|
||||
function askDeleteSlot(slot: TimeSlot) {
|
||||
deletedSlot.value = slot
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteSlot() {
|
||||
if (!deletedSlot.value) return
|
||||
try {
|
||||
await deleteSlot.mutateAsync({ courseId: props.course.id, slotId: deletedSlot.value.id })
|
||||
toast.success(t('schedule.slot-deleted'))
|
||||
} catch {
|
||||
toast.error(t('schedule.slot-delete-error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-6">
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="mr-auto font-medium first-letter:uppercase">{{ weekLabel }}</h3>
|
||||
<Button variant="outline" size="sm" @click="monday = startOfWeek(new Date())">
|
||||
{{ $t('calendar.this-week') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
:title="$t('calendar.previous-week')"
|
||||
@click="moveWeek(-1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
<span class="sr-only">{{ $t('calendar.previous-week') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
:title="$t('calendar.next-week')"
|
||||
@click="moveWeek(1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
<span class="sr-only">{{ $t('calendar.next-week') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('association.deadlines.hint') }}</p>
|
||||
|
||||
<WeekTimetable
|
||||
:courses="courses"
|
||||
:dates="week.slice(0, 5)"
|
||||
height="34rem"
|
||||
selectable
|
||||
deletable
|
||||
@select="onSelect"
|
||||
@delete="askDeleteSlot"
|
||||
>
|
||||
<template #day="{ date }">
|
||||
<span v-if="date" class="flex min-w-0 flex-col gap-0.5 text-left font-normal">
|
||||
<span
|
||||
v-for="task in tasksByDay.get(dayKey(date)) ?? []"
|
||||
:key="task.task_id"
|
||||
class="truncate rounded border-l-2 px-1 py-0.5 text-xs"
|
||||
:class="{
|
||||
'text-muted-foreground line-through': task.done,
|
||||
'font-medium text-destructive': !task.done && isPast(task.deadline),
|
||||
}"
|
||||
:style="{
|
||||
borderLeftColor: taskColor(task),
|
||||
backgroundColor: `color-mix(in oklab, ${taskColor(task)} 12%, transparent)`,
|
||||
}"
|
||||
:title="`${time(task)} · ${task.name}`"
|
||||
>
|
||||
<span class="tabular-nums opacity-70">{{ time(task) }}</span>
|
||||
{{ task.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</WeekTimetable>
|
||||
<p v-if="weekendTasks.length" class="text-sm text-muted-foreground">
|
||||
{{ $t('association.deadlines.weekend') }}
|
||||
<span v-for="task in weekendTasks" :key="task.task_id" class="ml-2 text-foreground">
|
||||
{{ task.name }}
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<CourseTasksTab :course-id="course.id" />
|
||||
|
||||
<SlotFormDialog v-model:open="slotFormOpen" :selection="slotSelection" :course-id="course.id" />
|
||||
<ConfirmDeleteDialog
|
||||
v-model:open="deleteOpen"
|
||||
:title="$t('schedule.delete-slot-title', { slot: deletedSlotLabel })"
|
||||
@confirm="confirmDeleteSlot"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
165
frontend/src/components/course/CourseFilesTab.vue
Normal file
165
frontend/src/components/course/CourseFilesTab.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Every file of the course in one list, with their tags (slides, notes...). For now the
|
||||
* only files are the lecture notes written in the app; uploads come with the NAS.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { FileText, FileWarning, FolderOpen, Pencil, Plus, Trash2, Upload } from '@lucide/vue'
|
||||
|
||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||
import TagBadge from '@/components/course/TagBadge.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCourseFiles, useLectureNoteMutations } from '@/services/api/course-files'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { formatDate } from '@/utils/dates'
|
||||
import { noteRoute } from '@/utils/routes'
|
||||
import type { Course, CourseFile } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ course: Course }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { tagsById } = useTags()
|
||||
const { data: files, isPending, isError, refetch } = useCourseFiles(() => props.course.id)
|
||||
const { remove } = useLectureNoteMutations(() => props.course.id)
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deletedFile = ref<CourseFile>()
|
||||
|
||||
function askDelete(file: CourseFile) {
|
||||
deletedFile.value = file
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletedFile.value) return
|
||||
try {
|
||||
await remove.mutateAsync(deletedFile.value.id)
|
||||
toast.success(t('files.deleted'))
|
||||
} catch {
|
||||
toast.error(t('files.delete-error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<CardTitle class="text-base">{{ $t(`files.title.${course.kind}`) }}</CardTitle>
|
||||
<CardDescription>{{ $t('files.description') }}</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<!-- A disabled button gets no pointer event: the tooltip is on its wrapper -->
|
||||
<span tabindex="0">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Upload />
|
||||
{{ $t('files.upload') }}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('files.coming-soon') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button size="sm" as-child>
|
||||
<RouterLink :to="noteRoute(course, 'new')">
|
||||
<Plus />
|
||||
{{ $t('notes.new') }}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div v-if="isPending" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="i in 3" :key="i" class="h-12" />
|
||||
</div>
|
||||
<div v-else-if="isError" class="flex flex-col items-center gap-2 py-4 text-sm">
|
||||
{{ $t('files.load-error') }}
|
||||
<Button variant="outline" size="sm" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!files?.length"
|
||||
class="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<FolderOpen class="size-8" />
|
||||
{{ $t('files.empty') }}
|
||||
</div>
|
||||
<ul v-else class="-mx-2 flex flex-col">
|
||||
<li
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
class="relative flex items-center gap-3 rounded-md px-2 py-2 hover:bg-accent/50"
|
||||
>
|
||||
<component
|
||||
:is="file.kind === 'note' && !file.has_pdf ? FileWarning : FileText"
|
||||
class="size-5 shrink-0"
|
||||
:class="
|
||||
file.kind === 'note' && !file.has_pdf ? 'text-destructive' : 'text-muted-foreground'
|
||||
"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<RouterLink
|
||||
:to="noteRoute(course, file.id)"
|
||||
class="truncate text-sm font-medium after:absolute after:inset-0 after:content-['']"
|
||||
>
|
||||
{{ file.name }}
|
||||
</RouterLink>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<template v-for="tagId in file.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ $t('files.updated-on', { date: formatDate(file.updated_at, locale) }) }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="file.kind === 'note' && !file.has_pdf"
|
||||
variant="outline"
|
||||
class="border-destructive/50 text-destructive"
|
||||
>
|
||||
{{ $t('notes.no-pdf') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10 flex shrink-0 gap-1">
|
||||
<Button
|
||||
v-if="file.kind === 'note'"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:title="$t('common.edit')"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="noteRoute(course, file.id, true)">
|
||||
<Pencil />
|
||||
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
:title="$t('common.delete')"
|
||||
@click="askDelete(file)"
|
||||
>
|
||||
<Trash2 />
|
||||
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model:open="deleteOpen"
|
||||
:title="$t('files.delete-title', { name: deletedFile?.name })"
|
||||
:description="$t('files.delete-description')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
171
frontend/src/components/course/CourseGradesTab.vue
Normal file
171
frontend/src/components/course/CourseGradesTab.vue
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<script setup lang="ts">
|
||||
/** Grades of the course and their weighted average out of 6, computed by the backend */
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { GraduationCap, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
|
||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||
import GradeFormDialog from '@/components/course/GradeFormDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useCourseGrades, useGradeMutations } from '@/services/api/grades'
|
||||
import type { Grade } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ courseId: number }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { data, isPending, isError, refetch } = useCourseGrades(() => props.courseId)
|
||||
const { remove } = useGradeMutations(() => props.courseId)
|
||||
|
||||
/** Swiss passing grade */
|
||||
const PASSING = 4
|
||||
|
||||
function number(value: number) {
|
||||
return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editedGrade = ref<Grade>()
|
||||
|
||||
function openForm(grade?: Grade) {
|
||||
editedGrade.value = grade
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deletedGrade = ref<Grade>()
|
||||
|
||||
function askDelete(grade: Grade) {
|
||||
deletedGrade.value = grade
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletedGrade.value) return
|
||||
try {
|
||||
await remove.mutateAsync(deletedGrade.value.id)
|
||||
toast.success(t('grades.deleted'))
|
||||
} catch {
|
||||
toast.error(t('grades.delete-error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Skeleton v-if="isPending" class="h-48 w-full rounded-xl" />
|
||||
|
||||
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('grades.load-error') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<template v-else-if="data">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<Card class="flex items-center gap-4 px-5 py-4">
|
||||
<GraduationCap class="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('grades.average') }}</p>
|
||||
<p class="text-3xl font-semibold tabular-nums">
|
||||
<span
|
||||
v-if="data.average !== null && data.average !== undefined"
|
||||
:class="
|
||||
data.average >= PASSING
|
||||
? 'text-green-600 dark:text-green-500'
|
||||
: 'text-destructive'
|
||||
"
|
||||
>
|
||||
{{ number(data.average) }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">–</span>
|
||||
<span class="text-lg font-normal text-muted-foreground"> / 6</span>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Button @click="openForm()">
|
||||
<Plus />
|
||||
{{ $t('grades.new') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card v-if="!data.grades.length" class="flex flex-col items-center gap-1 p-8 text-center">
|
||||
<CardTitle class="text-base">{{ $t('grades.empty') }}</CardTitle>
|
||||
<CardDescription>{{ $t('grades.empty-hint') }}</CardDescription>
|
||||
</Card>
|
||||
|
||||
<Card v-else class="overflow-hidden p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ $t('grades.form.name') }}</TableHead>
|
||||
<TableHead class="text-right">{{ $t('grades.score') }}</TableHead>
|
||||
<TableHead class="text-right">{{ $t('grades.swiss') }}</TableHead>
|
||||
<TableHead class="text-right">{{ $t('grades.form.weight') }}</TableHead>
|
||||
<TableHead class="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="grade in data.grades" :key="grade.id">
|
||||
<TableCell class="font-medium">{{ grade.name }}</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
{{ number(grade.score) }} / {{ number(grade.max_score) }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right font-medium tabular-nums"
|
||||
:class="{ 'text-destructive': grade.swiss_grade < PASSING }"
|
||||
>
|
||||
{{ number(grade.swiss_grade) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">{{ number(grade.weight) }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:title="$t('common.edit')"
|
||||
@click="openForm(grade)"
|
||||
>
|
||||
<Pencil />
|
||||
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
:title="$t('common.delete')"
|
||||
@click="askDelete(grade)"
|
||||
>
|
||||
<Trash2 />
|
||||
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p class="border-t px-4 py-3 text-xs text-muted-foreground">
|
||||
{{ $t('grades.total-weight', { total: number(data.total_weight) }) }}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<GradeFormDialog v-model:open="formOpen" :course-id="courseId" :grade="editedGrade" />
|
||||
<ConfirmDeleteDialog
|
||||
v-model:open="deleteOpen"
|
||||
:title="$t('grades.delete-title', { name: deletedGrade?.name })"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
65
frontend/src/components/course/CourseScheduleTab.vue
Normal file
65
frontend/src/components/course/CourseScheduleTab.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup lang="ts">
|
||||
/** Weekly slots of the course. They are added from the schedule page. */
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||
import WeekTimetable from '@/components/timetable/WeekTimetable.vue'
|
||||
import { useDeleteSlot } from '@/services/api/courses'
|
||||
import { toHourMinutes } from '@/utils/timetable'
|
||||
import type { Course, TimeSlot } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ course: Course }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const deleteSlot = useDeleteSlot()
|
||||
|
||||
const courses = computed(() => [props.course])
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deletedSlot = ref<TimeSlot>()
|
||||
const deletedSlotLabel = computed(() => {
|
||||
const slot = deletedSlot.value
|
||||
if (!slot) return ''
|
||||
return `${t(`weekdays.${slot.weekday}`)} ${toHourMinutes(slot.start_time)}–${toHourMinutes(slot.end_time)}`
|
||||
})
|
||||
|
||||
function askDeleteSlot(slot: TimeSlot) {
|
||||
deletedSlot.value = slot
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteSlot() {
|
||||
if (!deletedSlot.value) return
|
||||
try {
|
||||
await deleteSlot.mutateAsync({ courseId: props.course.id, slotId: deletedSlot.value.id })
|
||||
toast.success(t('schedule.slot-deleted'))
|
||||
} catch {
|
||||
toast.error(t('schedule.slot-delete-error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Same layout as the schedule page: the page keeps the width of the screen and the
|
||||
timetable scrolls horizontally inside it on a phone -->
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ course.slots.length ? $t('course.schedule.slots-hint') : $t('course.schedule.no-slot') }}
|
||||
<RouterLink
|
||||
:to="{ name: 'schedule' }"
|
||||
class="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{{ $t('nav.schedule') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
<WeekTimetable :courses="courses" deletable @delete="askDeleteSlot" />
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model:open="deleteOpen"
|
||||
:title="$t('schedule.delete-slot-title', { slot: deletedSlotLabel })"
|
||||
@confirm="confirmDeleteSlot"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
321
frontend/src/components/course/CourseTasksTab.vue
Normal file
321
frontend/src/components/course/CourseTasksTab.vue
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Homework, exams and anything to do for the course, with their subtasks. The backend
|
||||
* sorts them (not done first, then by deadline) and nests the subtasks. Each one can be
|
||||
* worked on with the pomodoro.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
CalendarClock,
|
||||
ClipboardList,
|
||||
ExternalLink,
|
||||
Hourglass,
|
||||
Pencil,
|
||||
Plus,
|
||||
Timer,
|
||||
Trash2,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||
import TagBadge from '@/components/course/TagBadge.vue'
|
||||
import TaskFormDialog from '@/components/course/TaskFormDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardDescription, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { toTaskBody, useCourseTasks, useTaskMutations } from '@/services/api/tasks'
|
||||
import { formatSpent, useFocus } from '@/services/focus'
|
||||
import { formatDateTime, isPast } from '@/utils/dates'
|
||||
import { focusRoute } from '@/utils/routes'
|
||||
import type { Task } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ courseId: number }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { tagsById } = useTags()
|
||||
const { data, isPending, isError, refetch } = useCourseTasks(() => props.courseId)
|
||||
const { create, update, remove } = useTaskMutations(() => props.courseId)
|
||||
const { session } = useFocus()
|
||||
|
||||
function inFocus(task: Task) {
|
||||
return session.value?.task?.task_id === task.id
|
||||
}
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editedTask = ref<Task>()
|
||||
const parentId = ref<number>()
|
||||
|
||||
function openForm(task?: Task, parent?: Task) {
|
||||
editedTask.value = task
|
||||
parentId.value = parent?.id
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function setDone(task: Task, done: boolean) {
|
||||
try {
|
||||
await update.mutateAsync({ id: task.id, body: toTaskBody(task, { done }) })
|
||||
} catch {
|
||||
toast.error(t('tasks.save-error'))
|
||||
}
|
||||
}
|
||||
|
||||
/** Name typed in the "add a subtask" field of each task */
|
||||
const newSubtasks = ref<Record<number, string>>({})
|
||||
|
||||
async function addSubtask(parent: Task) {
|
||||
const name = newSubtasks.value[parent.id]?.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await create.mutateAsync({ parent_id: parent.id, name, done: false, tag_ids: [], links: [] })
|
||||
newSubtasks.value[parent.id] = ''
|
||||
} catch (err) {
|
||||
toast.error(t('tasks.save-error'), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deletedTask = ref<Task>()
|
||||
|
||||
function askDelete(task: Task) {
|
||||
deletedTask.value = task
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletedTask.value) return
|
||||
try {
|
||||
await remove.mutateAsync(deletedTask.value.id)
|
||||
toast.success(t('tasks.deleted'))
|
||||
} catch {
|
||||
toast.error(t('tasks.delete-error'))
|
||||
}
|
||||
}
|
||||
|
||||
function isLate(task: Task) {
|
||||
return !task.done && !!task.deadline && isPast(task.deadline)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-end">
|
||||
<Button @click="openForm()">
|
||||
<Plus />
|
||||
{{ $t('tasks.new') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isPending" class="flex flex-col gap-3">
|
||||
<Skeleton v-for="i in 3" :key="i" class="h-24 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<Card v-else-if="isError" class="mx-auto flex max-w-md flex-col items-center gap-3 p-6">
|
||||
<CardTitle class="text-base">{{ $t('tasks.load-error') }}</CardTitle>
|
||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="!data?.tasks.length" class="flex flex-col items-center gap-1 p-8 text-center">
|
||||
<ClipboardList class="mb-2 size-8 text-muted-foreground" />
|
||||
<CardTitle class="text-base">{{ $t('tasks.empty') }}</CardTitle>
|
||||
<CardDescription>{{ $t('tasks.empty-hint') }}</CardDescription>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<Card
|
||||
v-for="task in data.tasks"
|
||||
:key="task.id"
|
||||
class="flex flex-col gap-3 p-4"
|
||||
:class="{ 'opacity-60': task.done }"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Checkbox
|
||||
class="mt-1"
|
||||
:model-value="task.done"
|
||||
:aria-label="$t('tasks.done')"
|
||||
@update:model-value="setDone(task, $event === true)"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium" :class="{ 'line-through': task.done }">{{
|
||||
task.name
|
||||
}}</span>
|
||||
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="isLate(task) ? 'font-medium text-destructive' : 'text-muted-foreground'"
|
||||
>
|
||||
<CalendarClock class="size-3.5" />
|
||||
{{ formatDateTime(task.deadline, locale) }}
|
||||
<template v-if="isLate(task)">· {{ $t('tasks.late') }}</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="task.time_spent >= 60"
|
||||
class="flex items-center gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Hourglass class="size-3.5" />
|
||||
{{ $t('tasks.time-spent', { time: formatSpent(task.time_spent) }) }}
|
||||
</span>
|
||||
<p v-if="task.description" class="text-sm whitespace-pre-line text-muted-foreground">
|
||||
{{ task.description }}
|
||||
</p>
|
||||
<ul v-if="task.links.length" class="flex flex-col gap-0.5">
|
||||
<li v-for="link in task.links" :key="link" class="min-w-0">
|
||||
<a
|
||||
:href="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex max-w-full items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
<ExternalLink class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ link }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
v-if="!task.done"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:class="{ 'text-primary': inFocus(task) }"
|
||||
:title="$t('focus.start')"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="focusRoute(task.id)">
|
||||
<Timer :class="{ 'animate-pulse': inFocus(task) }" />
|
||||
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:title="$t('common.edit')"
|
||||
@click="openForm(task)"
|
||||
>
|
||||
<Pencil />
|
||||
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
:title="$t('common.delete')"
|
||||
@click="askDelete(task)"
|
||||
>
|
||||
<Trash2 />
|
||||
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="ml-7 flex flex-col gap-1 border-l pl-3">
|
||||
<div
|
||||
v-for="subtask in task.subtasks"
|
||||
:key="subtask.id"
|
||||
class="group flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="subtask.done"
|
||||
:aria-label="$t('tasks.done')"
|
||||
@update:model-value="setDone(subtask, $event === true)"
|
||||
/>
|
||||
<span :class="{ 'text-muted-foreground line-through': subtask.done }">
|
||||
{{ subtask.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="subtask.deadline"
|
||||
class="text-xs"
|
||||
:class="isLate(subtask) ? 'font-medium text-destructive' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ formatDateTime(subtask.deadline, locale) }}
|
||||
</span>
|
||||
<template v-for="tagId in subtask.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
<div
|
||||
class="ml-auto flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
:class="{ 'opacity-100': inFocus(subtask) }"
|
||||
>
|
||||
<Button
|
||||
v-if="!subtask.done && !task.done"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-7"
|
||||
:class="{ 'text-primary': inFocus(subtask) }"
|
||||
:title="$t('focus.start')"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="focusRoute(subtask.id)">
|
||||
<Timer :class="{ 'animate-pulse': inFocus(subtask) }" />
|
||||
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-7"
|
||||
:title="$t('common.edit')"
|
||||
@click="openForm(subtask)"
|
||||
>
|
||||
<Pencil />
|
||||
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-7 text-destructive hover:text-destructive"
|
||||
:title="$t('common.delete')"
|
||||
@click="askDelete(subtask)"
|
||||
>
|
||||
<Trash2 />
|
||||
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<form class="flex items-center gap-2" @submit.prevent="addSubtask(task)">
|
||||
<Plus class="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="newSubtasks[task.id]"
|
||||
class="h-8 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
:placeholder="$t('tasks.add-subtask')"
|
||||
:aria-label="$t('tasks.add-subtask')"
|
||||
/>
|
||||
<Button
|
||||
v-if="newSubtasks[task.id]?.trim()"
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:disabled="create.isPending.value"
|
||||
>
|
||||
{{ $t('common.add') }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<TaskFormDialog
|
||||
v-model:open="formOpen"
|
||||
:course-id="courseId"
|
||||
:task="editedTask"
|
||||
:parent-id="parentId"
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
v-model:open="deleteOpen"
|
||||
:title="$t('tasks.delete-title', { name: deletedTask?.name })"
|
||||
:description="deletedTask?.subtasks.length ? $t('tasks.delete-description') : undefined"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
147
frontend/src/components/course/GradeFormDialog.vue
Normal file
147
frontend/src/components/course/GradeFormDialog.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<script setup lang="ts">
|
||||
/** Adds a grade to a course, or edits `grade` when given */
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useGradeMutations } from '@/services/api/grades'
|
||||
import type { Grade } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ courseId: number; grade?: Grade }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { save } = useGradeMutations(() => props.courseId)
|
||||
|
||||
const name = ref('')
|
||||
// Strings: an empty field is not a 0
|
||||
const score = ref('')
|
||||
const maxScore = ref('')
|
||||
const weight = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return
|
||||
name.value = props.grade?.name ?? ''
|
||||
score.value = props.grade ? String(props.grade.score) : ''
|
||||
maxScore.value = props.grade ? String(props.grade.max_score) : '6'
|
||||
weight.value = props.grade ? String(props.grade.weight) : ''
|
||||
submitted.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** Accepts "4,5" as well as "4.5" */
|
||||
function parse(value: string): number {
|
||||
return value.trim() === '' ? NaN : Number(value.replace(',', '.'))
|
||||
}
|
||||
|
||||
// Same rules as the backend, checked here to point at the faulty field
|
||||
const errors = computed(() => {
|
||||
const [s, max, w] = [parse(score.value), parse(maxScore.value), parse(weight.value)]
|
||||
return {
|
||||
name: !name.value.trim(),
|
||||
score: !(max > 0) || !(s >= 0 && s <= max),
|
||||
weight: !(w > 0),
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitted.value = true
|
||||
if (Object.values(errors.value).some(Boolean)) return
|
||||
try {
|
||||
await save.mutateAsync({
|
||||
id: props.grade?.id,
|
||||
body: {
|
||||
name: name.value.trim(),
|
||||
score: parse(score.value),
|
||||
max_score: parse(maxScore.value),
|
||||
weight: parse(weight.value),
|
||||
},
|
||||
})
|
||||
toast.success(props.grade ? t('grades.updated') : t('grades.created'))
|
||||
open.value = false
|
||||
} catch (err) {
|
||||
toast.error(t('grades.save-error'), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ grade ? $t('grades.edit') : $t('grades.new') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('grades.form.description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="grade-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
|
||||
<div class="col-span-2 flex flex-col gap-2">
|
||||
<Label for="grade-name">{{ $t('grades.form.name') }}</Label>
|
||||
<Input
|
||||
id="grade-name"
|
||||
v-model="name"
|
||||
:placeholder="$t('grades.form.name-placeholder')"
|
||||
:aria-invalid="submitted && errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="grade-score">{{ $t('grades.form.score') }}</Label>
|
||||
<Input
|
||||
id="grade-score"
|
||||
v-model="score"
|
||||
inputmode="decimal"
|
||||
placeholder="5"
|
||||
:aria-invalid="submitted && errors.score"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="grade-max">{{ $t('grades.form.max-score') }}</Label>
|
||||
<Input
|
||||
id="grade-max"
|
||||
v-model="maxScore"
|
||||
inputmode="decimal"
|
||||
placeholder="6"
|
||||
:aria-invalid="submitted && errors.score"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="submitted && errors.score" class="col-span-2 -mt-2 text-sm text-destructive">
|
||||
{{ $t('grades.form.errors.score') }}
|
||||
</p>
|
||||
<div class="col-span-2 flex flex-col gap-2">
|
||||
<Label for="grade-weight">{{ $t('grades.form.weight') }}</Label>
|
||||
<Input
|
||||
id="grade-weight"
|
||||
v-model="weight"
|
||||
inputmode="decimal"
|
||||
placeholder="30"
|
||||
:aria-invalid="submitted && errors.weight"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ $t('grades.form.weight-hint') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||
<Button type="submit" form="grade-form" :disabled="save.isPending.value">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
185
frontend/src/components/course/MarkdownEditor.vue
Normal file
185
frontend/src/components/course/MarkdownEditor.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Markdown textarea with a toolbar for what the course template supports: headings,
|
||||
* lists, code, math, tables and the definition / theorem / example boxes
|
||||
* (`::: {.defbox title="..."}`, turned into tcolorbox by `box-filter.lua`).
|
||||
*/
|
||||
import { ref, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Bold,
|
||||
BookMarked,
|
||||
Code,
|
||||
FileCode,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Highlighter,
|
||||
Italic,
|
||||
Lightbulb,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Sigma,
|
||||
SquareFunction,
|
||||
Table,
|
||||
Sparkles,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { applyEdit, block, prefixLines, wrap, type Edit } from '@/utils/markdown-editor'
|
||||
|
||||
const content = defineModel<string>({ required: true })
|
||||
const emit = defineEmits<{ save: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const textarea = ref<HTMLTextAreaElement>()
|
||||
|
||||
type Action = (selection: { text: string; start: number; end: number }) => Edit
|
||||
|
||||
interface Tool {
|
||||
/** i18n key under `notes.editor` */
|
||||
label: string
|
||||
icon: Component
|
||||
action: Action
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
const HEADING = /^#{1,6} /
|
||||
const LIST = /^(\s*)([-*] |\d+\. )/
|
||||
|
||||
/** Box of the template, titled with the translated label of the tool */
|
||||
function box(kind: string, label: string): Action {
|
||||
return (s) =>
|
||||
block(
|
||||
s,
|
||||
`::: {.${kind} title="${t(`notes.editor.${label}`)}"}\n`,
|
||||
'\n:::',
|
||||
t('notes.editor.box-content'),
|
||||
)
|
||||
}
|
||||
|
||||
const groups: Tool[][] = [
|
||||
[
|
||||
{ label: 'h1', icon: Heading1, action: (s) => prefixLines(s, () => '# ', HEADING) },
|
||||
{ label: 'h2', icon: Heading2, action: (s) => prefixLines(s, () => '## ', HEADING) },
|
||||
{ label: 'h3', icon: Heading3, action: (s) => prefixLines(s, () => '### ', HEADING) },
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'bold',
|
||||
icon: Bold,
|
||||
shortcut: 'Ctrl+B',
|
||||
action: (s) => wrap(s, '**', '**', t('notes.editor.text')),
|
||||
},
|
||||
{
|
||||
label: 'italic',
|
||||
icon: Italic,
|
||||
shortcut: 'Ctrl+I',
|
||||
action: (s) => wrap(s, '*', '*', t('notes.editor.text')),
|
||||
},
|
||||
{
|
||||
label: 'highlight',
|
||||
icon: Highlighter,
|
||||
action: (s) => wrap(s, '\\hword{', '}', t('notes.editor.text')),
|
||||
},
|
||||
{ label: 'code', icon: Code, action: (s) => wrap(s, '`', '`', 'code') },
|
||||
{
|
||||
label: 'link',
|
||||
icon: Link,
|
||||
action: (s) => wrap(s, '[', '](https://)', t('notes.editor.text')),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'bullets', icon: List, action: (s) => prefixLines(s, () => '- ', LIST) },
|
||||
{
|
||||
label: 'numbered',
|
||||
icon: ListOrdered,
|
||||
action: (s) => prefixLines(s, (i) => `${i + 1}. `, LIST),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'math', icon: Sigma, action: (s) => wrap(s, '$', '$', 'x^2') },
|
||||
{
|
||||
label: 'math-block',
|
||||
icon: SquareFunction,
|
||||
action: (s) => block(s, '$$\n', '\n$$', '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}'),
|
||||
},
|
||||
{
|
||||
label: 'code-block',
|
||||
icon: FileCode,
|
||||
action: (s) => block(s, '```c\n', '\n```', 'int main() {\n return 0;\n}'),
|
||||
},
|
||||
{
|
||||
label: 'table',
|
||||
icon: Table,
|
||||
action: (s) => block(s, '', '\n|:--|:--:|--:|\n| 1 | 2 | 3 |', '| A | B | C |'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'definition', icon: BookMarked, action: box('defbox', 'definition') },
|
||||
{ label: 'theorem', icon: Sparkles, action: box('theobox', 'theorem') },
|
||||
{ label: 'example', icon: Lightbulb, action: box('exbox', 'example') },
|
||||
],
|
||||
]
|
||||
|
||||
function run(action: Action) {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
applyEdit(el, action({ text: el.value, start: el.selectionStart, end: el.selectionEnd }))
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const el = event.currentTarget as HTMLTextAreaElement
|
||||
const mod = event.ctrlKey || event.metaKey
|
||||
if (mod && event.key === 's') {
|
||||
event.preventDefault()
|
||||
emit('save')
|
||||
} else if (mod && event.key === 'b') {
|
||||
event.preventDefault()
|
||||
run(groups[1]![0]!.action)
|
||||
} else if (mod && event.key === 'i') {
|
||||
event.preventDefault()
|
||||
run(groups[1]![1]!.action)
|
||||
} else if (event.key === 'Tab' && !event.shiftKey && el.selectionStart === el.selectionEnd) {
|
||||
// Indents (nested lists) instead of leaving the editor
|
||||
event.preventDefault()
|
||||
applyEdit(el, { from: el.selectionStart, to: el.selectionEnd, insert: ' ', select: [2, 2] })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-hidden rounded-lg border bg-background">
|
||||
<div class="flex flex-wrap items-center gap-0.5 border-b bg-muted/40 p-1">
|
||||
<template v-for="(group, index) in groups" :key="index">
|
||||
<Separator v-if="index > 0" orientation="vertical" class="mx-0.5 !h-5" />
|
||||
<Tooltip v-for="tool in group" :key="tool.label">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
:aria-label="$t(`notes.editor.${tool.label}`)"
|
||||
@click="run(tool.action)"
|
||||
>
|
||||
<component :is="tool.icon" class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ $t(`notes.editor.${tool.label}`) }}
|
||||
<span v-if="tool.shortcut" class="ml-1 opacity-70">{{ tool.shortcut }}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="content"
|
||||
class="min-h-[60svh] flex-1 resize-none bg-transparent p-4 font-mono text-sm leading-relaxed outline-none"
|
||||
spellcheck="true"
|
||||
:placeholder="$t('notes.editor.placeholder')"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
18
frontend/src/components/course/TagBadge.vue
Normal file
18
frontend/src/components/course/TagBadge.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { Tag } from '@/utils/types'
|
||||
|
||||
defineProps<{ tag: Tag }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:style="{
|
||||
backgroundColor: `color-mix(in oklab, ${tag.color} 15%, transparent)`,
|
||||
color: `color-mix(in oklab, ${tag.color} 75%, var(--foreground))`,
|
||||
}"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</template>
|
||||
274
frontend/src/components/course/TaskFormDialog.vue
Normal file
274
frontend/src/components/course/TaskFormDialog.vue
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Creates a task (or a subtask of `parentId`), or edits `task` when given. A task needs
|
||||
* at least one tag, a subtask may have none. Without `courseId` the course (or the
|
||||
* association) is picked in the form. `defaultDeadline` (ISO) prefills a new task.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText,
|
||||
} from '@/components/ui/tags-input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useCourses } from '@/services/api/courses'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { useAnyTaskMutations } from '@/services/api/tasks'
|
||||
import { fromDateTimeInput, toDateTimeInput } from '@/utils/dates'
|
||||
import type { Task, TaskBody } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{
|
||||
courseId?: number
|
||||
task?: Task
|
||||
parentId?: number
|
||||
defaultDeadline?: string
|
||||
}>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { data: tags } = useTags('task')
|
||||
const { data: courses } = useCourses()
|
||||
const { create, update } = useAnyTaskMutations()
|
||||
|
||||
/** Picked in the form when not given */
|
||||
const pickedCourseId = ref<number>()
|
||||
const courseId = computed(() => props.task?.course_id ?? props.courseId ?? pickedCourseId.value)
|
||||
const courseGroups = computed(() =>
|
||||
(['course', 'association'] as const)
|
||||
.map((kind) => ({ kind, courses: courses.value?.filter((c) => c.kind === kind) ?? [] }))
|
||||
.filter((group) => group.courses.length),
|
||||
)
|
||||
|
||||
const isSubtask = computed(() => (props.task ? props.task.parent_id != null : !!props.parentId))
|
||||
|
||||
const name = ref('')
|
||||
const tagIds = ref<number[]>([])
|
||||
const deadline = ref('')
|
||||
const description = ref('')
|
||||
const links = ref<string[]>([])
|
||||
const submitted = ref(false)
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return
|
||||
name.value = props.task?.name ?? ''
|
||||
tagIds.value = [...(props.task?.tag_ids ?? [])]
|
||||
const initialDeadline = props.task ? props.task.deadline : props.defaultDeadline
|
||||
deadline.value = initialDeadline ? toDateTimeInput(initialDeadline) : ''
|
||||
description.value = props.task?.description ?? ''
|
||||
links.value = [...(props.task?.links ?? [])]
|
||||
submitted.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleTag(id: number) {
|
||||
tagIds.value = tagIds.value.includes(id)
|
||||
? tagIds.value.filter((tagId) => tagId !== id)
|
||||
: [...tagIds.value, id]
|
||||
}
|
||||
|
||||
// Same rules as the backend, checked here to point at the faulty field
|
||||
const errors = computed(() => ({
|
||||
course: courseId.value === undefined,
|
||||
name: !name.value.trim(),
|
||||
tags: !isSubtask.value && tagIds.value.length === 0,
|
||||
links: links.value.some((link) => !/^https?:\/\//.test(link.trim())),
|
||||
}))
|
||||
|
||||
const isPending = computed(() => create.isPending.value || update.isPending.value)
|
||||
|
||||
async function submit() {
|
||||
submitted.value = true
|
||||
if (Object.values(errors.value).some(Boolean) || courseId.value === undefined) return
|
||||
|
||||
const body: TaskBody = {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || null,
|
||||
links: links.value.map((link) => link.trim()),
|
||||
deadline: fromDateTimeInput(deadline.value),
|
||||
done: props.task?.done ?? false,
|
||||
tag_ids: tagIds.value,
|
||||
}
|
||||
try {
|
||||
if (props.task) {
|
||||
await update.mutateAsync({ courseId: courseId.value, id: props.task.id, body })
|
||||
toast.success(t('tasks.updated'))
|
||||
} else {
|
||||
await create.mutateAsync({
|
||||
courseId: courseId.value,
|
||||
body: { ...body, parent_id: props.parentId ?? null },
|
||||
})
|
||||
toast.success(t('tasks.created'))
|
||||
}
|
||||
open.value = false
|
||||
} catch (err) {
|
||||
toast.error(t('tasks.save-error'), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[90svh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ task ? $t('tasks.edit') : isSubtask ? $t('tasks.new-subtask') : $t('tasks.new') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ $t('tasks.form.description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="task-form" class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
||||
<div v-if="!task && props.courseId === undefined" class="flex flex-col gap-2">
|
||||
<Label for="task-course">{{ $t('tasks.form.course') }}</Label>
|
||||
<Select v-model="pickedCourseId">
|
||||
<SelectTrigger
|
||||
id="task-course"
|
||||
class="w-full"
|
||||
:aria-invalid="submitted && errors.course"
|
||||
>
|
||||
<SelectValue :placeholder="$t('tasks.form.course-placeholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup v-for="group in courseGroups" :key="group.kind">
|
||||
<SelectLabel>{{ $t(`nav.${group.kind}s`) }}</SelectLabel>
|
||||
<SelectItem v-for="course in group.courses" :key="course.id" :value="course.id">
|
||||
{{ course.name }}
|
||||
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
|
||||
{{ course.code }}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="task-name">{{ $t('tasks.form.name') }}</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
v-model="name"
|
||||
:placeholder="$t('tasks.form.name-placeholder')"
|
||||
:aria-invalid="submitted && errors.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="mb-2 text-sm font-medium">
|
||||
{{ $t('tasks.form.tags') }}
|
||||
<span v-if="isSubtask" class="font-normal text-muted-foreground">
|
||||
({{ $t('courses.form.optional') }})
|
||||
</span>
|
||||
</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors"
|
||||
:class="
|
||||
tagIds.includes(tag.id) ? 'font-medium' : 'text-muted-foreground hover:bg-accent'
|
||||
"
|
||||
:style="
|
||||
tagIds.includes(tag.id)
|
||||
? {
|
||||
borderColor: tag.color,
|
||||
backgroundColor: `color-mix(in oklab, ${tag.color} 15%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:aria-pressed="tagIds.includes(tag.id)"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<span class="size-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="submitted && errors.tags" class="text-sm text-destructive">
|
||||
{{ $t('tasks.form.errors.tags') }}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="task-deadline">
|
||||
{{ $t('tasks.form.deadline') }}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
({{ $t('courses.form.optional') }})
|
||||
</span>
|
||||
</Label>
|
||||
<Input id="task-deadline" v-model="deadline" type="datetime-local" class="w-fit" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="task-description">
|
||||
{{ $t('tasks.form.details') }}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
({{ $t('courses.form.optional') }})
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea id="task-description" v-model="description" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="task-links">
|
||||
{{ $t('tasks.form.links') }}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
({{ $t('courses.form.optional') }})
|
||||
</span>
|
||||
</Label>
|
||||
<TagsInput
|
||||
v-model="links"
|
||||
add-on-blur
|
||||
add-on-paste
|
||||
:aria-invalid="submitted && errors.links"
|
||||
class="aria-invalid:border-destructive"
|
||||
>
|
||||
<TagsInputItem v-for="link in links" :key="link" :value="link" class="max-w-full">
|
||||
<TagsInputItemText class="truncate" />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput id="task-links" :placeholder="$t('tasks.form.links-placeholder')" />
|
||||
</TagsInput>
|
||||
<p v-if="submitted && errors.links" class="text-sm text-destructive">
|
||||
{{ $t('courses.form.errors.main-page') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||
<Button type="submit" form="task-form" :disabled="isPending">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
193
frontend/src/components/courses/CourseFormDialog.vue
Normal file
193
frontend/src/components/courses/CourseFormDialog.vue
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Creates a course or an association, or edits `course` when given. Slots are not edited
|
||||
* here but by selecting a time range in the schedule. An association has no teacher.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText,
|
||||
} from '@/components/ui/tags-input'
|
||||
import { useCreateCourse, useUpdateCourse } from '@/services/api/courses'
|
||||
import type { Course, CourseBody, CourseKind } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ kind: CourseKind; course?: Course }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Prefix of the texts of the form */
|
||||
const k = computed(() => (props.kind === 'course' ? 'courses' : 'associations'))
|
||||
const isCourse = computed(() => props.kind === 'course')
|
||||
const createCourse = useCreateCourse()
|
||||
const updateCourse = useUpdateCourse()
|
||||
|
||||
const name = ref('')
|
||||
const code = ref('')
|
||||
const teachers = ref<string[]>([])
|
||||
const mainPageUrl = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
// Reset the form every time the dialog opens
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return
|
||||
const course = props.course
|
||||
name.value = course?.name ?? ''
|
||||
code.value = course?.code ?? ''
|
||||
teachers.value = [...(course?.teachers ?? [])]
|
||||
mainPageUrl.value = course?.main_page_url ?? ''
|
||||
submitted.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Same rules as the backend, checked here to point at the faulty field
|
||||
const errors = computed(() => {
|
||||
const url = mainPageUrl.value.trim()
|
||||
return {
|
||||
name: !name.value.trim(),
|
||||
teachers: isCourse.value && !teachers.value.some((teacher) => teacher.trim()),
|
||||
mainPage: url !== '' && !/^https?:\/\//.test(url),
|
||||
}
|
||||
})
|
||||
const isValid = computed(() => !Object.values(errors.value).some(Boolean))
|
||||
|
||||
const isPending = computed(() => createCourse.isPending.value || updateCourse.isPending.value)
|
||||
|
||||
async function submit() {
|
||||
submitted.value = true
|
||||
if (!isValid.value) return
|
||||
|
||||
const body: CourseBody = {
|
||||
kind: props.kind,
|
||||
name: name.value.trim(),
|
||||
code: code.value.trim() || null,
|
||||
teachers: teachers.value.map((teacher) => teacher.trim()).filter(Boolean),
|
||||
main_page_url: mainPageUrl.value.trim() || null,
|
||||
}
|
||||
|
||||
try {
|
||||
if (props.course) {
|
||||
await updateCourse.mutateAsync({ id: props.course.id, body })
|
||||
toast.success(t(`${k.value}.updated`))
|
||||
} else {
|
||||
await createCourse.mutateAsync(body)
|
||||
toast.success(t(`${k.value}.created`))
|
||||
}
|
||||
open.value = false
|
||||
} catch (err) {
|
||||
toast.error(t(`${k.value}.save-error`), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[90svh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ course ? $t(`${k}.edit`) : $t(`${k}.new`) }}</DialogTitle>
|
||||
<DialogDescription>{{ $t(`${k}.form.description`) }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="course-form" class="flex flex-col gap-5" novalidate @submit.prevent="submit">
|
||||
<div class="grid gap-4 sm:grid-cols-[1fr_9rem]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="course-name">{{ $t('courses.form.name') }}</Label>
|
||||
<Input
|
||||
id="course-name"
|
||||
v-model="name"
|
||||
:placeholder="$t(`${k}.form.name-placeholder`)"
|
||||
:aria-invalid="submitted && errors.name"
|
||||
/>
|
||||
<p v-if="submitted && errors.name" class="text-sm text-destructive">
|
||||
{{ $t('courses.form.errors.name') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="course-code">
|
||||
{{ $t(`${k}.form.code`) }}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
({{ $t('courses.form.optional') }})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="course-code"
|
||||
v-model="code"
|
||||
:placeholder="$t(`${k}.form.code-placeholder`)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isCourse" class="flex flex-col gap-2">
|
||||
<Label for="course-teachers">{{ $t('courses.form.teachers') }}</Label>
|
||||
<TagsInput
|
||||
v-model="teachers"
|
||||
add-on-blur
|
||||
add-on-paste
|
||||
:aria-invalid="submitted && errors.teachers"
|
||||
class="aria-invalid:border-destructive"
|
||||
>
|
||||
<TagsInputItem v-for="teacher in teachers" :key="teacher" :value="teacher">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
id="course-teachers"
|
||||
:placeholder="$t('courses.form.teachers-placeholder')"
|
||||
/>
|
||||
</TagsInput>
|
||||
<p v-if="submitted && errors.teachers" class="text-sm text-destructive">
|
||||
{{ $t('courses.form.errors.teachers') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="course-main-page">
|
||||
{{ $t('courses.form.main-page') }}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
({{ $t('courses.form.optional') }})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="course-main-page"
|
||||
v-model="mainPageUrl"
|
||||
type="url"
|
||||
:placeholder="$t(`${k}.form.main-page-placeholder`)"
|
||||
:aria-invalid="submitted && errors.mainPage"
|
||||
/>
|
||||
<p v-if="submitted && errors.mainPage" class="text-sm text-destructive">
|
||||
{{ $t('courses.form.errors.main-page') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||
<Button type="submit" form="course-form" :disabled="isPending">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
216
frontend/src/components/schedule/SlotFormDialog.vue
Normal file
216
frontend/src/components/schedule/SlotFormDialog.vue
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Adds a slot to a course or an association, prefilled with the time range selected in
|
||||
* the schedule. `courseId` picks the course.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useAddSlot, useCourses } from '@/services/api/courses'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import {
|
||||
DAY_END,
|
||||
DAY_START,
|
||||
WEEKDAYS,
|
||||
toApiTime,
|
||||
toMinutes,
|
||||
type SlotSelection,
|
||||
} from '@/utils/timetable'
|
||||
|
||||
const props = defineProps<{ selection?: SlotSelection; courseId?: number }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { data: courses } = useCourses()
|
||||
const { data: tags } = useTags('slot')
|
||||
const addSlot = useAddSlot()
|
||||
|
||||
const groups = computed(() =>
|
||||
(['course', 'association'] as const)
|
||||
.map((kind) => ({ kind, courses: courses.value?.filter((c) => c.kind === kind) ?? [] }))
|
||||
.filter((group) => group.courses.length),
|
||||
)
|
||||
|
||||
const pickedCourseId = ref<number>()
|
||||
const tagId = ref<number>()
|
||||
const weekday = ref(1)
|
||||
const start = ref('')
|
||||
const end = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
// Reset the form every time the dialog opens, keeping the last course and tag picked
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return
|
||||
weekday.value = props.selection?.weekday ?? 1
|
||||
start.value = props.selection?.start ?? '08:15'
|
||||
end.value = props.selection?.end ?? '10:00'
|
||||
if (props.courseId !== undefined) {
|
||||
pickedCourseId.value = props.courseId
|
||||
} else if (!courses.value?.some((course) => course.id === pickedCourseId.value)) {
|
||||
pickedCourseId.value = courses.value?.[0]?.id
|
||||
}
|
||||
tagId.value ??= tags.value[0]?.id
|
||||
submitted.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Same rules as the backend, checked here to point at the faulty field
|
||||
const errors = computed(() => {
|
||||
const startMinutes = toMinutes(start.value)
|
||||
const endMinutes = toMinutes(end.value)
|
||||
return {
|
||||
course: pickedCourseId.value === undefined,
|
||||
time:
|
||||
!start.value ||
|
||||
!end.value ||
|
||||
startMinutes >= endMinutes ||
|
||||
startMinutes < toMinutes(DAY_START) ||
|
||||
endMinutes > toMinutes(DAY_END),
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitted.value = true
|
||||
if (errors.value.course || errors.value.time) return
|
||||
if (pickedCourseId.value === undefined || tagId.value === undefined) return
|
||||
|
||||
try {
|
||||
await addSlot.mutateAsync({
|
||||
courseId: pickedCourseId.value,
|
||||
body: {
|
||||
tag_id: tagId.value,
|
||||
weekday: weekday.value,
|
||||
start_time: toApiTime(start.value),
|
||||
end_time: toApiTime(end.value),
|
||||
},
|
||||
})
|
||||
toast.success(t('schedule.slot-created'))
|
||||
open.value = false
|
||||
} catch (err) {
|
||||
toast.error(t('schedule.slot-save-error'), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('schedule.new-slot') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('schedule.new-slot-description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="slot-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
|
||||
<div class="col-span-2 flex flex-col gap-2">
|
||||
<Label for="slot-course">{{ $t('schedule.form.course') }}</Label>
|
||||
<Select v-model="pickedCourseId">
|
||||
<SelectTrigger id="slot-course" class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup v-for="group in groups" :key="group.kind">
|
||||
<SelectLabel>{{ $t(`nav.${group.kind}s`) }}</SelectLabel>
|
||||
<SelectItem v-for="course in group.courses" :key="course.id" :value="course.id">
|
||||
{{ course.name }}
|
||||
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
|
||||
{{ course.code }}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="slot-tag">{{ $t('courses.form.tag') }}</Label>
|
||||
<Select v-model="tagId">
|
||||
<SelectTrigger id="slot-tag" class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="tag in tags" :key="tag.id" :value="tag.id">
|
||||
<span class="size-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="slot-weekday">{{ $t('courses.form.weekday') }}</Label>
|
||||
<Select v-model="weekday">
|
||||
<SelectTrigger id="slot-weekday" class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="day in WEEKDAYS" :key="day" :value="day">
|
||||
{{ $t(`weekdays.${day}`) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="slot-start">{{ $t('courses.form.start') }}</Label>
|
||||
<Input
|
||||
id="slot-start"
|
||||
v-model="start"
|
||||
type="time"
|
||||
:min="DAY_START"
|
||||
:max="DAY_END"
|
||||
step="300"
|
||||
:aria-invalid="submitted && errors.time"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="slot-end">{{ $t('courses.form.end') }}</Label>
|
||||
<Input
|
||||
id="slot-end"
|
||||
v-model="end"
|
||||
type="time"
|
||||
:min="DAY_START"
|
||||
:max="DAY_END"
|
||||
step="300"
|
||||
:aria-invalid="submitted && errors.time"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="submitted && errors.time" class="col-span-2 text-sm text-destructive">
|
||||
{{ $t('courses.form.errors.slot-time') }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||
<Button type="submit" form="slot-form" :disabled="addSlot.isPending.value">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
287
frontend/src/components/timetable/WeekTimetable.vue
Normal file
287
frontend/src/components/timetable/WeekTimetable.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Typical week (no dates): monday to friday, 8:00 to 19:00, with the slots of `courses`.
|
||||
* Slots that overlap are drawn side by side.
|
||||
* - `selectable`: a time range can be selected with the pointer, emitted as `select`
|
||||
* - `linkToCourse`: clicking a slot opens the page of its course
|
||||
* - `deletable`: each slot gets a button emitting `delete` (not with `linkToCourse`)
|
||||
* - `dates`: the days of a real week (monday to friday), shown in the header. The `day`
|
||||
* slot adds content under the header of each day (e.g. its deadlines).
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import {
|
||||
DAY_END_HOUR,
|
||||
DAY_START_HOUR,
|
||||
WEEKDAYS,
|
||||
fromMinutes,
|
||||
layoutOverlapping,
|
||||
toHourMinutes,
|
||||
toMinutes,
|
||||
type SlotSelection,
|
||||
} from '@/utils/timetable'
|
||||
import { coursePageRoute } from '@/utils/routes'
|
||||
import type { Course, Tag, TimeSlot } from '@/utils/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
courses: Course[]
|
||||
selectable?: boolean
|
||||
linkToCourse?: boolean
|
||||
deletable?: boolean
|
||||
/** Css height of the day columns */
|
||||
height?: string
|
||||
dates?: Date[]
|
||||
}>(),
|
||||
{ height: '55rem' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [selection: SlotSelection]
|
||||
delete: [slot: TimeSlot, course: Course]
|
||||
}>()
|
||||
|
||||
defineSlots<{ day?: (props: { weekday: number; date?: Date }) => unknown }>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const { tagsById } = useTags()
|
||||
|
||||
/** "5 oct." */
|
||||
function dayOfMonth(date: Date) {
|
||||
return new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function isToday(date: Date) {
|
||||
return date.toDateString() === new Date().toDateString()
|
||||
}
|
||||
|
||||
const HOURS = Array.from(
|
||||
{ length: DAY_END_HOUR - DAY_START_HOUR + 1 },
|
||||
(_, i) => DAY_START_HOUR + i,
|
||||
)
|
||||
const DAY_START_MINUTES = DAY_START_HOUR * 60
|
||||
const DAY_MINUTES = (DAY_END_HOUR - DAY_START_HOUR) * 60
|
||||
|
||||
interface Block {
|
||||
slot: TimeSlot
|
||||
course: Course
|
||||
tag?: Tag
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Positioned blocks of each weekday */
|
||||
const days = computed(() => {
|
||||
const blocks: Block[] = props.courses.flatMap((course) =>
|
||||
course.slots.map((slot) => ({
|
||||
slot,
|
||||
course,
|
||||
tag: tagsById.value.get(slot.tag_id),
|
||||
start: toMinutes(slot.start_time),
|
||||
end: toMinutes(slot.end_time),
|
||||
})),
|
||||
)
|
||||
return WEEKDAYS.map((weekday) => ({
|
||||
weekday,
|
||||
blocks: layoutOverlapping(
|
||||
blocks.filter((block) => block.slot.weekday === weekday),
|
||||
(block) => block.start,
|
||||
(block) => block.end,
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
function percent(minutes: number) {
|
||||
return `${((minutes - DAY_START_MINUTES) / DAY_MINUTES) * 100}%`
|
||||
}
|
||||
|
||||
function blockStyle(block: Block, lane: number, lanes: number) {
|
||||
const color = block.tag?.color ?? 'var(--primary)'
|
||||
return {
|
||||
top: percent(block.start),
|
||||
height: `${((block.end - block.start) / DAY_MINUTES) * 100}%`,
|
||||
left: `calc(${(lane / lanes) * 100}% + 2px)`,
|
||||
width: `calc(${100 / lanes}% - 4px)`,
|
||||
borderLeftColor: color,
|
||||
backgroundColor: `color-mix(in oklab, ${color} 22%, var(--card))`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Granularity of the time range selection, in minutes */
|
||||
const SELECTION_STEP = 15
|
||||
/** Length of the range proposed on a simple click, in minutes */
|
||||
const CLICK_DURATION = 60
|
||||
|
||||
interface Selection {
|
||||
weekday: number
|
||||
/** Steps where the pointer went down and where it is now, from the start of the day */
|
||||
anchor: number
|
||||
current: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
const selection = ref<Selection>()
|
||||
|
||||
/** Time range covered by the selection, in minutes since midnight */
|
||||
const selectedRange = computed(() => {
|
||||
if (!selection.value) return undefined
|
||||
const { anchor, current, moved } = selection.value
|
||||
const start = DAY_START_MINUTES + Math.min(anchor, current) * SELECTION_STEP
|
||||
const end = moved
|
||||
? DAY_START_MINUTES + (Math.max(anchor, current) + 1) * SELECTION_STEP
|
||||
: Math.min(start + CLICK_DURATION, DAY_START_MINUTES + DAY_MINUTES)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
/** Selection step under the pointer, in the day column the pointer went down on */
|
||||
function stepAt(event: PointerEvent) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const ratio = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 0.9999)
|
||||
return Math.floor((ratio * DAY_MINUTES) / SELECTION_STEP)
|
||||
}
|
||||
|
||||
function startSelection(event: PointerEvent, weekday: number) {
|
||||
if (!props.selectable || event.button !== 0)
|
||||
return // Keeps receiving the moves once the pointer leaves the column
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
const step = stepAt(event)
|
||||
selection.value = { weekday, anchor: step, current: step, moved: false }
|
||||
}
|
||||
|
||||
function moveSelection(event: PointerEvent) {
|
||||
if (!selection.value) return
|
||||
const step = stepAt(event)
|
||||
if (step !== selection.value.current) {
|
||||
selection.value.current = step
|
||||
selection.value.moved = true
|
||||
}
|
||||
}
|
||||
|
||||
function endSelection() {
|
||||
const range = selectedRange.value
|
||||
const weekday = selection.value?.weekday
|
||||
selection.value = undefined
|
||||
if (!range || weekday === undefined) return
|
||||
emit('select', { weekday, start: fromMinutes(range.start), end: fromMinutes(range.end) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto rounded-xl border bg-card">
|
||||
<div class="grid min-w-[44rem] grid-cols-[3.5rem_repeat(5,minmax(0,1fr))]">
|
||||
<!-- Header: weekdays -->
|
||||
<div class="border-b" />
|
||||
<div
|
||||
v-for="(day, index) in days"
|
||||
:key="day.weekday"
|
||||
class="flex min-w-0 flex-col gap-1.5 border-b border-l px-2 py-3 text-center text-sm font-medium"
|
||||
>
|
||||
<span>
|
||||
{{ $t(`weekdays.${day.weekday}`) }}
|
||||
<span
|
||||
v-if="dates?.[index]"
|
||||
class="ml-1 rounded-full px-1.5 py-0.5 text-xs tabular-nums"
|
||||
:class="
|
||||
isToday(dates[index]!)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'font-normal text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ dayOfMonth(dates[index]!) }}
|
||||
</span>
|
||||
</span>
|
||||
<slot name="day" :weekday="day.weekday" :date="dates?.[index]" />
|
||||
</div>
|
||||
|
||||
<!-- Hours column -->
|
||||
<div class="relative" :style="{ height }">
|
||||
<span
|
||||
v-for="hour in HOURS.slice(0, -1)"
|
||||
:key="hour"
|
||||
class="absolute right-2 mt-1 text-xs tabular-nums text-muted-foreground"
|
||||
:style="{ top: percent(hour * 60) }"
|
||||
>
|
||||
{{ hour }}:00
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- One column per weekday -->
|
||||
<div
|
||||
v-for="day in days"
|
||||
:key="day.weekday"
|
||||
class="relative border-l"
|
||||
:class="{ 'cursor-cell select-none': selectable }"
|
||||
:style="{ height }"
|
||||
@pointerdown="startSelection($event, day.weekday)"
|
||||
@pointermove="moveSelection"
|
||||
@pointerup="endSelection"
|
||||
@pointercancel="selection = undefined"
|
||||
>
|
||||
<div
|
||||
v-for="hour in HOURS.slice(1, -1)"
|
||||
:key="hour"
|
||||
class="pointer-events-none absolute inset-x-0 border-t border-dashed"
|
||||
:style="{ top: percent(hour * 60) }"
|
||||
/>
|
||||
|
||||
<Tooltip v-for="{ item: block, lane, lanes } in day.blocks" :key="block.slot.id">
|
||||
<TooltipTrigger as-child>
|
||||
<component
|
||||
:is="linkToCourse ? RouterLink : 'div'"
|
||||
:to="linkToCourse ? coursePageRoute(block.course) : undefined"
|
||||
class="group absolute flex flex-col gap-0.5 overflow-hidden rounded-md border-l-4 px-2 py-1 text-foreground transition-shadow hover:z-10 hover:shadow-md"
|
||||
:class="{ 'cursor-pointer': linkToCourse, 'cursor-default': !linkToCourse }"
|
||||
:style="blockStyle(block, lane, lanes)"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span class="line-clamp-2 pr-4 text-[0.8125rem] font-semibold leading-tight">
|
||||
{{ block.course.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs tabular-nums text-foreground/70">
|
||||
{{ toHourMinutes(block.slot.start_time) }}–{{
|
||||
toHourMinutes(block.slot.end_time)
|
||||
}}
|
||||
· {{ block.tag?.name }}
|
||||
</span>
|
||||
<button
|
||||
v-if="deletable"
|
||||
type="button"
|
||||
class="absolute top-0.5 right-0.5 rounded p-0.5 text-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/15 [@media(hover:none)]:opacity-100 hover:text-destructive focus-visible:opacity-100"
|
||||
:title="$t('schedule.delete-slot')"
|
||||
@click.stop.prevent="emit('delete', block.slot, block.course)"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
<span class="sr-only">{{ $t('schedule.delete-slot') }}</span>
|
||||
</button>
|
||||
</component>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex flex-col gap-0.5">
|
||||
<span class="font-medium">{{ block.course.name }}</span>
|
||||
<span v-if="block.course.code">{{ block.course.code }}</span>
|
||||
<span>
|
||||
{{ block.tag?.name }} · {{ $t(`weekdays.${block.slot.weekday}`) }}
|
||||
{{ toHourMinutes(block.slot.start_time) }}–{{ toHourMinutes(block.slot.end_time) }}
|
||||
</span>
|
||||
<span>{{ block.course.teachers.join(', ') }}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
v-if="selection?.weekday === day.weekday && selectedRange"
|
||||
class="pointer-events-none absolute inset-x-0.5 z-20 flex items-start rounded-md border-2 border-dashed border-primary bg-primary/10 px-2 py-1 text-xs font-medium tabular-nums text-primary"
|
||||
:style="{
|
||||
top: percent(selectedRange.start),
|
||||
height: `${((selectedRange.end - selectedRange.start) / DAY_MINUTES) * 100}%`,
|
||||
}"
|
||||
>
|
||||
{{ fromMinutes(selectedRange.start) }}–{{ fromMinutes(selectedRange.end) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full grid-cols-1 max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
33
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
33
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator class="grid place-content-center text-current">
|
||||
<slot>
|
||||
<Check class="h-4 w-4" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
1
frontend/src/components/ui/checkbox/index.ts
Normal file
1
frontend/src/components/ui/checkbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Checkbox } from './Checkbox.vue'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
|
@ -29,9 +29,10 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full grid-cols-1 max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
props.class,
|
||||
)"
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
|
@ -33,13 +33,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent
|
||||
const target = originalEvent.target as HTMLElement
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}"
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
|
@ -15,12 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||
<template>
|
||||
<DialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Label } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Label } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { default as Label } from "./Label.vue"
|
||||
export { default as Label } from './Label.vue'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
|
|
|||
|
|
@ -1,29 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
position: 'popper',
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
|
@ -31,16 +26,26 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }" :class="cn(
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)')">
|
||||
<SelectViewport
|
||||
:class="
|
||||
cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectGroup } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectItemTextProps } from "reka-ui"
|
||||
import { SelectItemText } from "reka-ui"
|
||||
import type { SelectItemTextProps } from 'reka-ui'
|
||||
import { SelectItemText } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { SelectLabel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
|
||||
<SelectScrollDownButton
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronUp } from "lucide-vue-next"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronUp } from '@lucide/vue'
|
||||
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
|
||||
<SelectScrollUpButton
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectSeparator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
|
@ -16,10 +16,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
props.class,
|
||||
)"
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from "reka-ui"
|
||||
import { SelectValue } from "reka-ui"
|
||||
import type { SelectValueProps } from 'reka-ui'
|
||||
import { SelectValue } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export { default as Select } from "./Select.vue"
|
||||
export { default as SelectContent } from "./SelectContent.vue"
|
||||
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||
export { default as SelectItem } from "./SelectItem.vue"
|
||||
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||
export { default as SelectValue } from "./SelectValue.vue"
|
||||
export { default as Select } from './Select.vue'
|
||||
export { default as SelectContent } from './SelectContent.vue'
|
||||
export { default as SelectGroup } from './SelectGroup.vue'
|
||||
export { default as SelectItem } from './SelectItem.vue'
|
||||
export { default as SelectItemText } from './SelectItemText.vue'
|
||||
export { default as SelectLabel } from './SelectLabel.vue'
|
||||
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
|
||||
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
|
||||
export { default as SelectSeparator } from './SelectSeparator.vue'
|
||||
export { default as SelectTrigger } from './SelectTrigger.vue'
|
||||
export { default as SelectValue } from './SelectValue.vue'
|
||||
|
|
|
|||
15
frontend/src/components/ui/tabs/Tabs.vue
Normal file
15
frontend/src/components/ui/tabs/Tabs.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
25
frontend/src/components/ui/tabs/TabsContent.vue
Normal file
25
frontend/src/components/ui/tabs/TabsContent.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { TabsContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsContent } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
:class="
|
||||
cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
||||
25
frontend/src/components/ui/tabs/TabsList.vue
Normal file
25
frontend/src/components/ui/tabs/TabsList.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsList } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsListProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
29
frontend/src/components/ui/tabs/TabsTrigger.vue
Normal file
29
frontend/src/components/ui/tabs/TabsTrigger.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
4
frontend/src/components/ui/tabs/index.ts
Normal file
4
frontend/src/components/ui/tabs/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TabsContent } from './TabsContent.vue'
|
||||
export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||
|
|
@ -1,20 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputRootEmits, TagsInputRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputRootEmits, TagsInputRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TagsInputRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputRoot v-bind="forwarded" :class="cn('flex flex-wrap gap-2 items-center rounded-md border border-input bg-background px-3 py-2 text-sm', props.class)">
|
||||
<TagsInputRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-wrap gap-2 items-center rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputInputProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputInput, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputInputProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputInput, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputInput v-bind="forwardedProps" :class="cn('text-sm min-h-6 focus:outline-none flex-1 bg-transparent px-1', props.class)" />
|
||||
<TagsInputInput
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-sm min-h-6 focus:outline-none flex-1 bg-transparent px-1', props.class)"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItem v-bind="forwardedProps" :class="cn('flex h-6 items-center rounded bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background', props.class)">
|
||||
<TagsInputItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-6 items-center rounded bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputItemDeleteProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import { TagsInputItemDelete, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputItemDeleteProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemDelete v-bind="forwardedProps" :class="cn('flex rounded bg-transparent mr-1', props.class)">
|
||||
<TagsInputItemDelete
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex rounded bg-transparent mr-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<X class="w-4 h-4" />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { TagsInputItemTextProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TagsInputItemText, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TagsInputItemTextProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TagsInputItemText, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemText v-bind="forwardedProps" :class="cn('py-1 px-2 text-sm rounded bg-transparent', props.class)" />
|
||||
<TagsInputItemText
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('py-1 px-2 text-sm rounded bg-transparent', props.class)"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { default as TagsInput } from "./TagsInput.vue"
|
||||
export { default as TagsInputInput } from "./TagsInputInput.vue"
|
||||
export { default as TagsInputItem } from "./TagsInputItem.vue"
|
||||
export { default as TagsInputItemDelete } from "./TagsInputItemDelete.vue"
|
||||
export { default as TagsInputItemText } from "./TagsInputItemText.vue"
|
||||
export { default as TagsInput } from './TagsInput.vue'
|
||||
export { default as TagsInputInput } from './TagsInputInput.vue'
|
||||
export { default as TagsInputItem } from './TagsInputItem.vue'
|
||||
export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue'
|
||||
export { default as TagsInputItemText } from './TagsInputItemText.vue'
|
||||
|
|
|
|||
1356
frontend/src/lib/api.d.ts
vendored
1356
frontend/src/lib/api.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ nav:
|
|||
home: Home
|
||||
schedule: Timetable
|
||||
courses: Courses
|
||||
associations: Associations
|
||||
pomodoro: Pomodoro
|
||||
calendar: Calendar
|
||||
settings: Settings
|
||||
|
|
@ -16,7 +17,276 @@ home:
|
|||
sections:
|
||||
schedule: Check your weekly timetable.
|
||||
courses: Find your courses and their resources.
|
||||
pomodoro: Work in focused sessions with regular breaks.
|
||||
associations: Follow the tasks and meetings of your associations.
|
||||
pomodoro: Focus on a task, the time is counted for you.
|
||||
calendar: Keep an eye on your exams and deadlines.
|
||||
theme:
|
||||
dark: Switch to dark mode
|
||||
light: Switch to light mode
|
||||
weekdays:
|
||||
'1': Monday
|
||||
'2': Tuesday
|
||||
'3': Wednesday
|
||||
'4': Thursday
|
||||
'5': Friday
|
||||
common:
|
||||
cancel: Cancel
|
||||
save: Save
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
retry: Retry
|
||||
add: Add
|
||||
courses:
|
||||
new: New course
|
||||
edit: Edit course
|
||||
empty: No course yet.
|
||||
empty-hint: Add your first course to fill your timetable.
|
||||
load-error: Unable to load the courses.
|
||||
main-page: Course page
|
||||
created: Course created
|
||||
updated: Course updated
|
||||
deleted: Course deleted
|
||||
save-error: Unable to save the course
|
||||
delete-error: Unable to delete the course
|
||||
delete-title: Do you really want to delete “{name}”?
|
||||
delete-description: The course, its slots, tasks and files will be permanently deleted.
|
||||
form:
|
||||
description: Slots are then added from the timetable.
|
||||
name: Name
|
||||
name-placeholder: Advanced operating systems
|
||||
code: Code
|
||||
code-placeholder: CS-477
|
||||
optional: optional
|
||||
teachers: Teachers
|
||||
teachers-placeholder: Name, then Enter
|
||||
main-page: Main page
|
||||
main-page-placeholder: https://moodle.epfl.ch/course/view.php?id=…
|
||||
weekday: Day
|
||||
tag: Type
|
||||
start: Start
|
||||
end: End
|
||||
errors:
|
||||
name: The name is required.
|
||||
teachers: Add at least one teacher.
|
||||
main-page: The address must start with http:// or https://.
|
||||
slot-time: Each slot must start before it ends, between 8:00 and 19:00.
|
||||
associations:
|
||||
new: New association
|
||||
edit: Edit association
|
||||
empty: No association yet.
|
||||
empty-hint: Add your associations to follow their tasks and meetings.
|
||||
load-error: Unable to load the associations.
|
||||
created: Association created
|
||||
updated: Association updated
|
||||
deleted: Association deleted
|
||||
save-error: Unable to save the association
|
||||
delete-error: Unable to delete the association
|
||||
delete-title: Do you really want to delete “{name}”?
|
||||
delete-description: The association, its slots, tasks and files will be permanently deleted.
|
||||
form:
|
||||
description: Its meetings are then added from the Deadlines tab or the timetable.
|
||||
name-placeholder: Cultural committee
|
||||
code: Acronym
|
||||
code-placeholder: CoCu
|
||||
main-page-placeholder: https://…
|
||||
association:
|
||||
back: Back to associations
|
||||
not-found: This association does not exist.
|
||||
tabs:
|
||||
files: Notes
|
||||
deadlines: Deadlines
|
||||
deadlines:
|
||||
hint: Click or drag on the week to add a weekly slot (meeting…).
|
||||
weekend: 'This weekend:'
|
||||
course:
|
||||
back: Back to courses
|
||||
not-found: This course does not exist.
|
||||
folder: Folder of the files on the NAS
|
||||
tabs:
|
||||
files: Lectures
|
||||
tasks: Deadlines
|
||||
schedule: Timetable
|
||||
grades: Grades
|
||||
schedule:
|
||||
slots-hint: 'Delete a slot with its cross. To add one, select a time range in the'
|
||||
no-slot: 'No slot. Select a time range in the'
|
||||
schedule:
|
||||
empty: No slot to show.
|
||||
empty-hint: Click or drag on the timetable to add a slot.
|
||||
no-course: Create a course first to add slots to it.
|
||||
select-hint: Click or drag on the timetable to add a slot
|
||||
go-to-courses: Go to courses
|
||||
load-error: Unable to load the timetable.
|
||||
new-slot: New slot
|
||||
new-slot-description: The slot is added to the chosen course, every week.
|
||||
slot-created: Slot added
|
||||
slot-deleted: Slot deleted
|
||||
slot-save-error: Unable to add the slot
|
||||
slot-delete-error: Unable to delete the slot
|
||||
delete-slot: Delete the slot
|
||||
delete-slot-title: Do you really want to delete the slot of {slot}?
|
||||
form:
|
||||
course: Course
|
||||
grades:
|
||||
new: Add a grade
|
||||
edit: Edit the grade
|
||||
created: Grade added
|
||||
updated: Grade updated
|
||||
deleted: Grade deleted
|
||||
save-error: Unable to save the grade
|
||||
delete-error: Unable to delete the grade
|
||||
load-error: Unable to load the grades.
|
||||
average: Course average
|
||||
empty: No grade yet.
|
||||
empty-hint: Add your grades to follow your average.
|
||||
score: Score
|
||||
swiss: Grade /6
|
||||
total-weight: 'Total weight: {total}. Weights are relative to each other, they do not need to add up to 100.'
|
||||
delete-title: Do you really want to delete the grade “{name}”?
|
||||
form:
|
||||
description: On any scale, it is converted to a grade out of 6.
|
||||
name: Assessment
|
||||
name-placeholder: Midterm
|
||||
score: Score
|
||||
max-score: Maximal score
|
||||
weight: Weight
|
||||
weight-hint: What this grade counts for in the total, e.g. 30 for 30 %.
|
||||
errors:
|
||||
score: The score must be between 0 and the maximal score.
|
||||
tasks:
|
||||
new: New task
|
||||
new-subtask: New subtask
|
||||
edit: Edit the task
|
||||
created: Task added
|
||||
updated: Task updated
|
||||
deleted: Task deleted
|
||||
save-error: Unable to save the task
|
||||
delete-error: Unable to delete the task
|
||||
load-error: Unable to load the tasks.
|
||||
empty: No task yet.
|
||||
empty-hint: Add your homework, exams and things to do for this course.
|
||||
done: Done
|
||||
late: late
|
||||
add-subtask: Add a subtask…
|
||||
delete-title: Do you really want to delete “{name}”?
|
||||
delete-description: Its subtasks will be deleted too.
|
||||
time-spent: '{time} of work'
|
||||
form:
|
||||
description: Homework, an exam or anything else to do.
|
||||
course: Course or association
|
||||
course-placeholder: Choose…
|
||||
name: Name
|
||||
name-placeholder: Exercise set 3
|
||||
tags: Tags
|
||||
deadline: Deadline
|
||||
details: Description
|
||||
links: Links
|
||||
links-placeholder: https://…, then Enter
|
||||
errors:
|
||||
tags: Pick at least one tag.
|
||||
notes:
|
||||
new: New note
|
||||
no-pdf: Pdf unavailable
|
||||
no-pdf-hint: The last typesetting failed. Fix the note to generate the pdf.
|
||||
not-found: This note does not exist.
|
||||
saved: Note saved
|
||||
saved-without-pdf: Note saved, but the pdf could not be generated
|
||||
save-error: Unable to save the note
|
||||
deleted: Note deleted
|
||||
delete-error: Unable to delete the note
|
||||
default-title: 'Lecture {n} Notes: '
|
||||
default-content: "# Introduction\n\n"
|
||||
title-label: Title
|
||||
title-placeholder: 'Lecture 1 Notes: Memory subsystem'
|
||||
rendering: Typesetting…
|
||||
download: Download
|
||||
pdf-error: 'LaTeX error: the pdf could not be generated'
|
||||
preview-hint: Save to generate the pdf, it will be shown here.
|
||||
leave-confirm: Your changes are not saved. Leave anyway?
|
||||
errors:
|
||||
title: The title is required.
|
||||
editor:
|
||||
placeholder: Write your note in markdown…
|
||||
text: text
|
||||
box-content: Content
|
||||
h1: Section
|
||||
h2: Subsection
|
||||
h3: Subsubsection
|
||||
bold: Bold
|
||||
italic: Italic
|
||||
highlight: Highlight a word
|
||||
code: Code
|
||||
link: Link
|
||||
bullets: Bullet list
|
||||
numbered: Numbered list
|
||||
math: Formula
|
||||
math-block: Centered formula
|
||||
code-block: Code block
|
||||
table: Table
|
||||
definition: Definition
|
||||
theorem: Theorem
|
||||
example: Example
|
||||
files:
|
||||
title:
|
||||
course: Course files
|
||||
association: Notes and files
|
||||
description: Slides, lecture notes and documents, soon stored on your NAS.
|
||||
upload: Upload
|
||||
coming-soon: File uploads are coming soon.
|
||||
empty: No file yet. Start with a lecture note.
|
||||
load-error: Unable to load the files.
|
||||
updated-on: updated on {date}
|
||||
deleted: File deleted
|
||||
delete-error: Unable to delete the file
|
||||
delete-title: Do you really want to delete “{name}”?
|
||||
delete-description: The file will be permanently deleted.
|
||||
calendar:
|
||||
show: Show
|
||||
nothing-shown: Turn on the courses or the associations to see their tasks.
|
||||
today: Today
|
||||
previous: Previous month
|
||||
next: Next month
|
||||
this-week: This week
|
||||
previous-week: Previous week
|
||||
next-week: Next week
|
||||
more: '+{n} more'
|
||||
nothing: Nothing due that day.
|
||||
add-that-day: Add
|
||||
load-error: Unable to load the deadlines.
|
||||
todo:
|
||||
title: To do
|
||||
empty: Nothing to do, enjoy!
|
||||
late: Late
|
||||
today: Today
|
||||
week: This week
|
||||
later: Later
|
||||
undated: No deadline
|
||||
focus:
|
||||
start: Work on it (pomodoro)
|
||||
back: Back to the pomodoro
|
||||
pick: What do you want to work on?
|
||||
no-task: No task to do.
|
||||
start-free: Free session
|
||||
free: Free session
|
||||
elapsed: Focus time
|
||||
pause: Pause
|
||||
resume: Resume
|
||||
stop: Finish
|
||||
paused: Paused for {time}
|
||||
suggested-break: suggested break {time}
|
||||
cycles: '{n} cycle of 25 min | {n} cycles of 25 min'
|
||||
cycle-done: '{n} × 25 min of focus'
|
||||
cycle-hint: Well done! Take a break when you reach a good stopping point.
|
||||
total: '{time} in total on this task'
|
||||
space-hint: Space to pause
|
||||
stopped: Session finished · {time}
|
||||
resources: Resources
|
||||
no-resource: No notes or slides for this course.
|
||||
open-tab: Open in a new tab
|
||||
close-resource: Close
|
||||
fullscreen: Full screen
|
||||
exit-fullscreen: Exit full screen
|
||||
error: Unable to reach the server, the session did not change
|
||||
timetagger-error: TimeTagger could not be updated
|
||||
placeholder:
|
||||
coming-soon: This page is coming soon.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ nav:
|
|||
home: Accueil
|
||||
schedule: Emploi du temps
|
||||
courses: Cours
|
||||
associations: Associations
|
||||
pomodoro: Pomodoro
|
||||
calendar: Calendrier
|
||||
settings: Configuration
|
||||
|
|
@ -16,7 +17,276 @@ home:
|
|||
sections:
|
||||
schedule: Consulte ton emploi du temps de la semaine.
|
||||
courses: Retrouve tes cours et leurs ressources.
|
||||
pomodoro: Travaille par sessions concentrées avec des pauses régulières.
|
||||
associations: Suis les tâches et réunions de tes associations.
|
||||
pomodoro: Concentre-toi sur une tâche, le temps est compté pour toi.
|
||||
calendar: Garde un œil sur tes examens et échéances.
|
||||
theme:
|
||||
dark: Passer en mode sombre
|
||||
light: Passer en mode clair
|
||||
weekdays:
|
||||
'1': Lundi
|
||||
'2': Mardi
|
||||
'3': Mercredi
|
||||
'4': Jeudi
|
||||
'5': Vendredi
|
||||
common:
|
||||
cancel: Annuler
|
||||
save: Enregistrer
|
||||
delete: Supprimer
|
||||
edit: Modifier
|
||||
retry: Réessayer
|
||||
add: Ajouter
|
||||
courses:
|
||||
new: Nouveau cours
|
||||
edit: Modifier le cours
|
||||
empty: Aucun cours pour le moment.
|
||||
empty-hint: Ajoute ton premier cours pour remplir ton emploi du temps.
|
||||
load-error: Impossible de charger les cours.
|
||||
main-page: Page du cours
|
||||
created: Cours créé
|
||||
updated: Cours modifié
|
||||
deleted: Cours supprimé
|
||||
save-error: Impossible d'enregistrer le cours
|
||||
delete-error: Impossible de supprimer le cours
|
||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||
delete-description: Le cours, ses créneaux, ses tâches et ses fichiers seront supprimés définitivement.
|
||||
form:
|
||||
description: Les créneaux s'ajoutent ensuite depuis l'emploi du temps.
|
||||
name: Nom
|
||||
name-placeholder: Advanced operating systems
|
||||
code: Code
|
||||
code-placeholder: CS-477
|
||||
optional: facultatif
|
||||
teachers: Enseignant·e·s
|
||||
teachers-placeholder: Nom, puis Entrée
|
||||
main-page: Page principale
|
||||
main-page-placeholder: https://moodle.epfl.ch/course/view.php?id=…
|
||||
weekday: Jour
|
||||
tag: Type
|
||||
start: Début
|
||||
end: Fin
|
||||
errors:
|
||||
name: Le nom est obligatoire.
|
||||
teachers: Ajoute au moins un·e enseignant·e.
|
||||
main-page: L'adresse doit commencer par http:// ou https://.
|
||||
slot-time: Chaque créneau doit commencer avant de finir, entre 8h et 19h.
|
||||
associations:
|
||||
new: Nouvelle association
|
||||
edit: Modifier l'association
|
||||
empty: Aucune association pour le moment.
|
||||
empty-hint: Ajoute tes associations pour suivre leurs tâches et réunions.
|
||||
load-error: Impossible de charger les associations.
|
||||
created: Association créée
|
||||
updated: Association modifiée
|
||||
deleted: Association supprimée
|
||||
save-error: Impossible d'enregistrer l'association
|
||||
delete-error: Impossible de supprimer l'association
|
||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||
delete-description: L'association, ses créneaux, ses tâches et ses fichiers seront supprimés définitivement.
|
||||
form:
|
||||
description: Ses réunions s'ajoutent ensuite depuis l'onglet Échéances ou l'emploi du temps.
|
||||
name-placeholder: Commission culturelle
|
||||
code: Acronyme
|
||||
code-placeholder: CoCu
|
||||
main-page-placeholder: https://…
|
||||
association:
|
||||
back: Retour aux associations
|
||||
not-found: Cette association n'existe pas.
|
||||
tabs:
|
||||
files: Notes
|
||||
deadlines: Échéances
|
||||
deadlines:
|
||||
hint: Clique ou glisse sur la semaine pour ajouter un créneau récurrent (réunion…).
|
||||
weekend: 'Ce week-end :'
|
||||
course:
|
||||
back: Retour aux cours
|
||||
not-found: Ce cours n'existe pas.
|
||||
folder: Dossier des fichiers sur le NAS
|
||||
tabs:
|
||||
files: Cours
|
||||
tasks: Échéances
|
||||
schedule: Emploi du temps
|
||||
grades: Notes
|
||||
schedule:
|
||||
slots-hint: 'Supprime un créneau avec sa croix. Pour en ajouter, sélectionne une plage horaire dans'
|
||||
no-slot: 'Aucun créneau. Sélectionne une plage horaire dans'
|
||||
schedule:
|
||||
empty: Aucun créneau à afficher.
|
||||
empty-hint: Clique ou glisse sur le calendrier pour ajouter un créneau.
|
||||
no-course: Crée d'abord un cours pour pouvoir lui ajouter des créneaux.
|
||||
select-hint: Clique ou glisse sur le calendrier pour ajouter un créneau
|
||||
go-to-courses: Aller aux cours
|
||||
load-error: Impossible de charger l'emploi du temps.
|
||||
new-slot: Nouveau créneau
|
||||
new-slot-description: Le créneau est ajouté au cours choisi, chaque semaine.
|
||||
slot-created: Créneau ajouté
|
||||
slot-deleted: Créneau supprimé
|
||||
slot-save-error: Impossible d'ajouter le créneau
|
||||
slot-delete-error: Impossible de supprimer le créneau
|
||||
delete-slot: Supprimer le créneau
|
||||
delete-slot-title: Voulez-vous vraiment supprimer le créneau du {slot} ?
|
||||
form:
|
||||
course: Cours
|
||||
grades:
|
||||
new: Ajouter une note
|
||||
edit: Modifier la note
|
||||
created: Note ajoutée
|
||||
updated: Note modifiée
|
||||
deleted: Note supprimée
|
||||
save-error: Impossible d'enregistrer la note
|
||||
delete-error: Impossible de supprimer la note
|
||||
load-error: Impossible de charger les notes.
|
||||
average: Moyenne du cours
|
||||
empty: Aucune note pour le moment.
|
||||
empty-hint: Ajoute tes notes pour suivre ta moyenne.
|
||||
score: Résultat
|
||||
swiss: Note /6
|
||||
total-weight: "Poids total : {total}. Les poids sont relatifs entre eux, ils n'ont pas besoin de faire 100."
|
||||
delete-title: Voulez-vous vraiment supprimer la note « {name} » ?
|
||||
form:
|
||||
description: Sur n'importe quelle échelle, elle est convertie sur 6.
|
||||
name: Évaluation
|
||||
name-placeholder: Examen intermédiaire
|
||||
score: Note obtenue
|
||||
max-score: Note maximale
|
||||
weight: Poids
|
||||
weight-hint: Ce que vaut cette note dans le total, par exemple 30 pour 30 %.
|
||||
errors:
|
||||
score: La note obtenue doit être entre 0 et la note maximale.
|
||||
tasks:
|
||||
new: Nouvelle tâche
|
||||
new-subtask: Nouvelle sous-tâche
|
||||
edit: Modifier la tâche
|
||||
created: Tâche ajoutée
|
||||
updated: Tâche modifiée
|
||||
deleted: Tâche supprimée
|
||||
save-error: Impossible d'enregistrer la tâche
|
||||
delete-error: Impossible de supprimer la tâche
|
||||
load-error: Impossible de charger les tâches.
|
||||
empty: Aucune tâche pour le moment.
|
||||
empty-hint: Ajoute tes devoirs, examens et choses à faire pour ce cours.
|
||||
done: Terminée
|
||||
late: en retard
|
||||
add-subtask: Ajouter une sous-tâche…
|
||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||
delete-description: Ses sous-tâches seront aussi supprimées.
|
||||
time-spent: '{time} de travail'
|
||||
form:
|
||||
description: Un devoir, un examen ou toute autre chose à faire.
|
||||
course: Cours ou association
|
||||
course-placeholder: Choisis…
|
||||
name: Nom
|
||||
name-placeholder: Série 3
|
||||
tags: Étiquettes
|
||||
deadline: Échéance
|
||||
details: Description
|
||||
links: Liens
|
||||
links-placeholder: https://…, puis Entrée
|
||||
errors:
|
||||
tags: Choisis au moins une étiquette.
|
||||
notes:
|
||||
new: Nouvelle note
|
||||
no-pdf: PDF indisponible
|
||||
no-pdf-hint: La dernière mise en page a échoué. Corrige la note pour générer le PDF.
|
||||
not-found: Cette note n'existe pas.
|
||||
saved: Note enregistrée
|
||||
saved-without-pdf: Note enregistrée, mais le PDF n'a pas pu être généré
|
||||
save-error: Impossible d'enregistrer la note
|
||||
deleted: Note supprimée
|
||||
delete-error: Impossible de supprimer la note
|
||||
default-title: 'Lecture {n} Notes: '
|
||||
default-content: "# Introduction\n\n"
|
||||
title-label: Titre
|
||||
title-placeholder: 'Lecture 1 Notes: Memory subsystem'
|
||||
rendering: Mise en page…
|
||||
download: Télécharger
|
||||
pdf-error: "Erreur LaTeX : le PDF n'a pas pu être généré"
|
||||
preview-hint: Enregistre pour générer le PDF, il s'affichera ici.
|
||||
leave-confirm: Tes modifications ne sont pas enregistrées. Quitter quand même ?
|
||||
errors:
|
||||
title: Le titre est obligatoire.
|
||||
editor:
|
||||
placeholder: Écris ta note en markdown…
|
||||
text: texte
|
||||
box-content: Contenu
|
||||
h1: Titre de section
|
||||
h2: Sous-section
|
||||
h3: Sous-sous-section
|
||||
bold: Gras
|
||||
italic: Italique
|
||||
highlight: Surligner un mot
|
||||
code: Code
|
||||
link: Lien
|
||||
bullets: Liste à puces
|
||||
numbered: Liste numérotée
|
||||
math: Formule
|
||||
math-block: Formule centrée
|
||||
code-block: Bloc de code
|
||||
table: Tableau
|
||||
definition: Définition
|
||||
theorem: Théorème
|
||||
example: Exemple
|
||||
files:
|
||||
title:
|
||||
course: Fichiers du cours
|
||||
association: Notes et fichiers
|
||||
description: Slides, notes de cours et documents, bientôt stockés sur ton NAS.
|
||||
upload: Importer
|
||||
coming-soon: L'import de fichiers arrive bientôt.
|
||||
empty: Aucun fichier pour le moment. Commence par une note de cours.
|
||||
load-error: Impossible de charger les fichiers.
|
||||
updated-on: modifié le {date}
|
||||
deleted: Fichier supprimé
|
||||
delete-error: Impossible de supprimer le fichier
|
||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||
delete-description: Le fichier sera supprimé définitivement.
|
||||
calendar:
|
||||
show: Afficher
|
||||
nothing-shown: Active les cours ou les associations pour voir leurs tâches.
|
||||
today: Aujourd'hui
|
||||
previous: Mois précédent
|
||||
next: Mois suivant
|
||||
this-week: Cette semaine
|
||||
previous-week: Semaine précédente
|
||||
next-week: Semaine suivante
|
||||
more: '+{n} autre | +{n} autres'
|
||||
nothing: Rien à rendre ce jour-là.
|
||||
add-that-day: Ajouter
|
||||
load-error: Impossible de charger les échéances.
|
||||
todo:
|
||||
title: À faire
|
||||
empty: Rien à faire, profite !
|
||||
late: En retard
|
||||
today: Aujourd'hui
|
||||
week: Cette semaine
|
||||
later: Plus tard
|
||||
undated: Sans échéance
|
||||
focus:
|
||||
start: Travailler dessus (pomodoro)
|
||||
back: Revenir au pomodoro
|
||||
pick: Sur quoi veux-tu travailler ?
|
||||
no-task: Aucune tâche à faire.
|
||||
start-free: Séance libre
|
||||
free: Séance libre
|
||||
elapsed: Temps de concentration
|
||||
pause: Pause
|
||||
resume: Reprendre
|
||||
stop: Terminer
|
||||
paused: En pause depuis {time}
|
||||
suggested-break: pause conseillée {time}
|
||||
cycles: '{n} cycle de 25 min | {n} cycles de 25 min'
|
||||
cycle-done: '{n} × 25 min de concentration'
|
||||
cycle-hint: Bravo ! Fais une pause quand tu arrives à un bon moment.
|
||||
total: '{time} au total sur cette tâche'
|
||||
space-hint: Espace pour mettre en pause
|
||||
stopped: Séance terminée · {time}
|
||||
resources: Ressources
|
||||
no-resource: Aucune note ni slide pour ce cours.
|
||||
open-tab: Ouvrir dans un nouvel onglet
|
||||
close-resource: Fermer
|
||||
fullscreen: Plein écran
|
||||
exit-fullscreen: Quitter le plein écran
|
||||
error: Impossible de joindre le serveur, la séance n'a pas changé
|
||||
timetagger-error: TimeTagger n'a pas pu être mis à jour
|
||||
placeholder:
|
||||
coming-soon: Cette page arrive bientôt.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
|
|
@ -6,6 +6,8 @@ declare module 'vue-router' {
|
|||
interface RouteMeta {
|
||||
/** i18n key of the page title, shown in the header */
|
||||
title: string
|
||||
/** Name of the sidebar section highlighted on this page, when it is not the page itself */
|
||||
section?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -19,12 +21,37 @@ const router = createRouter({
|
|||
component: () => import('@/views/ScheduleView.vue'),
|
||||
meta: { title: 'nav.schedule' },
|
||||
},
|
||||
// The associations share the pages of the courses: see `utils/routes.ts`
|
||||
...(['course', 'association'] as const).flatMap((kind) => {
|
||||
const list = `${kind}s`
|
||||
return [
|
||||
{
|
||||
name: 'courses',
|
||||
path: '/courses',
|
||||
name: list,
|
||||
path: `/${list}`,
|
||||
component: () => import('@/views/CoursesView.vue'),
|
||||
meta: { title: 'nav.courses' },
|
||||
props: { kind },
|
||||
meta: { title: `nav.${list}` },
|
||||
},
|
||||
{
|
||||
name: kind,
|
||||
path: `/${list}/:id(\\d+)`,
|
||||
component: () => import('@/views/CourseView.vue'),
|
||||
props: (route: RouteLocationNormalized) => ({ id: Number(route.params.id) }),
|
||||
meta: { title: `nav.${list}`, section: list },
|
||||
},
|
||||
{
|
||||
name: `${kind}-note`,
|
||||
// `new` to write a new note
|
||||
path: `/${list}/:id(\\d+)/notes/:noteId(\\d+|new)`,
|
||||
component: () => import('@/views/LectureNoteView.vue'),
|
||||
props: (route: RouteLocationNormalized) => ({
|
||||
id: Number(route.params.id),
|
||||
noteId: route.params.noteId === 'new' ? undefined : Number(route.params.noteId),
|
||||
}),
|
||||
meta: { title: `nav.${list}`, section: list },
|
||||
},
|
||||
]
|
||||
}),
|
||||
{
|
||||
name: 'pomodoro',
|
||||
path: '/pomodoro',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Component } from 'vue'
|
||||
import { BookOpen, CalendarClock, CalendarDays, Timer } from '@lucide/vue'
|
||||
import { BookOpen, CalendarClock, CalendarDays, Timer, Users } from '@lucide/vue'
|
||||
|
||||
export interface NavigationEntry {
|
||||
/** Route name, see `router/index.ts` */
|
||||
|
|
@ -21,6 +21,12 @@ export const sections: NavigationEntry[] = [
|
|||
icon: CalendarClock,
|
||||
},
|
||||
{ name: 'courses', title: 'nav.courses', description: 'home.sections.courses', icon: BookOpen },
|
||||
{
|
||||
name: 'associations',
|
||||
title: 'nav.associations',
|
||||
description: 'home.sections.associations',
|
||||
icon: Users,
|
||||
},
|
||||
{ name: 'pomodoro', title: 'nav.pomodoro', description: 'home.sections.pomodoro', icon: Timer },
|
||||
{
|
||||
name: 'calendar',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ client.use({
|
|||
`Server error from ${response.url}: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
throw new Error('Server error')
|
||||
} else if (response.status === HttpStatus.BAD_REQUEST) {
|
||||
// The backend explains what is wrong with the request in the body
|
||||
throw new Error(await response.clone().text())
|
||||
} else if (response.status === HttpStatus.NOT_FOUND) {
|
||||
throw new Error(`Not found: ${response.url}`)
|
||||
}
|
||||
|
|
|
|||
96
frontend/src/services/api/course-files.ts
Normal file
96
frontend/src/services/api/course-files.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { CourseFile, LectureNoteBody } from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
|
||||
function filesKey(courseId: MaybeRefOrGetter<number | undefined>) {
|
||||
return ['course', courseId, 'files']
|
||||
}
|
||||
|
||||
function noteKey(id: MaybeRefOrGetter<number | undefined>) {
|
||||
return ['note', id]
|
||||
}
|
||||
|
||||
function unexpected(status: number): never {
|
||||
throw new Error(`Unexpected status code received: ${status}`)
|
||||
}
|
||||
|
||||
/** Url of the pdf, changing on every save so the viewer never shows a stale one */
|
||||
export function lectureNotePdfUrl(note: CourseFile) {
|
||||
return `/api/notes/${note.id}/pdf?v=${encodeURIComponent(note.updated_at)}`
|
||||
}
|
||||
|
||||
/** Files of a course (lecture notes...), without their content. Disabled without course. */
|
||||
export function useCourseFiles(courseId: MaybeRefOrGetter<number | undefined>) {
|
||||
return useQuery({
|
||||
queryKey: filesKey(courseId),
|
||||
enabled: () => toValue(courseId) !== undefined,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/courses/{id}/files', {
|
||||
params: { path: { id: toValue(courseId)! } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** A note with its markdown, disabled while `id` is undefined (new note) */
|
||||
export function useLectureNote(id: MaybeRefOrGetter<number | undefined>) {
|
||||
return useQuery({
|
||||
queryKey: noteKey(id),
|
||||
enabled: () => toValue(id) !== undefined,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/notes/{id}', {
|
||||
params: { path: { id: toValue(id)! } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLectureNoteMutations(courseId: MaybeRefOrGetter<number>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/** Creates the note without `id`. The pdf error, if any, is in the result. */
|
||||
const save = useMutation({
|
||||
mutationFn: async ({ id, body }: { id?: number; body: LectureNoteBody }) => {
|
||||
if (id === undefined) {
|
||||
const { data, response } = await getClient().POST('/api/courses/{id}/notes', {
|
||||
params: { path: { id: toValue(courseId) } },
|
||||
body,
|
||||
})
|
||||
return response.status === HttpStatus.CREATED && data ? data : unexpected(response.status)
|
||||
}
|
||||
const { data, response } = await getClient().PUT('/api/notes/{id}', {
|
||||
params: { path: { id } },
|
||||
body,
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
onSuccess: ({ note }, { body }) => {
|
||||
queryClient.setQueryData<CourseFile[]>(filesKey(toValue(courseId)), (notes) =>
|
||||
notes ? [...notes.filter((n) => n.id !== note.id), note] : undefined,
|
||||
)
|
||||
queryClient.setQueryData(noteKey(note.id), { ...note, content: body.content })
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { response } = await getClient().DELETE('/api/notes/{id}', {
|
||||
params: { path: { id } },
|
||||
})
|
||||
return response.status === HttpStatus.NO_CONTENT ? id : unexpected(response.status)
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
queryClient.setQueryData<CourseFile[]>(filesKey(toValue(courseId)), (notes) =>
|
||||
notes?.filter((n) => n.id !== id),
|
||||
)
|
||||
queryClient.removeQueries({ queryKey: noteKey(id) })
|
||||
},
|
||||
})
|
||||
|
||||
return { save, remove }
|
||||
}
|
||||
149
frontend/src/services/api/courses.ts
Normal file
149
frontend/src/services/api/courses.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { Course, CourseBody, CourseKind, TimeSlotBody } from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
|
||||
const COURSES_KEY = ['courses']
|
||||
|
||||
export function courseKey(id: MaybeRefOrGetter<number>) {
|
||||
return ['course', id]
|
||||
}
|
||||
|
||||
function sortByName(courses: Course[]) {
|
||||
return [...courses].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** Puts a course returned by the backend in both the list and its own cache */
|
||||
function storeCourse(queryClient: QueryClient, course: Course) {
|
||||
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
||||
courses ? sortByName([...courses.filter((c) => c.id !== course.id), course]) : undefined,
|
||||
)
|
||||
queryClient.setQueryData(courseKey(course.id), course)
|
||||
}
|
||||
|
||||
/** Every course and association, sorted by name */
|
||||
export function useCourses() {
|
||||
return useQuery({
|
||||
queryKey: COURSES_KEY,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/courses')
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Only the courses, or only the associations */
|
||||
export function useCoursesOfKind(kind: MaybeRefOrGetter<CourseKind>) {
|
||||
const query = useCourses()
|
||||
const data = computed(() => query.data.value?.filter((c) => c.kind === toValue(kind)))
|
||||
return { ...query, data }
|
||||
}
|
||||
|
||||
export function useCourse(id: MaybeRefOrGetter<number>) {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: courseKey(id),
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/courses/{id}', {
|
||||
params: { path: { id: toValue(id) } },
|
||||
})
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
// Shown right away when coming from the list, refreshed in the background
|
||||
placeholderData: () =>
|
||||
queryClient.getQueryData<Course[]>(COURSES_KEY)?.find((c) => c.id === toValue(id)),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCourse() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (body: CourseBody) => {
|
||||
const { data, response } = await getClient().POST('/api/courses', { body })
|
||||
if (response.status === HttpStatus.CREATED && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
onSuccess: (course) => storeCourse(queryClient, course),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCourse() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, body }: { id: number; body: CourseBody }) => {
|
||||
const { data, response } = await getClient().PUT('/api/courses/{id}', {
|
||||
params: { path: { id } },
|
||||
body,
|
||||
})
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
onSuccess: (course) => storeCourse(queryClient, course),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCourse() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { response } = await getClient().DELETE('/api/courses/{id}', {
|
||||
params: { path: { id } },
|
||||
})
|
||||
if (response.status !== HttpStatus.NO_CONTENT) {
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
}
|
||||
return id
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
queryClient.setQueryData<Course[]>(COURSES_KEY, (courses) =>
|
||||
(courses ?? []).filter((c) => c.id !== id),
|
||||
)
|
||||
queryClient.removeQueries({ queryKey: courseKey(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddSlot() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ courseId, body }: { courseId: number; body: TimeSlotBody }) => {
|
||||
const { data, response } = await getClient().POST('/api/courses/{id}/slots', {
|
||||
params: { path: { id: courseId } },
|
||||
body,
|
||||
})
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
onSuccess: (course) => storeCourse(queryClient, course),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteSlot() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ courseId, slotId }: { courseId: number; slotId: number }) => {
|
||||
const { data, response } = await getClient().DELETE('/api/courses/{id}/slots/{slot_id}', {
|
||||
params: { path: { id: courseId, slot_id: slotId } },
|
||||
})
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
onSuccess: (course) => storeCourse(queryClient, course),
|
||||
})
|
||||
}
|
||||
62
frontend/src/services/api/grades.ts
Normal file
62
frontend/src/services/api/grades.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { CourseGrades, GradeBody } from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
|
||||
// Every mutation answers with all the grades of the course and the new average, which
|
||||
// replace the cache: no refetch needed.
|
||||
|
||||
function gradesKey(courseId: MaybeRefOrGetter<number>) {
|
||||
return ['course', courseId, 'grades']
|
||||
}
|
||||
|
||||
function unexpected(status: number): never {
|
||||
throw new Error(`Unexpected status code received: ${status}`)
|
||||
}
|
||||
|
||||
export function useCourseGrades(courseId: MaybeRefOrGetter<number>) {
|
||||
return useQuery({
|
||||
queryKey: gradesKey(courseId),
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/courses/{id}/grades', {
|
||||
params: { path: { id: toValue(courseId) } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Create (no `id`), update or delete the grades of one course */
|
||||
export function useGradeMutations(courseId: MaybeRefOrGetter<number>) {
|
||||
const queryClient = useQueryClient()
|
||||
const onSuccess = (grades: CourseGrades) =>
|
||||
queryClient.setQueryData(gradesKey(toValue(courseId)), grades)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async ({ id, body }: { id?: number; body: GradeBody }) => {
|
||||
const { data, response } =
|
||||
id === undefined
|
||||
? await getClient().POST('/api/courses/{id}/grades', {
|
||||
params: { path: { id: toValue(courseId) } },
|
||||
body,
|
||||
})
|
||||
: await getClient().PUT('/api/grades/{id}', { params: { path: { id } }, body })
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data, response } = await getClient().DELETE('/api/grades/{id}', {
|
||||
params: { path: { id } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
return { save, remove }
|
||||
}
|
||||
40
frontend/src/services/api/tags.ts
Normal file
40
frontend/src/services/api/tags.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Tag, TagScope } from '@/utils/types'
|
||||
import { i18n } from '../i18n'
|
||||
import { getClient } from './client'
|
||||
|
||||
/**
|
||||
* Tags (lecture, exam, slides...) with their name in the current locale. Every tag, or
|
||||
* only the ones that can label `scope` (to choose the tags of a task...).
|
||||
*/
|
||||
export function useTags(scope?: TagScope) {
|
||||
const query = useQuery({
|
||||
queryKey: ['tags', scope],
|
||||
staleTime: 3600 * 1000,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/tags', {
|
||||
params: { query: { scope } },
|
||||
})
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
})
|
||||
|
||||
const locale = i18n.locale
|
||||
|
||||
const tags = computed<Tag[]>(
|
||||
() =>
|
||||
query.data.value?.map((tag) => ({
|
||||
...tag,
|
||||
name: tag.name[locale.value],
|
||||
})) ?? [],
|
||||
)
|
||||
const tagsById = computed(() => new Map(tags.value.map((tag) => [tag.id, tag])))
|
||||
|
||||
return { ...query, data: tags, tagsById }
|
||||
}
|
||||
178
frontend/src/services/api/tasks.ts
Normal file
178
frontend/src/services/api/tasks.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type {
|
||||
CourseTasks,
|
||||
CreateTaskBody,
|
||||
FocusStart,
|
||||
FocusStop,
|
||||
Task,
|
||||
TaskBody,
|
||||
TimetaggerRecord,
|
||||
} from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
|
||||
// Every mutation answers with all the tasks of the course (nested, sorted) and the
|
||||
// deadlines, which replace the cache: no refetch needed.
|
||||
|
||||
function tasksKey(courseId: MaybeRefOrGetter<number>) {
|
||||
return ['course', courseId, 'tasks']
|
||||
}
|
||||
|
||||
const TODO_KEY = ['todo-tasks']
|
||||
|
||||
function unexpected(status: number): never {
|
||||
throw new Error(`Unexpected status code received: ${status}`)
|
||||
}
|
||||
|
||||
/** Body replacing `task` by itself, with `changes` applied */
|
||||
export function toTaskBody(task: Task, changes: Partial<TaskBody> = {}): TaskBody {
|
||||
return {
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
links: task.links,
|
||||
deadline: task.deadline,
|
||||
done: task.done,
|
||||
tag_ids: task.tag_ids,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCourseTasks(courseId: MaybeRefOrGetter<number>) {
|
||||
return useQuery({
|
||||
queryKey: tasksKey(courseId),
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/courses/{id}/tasks', {
|
||||
params: { path: { id: toValue(courseId) } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks and subtasks of every course with a deadline in `[from, to[` (ISO dates), for
|
||||
* the calendar
|
||||
*/
|
||||
export function useCalendarTasks(period: MaybeRefOrGetter<{ from: string; to: string }>) {
|
||||
return useQuery({
|
||||
queryKey: ['calendar-tasks', period],
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/tasks', {
|
||||
params: { query: toValue(period) },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Tasks not done of every course and association, the closest deadline first */
|
||||
export function useTodoTasks() {
|
||||
return useQuery({
|
||||
queryKey: TODO_KEY,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/tasks/todo')
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutations of the tasks of any course: the course is given with each call. See
|
||||
* `useTaskMutations` for the tasks of a single course.
|
||||
*/
|
||||
export function useAnyTaskMutations() {
|
||||
const queryClient = useQueryClient()
|
||||
const onSuccess = (tasks: CourseTasks, { courseId }: { courseId: number }) => {
|
||||
queryClient.setQueryData(tasksKey(courseId), tasks)
|
||||
// Refetched when they are shown again
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: TODO_KEY })
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async ({ courseId, body }: { courseId: number; body: CreateTaskBody }) => {
|
||||
const { data, response } = await getClient().POST('/api/courses/{id}/tasks', {
|
||||
params: { path: { id: courseId } },
|
||||
body,
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, body }: { courseId: number; id: number; body: TaskBody }) => {
|
||||
const { data, response } = await getClient().PUT('/api/tasks/{id}', {
|
||||
params: { path: { id } },
|
||||
body,
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async ({ id }: { courseId: number; id: number }) => {
|
||||
const { data, response } = await getClient().DELETE('/api/tasks/{id}', {
|
||||
params: { path: { id } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
return { create, update, remove }
|
||||
}
|
||||
|
||||
/** Mutations of the tasks of a single course */
|
||||
export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
||||
const { create, update, remove } = useAnyTaskMutations()
|
||||
return {
|
||||
create: {
|
||||
...create,
|
||||
mutateAsync: (body: CreateTaskBody) =>
|
||||
create.mutateAsync({ courseId: toValue(courseId), body }),
|
||||
},
|
||||
update: {
|
||||
...update,
|
||||
mutateAsync: ({ id, body }: { id: number; body: TaskBody }) =>
|
||||
update.mutateAsync({ courseId: toValue(courseId), id, body }),
|
||||
},
|
||||
remove: {
|
||||
...remove,
|
||||
mutateAsync: (id: number) => remove.mutateAsync({ courseId: toValue(courseId), id }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a focus session on a task, and its TimeTagger record */
|
||||
export async function startFocus(taskId: number): Promise<FocusStart> {
|
||||
const { data, response } = await getClient().POST('/api/tasks/{id}/focus', {
|
||||
params: { path: { id: taskId } },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
}
|
||||
|
||||
/** Ends (or pauses) a focus session: its time is added to the task */
|
||||
export async function stopFocus(
|
||||
taskId: number,
|
||||
seconds: number,
|
||||
timetagger: TimetaggerRecord | null,
|
||||
): Promise<FocusStop> {
|
||||
const { data, response } = await getClient().POST('/api/tasks/{id}/focus/stop', {
|
||||
params: { path: { id: taskId } },
|
||||
body: { seconds, timetagger },
|
||||
})
|
||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||
}
|
||||
|
||||
/** The time spent on the tasks changed: shown again from the server */
|
||||
export function useRefreshTasks() {
|
||||
const queryClient = useQueryClient()
|
||||
return (courseId: number) => {
|
||||
queryClient.invalidateQueries({ queryKey: tasksKey(courseId) })
|
||||
queryClient.invalidateQueries({ queryKey: TODO_KEY })
|
||||
}
|
||||
}
|
||||
204
frontend/src/services/focus.ts
Normal file
204
frontend/src/services/focus.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Focus session of the pomodoro, shared by every page and kept across reloads. The
|
||||
* pomodoro counts up: the session runs until it is paused or stopped.
|
||||
*
|
||||
* On a task, every run (from a start or a resume to a pause or a stop) is sent to the
|
||||
* backend, which adds its time to the task and counts it in TimeTagger. A free session,
|
||||
* without task, stays in the browser.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { startFocus, stopFocus } from '@/services/api/tasks'
|
||||
import type { FocusStart, TimetaggerRecord } from '@/utils/types'
|
||||
|
||||
/** One cycle of the classic pomodoro, in seconds: a break is suggested after each */
|
||||
export const CYCLE_SECONDS = 25 * 60
|
||||
|
||||
export interface FocusSession {
|
||||
/** The task, `null` for a free session */
|
||||
task: FocusStart | null
|
||||
/** Seconds of the runs before the current one */
|
||||
accumulated: number
|
||||
/** Start of the current run (ms), `null` while paused */
|
||||
runningSince: number | null
|
||||
/** Start of the pause (ms), `null` while running */
|
||||
pausedAt: number | null
|
||||
/** TimeTagger record of the current run */
|
||||
record: TimetaggerRecord | null
|
||||
/** Seconds spent on the task before the session */
|
||||
spentBefore: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'focus-session'
|
||||
|
||||
function load(): FocusSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as FocusSession) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const session = ref<FocusSession | null>(load())
|
||||
|
||||
watch(
|
||||
session,
|
||||
(value) => {
|
||||
try {
|
||||
if (value) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
else window.localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Private browsing: the session is only lost on reload
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
/** Current time, refreshed every second */
|
||||
const now = ref(Date.now())
|
||||
window.setInterval(() => (now.value = Date.now()), 1000)
|
||||
|
||||
/** Seconds of the current run */
|
||||
function runSeconds(value: FocusSession, at = now.value) {
|
||||
return value.runningSince === null ? 0 : Math.max(0, Math.round((at - value.runningSince) / 1000))
|
||||
}
|
||||
|
||||
/** Seconds of focus of the session */
|
||||
const elapsed = computed(() =>
|
||||
session.value ? session.value.accumulated + runSeconds(session.value) : 0,
|
||||
)
|
||||
/** Seconds of the current pause */
|
||||
const pauseElapsed = computed(() =>
|
||||
session.value?.pausedAt
|
||||
? Math.max(0, Math.round((now.value - session.value.pausedAt) / 1000))
|
||||
: 0,
|
||||
)
|
||||
const isRunning = computed(() => session.value?.runningSince != null)
|
||||
|
||||
/** Busy talking to the backend */
|
||||
const pending = ref(false)
|
||||
|
||||
async function withPending<T>(action: () => Promise<T>): Promise<T> {
|
||||
pending.value = true
|
||||
try {
|
||||
return await action()
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a run of `seconds` to the backend. Returns the TimeTagger error, if any. */
|
||||
async function endRun(value: FocusSession, seconds: number): Promise<string | null> {
|
||||
if (!value.task) return null
|
||||
const result = await stopFocus(value.task.task_id, seconds, value.record)
|
||||
return result.timetagger_error ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a session on a task (`null`: a free session), ending the running one. Returns
|
||||
* the TimeTagger error, if any: the session runs anyway.
|
||||
*/
|
||||
async function start(taskId: number | null): Promise<string | null> {
|
||||
return withPending(async () => {
|
||||
if (session.value) await stopNow()
|
||||
if (taskId === null) {
|
||||
session.value = {
|
||||
task: null,
|
||||
accumulated: 0,
|
||||
runningSince: Date.now(),
|
||||
pausedAt: null,
|
||||
record: null,
|
||||
spentBefore: 0,
|
||||
}
|
||||
return null
|
||||
}
|
||||
const task = await startFocus(taskId)
|
||||
session.value = {
|
||||
task,
|
||||
accumulated: 0,
|
||||
runningSince: new Date(task.started_at).getTime(),
|
||||
pausedAt: null,
|
||||
record: task.timetagger ?? null,
|
||||
spentBefore: task.time_spent,
|
||||
}
|
||||
return task.timetagger_error ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function pause(): Promise<string | null> {
|
||||
const value = session.value
|
||||
if (!value || value.runningSince === null) return null
|
||||
return withPending(async () => {
|
||||
const seconds = runSeconds(value, Date.now())
|
||||
const error = await endRun(value, seconds)
|
||||
value.accumulated += seconds
|
||||
value.runningSince = null
|
||||
value.pausedAt = Date.now()
|
||||
value.record = null
|
||||
return error
|
||||
})
|
||||
}
|
||||
|
||||
async function resume(): Promise<string | null> {
|
||||
const value = session.value
|
||||
if (!value || value.runningSince !== null) return null
|
||||
return withPending(async () => {
|
||||
let error: string | null = null
|
||||
if (value.task) {
|
||||
const task = await startFocus(value.task.task_id)
|
||||
value.task = task
|
||||
value.record = task.timetagger ?? null
|
||||
error = task.timetagger_error ?? null
|
||||
}
|
||||
value.runningSince = Date.now()
|
||||
value.pausedAt = null
|
||||
return error
|
||||
})
|
||||
}
|
||||
|
||||
async function stopNow(): Promise<string | null> {
|
||||
const value = session.value
|
||||
if (!value) return null
|
||||
const error =
|
||||
value.runningSince === null ? null : await endRun(value, runSeconds(value, Date.now()))
|
||||
session.value = null
|
||||
return error
|
||||
}
|
||||
|
||||
/** Ends the session. Returns the TimeTagger error, if any. */
|
||||
async function stop(): Promise<string | null> {
|
||||
return withPending(stopNow)
|
||||
}
|
||||
|
||||
export function useFocus() {
|
||||
return {
|
||||
session,
|
||||
elapsed,
|
||||
pauseElapsed,
|
||||
isRunning,
|
||||
pending,
|
||||
now,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
/** 3725 -> "1:02:05", 125 -> "02:05" */
|
||||
export function formatDuration(seconds: number): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const rest = seconds % 60
|
||||
return hours ? `${hours}:${pad(minutes)}:${pad(rest)}` : `${pad(minutes)}:${pad(rest)}`
|
||||
}
|
||||
|
||||
/** 5400 -> "1 h 30", 600 -> "10 min" */
|
||||
export function formatSpent(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (!hours) return `${minutes} min`
|
||||
return `${hours} h ${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--ring: oklch(0.707 0.165 254.624);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
|
@ -70,12 +70,12 @@
|
|||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-accent: oklch(0.932 0.032 255.585);
|
||||
--sidebar-accent-foreground: oklch(0.424 0.199 265.638);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-ring: oklch(0.707 0.165 254.624);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -85,8 +85,8 @@
|
|||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
|
|
@ -105,12 +105,12 @@
|
|||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.282 0.091 267.935);
|
||||
--sidebar-accent-foreground: oklch(0.882 0.059 254.128);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
39
frontend/src/utils/calendar.ts
Normal file
39
frontend/src/utils/calendar.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/** Month grid of the calendar, in local time. Weeks start on monday. */
|
||||
|
||||
/** "2026-10-05", in local time: the key of a day */
|
||||
export function dayKey(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** Monday of the week of `date`, at midnight */
|
||||
export function startOfWeek(date: Date): Date {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const offset = (day.getDay() + 6) % 7
|
||||
day.setDate(day.getDate() - offset)
|
||||
return day
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeks covering the month (`month` from 0), from the monday before its first day to
|
||||
* the sunday after its last one: 4 to 6 weeks of 7 days
|
||||
*/
|
||||
export function monthWeeks(year: number, month: number): Date[][] {
|
||||
const first = startOfWeek(new Date(year, month, 1))
|
||||
const last = new Date(year, month + 1, 0)
|
||||
const weeks: Date[][] = []
|
||||
const day = new Date(first)
|
||||
while (day <= last || weeks.length === 0 || weeks.at(-1)!.length < 7) {
|
||||
if (!weeks.length || weeks.at(-1)!.length === 7) weeks.push([])
|
||||
weeks.at(-1)!.push(new Date(day))
|
||||
day.setDate(day.getDate() + 1)
|
||||
}
|
||||
return weeks
|
||||
}
|
||||
|
||||
/** `[from, to[` of the weeks, as ISO dates for the api */
|
||||
export function weeksPeriod(weeks: Date[][]): { from: string; to: string } {
|
||||
const end = new Date(weeks.at(-1)!.at(-1)!)
|
||||
end.setDate(end.getDate() + 1)
|
||||
return { from: weeks[0]![0]!.toISOString(), to: end.toISOString() }
|
||||
}
|
||||
49
frontend/src/utils/dates.ts
Normal file
49
frontend/src/utils/dates.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/** ISO date -> "2026-10-10T22:00" in local time, the format of `<input type="datetime-local">` */
|
||||
export function toDateTimeInput(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
)
|
||||
}
|
||||
|
||||
/** Value of `<input type="datetime-local">` (local time) -> ISO date, `null` when empty */
|
||||
export function fromDateTimeInput(value: string): string | null {
|
||||
return value ? new Date(value).toISOString() : null
|
||||
}
|
||||
|
||||
/** "ven. 10 oct., 22:00" */
|
||||
export function formatDateTime(iso: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
/** "10 oct. 2026" */
|
||||
export function formatDate(iso: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(iso))
|
||||
}
|
||||
|
||||
/** "dans 3 jours", "il y a 2 heures"... relative to now */
|
||||
export function formatRelative(iso: string, locale: string): string {
|
||||
const seconds = (new Date(iso).getTime() - Date.now()) / 1000
|
||||
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60],
|
||||
]
|
||||
const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
||||
for (const [unit, size] of units) {
|
||||
if (Math.abs(seconds) >= size) return format.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
return format.format(0, 'minute')
|
||||
}
|
||||
|
||||
export function isPast(iso: string): boolean {
|
||||
return new Date(iso).getTime() < Date.now()
|
||||
}
|
||||
97
frontend/src/utils/markdown-editor.ts
Normal file
97
frontend/src/utils/markdown-editor.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Edits of the markdown editor (toolbar and shortcuts). Each helper computes an `Edit`
|
||||
* from the text and the selection; `applyEdit` performs it on the textarea through
|
||||
* `insertText`, so that ctrl+z still works.
|
||||
*/
|
||||
|
||||
export interface Edit {
|
||||
/** Range of the text replaced by `insert` */
|
||||
from: number
|
||||
to: number
|
||||
insert: string
|
||||
/** Selection after the edit, relative to `from` */
|
||||
select: [number, number]
|
||||
}
|
||||
|
||||
interface Selection {
|
||||
text: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** `**selection**`, or the placeholder selected when nothing is */
|
||||
export function wrap(
|
||||
{ text, start, end }: Selection,
|
||||
before: string,
|
||||
after: string,
|
||||
placeholder: string,
|
||||
): Edit {
|
||||
const content = text.slice(start, end) || placeholder
|
||||
return {
|
||||
from: start,
|
||||
to: end,
|
||||
insert: before + content + after,
|
||||
select: [before.length, before.length + content.length],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `prefix(index)` to every line touched by the selection, after removing what
|
||||
* `replaced` matches (e.g. an existing heading marker)
|
||||
*/
|
||||
export function prefixLines(
|
||||
{ text, start, end }: Selection,
|
||||
prefix: (index: number) => string,
|
||||
replaced: RegExp,
|
||||
): Edit {
|
||||
const from = text.lastIndexOf('\n', start - 1) + 1
|
||||
const lineEnd = text.indexOf('\n', end)
|
||||
const to = lineEnd === -1 ? text.length : lineEnd
|
||||
const insert = text
|
||||
.slice(from, to)
|
||||
.split('\n')
|
||||
.map((line, index) => prefix(index) + line.replace(replaced, ''))
|
||||
.join('\n')
|
||||
return { from, to, insert, select: [insert.length, insert.length] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Block on its own lines (code, box, table...): `before + content + after`, where the
|
||||
* content is the selection or the placeholder, which ends up selected
|
||||
*/
|
||||
export function block(
|
||||
{ text, start, end }: Selection,
|
||||
before: string,
|
||||
after: string,
|
||||
placeholder: string,
|
||||
): Edit {
|
||||
const content = text.slice(start, end) || placeholder
|
||||
const textBefore = text.slice(0, start)
|
||||
const textAfter = text.slice(end)
|
||||
const lead =
|
||||
textBefore === '' || textBefore.endsWith('\n\n')
|
||||
? ''
|
||||
: textBefore.endsWith('\n')
|
||||
? '\n'
|
||||
: '\n\n'
|
||||
const trail = textAfter.startsWith('\n\n') ? '' : textAfter.startsWith('\n') ? '\n' : '\n\n'
|
||||
const offset = lead.length + before.length
|
||||
return {
|
||||
from: start,
|
||||
to: end,
|
||||
insert: lead + before + content + after + trail,
|
||||
select: [offset, offset + content.length],
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEdit(textarea: HTMLTextAreaElement, edit: Edit) {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(edit.from, edit.to)
|
||||
// Deprecated but the only way to keep the undo history of a textarea
|
||||
const done = document.execCommand('insertText', false, edit.insert)
|
||||
if (!done) {
|
||||
textarea.setRangeText(edit.insert, edit.from, edit.to, 'end')
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
textarea.setSelectionRange(edit.from + edit.select[0], edit.from + edit.select[1])
|
||||
}
|
||||
43
frontend/src/utils/routes.ts
Normal file
43
frontend/src/utils/routes.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/** Routes of the courses and the associations, which share their pages */
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { CourseKind } from '@/utils/types'
|
||||
|
||||
const NAMES = {
|
||||
course: { list: 'courses', page: 'course', note: 'course-note' },
|
||||
association: { list: 'associations', page: 'association', note: 'association-note' },
|
||||
} as const
|
||||
|
||||
export function listRoute(kind: CourseKind): RouteLocationRaw {
|
||||
return { name: NAMES[kind].list }
|
||||
}
|
||||
|
||||
export function coursePageRoute(
|
||||
course: { id: number; kind: CourseKind },
|
||||
tab?: string,
|
||||
): RouteLocationRaw {
|
||||
return { name: NAMES[course.kind].page, params: { id: course.id }, query: tab ? { tab } : {} }
|
||||
}
|
||||
|
||||
/** `noteId` 'new' to write a new note */
|
||||
export function noteRoute(
|
||||
course: { id: number; kind: CourseKind },
|
||||
noteId: number | 'new',
|
||||
edit = false,
|
||||
): RouteLocationRaw {
|
||||
return {
|
||||
name: NAMES[course.kind].note,
|
||||
params: { id: course.id, noteId },
|
||||
query: edit ? { edit: '1' } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab listing the tasks, which differs between courses and associations */
|
||||
export function tasksTab(kind: CourseKind) {
|
||||
return kind === 'course' ? 'tasks' : 'deadlines'
|
||||
}
|
||||
|
||||
/** Starts the pomodoro on a task */
|
||||
export function focusRoute(taskId: number): RouteLocationRaw {
|
||||
return { name: 'pomodoro', query: { task: taskId } }
|
||||
}
|
||||
85
frontend/src/utils/timetable.ts
Normal file
85
frontend/src/utils/timetable.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/** Days and hours shown by the timetable. The backend enforces the same bounds. */
|
||||
export const WEEKDAYS = [1, 2, 3, 4, 5] as const
|
||||
export const DAY_START_HOUR = 8
|
||||
export const DAY_END_HOUR = 19
|
||||
export const DAY_START = '08:00'
|
||||
export const DAY_END = '19:00'
|
||||
|
||||
/** "10:15:00" or "10:15" -> 615 */
|
||||
export function toMinutes(time: string): number {
|
||||
const [hours = 0, minutes = 0] = time.split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
/** 615 -> "10:15" */
|
||||
export function fromMinutes(minutes: number): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`
|
||||
}
|
||||
|
||||
/** "10:15:00" -> "10:15", the format of `<input type="time">` */
|
||||
export function toHourMinutes(time: string): string {
|
||||
return time.slice(0, 5)
|
||||
}
|
||||
|
||||
/** "10:15" -> "10:15:00", the format expected by the backend */
|
||||
export function toApiTime(time: string): string {
|
||||
return time.length === 5 ? `${time}:00` : time
|
||||
}
|
||||
|
||||
/** Time range picked in the timetable, times as "HH:MM" */
|
||||
export interface SlotSelection {
|
||||
weekday: number
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export interface Positioned<T> {
|
||||
item: T
|
||||
/** Index of the column the item is drawn in, among `lanes` */
|
||||
lane: number
|
||||
/** Number of columns shared by the items overlapping this one */
|
||||
lanes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out the events of a day so that overlapping ones are drawn side by side.
|
||||
* Events are grouped into clusters of (transitively) overlapping events; inside a
|
||||
* cluster each event gets the first column that is free at its start time.
|
||||
*/
|
||||
export function layoutOverlapping<T>(
|
||||
items: T[],
|
||||
start: (item: T) => number,
|
||||
end: (item: T) => number,
|
||||
): Positioned<T>[] {
|
||||
const sorted = [...items].sort((a, b) => start(a) - start(b) || end(b) - end(a))
|
||||
const result: Positioned<T>[] = []
|
||||
|
||||
let cluster: Positioned<T>[] = []
|
||||
let laneEnds: number[] = []
|
||||
let clusterEnd = -Infinity
|
||||
|
||||
const closeCluster = () => {
|
||||
for (const positioned of cluster) positioned.lanes = laneEnds.length
|
||||
result.push(...cluster)
|
||||
cluster = []
|
||||
laneEnds = []
|
||||
}
|
||||
|
||||
for (const item of sorted) {
|
||||
if (start(item) >= clusterEnd) closeCluster()
|
||||
|
||||
let lane = laneEnds.findIndex((laneEnd) => laneEnd <= start(item))
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length
|
||||
laneEnds.push(end(item))
|
||||
} else {
|
||||
laneEnds[lane] = end(item)
|
||||
}
|
||||
cluster.push({ item, lane, lanes: 0 })
|
||||
clusterEnd = Math.max(clusterEnd, end(item))
|
||||
}
|
||||
closeCluster()
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,2 +1,36 @@
|
|||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly, e.g.
|
||||
// export type Course = components['schemas']['Course']
|
||||
import type { components } from '@/lib/api'
|
||||
|
||||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
||||
type Schemas = components['schemas']
|
||||
|
||||
export type LocalizedString = Schemas['LocalizedString']
|
||||
export type Course = Schemas['Course']
|
||||
export type CourseKind = Schemas['CourseKind']
|
||||
export type CourseSummary = Schemas['CourseSummary']
|
||||
export type CourseBody = Schemas['CourseBody']
|
||||
export type TimeSlot = Schemas['TimeSlot']
|
||||
export type TimeSlotBody = Schemas['TimeSlotBody']
|
||||
export type Grade = Schemas['Grade']
|
||||
export type GradeBody = Schemas['GradeBody']
|
||||
export type CourseGrades = Schemas['CourseGrades']
|
||||
export type Task = Schemas['Task']
|
||||
export type TaskBody = Schemas['TaskBody']
|
||||
export type CreateTaskBody = Schemas['CreateTaskBody']
|
||||
export type CourseTasks = Schemas['CourseTasks']
|
||||
export type CalendarTask = Schemas['CalendarTask']
|
||||
export type TodoTask = Schemas['TodoTask']
|
||||
export type FocusStart = Schemas['FocusStart']
|
||||
export type FocusStop = Schemas['FocusStop']
|
||||
export type TimetaggerRecord = Schemas['TimetaggerRecord']
|
||||
export type CourseFile = Schemas['CourseFile']
|
||||
export type TagScope = Schemas['TagScope']
|
||||
export type LectureNoteBody = Schemas['LectureNoteBody']
|
||||
export type LectureNoteContent = Schemas['LectureNoteContent']
|
||||
export type SavedLectureNote = Schemas['SavedLectureNote']
|
||||
|
||||
/** Tag with its name resolved in the current locale */
|
||||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,113 @@
|
|||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
/**
|
||||
* Deadlines of the courses and associations in a month calendar, next to everything left
|
||||
* to do. Courses and associations can each be hidden, and tasks created from here.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BookOpen, Plus, Users } from '@lucide/vue'
|
||||
|
||||
import TasksCalendar from '@/components/calendar/TasksCalendar.vue'
|
||||
import TodoList from '@/components/calendar/TodoList.vue'
|
||||
import TaskFormDialog from '@/components/course/TaskFormDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { CourseKind } from '@/utils/types'
|
||||
|
||||
const KINDS: { kind: CourseKind; icon: typeof BookOpen }[] = [
|
||||
{ kind: 'course', icon: BookOpen },
|
||||
{ kind: 'association', icon: Users },
|
||||
]
|
||||
|
||||
// Remembered in the browser: only a convenience
|
||||
const STORAGE_KEY = 'calendar-kinds'
|
||||
|
||||
function loadKinds(): CourseKind[] {
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null')
|
||||
if (Array.isArray(saved)) return saved.filter((k) => k === 'course' || k === 'association')
|
||||
} catch {
|
||||
// Unavailable or invalid: everything is shown
|
||||
}
|
||||
return ['course', 'association']
|
||||
}
|
||||
|
||||
const kinds = ref<CourseKind[]>(loadKinds())
|
||||
watch(kinds, (value) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Not remembered
|
||||
}
|
||||
})
|
||||
|
||||
function toggle(kind: CourseKind) {
|
||||
kinds.value = kinds.value.includes(kind)
|
||||
? kinds.value.filter((k) => k !== kind)
|
||||
: [...kinds.value, kind]
|
||||
}
|
||||
|
||||
const formOpen = ref(false)
|
||||
const defaultDeadline = ref<string>()
|
||||
|
||||
/** New task, due at the end of `day` when given */
|
||||
function openForm(day?: Date) {
|
||||
if (day) {
|
||||
const end = new Date(day)
|
||||
end.setHours(23, 59, 0, 0)
|
||||
defaultDeadline.value = end.toISOString()
|
||||
} else {
|
||||
defaultDeadline.value = undefined
|
||||
}
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
const nothingShown = computed(() => !kinds.value.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder title="nav.calendar" />
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex gap-2" role="group" :aria-label="$t('calendar.show')">
|
||||
<button
|
||||
v-for="{ kind, icon } in KINDS"
|
||||
:key="kind"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
kinds.includes(kind)
|
||||
? 'border-primary bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent'
|
||||
"
|
||||
:aria-pressed="kinds.includes(kind)"
|
||||
@click="toggle(kind)"
|
||||
>
|
||||
<component :is="icon" class="size-4" />
|
||||
{{ $t(`nav.${kind}s`) }}
|
||||
</button>
|
||||
</div>
|
||||
<Button class="ml-auto" @click="openForm()">
|
||||
<Plus />
|
||||
{{ $t('tasks.new') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="nothingShown" class="text-sm text-muted-foreground">
|
||||
{{ $t('calendar.nothing-shown') }}
|
||||
</p>
|
||||
|
||||
<div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<TasksCalendar :kinds="kinds" class="min-w-0" @create="openForm" />
|
||||
|
||||
<Card class="lg:sticky lg:top-4 lg:max-h-[calc(100svh-6rem)] lg:overflow-y-auto">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">{{ $t('calendar.todo.title') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TodoList :kinds="kinds" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TaskFormDialog v-model:open="formOpen" :default-deadline="defaultDeadline" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
125
frontend/src/views/CourseView.vue
Normal file
125
frontend/src/views/CourseView.vue
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Personal page of a course: its files, deadlines, timetable and grades. An association
|
||||
* has its notes, and its deadlines on top of its timetable.
|
||||
*/
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ExternalLink, Folder } from '@lucide/vue'
|
||||
|
||||
import AssociationDeadlinesTab from '@/components/course/AssociationDeadlinesTab.vue'
|
||||
import CourseFilesTab from '@/components/course/CourseFilesTab.vue'
|
||||
import CourseGradesTab from '@/components/course/CourseGradesTab.vue'
|
||||
import CourseScheduleTab from '@/components/course/CourseScheduleTab.vue'
|
||||
import CourseTasksTab from '@/components/course/CourseTasksTab.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useCourse } from '@/services/api/courses'
|
||||
import { coursePageRoute, listRoute } from '@/utils/routes'
|
||||
|
||||
const props = defineProps<{ id: number }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { data: course, isPending, isError, refetch } = useCourse(() => props.id)
|
||||
|
||||
/** Kind of the page, from the route: the course may not be loaded yet */
|
||||
const kind = computed(() => (route.name === 'association' ? 'association' : 'course'))
|
||||
const tabs = computed(() =>
|
||||
kind.value === 'course' ? ['files', 'tasks', 'schedule', 'grades'] : ['files', 'deadlines'],
|
||||
)
|
||||
|
||||
// In the url, so that coming back from a note opens the files again
|
||||
const tab = computed<string>({
|
||||
get: () => tabs.value.find((tab) => tab === route.query.tab) ?? 'files',
|
||||
set: (tab) => router.replace({ query: { ...route.query, tab } }),
|
||||
})
|
||||
|
||||
// A course opened as an association (or the other way round): to its own page
|
||||
watch(
|
||||
() => course.value?.kind,
|
||||
(courseKind) => {
|
||||
if (courseKind && courseKind !== kind.value) {
|
||||
router.replace(coursePageRoute(course.value!, route.query.tab as string | undefined))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-6xl flex-col gap-4">
|
||||
<Button variant="ghost" class="self-start" as-child>
|
||||
<RouterLink :to="listRoute(kind)">
|
||||
<ArrowLeft />
|
||||
{{ $t(`${kind}.back`) }}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
|
||||
<Skeleton v-if="isPending" class="h-64 w-full rounded-xl" />
|
||||
|
||||
<Card v-else-if="isError || !course" class="mx-auto max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t(`${kind}.not-found`) }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter class="justify-center">
|
||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-2xl font-semibold">{{ course.name }}</h2>
|
||||
<Badge v-if="course.code" variant="secondary" class="font-mono">
|
||||
{{ course.code }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p v-if="course.teachers.length" class="text-sm text-muted-foreground">
|
||||
{{ course.teachers.join(', ') }}
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
:title="$t('course.folder')"
|
||||
>
|
||||
<Folder class="size-3.5" />
|
||||
<span class="font-mono">{{ course.folder }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button v-if="course.main_page_url" variant="outline" as-child>
|
||||
<a :href="course.main_page_url" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink />
|
||||
{{ $t('courses.main-page') }}
|
||||
</a>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Tabs v-model="tab" class="gap-4">
|
||||
<TabsList class="w-full justify-start overflow-x-auto sm:w-auto">
|
||||
<TabsTrigger v-for="name in tabs" :key="name" :value="name">
|
||||
{{ $t(`${kind}.tabs.${name}`) }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="files">
|
||||
<CourseFilesTab :course="course" />
|
||||
</TabsContent>
|
||||
<TabsContent value="deadlines" class="min-w-0">
|
||||
<AssociationDeadlinesTab :course="course" />
|
||||
</TabsContent>
|
||||
<TabsContent value="tasks">
|
||||
<CourseTasksTab :course-id="course.id" />
|
||||
</TabsContent>
|
||||
<TabsContent value="schedule" class="min-w-0">
|
||||
<CourseScheduleTab :course="course" />
|
||||
</TabsContent>
|
||||
<TabsContent value="grades">
|
||||
<CourseGradesTab :course-id="course.id" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,169 @@
|
|||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
/** List of the courses, or of the associations: both are managed the same way */
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { BookOpen, Pencil, Plus, Trash2, Users } from '@lucide/vue'
|
||||
|
||||
import CourseFormDialog from '@/components/courses/CourseFormDialog.vue'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCoursesOfKind, useDeleteCourse } from '@/services/api/courses'
|
||||
import { coursePageRoute } from '@/utils/routes'
|
||||
import type { Course, CourseKind } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ kind: CourseKind }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { data: courses, isPending, isError, refetch } = useCoursesOfKind(() => props.kind)
|
||||
|
||||
/** Prefix of the texts of the page */
|
||||
const k = computed(() => (props.kind === 'course' ? 'courses' : 'associations'))
|
||||
const deleteCourse = useDeleteCourse()
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editedCourse = ref<Course>()
|
||||
|
||||
function openForm(course?: Course) {
|
||||
editedCourse.value = course
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deletedCourse = ref<Course>()
|
||||
|
||||
function askDelete(course: Course) {
|
||||
deletedCourse.value = course
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletedCourse.value) return
|
||||
try {
|
||||
await deleteCourse.mutateAsync(deletedCourse.value.id)
|
||||
toast.success(t(`${k.value}.deleted`))
|
||||
} catch {
|
||||
toast.error(t(`${k.value}.delete-error`))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder title="nav.courses" />
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-end">
|
||||
<Button @click="openForm()">
|
||||
<Plus />
|
||||
{{ $t(`${k}.new`) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isPending" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Skeleton v-for="i in 3" :key="i" class="h-24 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t(`${k}.load-error`) }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter class="justify-center">
|
||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="!courses?.length" class="mx-auto max-w-md text-center">
|
||||
<CardHeader>
|
||||
<component
|
||||
:is="kind === 'course' ? BookOpen : Users"
|
||||
class="mx-auto mb-2 size-8 text-muted-foreground"
|
||||
/>
|
||||
<CardTitle>{{ $t(`${k}.empty`) }}</CardTitle>
|
||||
<CardDescription>{{ $t(`${k}.empty-hint`) }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter class="justify-center">
|
||||
<Button @click="openForm()">
|
||||
<Plus />
|
||||
{{ $t(`${k}.new`) }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<!-- The link to the course page is stretched over the whole card -->
|
||||
<Card
|
||||
v-for="course in courses"
|
||||
:key="course.id"
|
||||
class="relative flex flex-row items-start gap-2 p-4 transition-shadow focus-within:ring-2 focus-within:ring-ring/50 hover:shadow-md"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<CardTitle class="text-lg leading-snug">
|
||||
<RouterLink
|
||||
:to="coursePageRoute(course)"
|
||||
class="outline-none after:absolute after:inset-0 after:content-['']"
|
||||
>
|
||||
{{ course.name }}
|
||||
</RouterLink>
|
||||
</CardTitle>
|
||||
<Badge v-if="course.code" variant="secondary" class="font-mono">
|
||||
{{ course.code }}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription v-if="course.teachers.length">
|
||||
{{ course.teachers.join(', ') }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex shrink-0 gap-1">
|
||||
<Button variant="ghost" size="icon" :title="$t('common.edit')" @click="openForm(course)">
|
||||
<Pencil />
|
||||
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive"
|
||||
:title="$t('common.delete')"
|
||||
@click="askDelete(course)"
|
||||
>
|
||||
<Trash2 />
|
||||
<span class="sr-only">{{ $t('common.delete') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CourseFormDialog v-model:open="formOpen" :kind="kind" :course="editedCourse" />
|
||||
|
||||
<AlertDialog v-model:open="deleteOpen">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{{ $t(`${k}.delete-title`, { name: deletedCourse?.name }) }}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{{ $t(`${k}.delete-description`) }}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class="bg-destructive text-white hover:bg-destructive/90"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { sections } from '@/router/navigation'
|
|||
<p class="text-muted-foreground">{{ $t('home.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5">
|
||||
<RouterLink
|
||||
v-for="section in sections"
|
||||
:key="section.name"
|
||||
|
|
|
|||
260
frontend/src/views/LectureNoteView.vue
Normal file
260
frontend/src/views/LectureNoteView.vue
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* A lecture note: its pdf, or the markdown editor. Saving renders the pdf again and
|
||||
* shows it; when LaTeX fails the editor stays open with the error.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ArrowLeft, Download, FileWarning, Loader2, Pencil, Save, Trash2, X } from '@lucide/vue'
|
||||
|
||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||
import MarkdownEditor from '@/components/course/MarkdownEditor.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCourse } from '@/services/api/courses'
|
||||
import {
|
||||
lectureNotePdfUrl,
|
||||
useLectureNote,
|
||||
useLectureNoteMutations,
|
||||
useCourseFiles,
|
||||
} from '@/services/api/course-files'
|
||||
import { coursePageRoute, noteRoute } from '@/utils/routes'
|
||||
import type { CourseFile } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ id: number; noteId?: number }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { data: course } = useCourse(() => props.id)
|
||||
const { data: files } = useCourseFiles(() => props.id)
|
||||
const noteCount = computed(() => files.value?.filter((file) => file.kind === 'note').length)
|
||||
const { data: note, isPending, isError } = useLectureNote(() => props.noteId)
|
||||
const { save, remove } = useLectureNoteMutations(() => props.id)
|
||||
|
||||
const isNew = computed(() => props.noteId === undefined)
|
||||
const editing = ref(isNew.value || route.query.edit === '1')
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const pdfError = ref<string>()
|
||||
|
||||
/** Content last loaded or saved, to warn before losing changes */
|
||||
const saved = ref({ title: '', content: '' })
|
||||
const dirty = computed(
|
||||
() =>
|
||||
editing.value && (title.value !== saved.value.title || content.value !== saved.value.content),
|
||||
)
|
||||
|
||||
function reset(values: { title: string; content: string }) {
|
||||
title.value = values.title
|
||||
content.value = values.content
|
||||
saved.value = { ...values }
|
||||
}
|
||||
|
||||
// New note: numbered like `mdpdf create` does
|
||||
watch(
|
||||
[isNew, noteCount],
|
||||
([isNew, count]) => {
|
||||
if (isNew && !saved.value.title) {
|
||||
reset({
|
||||
title: t('notes.default-title', { n: (count ?? 0) + 1 }),
|
||||
content: t('notes.default-content'),
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
note,
|
||||
(note) => {
|
||||
if (note && !dirty.value) reset({ title: note.name, content: note.content })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const kind = computed(() => (route.name === 'association-note' ? 'association' : 'course'))
|
||||
const backTo = computed(() => coursePageRoute({ id: props.id, kind: kind.value }, 'files'))
|
||||
|
||||
async function submit() {
|
||||
if (!title.value.trim()) {
|
||||
toast.error(t('notes.errors.title'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await save.mutateAsync({
|
||||
id: props.noteId,
|
||||
body: { title: title.value, content: content.value },
|
||||
})
|
||||
saved.value = { title: title.value, content: content.value }
|
||||
pdfError.value = result.pdf_error ?? undefined
|
||||
if (isNew.value) {
|
||||
await router.replace(
|
||||
noteRoute({ id: props.id, kind: kind.value }, result.note.id, !!result.pdf_error),
|
||||
)
|
||||
}
|
||||
if (result.pdf_error) {
|
||||
toast.warning(t('notes.saved-without-pdf'))
|
||||
} else {
|
||||
toast.success(t('notes.saved'))
|
||||
editing.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('notes.save-error'), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (note.value) reset({ title: note.value.name, content: note.value.content })
|
||||
pdfError.value = undefined
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
|
||||
async function confirmDelete() {
|
||||
if (props.noteId === undefined) return
|
||||
try {
|
||||
await remove.mutateAsync(props.noteId)
|
||||
toast.success(t('notes.deleted'))
|
||||
editing.value = false
|
||||
await router.push(backTo.value)
|
||||
} catch {
|
||||
toast.error(t('notes.delete-error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Unsaved changes: ask before leaving the page or closing the tab
|
||||
onBeforeRouteLeave(() => !dirty.value || window.confirm(t('notes.leave-confirm')))
|
||||
useEventListener(window, 'beforeunload', (event: BeforeUnloadEvent) => {
|
||||
if (dirty.value) event.preventDefault()
|
||||
})
|
||||
|
||||
function pdfUrl(note: CourseFile) {
|
||||
return lectureNotePdfUrl(note)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="ghost" as-child>
|
||||
<RouterLink :to="backTo">
|
||||
<ArrowLeft />
|
||||
{{ course?.name ?? $t(`${kind}.back`) }}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
|
||||
<div class="ml-auto flex flex-wrap gap-2">
|
||||
<template v-if="editing">
|
||||
<Button v-if="!isNew" variant="outline" @click="cancel">
|
||||
<X />
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="save.isPending.value" :title="'Ctrl+S'" @click="submit">
|
||||
<Loader2 v-if="save.isPending.value" class="animate-spin" />
|
||||
<Save v-else />
|
||||
{{ save.isPending.value ? $t('notes.rendering') : $t('common.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="note">
|
||||
<Button v-if="note.has_pdf" variant="outline" as-child>
|
||||
<a :href="pdfUrl(note)" :download="`${note.name}.pdf`">
|
||||
<Download />
|
||||
{{ $t('notes.download') }}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-destructive hover:text-destructive"
|
||||
@click="deleteOpen = true"
|
||||
>
|
||||
<Trash2 />
|
||||
{{ $t('common.delete') }}
|
||||
</Button>
|
||||
<Button @click="editing = true">
|
||||
<Pencil />
|
||||
{{ $t('common.edit') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="!isNew && isPending" class="h-[70svh] w-full rounded-xl" />
|
||||
|
||||
<Card v-else-if="!isNew && (isError || !note)" class="mx-auto max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('notes.not-found') }}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<!-- Editor, next to the last pdf on large screens -->
|
||||
<div v-else-if="editing" class="grid gap-4 xl:grid-cols-2">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Input
|
||||
v-model="title"
|
||||
class="h-11 text-lg font-medium"
|
||||
:placeholder="$t('notes.title-placeholder')"
|
||||
:aria-label="$t('notes.title-label')"
|
||||
/>
|
||||
<div
|
||||
v-if="pdfError"
|
||||
class="rounded-lg border border-destructive/50 bg-destructive/5 p-3 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<p class="mb-2 flex items-center gap-2 font-medium text-destructive">
|
||||
<FileWarning class="size-4" />
|
||||
{{ $t('notes.pdf-error') }}
|
||||
</p>
|
||||
<pre class="max-h-48 overflow-auto text-xs whitespace-pre-wrap">{{ pdfError }}</pre>
|
||||
</div>
|
||||
<MarkdownEditor v-model="content" @save="submit" />
|
||||
</div>
|
||||
<div class="hidden xl:block">
|
||||
<iframe
|
||||
v-if="note?.has_pdf"
|
||||
:src="pdfUrl(note)"
|
||||
:title="note.name"
|
||||
class="h-full min-h-[70svh] w-full rounded-lg border bg-muted"
|
||||
/>
|
||||
<Card v-else class="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<CardDescription>{{ $t('notes.preview-hint') }}</CardDescription>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pdf -->
|
||||
<template v-else-if="note">
|
||||
<iframe
|
||||
v-if="note.has_pdf"
|
||||
:src="pdfUrl(note)"
|
||||
:title="note.name"
|
||||
class="h-[80svh] w-full rounded-lg border bg-muted"
|
||||
/>
|
||||
<Card v-else class="mx-auto flex max-w-md flex-col items-center gap-3 p-6 text-center">
|
||||
<FileWarning class="size-8 text-destructive" />
|
||||
<CardTitle class="text-base">{{ $t('notes.no-pdf') }}</CardTitle>
|
||||
<CardDescription>{{ $t('notes.no-pdf-hint') }}</CardDescription>
|
||||
<Button @click="editing = true">
|
||||
<Pencil />
|
||||
{{ $t('common.edit') }}
|
||||
</Button>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model:open="deleteOpen"
|
||||
:title="$t('files.delete-title', { name: note?.name })"
|
||||
:description="$t('files.delete-description')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,461 @@
|
|||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
/**
|
||||
* Reverse pomodoro: a timer counting up the focus on a task, with the time of day, in a
|
||||
* quiet page. A ring fills up every 25 minutes and suggests a break, a fifth of the
|
||||
* focus time. The notes and slides of the course open next to the clock.
|
||||
*
|
||||
* `?task=<id>` starts a session on the task (see `services/focus.ts`).
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onKeyStroke, useFullscreen } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
Play,
|
||||
Square,
|
||||
Timer,
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import TagBadge from '@/components/course/TagBadge.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { lectureNotePdfUrl, useCourseFiles } from '@/services/api/course-files'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import { useRefreshTasks, useTodoTasks } from '@/services/api/tasks'
|
||||
import { CYCLE_SECONDS, formatDuration, formatSpent, useFocus } from '@/services/focus'
|
||||
import { formatRelative } from '@/utils/dates'
|
||||
import { coursePageRoute, tasksTab } from '@/utils/routes'
|
||||
import type { CourseFile } from '@/utils/types'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { tagsById } = useTags()
|
||||
const refreshTasks = useRefreshTasks()
|
||||
const focus = useFocus()
|
||||
const { session, elapsed, pauseElapsed, isRunning, pending, now } = focus
|
||||
|
||||
const task = computed(() => session.value?.task ?? null)
|
||||
|
||||
/** Shows the errors of an action: TimeTagger ones as a warning, the session runs anyway */
|
||||
async function run(action: () => Promise<string | null>) {
|
||||
const courseId = task.value?.course.id
|
||||
try {
|
||||
const timetaggerError = await action()
|
||||
if (timetaggerError) {
|
||||
toast.warning(t('focus.timetagger-error'), { description: timetaggerError })
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('focus.error'), { description: err instanceof Error ? err.message : undefined })
|
||||
} finally {
|
||||
if (courseId !== undefined) refreshTasks(courseId)
|
||||
}
|
||||
}
|
||||
|
||||
// `?task=<id>`: starts working on the task, then leaves the url clean so a reload does
|
||||
// not start it again
|
||||
watch(
|
||||
() => route.query.task,
|
||||
async (value) => {
|
||||
const taskId = Number(value)
|
||||
if (!value || !Number.isInteger(taskId)) return
|
||||
await router.replace({ query: {} })
|
||||
if (task.value?.task_id === taskId) {
|
||||
if (!isRunning.value) await run(focus.resume)
|
||||
return
|
||||
}
|
||||
await run(() => focus.start(taskId))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function togglePause() {
|
||||
if (pending.value || !session.value) return
|
||||
run(isRunning.value ? focus.pause : focus.resume)
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
const seconds = elapsed.value
|
||||
const name = task.value?.name
|
||||
await run(focus.stop)
|
||||
if (!session.value) {
|
||||
toast.success(t('focus.stopped', { time: formatSpent(seconds) }), { description: name })
|
||||
}
|
||||
openedFile.value = undefined
|
||||
}
|
||||
|
||||
onKeyStroke(' ', (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('input, textarea, button, a, [contenteditable]')) return
|
||||
event.preventDefault()
|
||||
togglePause()
|
||||
})
|
||||
|
||||
// The ring fills up once per cycle: a break is suggested each time it is full
|
||||
const cycles = computed(() => Math.floor(elapsed.value / CYCLE_SECONDS))
|
||||
const cycleProgress = computed(() => (elapsed.value % CYCLE_SECONDS) / CYCLE_SECONDS)
|
||||
const suggestedBreak = computed(() => Math.max(5 * 60, Math.round(elapsed.value / 5)))
|
||||
|
||||
watch(cycles, (count, previous) => {
|
||||
if (isRunning.value && previous !== undefined && count > previous) {
|
||||
toast(t('focus.cycle-done', { n: count }), { description: t('focus.cycle-hint') })
|
||||
}
|
||||
})
|
||||
|
||||
const RADIUS = 46
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
const clock = computed(() =>
|
||||
new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(now.value),
|
||||
)
|
||||
const today = computed(() =>
|
||||
new Intl.DateTimeFormat(locale.value, { weekday: 'long', day: 'numeric', month: 'long' }).format(
|
||||
now.value,
|
||||
),
|
||||
)
|
||||
|
||||
/** Time spent on the task, this session included */
|
||||
const totalSpent = computed(() => (session.value?.spentBefore ?? 0) + elapsed.value)
|
||||
|
||||
// The time in the tab title, visible from the other tabs
|
||||
const originalTitle = document.title
|
||||
watch(
|
||||
[elapsed, isRunning, session],
|
||||
() => {
|
||||
document.title = session.value
|
||||
? `${isRunning.value ? '⏱' : '⏸'} ${formatDuration(elapsed.value)} · ${originalTitle}`
|
||||
: originalTitle
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onBeforeUnmount(() => (document.title = originalTitle))
|
||||
|
||||
const container = useTemplateRef<HTMLElement>('container')
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(container)
|
||||
|
||||
// Resources of the course: its notes (and soon its slides), opened next to the clock
|
||||
const showResources = ref(true)
|
||||
const { data: files, isPending: filesPending } = useCourseFiles(() => task.value?.course.id)
|
||||
const openedFile = ref<CourseFile>()
|
||||
|
||||
function canOpen(file: CourseFile) {
|
||||
return file.kind === 'note' && file.has_pdf
|
||||
}
|
||||
|
||||
// The small clock is next to the resources: no resource without them
|
||||
watch([() => task.value?.course.id, showResources], () => (openedFile.value = undefined))
|
||||
|
||||
// Nothing running: pick a task
|
||||
const { data: todos, isPending: todosPending } = useTodoTasks()
|
||||
const choices = computed(() =>
|
||||
(todos.value ?? []).flatMap(({ course, task }) => [
|
||||
{ course, task, parent: undefined as string | undefined },
|
||||
...task.subtasks
|
||||
.filter((subtask) => !subtask.done)
|
||||
.map((subtask) => ({ course, task: subtask, parent: task.name })),
|
||||
]),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder title="nav.pomodoro" />
|
||||
<div
|
||||
ref="container"
|
||||
class="relative -m-4 flex min-h-[calc(100svh-3.5rem)] flex-col bg-gradient-to-b from-muted/40 to-background md:-m-6"
|
||||
>
|
||||
<!-- Top bar: quiet, out of the way -->
|
||||
<div class="flex items-center gap-2 px-4 pt-4 md:px-6">
|
||||
<span class="text-sm text-muted-foreground first-letter:uppercase">{{ today }}</span>
|
||||
<div class="ml-auto flex gap-1">
|
||||
<Button
|
||||
v-if="task"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-accent': showResources }"
|
||||
:aria-pressed="showResources"
|
||||
@click="showResources = !showResources"
|
||||
>
|
||||
<FolderOpen />
|
||||
<span class="hidden sm:inline">{{ $t('focus.resources') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:title="isFullscreen ? $t('focus.exit-fullscreen') : $t('focus.fullscreen')"
|
||||
@click="toggleFullscreen()"
|
||||
>
|
||||
<Minimize v-if="isFullscreen" />
|
||||
<Maximize v-else />
|
||||
<span class="sr-only">{{ $t('focus.fullscreen') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid flex-1 gap-4 p-4 md:p-6"
|
||||
:class="{ 'lg:grid-cols-[minmax(0,1fr)_20rem]': task && showResources }"
|
||||
>
|
||||
<!-- A resource opened: it takes the room, the clock moves to the side -->
|
||||
<div v-if="openedFile" class="flex min-h-[70svh] flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate text-sm font-medium">{{ openedFile.name }}</span>
|
||||
<Button variant="ghost" size="icon-sm" class="ml-auto" as-child>
|
||||
<a
|
||||
:href="lectureNotePdfUrl(openedFile)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="$t('focus.open-tab')"
|
||||
>
|
||||
<ExternalLink />
|
||||
<span class="sr-only">{{ $t('focus.open-tab') }}</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:title="$t('focus.close-resource')"
|
||||
@click="openedFile = undefined"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">{{ $t('focus.close-resource') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<iframe
|
||||
:src="lectureNotePdfUrl(openedFile)"
|
||||
:title="openedFile.name"
|
||||
class="w-full flex-1 rounded-lg border bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- The clock -->
|
||||
<div v-show="!openedFile" class="flex flex-col items-center justify-center gap-6 text-center">
|
||||
<template v-if="session">
|
||||
<div class="flex max-w-xl flex-col items-center gap-2">
|
||||
<template v-if="task">
|
||||
<RouterLink
|
||||
:to="coursePageRoute(task.course, tasksTab(task.course.kind))"
|
||||
class="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<span v-if="task.course.code" class="font-mono">{{ task.course.code }} · </span>
|
||||
{{ task.course.name }}
|
||||
</RouterLink>
|
||||
<h2 class="text-2xl font-semibold text-balance md:text-3xl">
|
||||
<span v-if="task.parent_name" class="text-muted-foreground">
|
||||
{{ task.parent_name }} ›
|
||||
</span>
|
||||
{{ task.name }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap justify-center gap-1">
|
||||
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<h2 v-else class="text-2xl font-semibold md:text-3xl">{{ $t('focus.free') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Ring of the current cycle, the focus time inside -->
|
||||
<div class="relative size-72 sm:size-96">
|
||||
<svg viewBox="0 0 100 100" class="size-full -rotate-90" aria-hidden="true">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
:r="RADIUS"
|
||||
fill="none"
|
||||
class="stroke-muted"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
:r="RADIUS"
|
||||
fill="none"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
:class="isRunning ? 'stroke-primary' : 'stroke-muted-foreground/40'"
|
||||
:stroke-dasharray="CIRCUMFERENCE"
|
||||
:stroke-dashoffset="CIRCUMFERENCE * (1 - cycleProgress)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
<span
|
||||
class="font-mono text-6xl font-light tracking-tight tabular-nums sm:text-7xl"
|
||||
:class="{ 'text-muted-foreground': !isRunning }"
|
||||
role="timer"
|
||||
:aria-label="$t('focus.elapsed')"
|
||||
>
|
||||
{{ formatDuration(elapsed) }}
|
||||
</span>
|
||||
<span class="text-lg text-muted-foreground tabular-nums">{{ clock }}</span>
|
||||
<span
|
||||
v-if="cycles"
|
||||
class="flex items-center gap-1"
|
||||
:title="$t('focus.cycles', { n: cycles })"
|
||||
>
|
||||
<span
|
||||
v-for="i in Math.min(cycles, 8)"
|
||||
:key="i"
|
||||
class="size-2 rounded-full bg-primary/70"
|
||||
/>
|
||||
<span v-if="cycles > 8" class="text-xs text-muted-foreground"
|
||||
>+{{ cycles - 8 }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!isRunning" class="text-sm text-muted-foreground">
|
||||
{{ $t('focus.paused', { time: formatDuration(pauseElapsed) }) }}
|
||||
· {{ $t('focus.suggested-break', { time: formatSpent(suggestedBreak) }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
class="h-14 min-w-40 rounded-full text-base"
|
||||
:disabled="pending"
|
||||
@click="togglePause"
|
||||
>
|
||||
<Loader2 v-if="pending" class="animate-spin" />
|
||||
<Pause v-else-if="isRunning" />
|
||||
<Play v-else />
|
||||
{{ isRunning ? $t('focus.pause') : $t('focus.resume') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
class="h-14 rounded-full"
|
||||
:disabled="pending"
|
||||
@click="stop"
|
||||
>
|
||||
<Square />
|
||||
{{ $t('focus.stop') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="flex flex-wrap justify-center gap-x-3 text-xs text-muted-foreground">
|
||||
<span v-if="task">{{ $t('focus.total', { time: formatSpent(totalSpent) }) }}</span>
|
||||
<span v-if="task?.timetagger_enabled" class="flex items-center gap-1">
|
||||
<span
|
||||
class="size-1.5 rounded-full"
|
||||
:class="session.record ? 'bg-green-500' : 'bg-muted-foreground/50'"
|
||||
/>
|
||||
TimeTagger
|
||||
</span>
|
||||
<span class="hidden md:inline">{{ $t('focus.space-hint') }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Nothing running -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="font-mono text-7xl font-light tracking-tight tabular-nums sm:text-8xl">
|
||||
{{ clock }}
|
||||
</span>
|
||||
<p class="text-muted-foreground">{{ $t('focus.pick') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-lg flex-col gap-2 text-left">
|
||||
<div v-if="todosPending" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="i in 3" :key="i" class="h-12 rounded-lg" />
|
||||
</div>
|
||||
<ul v-else class="flex max-h-[45svh] flex-col gap-1.5 overflow-y-auto">
|
||||
<li v-for="choice in choices" :key="choice.task.id">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg border bg-card px-3 py-2 text-left transition-colors hover:bg-accent disabled:opacity-60"
|
||||
:disabled="pending"
|
||||
@click="run(() => focus.start(choice.task.id))"
|
||||
>
|
||||
<Timer class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm font-medium">
|
||||
<span v-if="choice.parent" class="text-muted-foreground">
|
||||
{{ choice.parent }} ›
|
||||
</span>
|
||||
{{ choice.task.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs text-muted-foreground">
|
||||
<span class="font-mono">{{ choice.course.code ?? choice.course.name }}</span>
|
||||
<template v-if="choice.task.deadline">
|
||||
· {{ formatRelative(choice.task.deadline, locale) }}
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!choices.length" class="py-2 text-center text-sm text-muted-foreground">
|
||||
{{ $t('focus.no-task') }}
|
||||
</li>
|
||||
</ul>
|
||||
<Button variant="outline" :disabled="pending" @click="run(() => focus.start(null))">
|
||||
<Play />
|
||||
{{ $t('focus.start-free') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Side: the clock when a resource is opened, and the resources -->
|
||||
<aside v-if="task && showResources" class="flex min-w-0 flex-col gap-4">
|
||||
<div v-if="openedFile" class="flex items-center gap-3 rounded-xl border bg-card p-3">
|
||||
<span
|
||||
class="font-mono text-3xl font-light tabular-nums"
|
||||
:class="{ 'text-muted-foreground': !isRunning }"
|
||||
>
|
||||
{{ formatDuration(elapsed) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground tabular-nums">{{ clock }}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-full"
|
||||
:disabled="pending"
|
||||
:title="isRunning ? $t('focus.pause') : $t('focus.resume')"
|
||||
@click="togglePause"
|
||||
>
|
||||
<Pause v-if="isRunning" />
|
||||
<Play v-else />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-xl border bg-card p-3">
|
||||
<h3 class="text-sm font-medium">{{ $t('focus.resources') }}</h3>
|
||||
<div v-if="filesPending" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="i in 3" :key="i" class="h-9" />
|
||||
</div>
|
||||
<p v-else-if="!files?.length" class="text-sm text-muted-foreground">
|
||||
{{ $t('focus.no-resource') }}
|
||||
</p>
|
||||
<ul v-else class="-mx-1 flex flex-col">
|
||||
<li v-for="file in files" :key="file.id">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="{ 'bg-accent font-medium': openedFile?.id === file.id }"
|
||||
:disabled="!canOpen(file)"
|
||||
@click="openedFile = file"
|
||||
>
|
||||
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
|
||||
<template v-for="tagId in file.tag_ids" :key="tagId">
|
||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||
</template>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,83 @@
|
|||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
/**
|
||||
* Timetable of every course. A slot is added by selecting a time range, and clicking a
|
||||
* slot opens the page of its course.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { CalendarClock } from '@lucide/vue'
|
||||
|
||||
import SlotFormDialog from '@/components/schedule/SlotFormDialog.vue'
|
||||
import WeekTimetable from '@/components/timetable/WeekTimetable.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCourses } from '@/services/api/courses'
|
||||
import { useTags } from '@/services/api/tags'
|
||||
import type { SlotSelection } from '@/utils/timetable'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { data: courses, isPending, isError, refetch } = useCourses()
|
||||
const { data: tags } = useTags('slot')
|
||||
|
||||
const slotCount = computed(() =>
|
||||
(courses.value ?? []).reduce((count, course) => count + course.slots.length, 0),
|
||||
)
|
||||
|
||||
const slotFormOpen = ref(false)
|
||||
const slotSelection = ref<SlotSelection>()
|
||||
|
||||
function onSelect(selection: SlotSelection) {
|
||||
if (!courses.value?.length) {
|
||||
toast.info(t('schedule.no-course'))
|
||||
return
|
||||
}
|
||||
slotSelection.value = selection
|
||||
slotFormOpen.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder title="nav.schedule" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<span v-for="tag in tags" :key="tag.id" class="flex items-center gap-2">
|
||||
<span class="size-2.5 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span v-if="slotCount > 0" class="ml-auto text-muted-foreground">
|
||||
{{ $t('schedule.select-hint') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="isPending" class="h-[55rem] w-full rounded-xl" />
|
||||
|
||||
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('schedule.load-error') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter class="justify-center">
|
||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<Card v-if="slotCount === 0" class="flex flex-col items-center gap-2 p-4 sm:flex-row">
|
||||
<CalendarClock class="size-6 shrink-0 text-muted-foreground" />
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<CardTitle class="text-base">{{ $t('schedule.empty') }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ courses?.length ? $t('schedule.empty-hint') : $t('schedule.no-course') }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button v-if="!courses?.length" as-child>
|
||||
<RouterLink :to="{ name: 'courses' }">{{ $t('schedule.go-to-courses') }}</RouterLink>
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<WeekTimetable :courses="courses ?? []" selectable link-to-course @select="onSelect" />
|
||||
</template>
|
||||
|
||||
<SlotFormDialog v-model:open="slotFormOpen" :selection="slotSelection" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
168
src/api/course_files.rs
Normal file
168
src/api/course_files.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! Files of a course. For now only the lecture notes: markdown saved in the storage and
|
||||
//! rendered to pdf on every save.
|
||||
|
||||
use aide::{
|
||||
axum::{
|
||||
ApiRouter,
|
||||
routing::{get_with, post_with},
|
||||
},
|
||||
transform::TransformOperation,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Path,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
helpers::{desc, error_response},
|
||||
models::{CoursePath, LectureNoteBody, LectureNotePath},
|
||||
},
|
||||
core::{
|
||||
controller::AppController,
|
||||
models::course_file::{CourseFile, LectureNoteContent, SavedLectureNote},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn routes() -> ApiRouter {
|
||||
ApiRouter::new()
|
||||
.api_route(
|
||||
"/api/courses/{id}/files",
|
||||
get_with(get_course_files, get_course_files_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/api/courses/{id}/notes",
|
||||
post_with(create_lecture_note, create_lecture_note_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/api/notes/{id}",
|
||||
get_with(get_lecture_note, get_lecture_note_docs)
|
||||
.put_with(update_lecture_note, update_lecture_note_docs)
|
||||
.delete_with(delete_lecture_note, delete_lecture_note_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/api/notes/{id}/pdf",
|
||||
get_with(get_lecture_note_pdf, get_lecture_note_pdf_docs),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_course_files(
|
||||
controller: AppController,
|
||||
Path(path): Path<CoursePath>,
|
||||
) -> Result<Json<Vec<CourseFile>>, (StatusCode, String)> {
|
||||
match controller.get_course_files(path.id).await {
|
||||
Ok(files) => Ok(Json(files)),
|
||||
Err(err) => error_response("get_course_files", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_course_files_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Course files")
|
||||
.summary("Get the files of a course (lecture notes...), without their content")
|
||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||
}
|
||||
|
||||
async fn create_lecture_note(
|
||||
controller: AppController,
|
||||
Path(path): Path<CoursePath>,
|
||||
Json(body): Json<LectureNoteBody>,
|
||||
) -> Result<(StatusCode, Json<SavedLectureNote>), (StatusCode, String)> {
|
||||
match controller
|
||||
.create_lecture_note(path.id, body.title, body.content)
|
||||
.await
|
||||
{
|
||||
Ok(saved) => Ok((StatusCode::CREATED, Json(saved))),
|
||||
Err(err) => error_response("create_lecture_note", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Course files")
|
||||
.summary("Create a lecture note, tagged `notes`, and render it to pdf")
|
||||
.description("The note is created even when the rendering fails: see `pdf_error`.")
|
||||
.response_with::<201, Json<SavedLectureNote>, _>(desc("The created note"))
|
||||
.response_with::<400, String, _>(desc("The note is invalid"))
|
||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||
}
|
||||
|
||||
async fn get_lecture_note(
|
||||
controller: AppController,
|
||||
Path(path): Path<LectureNotePath>,
|
||||
) -> Result<Json<LectureNoteContent>, (StatusCode, String)> {
|
||||
match controller.get_lecture_note(path.id).await {
|
||||
Ok(note) => Ok(Json(note)),
|
||||
Err(err) => error_response("get_lecture_note", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Course files")
|
||||
.summary("Get a lecture note with its markdown content")
|
||||
.response_with::<404, String, _>(desc("The note does not exist"))
|
||||
}
|
||||
|
||||
async fn update_lecture_note(
|
||||
controller: AppController,
|
||||
Path(path): Path<LectureNotePath>,
|
||||
Json(body): Json<LectureNoteBody>,
|
||||
) -> Result<Json<SavedLectureNote>, (StatusCode, String)> {
|
||||
match controller
|
||||
.update_lecture_note(path.id, body.title, body.content)
|
||||
.await
|
||||
{
|
||||
Ok(saved) => Ok(Json(saved)),
|
||||
Err(err) => error_response("update_lecture_note", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Course files")
|
||||
.summary("Replace a lecture note and render it to pdf again")
|
||||
.description("The note is saved even when the rendering fails: see `pdf_error`.")
|
||||
.response_with::<400, String, _>(desc("The note is invalid"))
|
||||
.response_with::<404, String, _>(desc("The note does not exist"))
|
||||
}
|
||||
|
||||
async fn delete_lecture_note(
|
||||
controller: AppController,
|
||||
Path(path): Path<LectureNotePath>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
match controller.delete_lecture_note(path.id).await {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(err) => error_response("delete_lecture_note", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_lecture_note_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Course files")
|
||||
.summary("Delete a lecture note and its files")
|
||||
.response_with::<204, (), _>(desc("The note was deleted"))
|
||||
.response_with::<404, String, _>(desc("The note does not exist"))
|
||||
}
|
||||
|
||||
async fn get_lecture_note_pdf(
|
||||
controller: AppController,
|
||||
Path(path): Path<LectureNotePath>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
match controller.get_lecture_note_pdf(path.id).await {
|
||||
Ok(pdf) => Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/pdf"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
pdf,
|
||||
)
|
||||
.into_response()),
|
||||
Err(err) => error_response("get_lecture_note_pdf", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_lecture_note_pdf_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Course files")
|
||||
.summary("Get the pdf of a lecture note")
|
||||
.response_with::<404, String, _>(desc(
|
||||
"The note does not exist, or its last rendering failed",
|
||||
))
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use aide::{
|
||||
axum::{
|
||||
ApiRouter,
|
||||
routing::{get_with, put_with},
|
||||
routing::{delete_with, get_with, post_with},
|
||||
},
|
||||
transform::TransformOperation,
|
||||
};
|
||||
|
|
@ -9,32 +9,38 @@ use axum::{Json, extract::Path, http::StatusCode};
|
|||
|
||||
use crate::{
|
||||
api::{
|
||||
helpers::{desc, unexpected_error},
|
||||
models::{CourseBody, CoursePath},
|
||||
},
|
||||
core::{
|
||||
controller::{AppController, ControllerError},
|
||||
models::course::Course,
|
||||
helpers::{desc, error_response},
|
||||
models::{CourseBody, CoursePath, SlotPath, TimeSlotBody},
|
||||
},
|
||||
core::{controller::AppController, models::course::Course},
|
||||
};
|
||||
|
||||
pub fn routes() -> ApiRouter {
|
||||
ApiRouter::new()
|
||||
.api_route(
|
||||
"/",
|
||||
"/api/courses",
|
||||
get_with(get_courses, get_courses_docs).post_with(create_course, create_course_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/{id}",
|
||||
put_with(update_course, update_course_docs)
|
||||
"/api/courses/{id}",
|
||||
get_with(get_course, get_course_docs)
|
||||
.put_with(update_course, update_course_docs)
|
||||
.delete_with(delete_course, delete_course_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/api/courses/{id}/slots",
|
||||
post_with(add_slot, add_slot_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/api/courses/{id}/slots/{slot_id}",
|
||||
delete_with(delete_slot, delete_slot_docs),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_courses(controller: AppController) -> Result<Json<Vec<Course>>, (StatusCode, String)> {
|
||||
match controller.get_courses().await {
|
||||
Ok(courses) => Ok(Json(courses)),
|
||||
Err(err) => unexpected_error("get_courses", err),
|
||||
Err(err) => error_response("get_courses", err),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,20 +49,36 @@ fn get_courses_docs(op: TransformOperation) -> TransformOperation {
|
|||
.summary("Get every course with its time slots")
|
||||
}
|
||||
|
||||
async fn get_course(
|
||||
controller: AppController,
|
||||
Path(path): Path<CoursePath>,
|
||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||
match controller.get_course(path.id).await {
|
||||
Ok(course) => Ok(Json(course)),
|
||||
Err(err) => error_response("get_course", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_course_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Get a course with its time slots")
|
||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||
}
|
||||
|
||||
async fn create_course(
|
||||
controller: AppController,
|
||||
Json(body): Json<CourseBody>,
|
||||
) -> Result<(StatusCode, Json<Course>), (StatusCode, String)> {
|
||||
match controller.create_course(body.into()).await {
|
||||
Ok(course) => Ok((StatusCode::CREATED, Json(course))),
|
||||
Err(ControllerError::Course(err)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
||||
Err(err) => unexpected_error("create_course", err),
|
||||
Err(err) => error_response("create_course", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_course_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Create a course with its time slots")
|
||||
.summary("Create a course, without any time slot")
|
||||
.response_with::<201, Json<Course>, _>(desc("The created course"))
|
||||
.response_with::<400, String, _>(desc("The course is invalid"))
|
||||
}
|
||||
|
||||
|
|
@ -67,17 +89,13 @@ async fn update_course(
|
|||
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||
match controller.update_course(path.id, body.into()).await {
|
||||
Ok(course) => Ok(Json(course)),
|
||||
Err(ControllerError::Course(err)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
||||
Err(err) if err.is_not_found() => {
|
||||
Err((StatusCode::NOT_FOUND, "Course not found".to_owned()))
|
||||
}
|
||||
Err(err) => unexpected_error("update_course", err),
|
||||
Err(err) => error_response("update_course", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_course_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Replace a course and all its time slots")
|
||||
.summary("Replace a course, keeping its time slots")
|
||||
.response_with::<400, String, _>(desc("The course is invalid"))
|
||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||
}
|
||||
|
|
@ -88,16 +106,47 @@ async fn delete_course(
|
|||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
match controller.delete_course(path.id).await {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(err) if err.is_not_found() => {
|
||||
Err((StatusCode::NOT_FOUND, "Course not found".to_owned()))
|
||||
}
|
||||
Err(err) => unexpected_error("delete_course", err),
|
||||
Err(err) => error_response("delete_course", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_course_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Delete a course and its time slots")
|
||||
.summary("Delete a course with its slots, grades, tasks, notes and files")
|
||||
.response_with::<204, (), _>(desc("The course was deleted"))
|
||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||
}
|
||||
|
||||
async fn add_slot(
|
||||
controller: AppController,
|
||||
Path(path): Path<CoursePath>,
|
||||
Json(body): Json<TimeSlotBody>,
|
||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||
match controller.add_slot(path.id, body.into()).await {
|
||||
Ok(course) => Ok(Json(course)),
|
||||
Err(err) => error_response("add_slot", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_slot_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Add a weekly time slot to a course, returns the updated course")
|
||||
.response_with::<400, String, _>(desc("The slot is invalid"))
|
||||
.response_with::<404, String, _>(desc("The course does not exist"))
|
||||
}
|
||||
|
||||
async fn delete_slot(
|
||||
controller: AppController,
|
||||
Path(path): Path<SlotPath>,
|
||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||
match controller.delete_slot(path.id, path.slot_id).await {
|
||||
Ok(course) => Ok(Json(course)),
|
||||
Err(err) => error_response("delete_slot", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_slot_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Courses")
|
||||
.summary("Remove a time slot from a course, returns the updated course")
|
||||
.response_with::<404, String, _>(desc("The slot is not in this course"))
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue