39 Commits
Author SHA1 Message Date
schn33fuchs 04b9ef8f9e Merge pull request 'darkmode' (#41) from darkmode into styles-nino
Reviewed-on: #41
2026-05-28 17:29:23 +02:00
schn33fuchs 778c112dd4 Home button fixed
It is now an icon in the middle of the button instead of a unicode
number
2026-05-28 17:27:36 +02:00
schn33fuchs b9354f77b6 Darkmode implemented
There is now a darkmode button
2026-05-28 17:26:56 +02:00
schn33fuchs 6e836d9260 Some styling 2026-05-26 22:01:50 +02:00
schn33fuchs e096b9144b Final diagram fix
for real this time
2026-05-26 20:47:25 +02:00
schn33fuchs 74a573e38e Diagram fix final 2026-05-26 20:46:48 +02:00
schn33fuchs 7f8afe1938 Rename 2026-05-26 20:34:20 +02:00
schn33fuchs e070d36af9 Diagram fix
hopefully
2026-05-26 20:31:58 +02:00
schn33fuchs d3d0e9b83c Cleanup 2026-05-26 20:29:34 +02:00
schn33fuchs 3000bb0e5d Translation
Everything is now German
2026-05-26 20:20:36 +02:00
schn33fuchs e2cfb61caa Cleanup 2026-05-25 17:43:07 +02:00
schn33fuchs d1576ae8fa For real real 2026-05-22 15:14:16 +02:00
schn33fuchs 8287bea240 Last one for real 2026-05-22 15:13:47 +02:00
schn33fuchs 663f61fa34 Sequence diagram fix
Hopefully the last
2026-05-22 15:11:15 +02:00
schn33fuchs 50c79231bb More readme fixes 2026-05-22 15:09:28 +02:00
schn33fuchs 6950ec1c36 Another fix 2026-05-22 15:06:31 +02:00
schn33fuchs 9402344f77 Diagram update
Sequence diagram fixed
2026-05-22 14:47:03 +02:00
schn33fuchs 2b9aa03932 Sequence diagrams
Generated by antigravity
2026-05-22 14:40:23 +02:00
schn33fuchs 42f2d33a58 Direction 2026-05-22 14:16:38 +02:00
schn33fuchs 8de96aea11 Class diagram 2026-05-22 14:10:13 +02:00
schn33fuchs edb19c5569 README 2026-05-22 14:08:52 +02:00
schn33fuchs a75de3dbef README update 2026-05-22 14:04:45 +02:00
schn33fuchs d27a76ccc7 README update
important note added
2026-05-20 21:11:55 +02:00
schn33fuchs 721e43c380 Refined docs and stuff
Docs link to each other and are generally better
2026-05-20 12:50:00 +02:00
schn33fuchs 0db9b76cad A new env variable
BACKEND_PORT for the port of the backend
2026-05-20 12:49:33 +02:00
schn33fuchs 7f4237a6b7 README update 2026-05-20 12:49:11 +02:00
schn33fuchs d029c5a347 Some improvements 2026-05-16 19:57:17 +02:00
schn33fuchs b1869595ac Fix
Weekday bars now at the bottom of everything
2026-05-16 19:57:04 +02:00
schn33fuchs 0d4a6c4711 favicon 2026-05-16 19:56:47 +02:00
schn33fuchs 726a581a9e Component Fix
easy fix carried over removed now
2026-05-16 19:56:25 +02:00
schn33fuchs e99026e3d6 Styles
Its more beautifull now
2026-05-16 19:54:13 +02:00
schn33fuchs b25e045b99 Styles better 2026-05-16 19:51:08 +02:00
schn33fuchs 99c5aa613c .gitignore 2026-05-13 16:59:32 +02:00
schn33fuchs cf24d6156c Small Fix
Removed unneccesary things from archive
2026-05-12 15:47:35 +02:00
schn33fuchs de2199e1c3 After merge fix
Forgor a &&
2026-05-11 21:23:08 +02:00
schn33fuchs 5199300856 Merge branch 'main' into styles-nino 2026-05-11 21:06:26 +02:00
schn33fuchs 289f83df3b Old logout look
It's better
2026-05-11 20:48:04 +02:00
schn33fuchs 928ca1de11 Improvements
More styling
2026-05-11 19:53:30 +02:00
schn33fuchs 10de47b911 Styling
Copied Ninos styles with copilot
2026-05-11 13:15:03 +02:00
60 changed files with 1765 additions and 2173 deletions
-13
View File
@@ -1,13 +0,0 @@
target/
backend/target/
frontend/target/
frontend/dist/
frontend/node_modules/
dist/
.git/
.idea/
.env
.antigravitycli/
*.pdb
**/*.rs.bk
info ding video.mp4
+1 -4
View File
@@ -21,12 +21,9 @@ frontend/node_modules/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
.antigravitycli/
# Added by cargo
/target
.antigravitycli/
.sqlx/
backend/.sqlx/
dist/
Generated
+6 -465
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -29,12 +23,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "anymap2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
[[package]]
name = "argon2"
version = "0.5.3"
@@ -156,9 +144,7 @@ dependencies = [
"axum-extra",
"chrono",
"dotenv",
"hex",
"jsonwebtoken",
"rand",
"serde",
"serde_json",
"sqlx",
@@ -222,36 +208,18 @@ dependencies = [
"generic-array",
]
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -374,15 +342,6 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -614,15 +573,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
@@ -645,16 +595,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
@@ -706,21 +646,17 @@ dependencies = [
name = "frontend"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"chrono-tz",
"dotenv",
"gloo 0.12.0",
"gloo-net 0.7.0",
"gloo-storage 0.4.0",
"qrcode-generator",
"serde",
"serde_json",
"theme",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew 0.23.0",
"yew",
"yew-router",
]
@@ -859,44 +795,6 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gloo"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d"
dependencies = [
"gloo-console 0.2.3",
"gloo-dialogs 0.1.1",
"gloo-events 0.1.2",
"gloo-file 0.2.3",
"gloo-history 0.1.5",
"gloo-net 0.3.1",
"gloo-render 0.1.1",
"gloo-storage 0.2.2",
"gloo-timers 0.2.6",
"gloo-utils 0.1.7",
"gloo-worker 0.2.1",
]
[[package]]
name = "gloo"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249"
dependencies = [
"gloo-console 0.3.0",
"gloo-dialogs 0.2.0",
"gloo-events 0.2.0",
"gloo-file 0.3.0",
"gloo-history 0.2.2",
"gloo-net 0.4.0",
"gloo-render 0.2.0",
"gloo-storage 0.3.0",
"gloo-timers 0.3.0",
"gloo-utils 0.2.0",
"gloo-worker 0.4.0",
]
[[package]]
name = "gloo"
version = "0.11.0"
@@ -935,19 +833,6 @@ dependencies = [
"gloo-worker 0.6.0",
]
[[package]]
name = "gloo-console"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-console"
version = "0.3.0"
@@ -974,16 +859,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-dialogs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-dialogs"
version = "0.2.0"
@@ -1004,16 +879,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-events"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-events"
version = "0.2.0"
@@ -1034,18 +899,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-file"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7"
dependencies = [
"gloo-events 0.1.2",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-file"
version = "0.3.0"
@@ -1071,22 +924,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-history"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f"
dependencies = [
"gloo-events 0.1.2",
"gloo-utils 0.1.7",
"serde",
"serde-wasm-bindgen 0.5.0",
"serde_urlencoded",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-history"
version = "0.2.2"
@@ -1097,7 +934,7 @@ dependencies = [
"gloo-events 0.2.0",
"gloo-utils 0.2.0",
"serde",
"serde-wasm-bindgen 0.6.5",
"serde-wasm-bindgen",
"serde_urlencoded",
"thiserror 1.0.69",
"wasm-bindgen",
@@ -1114,55 +951,13 @@ dependencies = [
"gloo-events 0.3.0",
"gloo-utils 0.3.0",
"serde",
"serde-wasm-bindgen 0.6.5",
"serde-wasm-bindgen",
"serde_urlencoded",
"thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-net"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.1.7",
"http 0.2.12",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-net"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.2.0",
"http 0.2.12",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-net"
version = "0.5.0"
@@ -1205,16 +1000,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-render"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-render"
version = "0.2.0"
@@ -1235,21 +1020,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-storage"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-storage"
version = "0.3.0"
@@ -1280,16 +1050,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -1312,19 +1072,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
@@ -1351,42 +1098,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-worker"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a"
dependencies = [
"anymap2",
"bincode",
"gloo-console 0.2.3",
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-worker"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400"
dependencies = [
"bincode",
"futures",
"gloo-utils 0.2.0",
"gloo-worker-macros 0.1.0",
"js-sys",
"pinned",
"serde",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-worker"
version = "0.5.0"
@@ -1555,12 +1266,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "html-escape"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e"
[[package]]
name = "http"
version = "0.2.12"
@@ -1785,29 +1490,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
]
[[package]]
name = "implicit-clone"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84"
dependencies = [
"implicit-clone-derive",
"indexmap",
]
[[package]]
name = "implicit-clone"
version = "0.6.0"
@@ -1985,16 +1667,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -2006,16 +1678,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -2332,19 +1994,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2440,46 +2089,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prokio"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488"
dependencies = [
"futures",
"gloo 0.8.1",
"num_cpus",
"once_cell",
"pin-project",
"pinned",
"tokio",
"tokio-stream",
"wasm-bindgen-futures",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qrcode-generator"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0051849b5465059b75f59d388c7318aad6554701b74ecf02afc2573b0306c"
dependencies = [
"html-escape",
"image",
"qrcodegen",
]
[[package]]
name = "qrcodegen"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
[[package]]
name = "quote"
version = "1.0.45"
@@ -2681,17 +2290,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
@@ -2810,12 +2408,6 @@ dependencies = [
"rand_core",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simple_asn1"
version = "0.6.4"
@@ -3145,17 +2737,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "theme"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5379f4c4eb851af44d4d818ba43fac8cff83e9b8e624aedc8a3346ca45065fe"
dependencies = [
"serde",
"web-sys",
"yew 0.21.0",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3878,31 +3459,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yew"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac"
dependencies = [
"console_error_panic_hook",
"futures",
"gloo 0.10.0",
"implicit-clone 0.4.9",
"indexmap",
"js-sys",
"prokio",
"rustversion",
"serde",
"slab",
"thiserror 1.0.69",
"tokio",
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew-macro 0.21.0",
]
[[package]]
name = "yew"
version = "0.23.0"
@@ -3912,7 +3468,7 @@ dependencies = [
"console_error_panic_hook",
"futures",
"gloo 0.11.0",
"implicit-clone 0.6.0",
"implicit-clone",
"indexmap",
"js-sys",
"rustversion",
@@ -3925,22 +3481,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew-macro 0.23.0",
]
[[package]]
name = "yew-macro"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2"
dependencies = [
"boolinator",
"once_cell",
"prettyplease",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.117",
"yew-macro",
]
[[package]]
@@ -3972,7 +3513,7 @@ dependencies = [
"urlencoding",
"wasm-bindgen",
"web-sys",
"yew 0.23.0",
"yew",
"yew-router-macro",
]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Silas Raffauf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -8
View File
@@ -45,19 +45,14 @@ location /api/ {
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation:
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
I've gone over most of it and modified it to my needs and opinions
+3 -24
View File
@@ -30,12 +30,8 @@ Keys:
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
Alternatively download the executable from the 'Releases' Page for your device
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting WASM
Alternatively download the fontend files from the 'Releases' Page and serve them via any webserver supporting WASM
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting wasm
> [!NOTE]
> To install trunk run `cargo install trunk`
@@ -48,9 +44,6 @@ Alternatively download the fontend files from the 'Releases' Page and serve them
> }
> ```
### Example
[![Watch the video]()](https://git.raffauf-clan.de/schn33fuchs/ticketsystem/raw/branch/main/info%20ding%20video.mp4)
## Diagrams
### Class Diagramm
#### Backend
@@ -255,10 +248,6 @@ classDiagram
class SidebarShellProps {
+children: Children
}
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps {
+children: Children
}
@@ -266,7 +255,6 @@ classDiagram
RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
```
@@ -363,15 +351,6 @@ sequenceDiagram
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Update ticket status in UI
Note over Admin, DB: Ticket Archiving Flow (Admin Only Route)
Admin->>FE: Open Archived Tickets page
FE->>BE: GET /api/tickets/archive (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & admin role
BE->>DB: SELECT * FROM tickets WHERE status = 'Archived'
DB-->>BE: Return archived tickets
BE-->>FE: HTTP 200 OK [Archived Tickets]
FE-->>Admin: Render historical archive board
```
## Usage of AI
@@ -387,6 +366,6 @@ Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
I've gone over most of it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
The sequence Diagrams above
+1 -3
View File
@@ -8,7 +8,7 @@ axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie", "typed-header", "form"] }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono", "migrate"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono"] }
tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] }
dotenv = "0.15.0"
chrono = { workspace = true }
@@ -17,5 +17,3 @@ argon2 = "0.5.3"
time = "0.3.47"
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["cors"] }
rand = '0.8'
hex = '0.4'
@@ -6,3 +6,9 @@ CREATE TABLE users (
pwd TEXT,
is_admin BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO users(last_name, first_name, username, pwd, is_admin) VALUES ('Raffauf', 'Silas', 'raffaufsil',
'$argon2id$v=19$m=19456,t=2,p=1$urV91vY49mehWQoShkm6pw$1AGLuMDuFnY1CKuQU9ed89s1u9kvuszQjX+eIXmCBKI', true);
INSERT INTO users(last_name, first_name, username, pwd, is_admin) VALUES ('Misun', 'Sam', 'misunsam',
'$argon2id$v=19$m=19456,t=2,p=1$AMmy1zMOQG8Wg1jE5BXhDg$xhYIZPAPPOEoKQMtzWll/uhG8YJQvcpHKQU2++L23o4', true);
@@ -1 +0,0 @@
DROP TABLE qr_codes;
@@ -1,8 +0,0 @@
CREATE TABLE qr_codes (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id SMALLINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_qr_token_hash ON qr_codes(token_hash);
+2 -2
View File
@@ -43,7 +43,7 @@ pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String {
expires: expires as usize,
};
let token = encode(header, &claims, key);
return token.expect("token return failed");
token.expect("token return failed")
}
/// Decodes and validates a JSON Web Token (JWT).
@@ -77,5 +77,5 @@ pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusC
(StatusCode::UNAUTHORIZED, Json(error))
})?
.claims;
return Ok(claims);
Ok(claims)
}
+4 -16
View File
@@ -48,13 +48,7 @@ pub async fn validate_token(
.headers()
.get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
.and_then(|value| value.strip_prefix("Bearer ").map(|s| s.to_owned()))
});
let token = token.ok_or_else(|| {
@@ -77,7 +71,7 @@ pub async fn validate_token(
(status, Json(error))
})?;
let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let uuid = claims.sub.parse::<i16>().map_err(|_| {
let error = json!({
"status": "error",
"message": "Invalid user id"
@@ -143,13 +137,7 @@ pub async fn validate_admin(
.headers()
.get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
.and_then(|value| value.strip_prefix("Bearer ").map(|s| s.to_owned()))
});
let token = token.ok_or_else(|| {
@@ -172,7 +160,7 @@ pub async fn validate_admin(
(status, Json(error))
})?;
let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let uuid = claims.sub.parse::<i16>().map_err(|_| {
let error = json!({
"status": "error",
"message": "Invalid user id"
+4
View File
@@ -21,6 +21,8 @@ pub struct Env {
pub token_secret: String,
/// Frontend origin URL for CORS policy
pub origin: String,
/// Backend port number
pub backend_port: String,
}
impl Env {
@@ -45,10 +47,12 @@ impl Env {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set");
let origin = std::env::var("ORIGIN").expect("ORIGIN must be set");
let backend_port = std::env::var("BACKEND_PORT").expect("BACKEND_PORT must be set");
Env {
db_url,
token_secret,
origin,
backend_port,
}
}
}
+17 -28
View File
@@ -60,7 +60,7 @@ pub async fn create_user(
)
})?;
if let Some(_) = exist_check {
if exist_check.is_some() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "user already exists"})),
@@ -90,10 +90,10 @@ pub async fn create_user(
})?;
if user.rows_affected() < 1 {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating user"})),
));
))
} else {
Ok(Json(json!({"status": "success", "result": "User created"})))
}
@@ -151,7 +151,7 @@ pub async fn login(
let pwd_hash = PasswordHash::new(&user.pwd);
let valid_pwd = Argon2::default()
.verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap())
.verify_password(request.pwd.as_bytes(), &pwd_hash.unwrap())
.is_ok();
if !valid_pwd {
@@ -268,7 +268,7 @@ pub async fn get_current_user(
/// - `404 Not Found` if user doesn't exist
/// - `500 Internal Server Error` if database error occurs
pub async fn delete_user(
Path(id): Path<i16>,
Path(id): Path<i32>,
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
@@ -340,7 +340,7 @@ pub async fn get_users(
let response = users
.iter()
.map(|user| filter_user(&user))
.map(filter_user)
.collect::<Vec<FilteredUser>>();
let json_respnse = json!(response);
Ok(Json(json_respnse))
@@ -372,22 +372,22 @@ pub async fn get_user_by_id(
match query {
Ok(user) => {
let response = serde_json::json!(filter_user(&user));
return Ok(Json(response));
Ok(Json(response))
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("User with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
Err((StatusCode::NOT_FOUND, Json(error_response)))
}
Err(e) => {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
));
))
}
}
};
}
/// Updates an existing user's information.
@@ -411,11 +411,10 @@ pub async fn get_user_by_id(
/// - This endpoint requires admin privileges (enforced by middleware via
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
pub async fn update_user(
Path(id): Path<i16>,
Path(id): Path<i32>,
State(data): State<Arc<AppState>>,
Json(body): Json<UserUpdateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let update_result = if !body.new_pwd.is_empty() {
let argon = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) {
@@ -423,25 +422,15 @@ pub async fn update_user(
Err(e) => panic!("Error hashing {:}", e),
};
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
let update_result = sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
.bind(body.first_name.to_owned())
.bind(body.last_name.to_owned())
.bind(body.username.to_owned())
.bind(hashed_pwd)
.bind(&hashed_pwd)
.bind(body.make_admin.to_owned())
.bind(id)
.execute(&data.db)
.await
} else {
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, is_admin = $4 WHERE id = $5"#)
.bind(body.first_name.to_owned())
.bind(body.last_name.to_owned())
.bind(body.username.to_owned())
.bind(body.make_admin.to_owned())
.bind(id)
.execute(&data.db)
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -583,10 +572,10 @@ pub async fn setup_initial_admin(
})?;
if user.rows_affected() < 1 {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating admin user"})),
));
))
} else {
Ok(Json(
json!({"status": "success", "result": "Admin user created"}),
@@ -627,6 +616,6 @@ pub fn filter_user(user: &User) -> FilteredUser {
first_name: user.first_name.clone(),
last_name: user.last_name.clone(),
username: user.username.clone(),
is_admin: user.is_admin.clone(),
is_admin: user.is_admin,
}
}
-1
View File
@@ -3,5 +3,4 @@
//! It serves as a central point for managing the logic that responds to various
//! HTTP requests, categorizing handlers by their domain (e.g., authentication, tickets).
pub mod auth;
pub mod qr;
pub mod ticket;
-130
View File
@@ -1,130 +0,0 @@
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use axum::{
Json,
extract::{Query, State},
http::{StatusCode, header::SET_COOKIE},
response::{Html, IntoResponse, Response},
};
use jsonwebtoken::{EncodingKey, Header};
use rand::{RngCore, thread_rng};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::{AppState, cookie::jwt::encode_token};
#[derive(Serialize)]
pub struct QrCodeResponse {
pub raw_token: String,
}
#[derive(Serialize, Deserialize)]
pub struct AuthQuery {
pub token: String,
}
#[derive(Deserialize)]
pub struct GenerateQrRequest {
pub user_id: i16,
}
pub async fn generate_qr_token(
State(data): State<Arc<AppState>>,
Json(payload): Json<GenerateQrRequest>,
) -> Result<impl IntoResponse, StatusCode> {
let mut token_bytes = [0u8; 32];
thread_rng().fill_bytes(&mut token_bytes);
let raw_token = hex::encode(token_bytes);
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let token_hash = argon2
.hash_password(raw_token.as_bytes(), &salt)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.to_string();
sqlx::query("INSERT INTO qr_codes (user_id, token_hash) VALUES ($1, $2)")
.bind(payload.user_id)
.bind(token_hash)
.execute(&data.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(QrCodeResponse { raw_token })))
}
pub async fn validate_qr_token(
State(data): State<Arc<AppState>>,
Query(params): Query<AuthQuery>,
) -> Response {
let active_tokens_res = sqlx::query("SELECT user_id, token_hash FROM qr_codes")
.fetch_all(&data.db)
.await;
let active_tokens = match active_tokens_res {
Ok(tokens) => tokens,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let mut authenticated_user_id: Option<i16> = None;
let argon2 = Argon2::default();
for record in active_tokens {
let user_id: i16 = record.get("user_id");
let token_hash: String = record.get("token_hash");
if let Ok(parsed_hash) = PasswordHash::new(&token_hash) {
if argon2
.verify_password(params.token.as_bytes(), &parsed_hash)
.is_ok()
{
authenticated_user_id = Some(user_id);
break;
}
}
}
if let Some(user_id) = authenticated_user_id {
let base_origin = data.env.origin.trim_end_matches('/');
let header = Header::default();
let id_str = user_id.to_string();
let secret_bytes = data.env.token_secret.as_bytes();
let encoding_key = EncodingKey::from_secret(secret_bytes);
let token = encode_token(&header, id_str, &encoding_key);
let cookie_value = format!(
"token={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600",
token
);
let redirect_html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; url={}/" />
<script type="text/javascript">
window.location.href = "{}/"
</script>
<title>Redirecting...</title>
</head>
<body>
<p>Authenticated! Redirecting you to the dashboard...</p>
</body>
</html>"#,
base_origin, base_origin
);
let mut response = Html(redirect_html).into_response();
if let Ok(header_value) = axum::http::HeaderValue::from_str(&cookie_value) {
response.headers_mut().insert(SET_COOKIE, header_value);
}
response
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
+5 -5
View File
@@ -230,22 +230,22 @@ pub async fn get_ticket_by_id(
user_last_name: row.get("last_name"),
};
let response = serde_json::json!(ticket_response);
return Ok(Json(response));
Ok(Json(response))
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Ticket with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
Err((StatusCode::NOT_FOUND, Json(error_response)))
}
Err(e) => {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
));
))
}
}
};
}
/// Updates a ticket's status.
+5 -14
View File
@@ -32,10 +32,8 @@ use crate::env::Env;
/// - `db`: PostgreSQL connection pool for database access (via `sqlx::PgPool`)
/// - `env`: [`Env`] configuration loaded from environment variables
pub struct AppState {
/// PostgreSQL connection pool for all database operations
pub db: PgPool,
/// Environment configuration with secrets and settings
pub env: Env,
db: PgPool,
env: Env,
}
/// Main application entry point.
@@ -66,7 +64,7 @@ async fn main() {
let database_url = &env.db_url;
// Establish connection pool to PostgreSQL
let pool = match PgPoolOptions::new().connect(&database_url).await {
let pool = match PgPoolOptions::new().connect(database_url).await {
Ok(pool) => {
println!("Database connection successful");
pool
@@ -77,14 +75,6 @@ async fn main() {
}
};
// Run database migrations on container/application startup
println!("Running database migrations...");
if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await {
println!("Failed to run database migrations: {:?}", err);
std::process::exit(1);
}
println!("Database migrations completed successfully");
// Configure CORS to allow requests from frontend
let cors = CorsLayer::new()
.allow_origin(env.origin.parse::<HeaderValue>().unwrap())
@@ -100,6 +90,7 @@ async fn main() {
.layer(cors);
// Start listening for incoming connections
let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap();
let uri = format!("0.0.0.0:{}", env.backend_port);
let listener = tokio::net::TcpListener::bind(&uri).await.unwrap();
let _ = axum::serve(listener, app).await;
}
-39
View File
@@ -33,25 +33,15 @@ use serde::{Deserialize, Serialize};
/// ```
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct TicketResponse {
/// Unique ticket identifier
pub id: i32,
/// Ticket category/type (e.g., "maintenance", "support")
pub category: String,
/// Ticket subject line
pub betreff: String,
/// Detailed ticket description
pub description: String,
/// Room number associated with the issue
pub room: i16,
/// Current ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String,
/// When the ticket was created (UTC timestamp)
pub date: chrono::DateTime<chrono::Utc>,
/// ID of the user who created the ticket
pub user_id: i16,
/// First name of the ticket creator (denormalized from `User`)
pub user_first_name: String,
/// Last name of the ticket creator (denormalized from `User`)
pub user_last_name: String,
}
@@ -72,17 +62,11 @@ pub struct TicketResponse {
/// Use [`filter_user()`](`crate::handlers::auth::filter_user`) to convert to [`FilteredUser`] for responses.
#[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)]
pub struct User {
/// Unique user identifier
pub id: i16,
/// User's last name
pub last_name: String,
/// User's first name
pub first_name: String,
/// Unique login username (must be unique in the database)
pub username: String,
/// Whether this user has administrator privileges
pub is_admin: bool,
/// Argon2 password hash (NEVER expose to clients)
pub pwd: String,
}
@@ -98,13 +82,9 @@ pub struct User {
/// - `room`: Room number where the issue is located
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketCreateScheme {
/// Ticket category/type
pub category: String,
/// Subject line for the ticket
pub betreff: String,
/// Detailed problem description
pub description: String,
/// Room number where the issue is located
pub room: i16,
}
@@ -117,7 +97,6 @@ pub struct TicketCreateScheme {
/// - `status`: New ticket status (e.g., "open", "in_progress", "resolved")
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketUpdateScheme {
/// New ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String,
}
@@ -134,17 +113,11 @@ pub struct TicketUpdateScheme {
/// - `new_pwd`: New password (empty string = keep existing password)
#[derive(Deserialize, Serialize, Debug)]
pub struct UserUpdateScheme {
/// User ID to update
pub id: i16,
/// Updated user first name
pub first_name: String,
/// Updated user last name
pub last_name: String,
/// Updated login username
pub username: String,
/// New admin privilege status
pub make_admin: bool,
/// New password (empty string = keep existing password)
pub new_pwd: String,
}
@@ -161,15 +134,10 @@ pub struct UserUpdateScheme {
/// - `pwd`: Plain text password (hashed on server)
#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)]
pub struct UserCreateScheme {
/// User's first name
pub first_name: String,
/// User's last name
pub last_name: String,
/// Unique username for login
pub username: String,
/// Whether to grant admin privileges
pub is_admin: bool,
/// Plain text password (hashed on server before storage)
pub pwd: String,
}
@@ -182,9 +150,7 @@ pub struct UserCreateScheme {
/// The password is never stored in plain text - only the Argon2 hash is persisted.
#[derive(Deserialize, Serialize, Debug)]
pub struct LoginScheme {
/// Username for login
pub username: String,
/// Plain text password (verified against stored Argon2 hash)
pub pwd: String,
}
@@ -195,15 +161,10 @@ pub struct LoginScheme {
/// to prevent leaking sensitive data.
#[derive(Debug, Clone, Serialize)]
pub struct FilteredUser {
/// Unique user identifier
pub id: i16,
/// User's first name
pub first_name: String,
/// User's last name
pub last_name: String,
/// Login username
pub username: String,
/// Whether user has admin privileges
pub is_admin: bool,
}
+5 -23
View File
@@ -1,10 +1,7 @@
use std::sync::Arc;
use axum::{
Router,
http::StatusCode,
middleware,
response::IntoResponse,
Router, middleware,
routing::{get, post},
};
@@ -13,22 +10,13 @@ use crate::{
cookie::validation::{validate_admin, validate_token},
handlers::{
auth::{
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id,
get_users, login, logout, setup_initial_admin, update_user,
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, get_users, login, logout,
setup_initial_admin, update_user,
},
qr::{generate_qr_token, validate_qr_token},
ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets},
},
};
async fn debug_fallback_handler(uri: axum::http::Uri) -> impl IntoResponse {
println!(
"[BACKEND DEBUG] Axum Fallback triggered! Unmatched request path: {}",
uri
);
(StatusCode::NOT_FOUND, "Backend route not found")
}
/// Creates the complete router with all API endpoints.
///
/// The router is organized in layers for proper middleware application. Uses [`AppState`]
@@ -75,7 +63,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/api/users/{id}",
get(get_user_by_id).delete(delete_user).patch(update_user),
)
.route("/api/generate_qr", post(generate_qr_token))
.layer(middleware::from_fn_with_state(
state.clone(),
validate_admin,
@@ -92,15 +79,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
validate_token,
));
let public_routes = Router::new()
Router::new()
.merge(protected_routes)
.route("/api/login", post(login))
.route("/api/check-admin", get(check_admin_exists))
.route("/api/setup-admin", post(setup_initial_admin))
.route("/api/qr_login", get(validate_qr_token));
Router::new()
.merge(protected_routes)
.merge(public_routes)
.fallback(debug_fallback_handler)
.with_state(state)
}
+14
View File
@@ -0,0 +1,14 @@
services:
postgres:
image: postgres:latest
container_name: postgres_ticket
restart: unless-stopped
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=tickets
volumes:
- pg_data:/var/lib/postgresql/pg_data
volumes:
pg_data:
-127
View File
@@ -1,127 +0,0 @@
# ============================================================
# Dockerfile — Full application (frontend + backend)
# ============================================================
# Multi-stage build:
# 1. chef install cargo-chef for dependency caching
# 2. planner generate recipe.json from the workspace
# 3. backend build the backend binary (release)
# 4. frontend build the frontend WASM bundle with Trunk
# 5. runtime minimal image: backend binary + static frontend + nginx
# ============================================================
# --------------- Stage 1: Chef base ---------------
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# --------------- Stage 2: Planner ---------------
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo chef prepare --recipe-path recipe.json
# --------------- Stage 3: Backend builder ---------------
FROM chef AS backend-builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p backend
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo build --release -p backend
# --------------- Stage 4: Frontend builder ---------------
FROM chef AS frontend-builder
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
# Install Trunk (the WASM bundler used by the frontend) and sass (for SCSS)
RUN cargo install trunk
# Cache frontend dependencies
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --target wasm32-unknown-unknown --recipe-path recipe.json -p frontend
# Copy full workspace source for the build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
# Build the frontend WASM bundle
# Trunk outputs to frontend/dist by default
WORKDIR /app/frontend
RUN trunk build --release
# --------------- Stage 5: Runtime ---------------
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies:
# - ca-certificates & libssl3: TLS for PostgreSQL connections
# - nginx: serves the frontend static files and reverse-proxies /api to the backend
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libssl3 nginx && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled backend binary
COPY --from=backend-builder /app/target/release/backend /app/backend
# Copy migrations
COPY backend/migrations/ /app/migrations/
# Copy the frontend static build output
COPY --from=frontend-builder /app/frontend/dist /var/www/html
# Nginx configuration: serve frontend + reverse-proxy /api to the backend
RUN cat > /etc/nginx/sites-available/default <<'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
# Serve static frontend files, fall back to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Reverse-proxy API requests to the Axum backend
location /api/ {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
# Startup script: launch backend in the background, then nginx in the foreground
RUN cat > /app/start.sh <<'SCRIPT'
#!/bin/bash
set -e
# Start the backend API server in the background
/app/backend &
# Start nginx in the foreground (keeps the container alive)
nginx -g 'daemon off;'
SCRIPT
RUN chmod +x /app/start.sh
EXPOSE 80
# Environment variables should be provided at runtime:
# DATABASE_URL PostgreSQL connection string
# TOKEN_SECRET JWT signing key
# ORIGIN Allowed CORS origin (should be the public URL of this container)
CMD ["/app/start.sh"]
-66
View File
@@ -1,66 +0,0 @@
# ============================================================
# Dockerfile.backend — Backend only
# ============================================================
# Multi-stage build:
# 1. chef install cargo-chef for dependency caching
# 2. planner generate a recipe.json from the workspace
# 3. builder build dependencies first (cached), then the backend binary
# 4. runtime minimal image with just the compiled binary
# ============================================================
# --------------- Stage 1: Chef base ---------------
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# --------------- Stage 2: Planner ---------------
FROM chef AS planner
# Copy the full workspace so cargo-chef can resolve all dependencies
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo chef prepare --recipe-path recipe.json
# --------------- Stage 3: Builder ---------------
FROM chef AS builder
# Cache dependency compilation in its own layer
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p backend
# Now copy the real source and build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo build --release -p backend
# --------------- Stage 4: Runtime ---------------
FROM debian:bookworm-slim AS runtime
# Install TLS root certificates and PostgreSQL client libs (needed by sqlx
# at runtime for TLS connections via native-tls)
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libssl3 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled binary
COPY --from=builder /app/target/release/backend /app/backend
# Copy migrations so they can be run at startup if needed
COPY backend/migrations/ /app/migrations/
EXPOSE 8001
# Environment variables should be provided at runtime via docker run --env
# or docker compose environment/env_file.
#
# Required:
# DATABASE_URL PostgreSQL connection string
# TOKEN_SECRET JWT signing key
# ORIGIN Allowed CORS origin (frontend URL)
CMD ["/app/backend"]
-58
View File
@@ -1,58 +0,0 @@
services:
postgres:
image: postgres:latest
container_name: postgres_ticket
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: tickets
POSTGRES_DB: system_data
volumes:
- pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
# Full application (frontend + backend + nginx)
# Usage: docker compose --profile full up --build
app:
profiles: ["full"]
build:
context: ..
dockerfile: docker/Dockerfile
container_name: ticketsystem
restart: unless-stopped
ports:
- 8080:80
environment:
DATABASE_URL: postgres://postgres:tickets@postgres:5432/system_data
TOKEN_SECRET: 160257c8c5ff2298363529e963a5901d2efa6d6ae67d634487c4d2bbc41c533f
ORIGIN: http://localhost:8080
depends_on:
postgres:
condition: service_healthy
# Backend only (API server)
# Usage: docker compose --profile backend up --build
backend:
profiles: ["backend"]
build:
context: ..
dockerfile: docker/Dockerfile.backend
container_name: ticketsystem-backend
restart: unless-stopped
ports:
- 8001:8001
environment:
DATABASE_URL: postgres://postgres:tickets@postgres:5432/system_data
TOKEN_SECRET: 160257c8c5ff2298363529e963a5901d2efa6d6ae67d634487c4d2bbc41c533f
ORIGIN: http://localhost:8000
depends_on:
postgres:
condition: service_healthy
volumes:
pg_data:
+2 -5
View File
@@ -16,7 +16,8 @@ serde = { workspace = true }
wasm-bindgen-futures = "0.4.70"
web-sys = { version = "0.3.95", features = [
"Window","Document","Request","Response","Headers", "HtmlSelectElement", "RequestCredentials",
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent"
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent",
"Element", "MediaQueryList", "DomTokenList"
] }
gloo-net = "0.7.0"
gloo-storage = "0.4.0"
@@ -26,7 +27,3 @@ gloo = "0.12.0"
chrono = { workspace = true }
chrono-tz = "0.10.4"
wasm-bindgen = "0.2.120"
theme = { version = "0.0.3", features = ["yew"] }
qrcode-generator = "5.0.0"
base64 = '0.22'
dotenv = '0.15.0'
+1 -1
View File
@@ -1,6 +1,6 @@
[serve]
# The address to serve on LAN.
addresses = ["127.0.0.1"] #,"10.150.9.7"]
addresses = ["127.0.0.1"] #,"10.150.9.116"]
# The address to serve on WAN.
# address = "0.0.0.0"
# The port to serve on.
+1 -2
View File
@@ -5,9 +5,8 @@
<link data-trunk rel="rust" data-bin="bin" />
<link data-trunk rel="scss" href="src/styles/main.scss" />
<link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
<link data-trunk rel="copy-dir" href="src/assets" data-target-path="assets" />
<meta charset="utf-8" />
<title>Yew App</title>
<title>Ticket System CSG</title>
</head>
<body></body>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+2 -2
View File
@@ -109,7 +109,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
AuthState {
is_authenticated: None,
..
} => html! { <div>{ "Wird geladen..." } </div> },
} => html! { <div>{ "Lade..." } </div> },
AuthState {
is_authenticated: Some(false),
..
@@ -126,7 +126,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
Some(false) => {
html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> }
}
None => html! { <div>{ "Überprüfe Berechtigungen..." }</div> },
None => html! { <div>{ "Berechtigungen werden geprüft..." }</div> },
}
} else {
props.children.clone().into()
+65
View File
@@ -0,0 +1,65 @@
use gloo_storage::{LocalStorage, Storage};
use web_sys::window;
use yew::prelude::*;
#[function_component(DarkModeToggle)]
pub fn dark_mode_toggle() -> Html {
// 1. Initialize state from LocalStorage or system preference
let is_dark = use_state(|| {
LocalStorage::get::<bool>("dark-mode").unwrap_or_else(|_| {
// Fallback to system preference if not set
window()
.and_then(|w| w.match_media("(prefers-color-scheme: dark)").ok().flatten())
.map(|m| m.matches())
.unwrap_or(false)
})
});
// 2. Synchronize the class on the body when the state changes
{
let is_dark = is_dark.clone();
use_effect_with(is_dark, |is_dark| {
if let Some(win) = window() {
if let Some(doc) = win.document() {
if let Some(body) = doc.body() {
if **is_dark {
let _ = body.class_list().add_1("theme-dark");
let _ = body.class_list().remove_1("theme-light");
let _ = LocalStorage::set("dark-mode", true);
} else {
let _ = body.class_list().add_1("theme-light");
let _ = body.class_list().remove_1("theme-dark");
let _ = LocalStorage::set("dark-mode", false);
}
}
}
}
|| ()
});
}
// 3. Toggle action
let onclick = {
let is_dark = is_dark.clone();
Callback::from(move |_| {
is_dark.set(!*is_dark);
})
};
html! {
<button class="dark-mode-toggle" {onclick}>
if *is_dark {
// Sun Icon for switching to light mode
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
} else {
// Moon Icon for switching to dark mode
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
}
</button>
}
}
-78
View File
@@ -1,78 +0,0 @@
use yew::prelude::*;
/// A button component that toggles between light and dark theme modes.
///
/// Clicking this button switches the application's theme by modifying the `data-theme` attribute
/// on the document element. The theme preference is applied via CSS variables for consistent styling.
///
/// # Behavior
/// - Reads the current `data-theme` attribute value from the HTML element
/// - Sets `data-theme="dark"` if currently in light mode
/// - Clears the attribute (light mode) if currently in dark mode
/// - Re-renders to reflect the visual changes (typically toggled via CSS)
///
/// # Example Usage
/// ```ignore
/// html! {
/// <ThemeToggle />
/// }
/// ```
#[function_component]
pub fn ThemeToggle() -> Html {
let is_dark = use_state(|| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
return html.get_attribute("data-theme").unwrap_or_default() == "dark";
}
}
}
false
});
let onclick = {
let is_dark = is_dark.clone();
Callback::from(move |_| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
let current = html.get_attribute("data-theme").unwrap_or_default();
let new_theme = if current == "dark" { "" } else { "dark" };
let _ = html.set_attribute("data-theme", new_theme);
is_dark.set(new_theme == "dark");
}
}
}
})
};
let icon_html = if *is_dark {
// Sun SVG for dark mode
html! {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
}
} else {
// Moon SVG for light mode
html! {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
}
};
html! {
<button {onclick} class="sidebar-button theme-toggle-btn">
{icon_html}
</button>
}
}
+6 -50
View File
@@ -1,6 +1,6 @@
mod auth;
mod darkmode;
mod pages;
mod dark_mode;
use crate::auth::ProtectedRoute;
use crate::pages::*;
use gloo_net::http::Request;
@@ -70,15 +70,8 @@ pub struct SidebarShellProps {
/// This component is designed to wrap page-specific content, ensuring that the sidebar
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
///
/// # Mobile Support
/// On mobile displays, the sidebar is hidden by default and can be toggled:
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
/// - **Dark Mode Toggle**: Floating button to change theme.
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
///
/// # Components
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state.
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component.
/// - Main content area: Renders the `children` passed to this component.
///
/// # Example
@@ -91,46 +84,9 @@ pub struct SidebarShellProps {
/// ```
#[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html {
let route = use_route::<Route>();
let mobile_sidebar_open = use_state(|| false);
// Close mobile sidebar automatically on any route transition
{
let mobile_sidebar_open = mobile_sidebar_open.clone();
use_effect_with(route, move |_| {
mobile_sidebar_open.set(false);
|| ()
});
}
let on_open = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
};
let on_close = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
};
let on_close_click = {
let on_close = on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
};
html! {
<div class="layout">
<button class="mobile-menu-toggle" onclick={on_open}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
</button>
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
<sidebar::Sidebar/>
<main class="content">
{ for props.children.iter() }
</main>
@@ -192,10 +148,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
}
match *admin_exists {
None => html! { <div>{ "Wird geladen..." }</div> },
None => html! { <div>{ "Lade..." }</div> },
Some(false) => {
navigator.push(&Route::Setup);
html! { <div>{ "Wird weitergeleitet zur Einrichtung..." }</div> }
html! { <div>{ "Leite weiter zur Einrichtung..." }</div> }
}
Some(true) => props.children.clone().into(),
}
@@ -316,7 +272,7 @@ fn switch(route: Route) -> Html {
pub fn app() -> Html {
html! {
<BrowserRouter>
<basic_pages::Icon/>
<dark_mode::DarkModeToggle />
<Switch<Route> render={switch} />
</BrowserRouter>
}
-4
View File
@@ -1,9 +1,5 @@
use frontend::App;
/// Main entry point for the Yew frontend application.
///
/// Initializes the Yew framework and renders the root [`App`] component into the DOM.
/// This function is typically called automatically by the WebAssembly runtime.
fn main() {
yew::Renderer::<App>::new().render();
}
+22 -18
View File
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
#[macro_export]
/// Removes surrounding double quotes from a string.
///
/// This macro takes an expression that evaluates to a string and returns a new `String`
@@ -71,7 +72,7 @@ pub fn home_component() -> Html {
);
name.set(name_value);
}
_ => name.set("Unknown".to_string()),
_ => name.set("Unbekannt".to_string()),
}
});
|| ()
@@ -79,11 +80,15 @@ pub fn home_component() -> Html {
}
html! {
<div class="rundbg home">
<div class="form-container home">
<div class="page-header">
<h1>{ "Willkommen" }</h1>
</div>
<crate::utilities::TicketCount/>
<p class="login-status">{ "Sie sind angemeldet als: " }</p>
<p class="name">{ &*name }</p>
<div>
<p>{ "Sie sind angemeldet als: " }</p>
<p class="text-muted">{ &*name }</p>
</div>
</div>
}
}
@@ -103,9 +108,12 @@ pub fn home_component() -> Html {
pub fn not_found_component() -> Html {
let message = "404 Nicht gefunden";
html! {
<div>
<div class="form-container">
<div class="empty-state">
<h1>{&message}</h1>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
<p>{ "Die von Ihnen gesuchte Seite existiert nicht." }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
}
}
@@ -125,17 +133,13 @@ pub fn not_found_component() -> Html {
#[component(PermissionDenied)]
pub fn denied_component() -> Html {
html! {
<div>
<h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1>
<h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
<div class="form-container">
<div class="empty-state">
<h1>{ "Zugriff verweigert" }</h1>
<p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p>
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
}
}
#[component(Icon)]
pub fn icon_component() -> Html {
html! {
<img src="/assets/csg.png" class="csg-icon" />
}
}
-1
View File
@@ -6,7 +6,6 @@
//! By re-exporting them here, other parts of the application can import page
//! components more conveniently using `crate::pages::*`.
pub mod basic_pages;
pub mod qr;
pub mod setup;
pub mod sidebar;
pub mod ticket;
-118
View File
@@ -1,118 +0,0 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use yew::prelude::*;
#[derive(Deserialize, Clone)]
struct QrResponse {
raw_token: String,
}
#[derive(Properties, PartialEq)]
pub struct QrManagerProps {
pub user_id: i16,
}
#[derive(Serialize)]
pub struct QrPayload {
user_id: i16,
}
#[function_component(QrManager)]
pub fn qr_manager(props: &QrManagerProps) -> Html {
let qr_image_data = use_state(|| None::<String>);
let error_message = use_state(|| None::<String>);
let user_id = props.user_id;
let on_generate_click = {
let qr_image_data = qr_image_data.clone();
let error_message = error_message.clone();
Callback::from(move |_| {
let qr_image_data = qr_image_data.clone();
let error_message = error_message.clone();
wasm_bindgen_futures::spawn_local(async move {
let payload = QrPayload { user_id };
match Request::post("/api/generate_qr")
.json(&payload)
.unwrap()
.send()
.await
{
Ok(response) if response.ok() => {
if let Ok(data) = response.json::<QrResponse>().await {
// Point scanned link to the public login endpoint with the token
let target_url = format!(
"{}/api/qr_login?token={}",
std::env::var("ORIGIN")
.ok()
.unwrap_or("http://localhost:8000".to_string()),
data.raw_token
);
match qrcode_generator::to_png_to_vec(
target_url,
qrcode_generator::QrCodeEcc::Medium,
250,
) {
Ok(png_bytes) => {
let base64_png = format!(
"data:image/png;base64,{}",
STANDARD.encode(&png_bytes)
);
qr_image_data.set(Some(base64_png));
error_message.set(None);
}
Err(_) => {
error_message
.set(Some("Failed to render QR graphic.".to_string()));
}
}
}
}
_ => {
error_message.set(Some(
"Failed to generate token. Are you logged in as an admin?".to_string(),
));
}
}
});
})
};
html! {
<div style="text-align: center; padding: 15px; border: 1px dashed #ccc; border-radius: 6px;">
<h4>{"Permanent Login Access"}</h4>
<p style="font-size: 0.85em; color: #555;">
{ format!("Generate a permanent bypass login QR code for User #{}", user_id) }
</p>
<button
onclick={on_generate_click}
style="padding: 8px 16px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;"
>
{"Generate QR Code"}
</button>
{ if let Some(ref err) = *error_message {
html! { <p style="color: #dc3545; margin-top: 10px; font-size: 0.9em;">{err}</p> }
} else {
html! {}
}}
{ if let Some(ref qr_src) = *qr_image_data {
html! {
<div style="margin-top: 15px;">
<img src={qr_src.clone()} alt="Login QR Code" style="border: 1px solid #ddd; padding: 8px; background: white;" />
<p style="font-size: 0.8em; color: #dc3545; font-weight: bold; margin-top: 8px;">
{"Warn the user to keep this QR code secure!"}
</p>
</div>
}
} else {
html! {}
}}
</div>
}
}
+8 -10
View File
@@ -20,7 +20,6 @@ pub struct AdminSetupScheme {
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
pub pwd: String,
}
@@ -68,7 +67,7 @@ pub fn initial_admin_setup() -> Html {
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let pwd_confirm = use_state(|| "".to_string());
let error = use_state(|| String::new());
let error = use_state(String::new);
let success = use_state(|| false);
let loading = use_state(|| false);
let admin_check_done = use_state(|| false);
@@ -155,7 +154,6 @@ pub fn initial_admin_setup() -> Html {
first_name: first_name_val,
last_name: last_name_val,
username: username_val,
is_admin: true,
pwd: pwd_val,
};
@@ -174,8 +172,8 @@ pub fn initial_admin_setup() -> Html {
navigator.push(&crate::Route::Login);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(text);
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
}
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
@@ -184,10 +182,10 @@ pub fn initial_admin_setup() -> Html {
};
html! {
<div class="setup-container rundbg">
<div class="setup-container">
<div class="setup-box">
<h1>{ "Admin-Einrichtung" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administratorkonto" }</p>
<h1>{ "Erstmalige Admin-Einrichtung" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administrator-Konto" }</p>
<form {onsubmit} class="setup-form">
<div class="form-group">
@@ -266,7 +264,7 @@ pub fn initial_admin_setup() -> Html {
</div>
<button type="submit" disabled={*loading} class="submit-btn">
{ if *loading { "Wird erstellt..." } else { "Administratorkonto erstellen" } }
{ if *loading { "Wird erstellt..." } else { "Admin-Konto erstellen" } }
</button>
if !error.is_empty() {
@@ -274,7 +272,7 @@ pub fn initial_admin_setup() -> Html {
}
if *success {
<p class="success-message" style="color:green">{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }</p>
<p class="success-message" style="color:green">{ "Admin-Konto erfolgreich erstellt! Weiterleitung zum Login..." }</p>
}
</form>
</div>
+11 -55
View File
@@ -234,7 +234,7 @@ pub fn ticket_menu() -> Html {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket einreichen" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket erstellen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>>
@@ -319,11 +319,6 @@ pub fn users_menu() -> Html {
/// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display.
///
/// # Mobile Support
/// On small screens:
/// - Slides into view from the left when `props.is_open` is `true`.
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
///
/// # Structure
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
@@ -342,22 +337,8 @@ pub fn users_menu() -> Html {
/// # Logout Functionality
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
/// and then redirects the user to the login page (`crate::Route::Login`).
/// Properties for the [`Sidebar`] component.
///
/// These properties enable managing and controlling the responsive mobile sidebar
/// layout and its visibility settings.
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
#[prop_or_default]
pub is_open: bool,
/// A callback emitted when the user requests to close/hide the mobile sidebar.
#[prop_or_default]
pub on_close: Callback<()>,
}
#[component(Sidebar)]
pub fn sidebar(props: &SidebarComponentProps) -> Html {
pub fn sidebar() -> Html {
let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router");
@@ -398,34 +379,23 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
};
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Wird geladen..." }</div> },
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => {
let on_close = props.on_close.clone();
html! {
Some(false) => html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<nav class="sidebar user">
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
@@ -434,38 +404,26 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
</ul>
</nav>
</SidebarStateProvider>
}
}
},
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => {
let on_close = props.on_close.clone();
html! {
Some(true) => html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<nav class="sidebar admin">
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
@@ -474,8 +432,6 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
</ul>
</nav>
</SidebarStateProvider>
},
}
}
}
}
+71 -75
View File
@@ -208,11 +208,10 @@ pub fn submit_ticket_component() -> Html {
.expect("Failed to build request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
status.set(Some(text));
},
Ok(response) if response.status() == 200 => {
status.set(Some("Erfolgreich".into()))
}
Ok(response) => status.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
@@ -268,10 +267,7 @@ pub fn submit_ticket_component() -> Html {
Err(_) => None,
}
} else {
match raw_trim.parse::<i16>() {
Ok(n) => Some(n),
Err(_) => None,
}
raw_trim.parse::<i16>().ok()
}
};
@@ -290,15 +286,17 @@ pub fn submit_ticket_component() -> Html {
let room_valid = (*room).is_some();
html! {
<form class="rundbg" {onsubmit}>
<div class="form-container">
<div class="page-header">
<h1>{ "Ticket erstellen" }</h1>
</div>
<form {onsubmit}>
<label>{ "Betreff:" }
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
</label>
<br/>
<label>{ "Beschreibung:" }
<input type="text" value={(*description).clone()} oninput={description_change}/>
<textarea value={(*description).clone()} oninput={description_change}/>
</label>
<br/>
<label>{ "Kategorie:" }
<select value={(*category).clone()} onchange={category_change}>
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
@@ -309,33 +307,32 @@ pub fn submit_ticket_component() -> Html {
<option value="Sonstiges">{ "Sonstiges" }</option>
</select>
</label>
<br/>
<label>{ "Raum:" }
<input type="text" value={(*room_input).clone()} oninput={room_change}/>
</label>
{
if !room_valid {
html! {
<p class="warning" >{ "Ungültiger/nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
<p class="alert error">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K8, D1)" }</p>
}
} else {
html! {}
}
}
<br/>
<button type="submit">{ "Absenden" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Alle Tickets anzeigen" }</Link<crate::Route>>
{
if let Some(s) = &*status {
html!{ <p>{ s }</p> }
html!{ <p class="alert success">{ s }</p> }
} else {
html!{}
}
}
</form>
</div>
}
}
@@ -399,7 +396,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
if status == 200 {
match response.json::<Ticket>().await {
Ok(t) => ticket.set(Some(t)),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))),
}
} else {
match response.json::<ApiError>().await {
@@ -408,13 +405,13 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Server error: {}", status)));
error.set(Some(format!("Server-Fehler: {}", status)));
}
}
}
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -423,7 +420,6 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
}
let onsubmit = {
let status = status.clone();
let id = id.clone();
let error = error.clone();
Callback::from(move |e: SubmitEvent| {
@@ -439,7 +435,6 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.unwrap_or_else(|| (*status).clone());
status.set(new_status.clone());
let id = id.clone();
let error = error.clone();
spawn_local(async move {
@@ -451,11 +446,10 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.expect("Failed to construct request");
match request.send().await {
Ok(response) if response.status() == 200 => error.set(Some("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(Some(text));
},
Ok(response) if response.status() == 200 => {
error.set(Some("Erfolgreich".into()))
}
Ok(response) => error.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
@@ -469,7 +463,6 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let ticket_state = ticket.clone();
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
@@ -494,11 +487,12 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
match request.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
ticket_state.set(None);
// remove local state or navigate away
ticket_state.set(None); // clears the shown item
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
delete_error.set(Some(txt));
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
@@ -508,12 +502,12 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
};
if *loading {
html! {<p>{ "Wird geladen" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else if let Some(t) = &*ticket {
html! {
<div class="rundbg">
<div>
<h1>{ format!("Ticket #{}", t.id) }</h1>
<p><strong>{ "Kategorie: " }</strong>{ &t.category }</p>
<p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p>
@@ -543,13 +537,13 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
<button type="submit">{ "Aktualisieren" }</button>
</form>
<button onclick={ondelete} disabled={*deleting}>
<button onclick={ondelete} disabled={*deleting} class="delete">
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllTickets} classes="return-to">{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
<p class="alert error">{ err.clone() }</p>
}
</div>
}
@@ -593,7 +587,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
/// ```
#[component(AllTickets)]
pub fn all_tickets_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let tickets = use_state(Vec::<Ticket>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
@@ -609,22 +603,22 @@ pub fn all_tickets_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("parse error: {}", e))),
Err(e) => error.set(Some(format!("Parser-Fehler: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -641,9 +635,9 @@ pub fn all_tickets_component() -> Html {
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
@@ -656,20 +650,22 @@ pub fn all_tickets_component() -> Html {
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
}
});
|| ()
});
}
if *loading {
html! {<p>{ "Wird geladen" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class= "postits">
<div>
<div class="page-header">
<h1>{ "Alle Tickets" }</h1>
</div>
<ul class="ticket-list">
{ for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| {
let status_class = match t.status.as_str() {
"ToDo" => "To-Do",
@@ -679,14 +675,17 @@ pub fn all_tickets_component() -> Html {
_ => "To-Do"
};
html! {
<div class={classes!{status_class, "listbox"}}>
<li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff,) }</h3></Link<crate::Route>>
<li key={t.id.to_string()} class={status_class}>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
<p>{ &t.description }</p>
</li>
</div>
}})}
}
})}
</ul>
<div class="ticket-list-actions">
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
}
}
}
@@ -726,7 +725,7 @@ pub fn all_tickets_component() -> Html {
/// ```
#[component(ArchivedTickets)]
pub fn archived_tickets_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let tickets = use_state(Vec::<Ticket>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
@@ -742,12 +741,12 @@ pub fn archived_tickets_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
Err(e) => error.set(Some(format!("Parser-Fehler: {}", e))),
}
}
Ok(response) => {
@@ -774,9 +773,9 @@ pub fn archived_tickets_component() -> Html {
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
@@ -789,37 +788,34 @@ pub fn archived_tickets_component() -> Html {
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
}
});
|| ()
});
}
if *loading {
html! {<p>{ "Wird geladen" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class="postits">
<div>
<div class="page-header">
<h1>{ "Archivierte Tickets" }</h1>
</div>
<ul class="ticket-list">
{ for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! {
<div class="listbox">
<div>
<li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{} - #{}", t.betreff, t.id) }</h3></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
<p>{ &t.description }</p>
<p>{ match t.status.as_str() {
"ToDo" => "Zu tun",
"InProgress" => "In Bearbeitung",
"Completed" => "Erledigt",
"Archived" => "Archiviert",
_ => "Ungültiger Status"
}}</p>
</li>
</div>
})}
</ul>
</div>
}
}
}
+72 -47
View File
@@ -181,11 +181,10 @@ pub fn register_component() -> Html {
.expect("Error building request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
status.set(Some(text));
Ok(response) if response.status() == 200 => {
status.set(Some("Erfolgreich".into()))
}
Ok(response) => status.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
@@ -233,7 +232,11 @@ pub fn register_component() -> Html {
};
html! {
<form {onsubmit} class="rundbg">
<div class="form-container">
<div class="page-header">
<h1>{ "Benutzer registrieren" }</h1>
</div>
<form {onsubmit}>
<label>{ "Vorname:" }
<input type="text" value={(*first_name).clone()} oninput={fn_change}/>
</label>
@@ -246,11 +249,12 @@ pub fn register_component() -> Html {
<label>{ "Admin:" }
<input type="checkbox" checked={*is_admin} onchange={admin_change}/>
</label>
<label>{ "Password:" }
<label>{ "Passwort:" }
<input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
</label>
<button type="submit">{ "Bestätigen" }</button>
</form>
</div>
}
}
@@ -285,7 +289,7 @@ pub fn login_component() -> Html {
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let loading = use_state(|| false);
let error = use_state(|| String::new());
let error = use_state(String::new);
let success = use_state(|| false);
let navigator = use_navigator().unwrap();
@@ -329,8 +333,8 @@ pub fn login_component() -> Html {
navigator.push(&crate::Route::Home);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(text);
let text = r.text().await.unwrap_or_else(|_| "unbekannt".into());
error.set(format!("HTTP {}: {}", r.status(), text));
}
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
@@ -339,9 +343,12 @@ pub fn login_component() -> Html {
};
html! {
<div>
<h1 class="headline">{ "Anmeldung" }</h1>
<form {onsubmit} class="rundbg login">
<main class="content">
<div class="form-container">
<div class="page-header">
<h1>{ "Anmelden" }</h1>
</div>
<form {onsubmit}>
<input
placeholder="Benutzername"
value={(*username).clone()}
@@ -359,10 +366,11 @@ pub fn login_component() -> Html {
pwd.set(input.value());
})}
/>
<button type="submit" disabled={*loading}>{ if *loading { "Anmeldung läuft..." } else { "Anmelden" } }</button>
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> }
<button type="submit" disabled={*loading}>{ if *loading { "Wird angemeldet..." } else { "Anmelden" } }</button>
if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
</form>
</div>
</main>
}
}
@@ -395,7 +403,7 @@ pub fn login_component() -> Html {
/// ```
#[component(AllUsers)]
pub fn all_users_component() -> Html {
let users = use_state(|| Vec::<FilteredUser>::new());
let users = use_state(Vec::<FilteredUser>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
@@ -407,22 +415,22 @@ pub fn all_users_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/users");
let url = "/api/users".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<FilteredUser>>().await {
Ok(u) => users.set(u),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -431,18 +439,37 @@ pub fn all_users_component() -> Html {
}
if *loading {
html! {<p>{ "Wird geladen" }</p>}
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "Alle Benutzer" }</h1>
</div>
<p>{ "Lade..." }</p>
</div>
}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "Alle Benutzer" }</h1>
</div>
<p class="alert error">{ format!("Fehler: {}", e) }</p>
</div>
}
} else {
html! {
<ul class="postits">
<div class="form-container">
<div class="page-header">
<h1>{ "Alle Benutzer" }</h1>
</div>
<ul class="user-list">
{ for users.iter().map(|t| html! {
<li key={t.id.to_string()} class="listbox">
<li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>>
</li>
})}
</ul>
</div>
}
}
}
@@ -510,7 +537,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if status == 200 {
match response.json::<FilteredUser>().await {
Ok(u) => user.set(Some(u)),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))),
}
} else {
match response.json::<ApiError>().await {
@@ -519,13 +546,13 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Server error: {}", status)));
error.set(Some(format!("Server-Fehler: {}", status)));
}
}
}
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -537,7 +564,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string());
let make_admin = use_state(|| false);
let new_pwd = use_state(|| String::new());
let new_pwd = use_state(String::new);
let saving = use_state(|| false);
let save_error = use_state(|| None::<String>);
let save_success = use_state(|| false);
@@ -577,7 +604,6 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let last_name = (*last_name).clone();
let username = (*username).clone();
let make_admin = *make_admin;
let new_pwd_state = new_pwd.clone();
let new_pwd = (*new_pwd).clone();
saving.set(true);
@@ -588,11 +614,10 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let save_error = save_error.clone();
let save_success = save_success.clone();
let user_state = user_state.clone();
let id = id;
spawn_local(async move {
let payload = UserUpdateScheme {
id: id,
id,
first_name,
last_name,
username,
@@ -612,14 +637,13 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if let Ok(updated) = resp.json::<FilteredUser>().await {
user_state.set(Some(updated));
}
new_pwd_state.set(String::new());
save_success.set(true);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into());
save_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => save_error.set(Some(format!("Network error: {}", err))),
Err(err) => save_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
saving.set(false);
});
@@ -633,14 +657,15 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let user_state = user.clone(); // or ticket
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
// confirm
if !web_sys::window()
.and_then(|w| {
w.confirm_with_message("Möchten Sie diesen Eintrag wirklich löschen?")
w.confirm_with_message(
"Sind Sie sicher, dass Sie dieses Element löschen möchten?",
)
.ok()
})
.unwrap_or(false)
@@ -656,16 +681,17 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let user_state = user_state.clone();
spawn_local(async move {
let url = format!("/api/users/{}", id);
let url = format!("/api/users/{}", id); // or /api/tickets/{}
let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
match req.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
user_state.set(None);
// remove local state or navigate away
user_state.set(None); // clears the shown item
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
delete_error.set(Some(txt));
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
@@ -675,12 +701,12 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
};
if *loading {
html! {<p>{ "Wird geladen" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else if let Some(u) = &*user {
html! {
<div class="rundbg">
<div>
<div>
<p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p>
<p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p>
@@ -688,8 +714,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div>
<crate::pages::qr::QrManager user_id={u.id}/>
<h1>{ format!("Benutzer #{}", u.id) }</h1>
<form onsubmit={onsubmit}>
<div>
<label>{ "Vorname" }
@@ -733,7 +758,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
</label>
</div>
<div>
<label>{ "Neues Passwort (leer = unchanged)" }
<label>{ "Neues Passwort (leer = unverändert)" }
<input name="new_pwd" type="password"
value={(*new_pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
@@ -753,11 +778,11 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
}
</form>
<button onclick={ondelete} disabled={*deleting}>
<button onclick={ondelete} disabled={*deleting} class="delete">
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllUsers}>{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllUsers} classes="return-to">{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
}
+165 -30
View File
@@ -6,6 +6,7 @@ use serde::Deserialize;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use crate::dequote;
use crate::pages::ticket::{ActiveUser, Ticket};
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
@@ -20,6 +21,23 @@ use crate::pages::ticket::{ActiveUser, Ticket};
struct TicketPartial {
date: DateTime<Utc>,
room: i16,
user_id: i16,
}
/// A partial representation of a user, containing only the fields necessary for statistical analysis.
///
/// This struct is used to efficiently retrieve and process user data for diagnostics
/// without fetching the full user details.
///
/// # Fields
/// - `id`: The unique identifier of the user.
/// - `first_name`: The first name of the user.
/// - `last_name`: The last name of the user.
#[derive(Debug, Deserialize, Clone, PartialEq)]
struct UserPartial {
id: i16,
first_name: String,
last_name: String,
}
/// Properties for components that display room-wise ticket totals.
@@ -34,6 +52,20 @@ struct RoomTotalsProps {
tickets: Vec<TicketPartial>,
}
/// Properties for components that display user-wise ticket totals.
///
/// This struct passes a list of partial user data and partial ticket data to a component
/// responsible for calculating and visualizing ticket distribution per user.
///
/// # Fields
/// - `users`: A vector of [`UserPartial`] containing user data relevant for user totals.
/// - `tickets`: A vector of [`TicketPartial`] containing ticket data relevant for user totals.
#[derive(Properties, PartialEq)]
struct UserTotalProps {
users: Vec<UserPartial>,
tickets: Vec<TicketPartial>,
}
/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
///
/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
@@ -124,7 +156,7 @@ fn day_counts(partials: &[TicketPartial]) -> [usize; 7] {
chrono::Weekday::Sun => 6,
};
occ[idx] += 1;
current = current + chrono::Duration::days(1);
current += chrono::Duration::days(1);
}
occ
@@ -170,7 +202,7 @@ fn parse_room(r: i16) -> String {
#[component(Diagnostics)]
pub fn diagnostics_component() -> Html {
html! {
<div class="rundbg">
<div>
<TicketCount/>
<SubmitStats/>
</div>
@@ -204,7 +236,7 @@ pub fn diagnostics_component() -> Html {
/// ```
#[component(TicketCount)]
pub fn ticket_count_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let tickets = use_state(Vec::<Ticket>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
@@ -220,22 +252,22 @@ pub fn ticket_count_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
Err(e) => error.set(Some(format!("parse error: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Status {}", response.status())));
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
@@ -252,9 +284,8 @@ pub fn ticket_count_component() -> Html {
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
@@ -267,15 +298,13 @@ pub fn ticket_count_component() -> Html {
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
}
});
|| ()
});
}
if *loading {
html! {<p>{ "Wird geladen" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
@@ -284,13 +313,13 @@ pub fn ticket_count_component() -> Html {
.iter()
.filter(|t| {
status_conditions(t)
&& (user.is_admin || user.id.map_or(false, |uid| t.user_id == uid))
&& (user.is_admin || (user.id == Some(t.user_id)))
})
.count();
html! {
<div class="ticket-count">
<h2>{ "Offene Tickets" }</h2>
<h4>{ count }</h4>
<div class="open-tickets">
<h2 class="left">{ "Offene Tickets" }</h2>
<h4 class="ticket_count center">{ count }</h4>
</div>
}
}
@@ -325,12 +354,14 @@ pub fn ticket_count_component() -> Html {
/// ```
#[component(SubmitStats)]
pub fn submit_stats_component() -> Html {
let tickets = use_state(|| Vec::<TicketPartial>::new());
let tickets = use_state(Vec::<TicketPartial>::new);
let users = use_state(Vec::<UserPartial>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
{
let tickets = tickets.clone();
let users = users.clone();
let error = error.clone();
let loading = loading.clone();
@@ -342,18 +373,36 @@ pub fn submit_stats_component() -> Html {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<TicketPartial>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
Err(e) => error.set(Some(format!("parse error: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Status {}", response.status())));
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
match Request::get("/api/users").send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<UserPartial>>().await {
Ok(u) => users.set(u),
Err(e) => error.set(Some(format!("users parse error: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("users status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("users network error: {}", err))),
}
loading.set(false);
});
|| ()
@@ -383,7 +432,7 @@ pub fn submit_stats_component() -> Html {
html! {
<div class="diagnostics-section">
if *loading {
<p>{ "Wird geladen..." }</p>
<p>{ "Lade..." }</p>
}
if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p>
@@ -415,8 +464,17 @@ pub fn submit_stats_component() -> Html {
})}
</div>
</div>
<div class="diagnostics-row">
<div class="diagnostics-column">
<h3>{ "Tickets pro Raum" }</h3>
<RoomTotalTickets tickets={(*tickets).clone()}/>
</div>
<div class="diagnostics-column">
<h3>{ "Tickets pro Benutzer" }</h3>
<UserTotal users={(*users).clone()} tickets={(*tickets).clone()}/>
</div>
</div>
</div>
}
}
@@ -457,19 +515,96 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
html! {
<div class="diagnostics-section">
<h3>{ "Tickets pro Raum" }</h3>
<div class="room-chart">
<div class="chart">
{ for totals_vec.into_iter().map(|(room, count)| {
let label = parse_room(room);
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
html! {
<div class="room-bar-item">
<div class="room-header">
<span class="room-label">{ label }</span>
<span class="room-count">{ count }</span>
<div class="bar-item">
<div class="diagnostics-header">
<span class="diagnostics-label">{ label }</span>
<span class="diagnostics-count">{ count }</span>
</div>
<div class="room-bar-container">
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}>
<div class="bar-container">
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
</div>
</div>
</div>
}
}) }
</div>
</div>
}
}
/// A component that displays the total number of tickets per user.
///
/// This component takes lists of [`UserPartial`] and [`TicketPartial`] items to calculate
/// and display the total number of tickets submitted by each user.
/// The results are presented in a sorted list with a bar chart visualization.
///
/// # Props
/// - `users`: A `Vec<UserPartial>` containing partial user data.
/// - `tickets`: A `Vec<TicketPartial>` containing partial ticket data for analysis.
///
/// # Functionality
/// - **Maps User Names**: Creates a map from user IDs to their first and last names for display.
/// - **Calculates Totals**: Aggregates ticket counts for each user.
/// - **Sorts Results**: Displays users sorted by their ticket count in descending order.
/// - **Visualizes Data**: Renders a bar chart where the width of each bar is proportional
/// to the ticket count for that user, relative to the user with the maximum tickets.
/// - **Name Formatting**: Uses the [`dequote!`] macro to clean up user names before display.
///
/// # Example
/// ```rust
/// html! {
/// <UserTotal users={my_user_partials} tickets={my_ticket_partials} />
/// }
/// ```
#[component(UserTotal)]
fn user_total_component(props: &UserTotalProps) -> Html {
let name_map: HashMap<i16, (String, String)> = props
.users
.iter()
.map(|u| (u.id, (u.first_name.clone(), u.last_name.clone())))
.collect();
let mut counts: HashMap<i16, usize> = HashMap::new();
for t in &props.tickets {
*counts.entry(t.user_id).or_insert(0) += 1;
}
let mut totals_vec: Vec<(i16, String, String, usize)> = name_map
.into_iter()
.map(|(id, (fname, lname))| {
let c = counts.get(&id).cloned().unwrap_or(0);
(id, fname, lname, c)
})
.collect();
totals_vec.sort_by(|a, b| b.3.cmp(&a.3));
let max_count = totals_vec
.iter()
.map(|(_, _, _, c)| *c)
.max()
.unwrap_or(1)
.max(1);
html! {
<div class="diagnostics-section">
<div class="chart">
{ for totals_vec.into_iter().map(|(_id, fname, lname, count)| {
let label = format!("{} {}", dequote!(fname), dequote!(lname));
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
html! {
<div class="bar-item">
<div class="diagnostics-header">
<span class="diagnostics-label">{ label }</span>
<span class="diagnostics-count">{ count }</span>
</div>
<div class="bar-container">
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
</div>
</div>
</div>
+8 -4
View File
@@ -1,8 +1,12 @@
@use "variables" as *;
@mixin card {
backgroud: #fff;
border-radius: &border-radius;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
padding: var(--spacing-md);
background: $color-container;
border-radius: $border-radius;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: $spacing-md;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
@use "variables" as *;
.u-hidden { display: none !important; }
.u-gap-sm { gap: var(--spacing-sm); }
.text-muted { color: var(--color-muted); }
.u-gap-sm { gap: $spacing-sm; }
.text-muted { color: $color-muted; }
+32 -32
View File
@@ -1,34 +1,34 @@
:root {
--color-bg: #ffffff;
--color-sidebar: #570000;
--color-accent: #2563eb;
--color-muted: #6b7280;
--spacing-sm: 8px;
--spacing-md: 16px;
--border-radius: 6px;
--rundbgbackgroundcolor: #a0a0a0;
--sidebar-font: #fffefe;
--color: #000000;
--color-container: #f0f0f0;
--color-text: #000000;
--color-placeholder: #757575;
--color-input: #1a1a1a;
}
// Color Palette (Reference Style)
$color-bg: var(--color-bg);
$color-bg-dark: var(--color-bg-dark);
$color-container: var(--color-container);
$color-container-dark: var(--color-container-dark);
$color-sidebar: #0f172a;
$color-primary: #2b79c2;
$color-primary-hover: #1d5fa0;
$color-accent: #2b79c2;
$color-muted: #6b7280;
$color-text: var(--color-text);
$color-text-dark: var(--color-text-dark);
[data-theme='dark'] {
--color-bg: #000000;
--color-sidebar: #b7d300;
--color-accent: #eb2525;
--color-muted: #999999;
--spacing-sm: 8px;
--spacing-md: 16px;
--border-radius: 6px;
--rundbgbackgroundcolor: #505050;
--sidebar-font: #000000;
--color: #000000;
--color-container: #1a1a1a;
--color-text: #000000;
--color-placeholder: #a0a0a0;
--color-input: #f0f0f0;
}
// Status Colors
$color-status-todo: #ffcccc;
$color-status-todo-text: #a00;
$color-status-inprogress: #fff3cd;
$color-status-inprogress-text: #856404;
$color-status-done: #d4edda;
$color-status-done-text: #155724;
$color-status-archived: #e0e0e0;
$color-status-archived-text: #666666;
// Action Colors
$color-logout: #ff4d4d;
$color-logout-hover: #e60000;
// Spacing
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 2rem;
$border-radius: 0.5rem;
$border-radius-lg: 10px;
@@ -0,0 +1,82 @@
@use "../variables" as *;
.ticket-list {
list-style: none;
padding: 0;
margin: 0;
margin-bottom: $spacing-lg;
li {
border-left: 4px solid;
padding: 1rem;
margin-bottom: 1rem;
border-radius: $border-radius-lg;
background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h3 {
margin-top: 0;
margin-bottom: $spacing-md;
color: $color-primary;
a {
color: $color-primary;
text-decoration: none;
transition: color 0.2s ease-in-out;
&:hover {
color: $color-primary-hover;
text-decoration: underline;
}
}
}
p {
margin: 0.3rem 0;
}
&.To-Do {
border-left-color: $color-status-todo-text;
}
&.InProgress {
border-left-color: $color-status-inprogress-text;
}
&.Completed {
border-left-color: $color-status-done-text;
}
&.Archived {
border-left-color: $color-status-archived-text;
}
}
}
.ticket-list-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
margin-top: $spacing-lg;
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
@@ -0,0 +1,13 @@
@use "../variables" as *;
.user-list {
list-style: none;
padding-left: 0;
margin: 0;
li {
h3 {
margin-top: 0;
}
}
}
@@ -0,0 +1,75 @@
:root {
--color-bg: #f0f2f5;
--color-bg-dark: #121212;
--color-container: #ffffff;
--color-container-dark: #333333;
--color-text: #111827;
--color-text-dark: #e2e2e2;
}
// Automatically respect standard media query if no override is set
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #121212;
--color-container: #333333;
--color-text: #e2e2e2;
}
}
// Force Light Mode overrides
body.theme-light {
--color-bg: #f0f2f5;
--color-bg-dark: #f0f2f5;
--color-container: #ffffff;
--color-container-dark: #ffffff;
--color-text: #111827;
--color-text-dark: #111827;
}
// Force Dark Mode overrides
body.theme-dark {
--color-bg: #121212;
--color-bg-dark: #121212;
--color-container: #333333;
--color-container-dark: #333333;
--color-text: #e2e2e2;
--color-text-dark: #e2e2e2;
}
// Fixed positioning ensures NO other component's layout is ever altered
.dark-mode-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: 55px;
height: 55px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
svg {
width: 36px;
height: 36px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
background-color: var(--color-bg);
}
&:active {
transform: translateY(0);
}
}
@@ -1,27 +0,0 @@
body {
/* Formular Styles */
input, textarea, select, button {
width: 100%;
margin-top: 1rem;
padding: 0.75rem;
font-size: 1rem;
border-radius: 0.5rem;
color: var(--color-input);
border: none;
cursor: pointer;
margin-bottom: 15px;
}
input::placeholder,
textarea::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
button:hover {
cursor: pointer;
border: none;
transition: all 0.2s ease-in-out;
margin-bottom: 15px;
}
}
+107 -28
View File
@@ -4,18 +4,22 @@
.diagnostics-container {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
gap: $spacing-md;
}
.diagnostics-section {
background-color: #ffffff;
border-radius: var(--border-radius);
padding: var(--spacing-md);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
background-color: $color-container;
border-radius: $border-radius;
padding: $spacing-md;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
}
h3 {
margin: 0 0 var(--spacing-md) 0;
color: #1f2937;
margin: 0;
color: $color-primary;
font-size: 1.1rem;
font-weight: 600;
}
@@ -26,8 +30,14 @@
flex-direction: column;
gap: 12px;
border: 1px solid #e5e7eb;
border-radius: var(--border-radius);
background-color: #f9fafb;
border-radius: $border-radius;
background-color: $color-container;
margin: 16px 0px;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
}
.weekday-bars {
@@ -35,7 +45,7 @@
gap: 12px;
align-items: flex-end;
height: 205px;
padding: var(--spacing-md);
padding: $spacing-md;
overflow-x: auto;
padding-bottom: 0px;
}
@@ -43,15 +53,15 @@
.weekday-bar {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
flex: 1;
min-width: 60px;
height: 100%;
justify-content: end;
.bar {
width: 100%;
background-color: var(--color-accent);
background-color: $color-primary;
border-radius: 4px 4px 0 0;
transition: background-color 0.2s ease;
@@ -64,7 +74,7 @@
.weekday-labels {
display: flex;
gap: 12px;
padding: var(--spacing-md);
padding: $spacing-md;
padding-top: 0;
overflow-x: auto;
@@ -77,62 +87,131 @@
.day {
font-weight: 600;
font-size: 12px;
color: #1f2937;
color: $color-text;
margin-bottom: 2px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
.value {
font-size: 11px;
color: var(--color-muted);
color: $color-muted;
}
}
}
.room-chart {
.chart {
display: flex;
flex-direction: column;
gap: 12px;
padding: var(--spacing-md);
padding: $spacing-md;
border: 1px solid #e5e7eb;
border-radius: var(--border-radius);
background-color: #f9fafb;
border-radius: $border-radius;
background-color: $color-container;
width: 100%;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
}
.room-bar-item {
.bar-item {
display: flex;
flex-direction: column;
gap: 4px;
.room-header {
.diagnostics-header {
display: flex;
justify-content: space-between;
align-items: center;
.room-label {
.diagnostics-label {
font-weight: 600;
font-size: 14px;
color: #1f2937;
color: $color-text;
min-width: 50px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
.room-count {
.diagnostics-count {
font-size: 12px;
color: var(--color-muted);
color: $color-muted;
font-weight: 500;
}
}
.room-bar-container {
.bar-container {
width: 100%;
height: 24px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
.room-bar {
@media (prefers-color-scheme: dark) {
background-color: #555;
}
.bar {
height: 100%;
background-color: #570000;
background-color: $color-primary;
transition: width 0.3s ease;
}
}
}
.diagnostics-row {
display: flex;
gap: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
.diagnostics-column {
display: flex;
flex-direction: column;
gap: 12px;
width: 48%;
min-width: 280px;
}
.open-tickets{
display: flex;
align-items: center;
position: relative;
width: 100%;
padding: 0 1rem;
box-sizing: border-box;
min-height: 60px;
.left {
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 30%;
h2 {
margin: 0;
white-space: nowrap;
}
}
.center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
.home {
.ticket_count {
font-size: xxx-large;
height: 115px;
}
}
@@ -0,0 +1,97 @@
@use "../variables" as *;
.form-container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
margin-bottom: $spacing-lg;
form {
display: flex;
flex-direction: column;
gap: $spacing-md;
label {
display: flex;
flex-direction: column;
gap: $spacing-sm;
font-weight: 500;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
input, textarea, select {
padding: $spacing-md;
border: 1px solid #ccc;
border-radius: $border-radius;
font-family: Arial, sans-serif;
font-size: 1rem;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: $color-text-dark;
border-color: #555;
}
&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
}
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
align-self: flex-start;
padding: $spacing-md $spacing-lg;
background-color: $color-primary;
color: white;
border: none;
border-radius: $border-radius;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
&:active {
transform: scale(0.98);
}
}
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
text-align: center;
transition: background-color 0.2s ease-in-out;
align-self: flex-start;
&:hover {
background-color: $color-primary-hover;
}
}
}
ul {
list-style: none;
padding-left: 0;
li h3 {
margin-top: 0;
}
}
}
@@ -1,7 +0,0 @@
.headline {
font-size: 40px;
font-weight: bold;
text-align: center;
color: #570000;
margin-top: 150px;
}
@@ -1,9 +0,0 @@
.login-status {
font-weight: bold;
margin: 4px 8px;
}
.name {
font-size: 24px;
margin: 4px 8px;
}
-27
View File
@@ -1,27 +0,0 @@
.csg-icon {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
@media (max-width: 768px) {
max-width: 60px;
img {
width: 100%;
height: auto;
}
}
img {
width: auto;
height: auto;
}
}
@@ -0,0 +1,39 @@
@use "../variables" as *;
.login-btn {
display: inline-block;
padding: 12px 400px;
background: $color-primary;
color: white;
border-radius: $border-radius-lg;
text-decoration: none;
font-weight: bold;
font-size: 1rem;
cursor: pointer;
border: none;
transition: all 0.2s ease-in-out;
margin-bottom: 15px;
&:hover {
background: $color-primary-hover;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
.logout-btn {
display: inline-block;
padding: 10px 20px;
background: $color-logout;
color: white;
border-radius: $border-radius-lg;
text-decoration: none;
font-weight: bold;
transition: 0.2s ease-in-out;
margin-bottom: 20px;
&:hover {
background: $color-logout-hover;
transform: scale(1.05);
}
}
+294
View File
@@ -0,0 +1,294 @@
@use "../variables" as *;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid #e5e7eb;
@media (prefers-color-scheme: dark) {
border-bottom-color: #555;
}
h1 {
margin: 0;
color: $color-primary;
font-size: 1.75rem;
}
}
.page-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
a, button {
display: inline-block;
padding: $spacing-md;
background: $color-primary;
color: white;
text-decoration: none;
border: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
input[type="checkbox"] {
width: fit-content;
}
.grid-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: $spacing-lg;
margin-top: $spacing-lg;
.grid-item {
background: $color-container;
border: 1px solid #e5e7eb;
border-radius: $border-radius;
padding: $spacing-md;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease, transform 0.2s ease;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
border-color: #555;
}
&:hover {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
h3 {
color: $color-primary;
margin-top: 0;
margin-bottom: $spacing-md;
}
p {
margin: $spacing-sm 0;
color: $color-muted;
&:last-child {
margin-bottom: 0;
}
}
.item-actions {
display: flex;
gap: $spacing-md;
margin-top: $spacing-md;
flex-wrap: wrap;
a, button {
flex: 1;
min-width: 100px;
padding: $spacing-md;
border: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background-color 0.2s ease-in-out;
}
a.view, button.view {
background: $color-primary;
color: white;
&:hover {
background-color: $color-primary-hover;
}
}
a.edit, button.edit {
background: #f59e0b;
color: white;
&:hover {
background-color: #d97706;
}
}
a.delete, button.delete {
background: $color-logout;
color: white;
&:hover {
background-color: $color-logout-hover;
}
}
}
}
}
.table-container {
overflow-x: auto;
margin-top: $spacing-lg;
border-radius: $border-radius;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
}
table {
width: 100%;
border-collapse: collapse;
background: $color-container;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
thead {
background: $color-primary;
color: white;
th {
padding: $spacing-md;
text-align: left;
font-weight: 600;
border-bottom: 2px solid darken($color-primary, 10%);
}
}
tbody {
tr {
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.2s ease;
@media (prefers-color-scheme: dark) {
border-bottom-color: #555;
}
&:hover {
background-color: #f9fafb;
@media (prefers-color-scheme: dark) {
background-color: #444;
}
}
td {
padding: $spacing-md;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
}
}
}
}
.alert {
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-lg;
border-left: 4px solid;
&.success {
background: $color-status-done;
color: $color-status-done-text;
border-left-color: $color-status-done-text;
}
&.error {
background: $color-status-todo;
color: $color-status-todo-text;
border-left-color: $color-status-todo-text;
}
&.warning {
background: $color-status-inprogress;
color: $color-status-inprogress-text;
border-left-color: $color-status-inprogress-text;
}
&.info {
background: #dbeafe;
color: #1e40af;
border-left-color: #1e40af;
@media (prefers-color-scheme: dark) {
background: #1e3a8a;
color: #93c5fd;
border-left-color: #93c5fd;
}
}
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e5e7eb;
border-top-color: $color-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
@keyframes spin {
to { transform: rotate(360deg); }
}
}
.empty-state {
text-align: center;
padding: $spacing-lg;
color: $color-muted;
p {
font-size: 1.1rem;
margin-bottom: $spacing-md;
}
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
.return-to {
display: block;
padding: 16px 2rem;
background: #2b79c2;
background-color: rgb(43, 121, 194);
color: white;
text-decoration: none;
text-align: center;
border-radius: 0.5rem;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: #1d5fa0;
}
}
.delete {
display: block;
width: 100%;
text-align: center;
}
+120
View File
@@ -0,0 +1,120 @@
@use "../variables" as *;
.setup-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: $color-bg;
padding: $spacing-lg;
@media (prefers-color-scheme: dark) {
background: $color-bg-dark;
}
}
.setup-box {
width: 100%;
max-width: 400px;
background: $color-container;
padding: $spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h1 {
color: $color-primary;
text-align: center;
margin-top: 0;
margin-bottom: $spacing-md;
}
p {
text-align: center;
color: $color-muted;
margin-bottom: $spacing-lg;
}
}
.setup-form {
display: flex;
flex-direction: column;
gap: $spacing-md;
.form-group {
display: flex;
flex-direction: column;
gap: $spacing-sm;
label {
font-weight: 500;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
}
input {
padding: $spacing-md;
border: 1px solid #ccc;
border-radius: $border-radius;
font-family: Arial, sans-serif;
font-size: 1rem;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: $color-text-dark;
border-color: #555;
}
&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
}
}
button {
padding: $spacing-md;
background-color: $color-primary;
color: white;
border: none;
border-radius: $border-radius;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
margin-top: $spacing-md;
&:hover {
background-color: $color-primary-hover;
}
&:active {
transform: scale(0.98);
}
}
}
.success-message {
background: $color-status-done;
color: $color-status-done-text;
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-md;
border-left: 4px solid $color-status-done-text;
}
.error-message {
background: $color-status-todo;
color: $color-status-todo-text;
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-md;
border-left: 4px solid $color-status-todo-text;
}
+13 -141
View File
@@ -1,30 +1,23 @@
@use "../variables" as *;
@use "../mixins" as *;
:root {
color-scheme: dark light;
}
.sidebar {
width: 260px;
background: var(--color-sidebar);
color: var(--sidebar-font);
background: $color-sidebar;
color: #fff;
min-height: 100%;
padding: var(--spacing-md);
padding: $spacing-md;
display: flex;
flex-direction: column;
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--spacing-sm); flex: 1; }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $spacing-sm; flex: 1; }
a, .menu-toggle {
color: var(--sidebar-font);
color: #fff;
text-decoration: none;
display: block;
padding: 8px 12px;
border-radius: var(--border-radius);
border-radius: $border-radius;
transition: background 0.2s ease-in-out;
&:hover { background: rgba(255,255,255,0.1); }
}
@@ -34,7 +27,7 @@
.submenu {
margin-left: 8px;
background: rgba(255,255,255,0.05);
border-radius: var(--border-radius);
border-radius: $border-radius;
padding: 6px;
li { padding: 4px 0; }
}
@@ -42,71 +35,23 @@
.logout-item {
margin-top: auto;
border-top: 1px solid rgba(255,255,255,0.1);
padding-top: var(--spacing-sm);
padding-top: $spacing-sm;
}
.sidebar-button {
background: rgba(255, 93, 43, 0.527);
color: var(--sidebar-font);
border: 1px solid rgba(255, 255, 255, 0.801);
.logout-button {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px 12px;
border-radius: 4px;
width: 100%;
cursor: pointer;
text-align: left;
margin-top: var(--spacing-sm);
&.theme-toggle-btn {
display: flex;
justify-content: center;
align-items: center;
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--spacing-md);
.home {
flex-grow: 1;
display: flex;
justify-content: center;
@media (max-width: 768px) {
padding-left: 36px;
}
}
.sidebar-close {
display: none;
background: transparent;
border: none;
color: var(--sidebar-font);
cursor: pointer;
padding: 8px;
border-radius: 50%;
align-items: center;
justify-content: center;
transition: background 0.2s ease-in-out;
margin-bottom: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
stroke: var(--sidebar-font);
}
@media (max-width: 768px) {
display: flex;
}
}
}
.home-svg {
@@ -116,79 +61,6 @@
margin-right: 8px;
}
&.user { width: 220px; background: darken(#570000, 6%); }
}
// Floating mobile menu toggle button
.mobile-menu-toggle {
display: none;
@media (max-width: 768px) {
display: flex;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 999;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease-in-out;
svg {
width: 24px;
height: 24px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: var(--color-bg);
color: var(--color-container);
svg {
stroke: var(--color-container);
}
}
&:active {
transform: translateY(0);
}
}
}
// Overlay backdrop that dims the screen and allows dismissals on mobile
.sidebar-overlay {
display: none;
@media (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
&.open {
opacity: 1;
pointer-events: auto;
}
}
&.user { width: 220px; background: darken($color-sidebar, 6%); }
}
+57 -56
View File
@@ -1,79 +1,80 @@
@use "../mixins" as *;
@use "../variables" as *;
.ticket-card {
@include card();
border-left: 4px solid var(--color-accent);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
.meta { color: var(--color-muted); font-size: 0.9rem; }
.title { font-weight: 600; }
.ticket {
border: 1px solid #ccc;
padding: 1rem;
margin-bottom: 1rem;
border-radius: $border-radius-lg;
background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
.listbox {
max-width: auto;
margin:0 0;
background-color: #90ff82;
background-color: var(--rundbgbackgroundcolor);
padding-bottom: 8px;
color: white;
padding-left: 8px;
padding-right: 8px;
padding-top: 8px;
border-radius: 20px;
border-color: #000000;
border-style: double;
box-shadow: 10px 10px 10px rgba(43, 43, 43, 0.8);
font-size: 26px;
list-style-type: none;
text-align: center;
h3 {
margin-top: 0;
color: $color-primary;
}
p {
margin: 0.3rem 0;
}
}
.ticket-card {
@include card();
border-left: 4px solid $color-primary;
display: flex;
flex-direction: column;
gap: $spacing-sm;
.meta { color: $color-muted; font-size: 0.9rem; }
.title { font-weight: 600; }
}
.status {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: bold;
margin-top: 8px;
&.To-Do {
background-color: #b1003b;
background: $color-status-todo;
color: $color-status-todo-text;
}
&.InProgress {
background-color: #fff385;
background: $color-status-inprogress;
color: $color-status-inprogress-text;
}
&.Done {
background: $color-status-done;
color: $color-status-done-text;
}
&.Completed {
background-color: #90ff82;
background: $color-status-done;
color: $color-status-done-text;
}
&.Archived {
background-color: #af69ee;
}
a {
text-decoration: none;
color: white;
background: $color-status-archived;
color: $color-status-archived-text;
}
}
.postits {
display: grid;
gap: 8px;
margin-top: 96px;
}
.warning {
color: #570000;
}
.ticket-count {
border-radius: 10px;
border: 2px solid black;
width: fit-content;
padding: 8px;
margin: 0 auto;
margin-bottom: 32px;
h4 {
.ticket_count {
text-align: center;
font-size: 32px;
margin: 8px;
}
align-content: center;
color: #005f00;
border: 2px solid #848484;
border-radius: 10px;
height: 50px;
width: 250px;
margin: 0;
font-size: xx-large;
}
+133 -96
View File
@@ -3,25 +3,126 @@
@use "utilities";
@use "components/sidebar";
@use "components/tickets";
@use "components/alltickets";
@use "components/allusers";
@use "components/forms";
@use "components/login";
@use "components/diagnostics";
@use "components/frontpage";
@use "components/home";
@use "components/icon";
@use "components/pages";
@use "components/setup";
@use "components/dark_mode";
* {
box-sizing: border-box;
}
body {
background: var(--color-bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
color: #111827;
background: variables.$color-bg;
font-family: Arial, sans-serif;
color: variables.$color-text;
margin: 0;
padding: 0;
@media (prefers-color-scheme: dark) {
background: variables.$color-bg-dark;
color: variables.$color-text-dark;
}
}
input, textarea, select {
width: 100%;
padding: variables.$spacing-md;
font-size: 1rem;
border-radius: variables.$border-radius;
border: 1px solid #ccc;
box-sizing: border-box;
margin-bottom: 15px;
font-family: Arial, sans-serif;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: variables.$color-text-dark;
border-color: #555;
}
}
button {
padding: variables.$spacing-md;
font-size: 1rem;
border-radius: variables.$border-radius;
background-color: variables.$color-primary;
color: white;
border: none;
cursor: pointer;
margin-bottom: 15px;
transition: background-color 0.2s ease-in-out;
font-family: Arial, sans-serif;
font-weight: bold;
&:hover {
background-color: variables.$color-primary-hover;
}
}
form {
display: flex;
flex-direction: column;
gap: variables.$spacing-md;
label {
display: flex;
flex-direction: column;
gap: variables.$spacing-sm;
font-weight: 500;
}
a {
display: inline-block;
padding: variables.$spacing-md;
background: variables.$color-primary;
color: white;
text-decoration: none;
border-radius: variables.$border-radius;
text-align: center;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: variables.$color-primary-hover;
}
}
}
.container {
max-width: 900px;
margin: auto;
background: variables.$color-container;
padding: variables.$spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: variables.$color-container-dark;
}
}
.header-with-image {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-with-image img {
height: 60px;
}
.admin { display: flex; }
.layout {
display: flex;
height: 100vh;
overflow: hidden;
box-sizing: border-box;
min-height: 100vh;
margin: 0;
padding: 0;
}
.sidebar {
@@ -32,108 +133,44 @@ body {
top: 0;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
}
.content {
flex: 1;
min-width: 0;
margin-left: 260px;
height: 100%;
overflow-y: auto;
padding: var(--spacing-md);
box-sizing: border-box;
padding: variables.$spacing-lg;
> :first-child {
max-width: 900px;
margin: 0 auto;
background: variables.$color-container;
padding: variables.$spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: variables.$color-container-dark;
}
}
h1, h2, h3 {
color: variables.$color-primary;
margin-top: 0;
}
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: -260px;
transition: left .3s ease-in-out;
z-index: 1000;
height: 100%;
box-sizing: border-box;
}
.sidebar.open { left: 0; }
.content { margin-left: 0; padding: var(--spacing-md); box-sizing: border-box; }
.sidebar.open {
left: 0;
}
.rundbg {
display: flex;
justify-content: center;
flex-direction: column;
max-width: 60%;
margin: 0 auto;
background-color: var(--rundbgbackgroundcolor);
padding-bottom: 96px;
padding-left: 32px;
padding-right: 32px;
padding-top: 96px;
border-radius: 20px;
gap: 8px;
border-color: #000000;
border-style: double;
box-shadow: 20px 20px 10px rgb(43, 43, 43);
font-size: 26px;
label {
display: block;
width: 100%;
box-sizing: border-box;
}
input, select, button, a {
width: 100%;
box-sizing: border-box;
padding: 16px;
font-size: 1.8rem;
background-color: #f0f0f0;
color: var(--color-input);
box-shadow: #6d6d6d;
box-shadow: 6px 6px 8px;
}
input[type="checkbox"] {
width: auto;
}
button {
margin: 16px 0;
}
a {
color: var (--color);
text-align: center;
border-style: dashed;
}
&.login {
margin-top: 96px;
max-width: 40%;
}
&.home {
padding-top: 16px;
.content {
margin-left: 0;
}
}
.setup-form input {
width: 100%;
box-sizing: border-box;
}
input, select, textarea {
color: var(--color-input);
}
input::placeholder,
textarea::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
Binary file not shown.