Frontend Rework #185

Merged
Quaternions merged 17 commits from staging into master 2025-06-09 01:09:18 +00:00
75 changed files with 4063 additions and 2622 deletions

275
Cargo.lock generated
View File

@@ -68,9 +68,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-nats"
version = "0.40.0"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e23419d455dc57d3ae60a2f4278cf561fc74fe866e548e14d2b0ad3e1b8ca0b2"
checksum = "2cf0ae68ffe9ef362127a2223b42f57104edb20a50429f8c6e058912212884f7"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -126,7 +126,7 @@ dependencies = [
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -143,9 +143,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.7.3"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bitflags"
@@ -155,9 +155,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "blake3"
@@ -183,9 +183,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.17.0"
version = "3.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
[[package]]
name = "byteorder"
@@ -204,9 +204,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.22"
version = "1.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
dependencies = [
"jobserver",
"libc",
@@ -403,9 +403,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.11"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.59.0",
@@ -425,9 +425,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "flate2"
version = "1.1.1"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -684,11 +684,10 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.5"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"futures-util",
"http",
"hyper",
"hyper-util",
@@ -717,22 +716,28 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.11"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -808,9 +813,9 @@ checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
[[package]]
name = "icu_properties"
version = "2.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a"
checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
dependencies = [
"displaydoc",
"icu_collections",
@@ -824,9 +829,9 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04"
checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
[[package]]
name = "icu_provider"
@@ -882,6 +887,16 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -957,9 +972,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "lock_api"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
dependencies = [
"autocfg",
"scopeguard",
@@ -1043,13 +1058,13 @@ dependencies = [
[[package]]
name = "mio"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1125,11 +1140,11 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.72"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
"cfg-if",
"foreign-types",
"libc",
@@ -1157,9 +1172,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.108"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
@@ -1169,9 +1184,9 @@ dependencies = [
[[package]]
name = "parking_lot"
version = "0.12.3"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
dependencies = [
"lock_api",
"parking_lot_core",
@@ -1179,15 +1194,15 @@ dependencies = [
[[package]]
name = "parking_lot_core"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -1261,9 +1276,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "potential_utf"
@@ -1465,7 +1480,7 @@ version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
]
[[package]]
@@ -1499,9 +1514,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.15"
version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -1525,21 +1540,20 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-registry",
]
[[package]]
@@ -1599,7 +1613,7 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
"errno",
"libc",
"linux-raw-sys",
@@ -1674,9 +1688,9 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.20"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]]
name = "ryu"
@@ -1705,7 +1719,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -1857,15 +1871,15 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.15.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.9"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
@@ -1889,7 +1903,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "submissions-api"
version = "0.7.1"
version = "0.7.2"
dependencies = [
"reqwest",
"serde",
@@ -1941,7 +1955,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
"core-foundation",
"system-configuration-sys",
]
@@ -2032,9 +2046,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.45.0"
version = "1.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
dependencies = [
"backtrace",
"bytes",
@@ -2127,6 +2141,24 @@ dependencies = [
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
"futures-util",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -2152,9 +2184,9 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.28"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662"
dependencies = [
"proc-macro2",
"quote",
@@ -2163,9 +2195,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.33"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [
"once_cell",
]
@@ -2378,15 +2410,15 @@ dependencies = [
[[package]]
name = "windows-core"
version = "0.61.0"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.4.0",
"windows-strings",
]
[[package]]
@@ -2419,38 +2451,29 @@ checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-registry"
version = "0.4.0"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820"
dependencies = [
"windows-link",
"windows-result",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.3.2"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
]
@@ -2461,7 +2484,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -2470,7 +2493,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -2479,30 +2502,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@@ -2511,103 +2518,55 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
]
[[package]]

View File

@@ -114,6 +114,13 @@ paths:
format: int32
minimum: 0
maximum: 4
description: >
Sort order:
* `0` - Disabled
* `1` - DisplayNameAscending
* `2` - DisplayNameDescending
* `3` - DateAscending
* `4` - DateDescending
responses:
"200":
description: Successful response
@@ -178,6 +185,11 @@ paths:
format: int32
minimum: 1
maximum: 5
description: >
Game ID:
* `1` - Bhop
* `2` - Surf
* `5` - FlyTrials
- name: Sort
in: query
schema:
@@ -185,6 +197,13 @@ paths:
format: int32
minimum: 0
maximum: 4
description: >
Sort order:
* `0` - Disabled
* `1` - DisplayNameAscending
* `2` - DisplayNameDescending
* `3` - DateAscending
* `4` - DateDescending
- name: Submitter
in: query
schema:
@@ -210,6 +229,24 @@ paths:
format: int32
minimum: 0
maximum: 9
description: >
// Phase: Creation
* `0` - UnderConstruction
* `1` - ChangesRequested
// Phase: Review
* `2` - Submitting
* `3` - Submitted
// Phase: Testing
* `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
* `5` - Validating
* `6` - Validated
* `7` - Uploading
// Phase: Final MapfixStatus
* `8` - Uploaded // uploaded to the group, but pending release
* `9` - Rejected
responses:
"200":
description: Successful response
@@ -602,6 +639,11 @@ paths:
format: int32
minimum: 1
maximum: 5
description: >
Game ID:
* `1` - Bhop
* `2` - Surf
* `5` - FlyTrials
- name: Sort
in: query
schema:
@@ -609,6 +651,13 @@ paths:
format: int32
minimum: 0
maximum: 4
description: >
Sort order:
* `0` - Disabled
* `1` - DisplayNameAscending
* `2` - DisplayNameDescending
* `3` - DateAscending
* `4` - DateDescending
- name: Submitter
in: query
schema:
@@ -634,6 +683,25 @@ paths:
format: int32
minimum: 0
maximum: 10
description: >
// Phase: Creation
* `0` - UnderConstruction
* `1` - ChangesRequested
// Phase: Review
* `2` - Submitting
* `3` - Submitted
// Phase: Testing
* `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
* `5` - Validating
* `6` - Validated
* `7` - Uploading
* `8` - Uploaded // uploaded to the group, but pending release
// Phase: Final SubmissionStatus
* `9` - Rejected
* `10` - Released
responses:
"200":
description: Successful response

View File

@@ -2879,16 +2879,25 @@ func decodeListMapfixAuditEventsParams(args [1]string, argsEscaped bool, r *http
// ListMapfixesParams is parameters of listMapfixes operation.
type ListMapfixesParams struct {
Page int32
Limit int32
DisplayName OptString
Creator OptString
GameID OptInt32
Page int32
Limit int32
DisplayName OptString
Creator OptString
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
GameID OptInt32
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
// DateAscending * `4` - DateDescending.
Sort OptInt32
Submitter OptInt64
AssetID OptInt64
TargetAssetID OptInt64
StatusID OptInt32
// // Phase: Creation * `0` - UnderConstruction * `1` - ChangesRequested
// // Phase: Review * `2` - Submitting * `3` - Submitted
// // Phase: Testing * `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
// * `5` - Validating * `6` - Validated * `7` - Uploading
// // Phase: Final MapfixStatus * `8` - Uploaded // uploaded to the group, but pending release * `9`
// - Rejected.
StatusID OptInt32
}
func unpackListMapfixesParams(packed middleware.Parameters) (params ListMapfixesParams) {
@@ -3617,7 +3626,9 @@ type ListMapsParams struct {
DisplayName OptString
Creator OptString
GameID OptInt32
Sort OptInt32
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
// DateAscending * `4` - DateDescending.
Sort OptInt32
}
func unpackListMapsParams(packed middleware.Parameters) (params ListMapsParams) {
@@ -5117,16 +5128,25 @@ func decodeListSubmissionAuditEventsParams(args [1]string, argsEscaped bool, r *
// ListSubmissionsParams is parameters of listSubmissions operation.
type ListSubmissionsParams struct {
Page int32
Limit int32
DisplayName OptString
Creator OptString
GameID OptInt32
Page int32
Limit int32
DisplayName OptString
Creator OptString
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
GameID OptInt32
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
// DateAscending * `4` - DateDescending.
Sort OptInt32
Submitter OptInt64
AssetID OptInt64
UploadedAssetID OptInt64
StatusID OptInt32
// // Phase: Creation * `0` - UnderConstruction * `1` - ChangesRequested
// // Phase: Review * `2` - Submitting * `3` - Submitted
// // Phase: Testing * `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
// * `5` - Validating * `6` - Validated * `7` - Uploading * `8` - Uploaded // uploaded to the group,
// but pending release
// // Phase: Final SubmissionStatus * `9` - Rejected * `10` - Released.
StatusID OptInt32
}
func unpackListSubmissionsParams(packed middleware.Parameters) (params ListSubmissionsParams) {

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"io"
"git.itzana.me/strafesnet/go-grpc/users"
"git.itzana.me/strafesnet/maps-service/pkg/api"
@@ -43,18 +44,16 @@ func (svc *Service) CreateMapfixAuditComment(ctx context.Context, req api.Create
}
}
data := []byte{}
_, err = req.Read(data)
data, err := io.ReadAll(req)
if err != nil {
return err
}
Comment := string(data)
event_data := model.AuditEventDataComment{
Comment: Comment,
Comment: string(data),
}
EventData, err := json.Marshal(event_data)
EventData, err := json.Marshal(&event_data)
if err != nil {
return err
}
@@ -173,18 +172,16 @@ func (svc *Service) CreateSubmissionAuditComment(ctx context.Context, req api.Cr
}
}
data := []byte{}
_, err = req.Read(data)
data, err := io.ReadAll(req)
if err != nil {
return err
}
Comment := string(data)
event_data := model.AuditEventDataComment{
Comment: Comment,
Comment: string(data),
}
EventData, err := json.Marshal(event_data)
EventData, err := json.Marshal(&event_data)
if err != nil {
return err
}

View File

@@ -396,10 +396,42 @@ func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, params api.A
return ErrPermissionDeniedNeedRoleMapfixReview
}
userId, err := userInfo.GetUserID()
if err != nil {
return err
}
// transaction
target_status := model.MapfixStatusChangesRequested
smap := datastore.Optional()
smap.Add("status_id", model.MapfixStatusChangesRequested)
return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
smap.Add("status_id", target_status)
err = svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
if err != nil {
return err
}
event_data := model.AuditEventDataAction{
TargetStatus: uint32(target_status),
}
EventData, err := json.Marshal(event_data)
if err != nil {
return err
}
_, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{
ID: 0,
User: userId,
ResourceType: model.ResourceMapfix,
ResourceID: params.MapfixID,
EventType: model.AuditEventTypeAction,
EventData: EventData,
})
if err != nil {
return err
}
return nil
}
// ActionMapfixRevoke invokes actionMapfixRevoke operation.

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
submissions-api = { path = "api", features = ["internal"], default-features = false, registry = "strafesnet" }
async-nats = "0.40.0"
async-nats = "0.41.0"
futures = "0.3.31"
rbx_asset = { version = "0.4.5", registry = "strafesnet" }
rbx_binary = "1.0.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "submissions-api"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
publish = ["strafesnet"]
repository = "https://git.itzana.me/StrafesNET/maps-service"

View File

@@ -44,4 +44,8 @@ impl Context{
.body(body)
.send().await
}
pub async fn delete(&self,url:impl reqwest::IntoUrl)->Result<reqwest::Response,reqwest::Error>{
self.client.delete(url)
.send().await
}
}

View File

@@ -46,7 +46,7 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,SingleItemError>{
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,ScriptSingleItemError>{
let scripts=self.get_scripts(GetScriptsRequest{
Page:1,
Limit:2,
@@ -57,7 +57,7 @@ impl Context{
ResourceID:None,
}).await.map_err(SingleItemError::Other)?;
if 1<scripts.len(){
return Err(SingleItemError::DuplicateItems);
return Err(SingleItemError::DuplicateItems(scripts));
}
Ok(scripts.into_iter().next())
}
@@ -72,6 +72,16 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn delete_script(&self,config:GetScriptRequest)->Result<(),Error>{
let url_raw=format!("{}/scripts/{}",self.0.base_url,config.ScriptID.0);
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
response_ok(
self.0.delete(url).await.map_err(Error::Reqwest)?
).await.map_err(Error::Response)?;
Ok(())
}
pub async fn get_script_policies(&self,config:GetScriptPoliciesRequest<'_>)->Result<Vec<ScriptPolicyResponse>,Error>{
let url_raw=format!("{}/script-policy",self.0.base_url);
let mut url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
@@ -96,7 +106,7 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,ScriptPolicySingleItemError>{
let policies=self.get_script_policies(GetScriptPoliciesRequest{
Page:1,
Limit:2,
@@ -105,7 +115,7 @@ impl Context{
Policy:None,
}).await.map_err(SingleItemError::Other)?;
if 1<policies.len(){
return Err(SingleItemError::DuplicateItems);
return Err(SingleItemError::DuplicateItems(policies));
}
Ok(policies.into_iter().next())
}
@@ -130,6 +140,16 @@ impl Context{
self.0.post(url,body).await.map_err(Error::Reqwest)?
).await.map_err(Error::Response)?;
Ok(())
}
pub async fn delete_script_policy(&self,config:GetScriptPolicyRequest)->Result<(),Error>{
let url_raw=format!("{}/script-policy/{}",self.0.base_url,config.ScriptPolicyID.0);
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
response_ok(
self.0.delete(url).await.map_err(Error::Reqwest)?
).await.map_err(Error::Response)?;
Ok(())
}
}

View File

@@ -76,7 +76,7 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,SingleItemError>{
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,ScriptSingleItemError>{
let scripts=self.get_scripts(GetScriptsRequest{
Page:1,
Limit:2,
@@ -87,7 +87,7 @@ impl Context{
ResourceID:None,
}).await.map_err(SingleItemError::Other)?;
if 1<scripts.len(){
return Err(SingleItemError::DuplicateItems);
return Err(SingleItemError::DuplicateItems(scripts));
}
Ok(scripts.into_iter().next())
}
@@ -126,7 +126,7 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,ScriptPolicySingleItemError>{
let policies=self.get_script_policies(GetScriptPoliciesRequest{
Page:1,
Limit:2,
@@ -135,7 +135,7 @@ impl Context{
Policy:None,
}).await.map_err(SingleItemError::Other)?;
if 1<policies.len(){
return Err(SingleItemError::DuplicateItems);
return Err(SingleItemError::DuplicateItems(policies));
}
Ok(policies.into_iter().next())
}

View File

@@ -14,16 +14,21 @@ impl std::fmt::Display for Error{
impl std::error::Error for Error{}
#[derive(Debug)]
pub enum SingleItemError{
DuplicateItems,
pub enum SingleItemError<Items>{
DuplicateItems(Items),
Other(Error),
}
impl std::fmt::Display for SingleItemError{
impl<Items> std::fmt::Display for SingleItemError<Items>
where
Items:std::fmt::Debug
{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for SingleItemError{}
impl<Items> std::error::Error for SingleItemError<Items> where Items:std::fmt::Debug{}
pub type ScriptSingleItemError=SingleItemError<Vec<ScriptResponse>>;
pub type ScriptPolicySingleItemError=SingleItemError<Vec<ScriptPolicyResponse>>;
#[allow(dead_code)]
#[derive(Debug)]
@@ -173,6 +178,10 @@ pub enum Policy{
Replace=4,
}
#[allow(nonstandard_style)]
pub struct GetScriptPolicyRequest{
pub ScriptPolicyID:ScriptPolicyID,
}
#[allow(nonstandard_style)]
#[derive(Clone,Debug,serde::Serialize)]
pub struct GetScriptPoliciesRequest<'a>{

View File

@@ -1,6 +1,6 @@
use std::collections::{HashSet,HashMap};
use crate::download::download_asset_version;
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
use crate::rbx_util::{get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
use heck::{ToSnakeCase,ToTitleCase};
@@ -225,27 +225,33 @@ pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_d
// count objects (default count is 0)
let mut counts=Counts::default();
for instance in dom.descendants_of(model_instance.referent()){
if class_is_a(instance.class.as_str(),"BasePart"){
// Zones
match instance.name.parse(){
Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
Err(_)=>(),
}
// Spawns & Teleports
match instance.name.parse(){
Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()),
Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1,
Err(_)=>(),
}
// Wormholes
match instance.name.parse(){
Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1,
Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1,
Err(_)=>(),
}
let db=rbx_reflection_database::get();
let base_part=&db.classes["BasePart"];
let base_parts=dom.descendants_of(model_instance.referent()).filter(|&instance|
db.classes.get(instance.class.as_str()).is_some_and(|class|
db.has_superclass(class,base_part)
)
);
for instance in base_parts{
// Zones
match instance.name.parse(){
Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
Err(_)=>(),
}
// Spawns & Teleports
match instance.name.parse(){
Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()),
Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1,
Err(_)=>(),
}
// Wormholes
match instance.name.parse(){
Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1,
Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1,
Err(_)=>(),
}
}

View File

@@ -28,14 +28,6 @@ pub fn static_ustr(s:&'static str)->rbx_dom_weak::Ustr{
rbx_dom_weak::ustr(s)
}
pub fn class_is_a(class:&str,superclass:&str)->bool{
let db=rbx_reflection_database::get();
let (Some(class),Some(superclass))=(db.classes.get(class),db.classes.get(superclass))else{
return false;
};
db.has_superclass(class,superclass)
}
fn find_first_child_name_and_class<'a>(dom:&'a rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance,name:&str,class:&str)->Option<&'a rbx_dom_weak::Instance>{
instance.children().iter().filter_map(|&r|dom.get_by_ref(r)).find(|inst|inst.name==name&&inst.class==class)
}

View File

@@ -39,11 +39,11 @@ pub enum Error{
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
Download(crate::download::Error),
ModelFileDecode(ReadDomError),
ApiGetScriptPolicyFromHash(submissions_api::types::SingleItemError),
ApiGetScriptPolicyFromHash(submissions_api::types::ScriptPolicySingleItemError),
ApiGetScript(submissions_api::Error),
ApiCreateScript(submissions_api::Error),
ApiCreateScriptPolicy(submissions_api::Error),
ApiGetScriptFromHash(submissions_api::types::SingleItemError),
ApiGetScriptFromHash(submissions_api::types::ScriptSingleItemError),
ApiUpdateMapfixModel(submissions_api::Error),
ApiUpdateSubmissionModel(submissions_api::Error),
ModelFileRootMustHaveOneChild,

View File

@@ -13,18 +13,19 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"date-fns": "^4.1.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.82.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^20.17.9",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0"
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,43 @@
import { Button, Container, Paper, Typography } from "@mui/material";
import Webpage from "@/app/_components/webpage";
interface ErrorDisplayProps {
title: string;
message: string;
buttonText?: string;
onButtonClick?: () => void;
}
export function ErrorDisplay({
title,
message,
buttonText,
onButtonClick
}: ErrorDisplayProps) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom>{title}</Typography>
<Typography variant="body1">{message}</Typography>
{buttonText && onButtonClick && (
<Button
variant="contained"
onClick={onButtonClick}
sx={{ mt: 3 }}
>
{buttonText}
</Button>
)}
</Paper>
</Container>
</Webpage>
);
}

View File

@@ -0,0 +1,151 @@
import {Box, IconButton, Typography} from "@mui/material";
import {useEffect, useRef, useState} from "react";
import Link from "next/link";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import {SubmissionInfo} from "@/app/ts/Submission";
import {MapfixInfo} from "@/app/ts/Mapfix";
// Type for the items in the carousel
type CarouselItem = SubmissionInfo | MapfixInfo;
// Props for the Carousel component
interface CarouselProps<T extends CarouselItem> {
title: string;
items: T[] | undefined;
renderItem: (item: T) => React.ReactNode;
viewAllLink: string;
}
export function Carousel<T extends CarouselItem>({ title, items, renderItem, viewAllLink }: CarouselProps<T>) {
const carouselRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState<number>(0);
const [maxScroll, setMaxScroll] = useState<number>(0);
const SCROLL_AMOUNT = 300;
useEffect(() => {
if (carouselRef.current) {
const scrollWidth = carouselRef.current.scrollWidth;
const clientWidth = carouselRef.current.clientWidth;
setMaxScroll(scrollWidth - clientWidth);
}
}, [items]);
const scroll = (direction: 'left' | 'right'): void => {
if (carouselRef.current) {
const scrollAmount = direction === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT;
carouselRef.current.scrollBy({
left: scrollAmount,
behavior: 'smooth'
});
setTimeout(() => {
if (carouselRef.current) {
setScrollPosition(carouselRef.current.scrollLeft);
}
}, 300);
}
};
useEffect(() => {
const handleScroll = () => {
if (carouselRef.current) {
setScrollPosition(carouselRef.current.scrollLeft);
}
};
const ref = carouselRef.current;
if (ref) {
ref.addEventListener('scroll', handleScroll);
return () => ref.removeEventListener('scroll', handleScroll);
}
}, []);
return (
<Box mb={6}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4" component="h2" fontWeight="bold">
{title}
</Typography>
<Link href={viewAllLink} style={{textDecoration: 'none'}}>
<Typography component="span" color="primary">
View All
</Typography>
</Link>
</Box>
<Box position="relative">
<IconButton
sx={{
position: 'absolute',
left: -20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
backgroundColor: 'background.paper',
boxShadow: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
visibility: scrollPosition <= 5 ? 'hidden' : 'visible',
}}
onClick={() => scroll('left')}
>
<ArrowBackIosNewIcon />
</IconButton>
<Box
ref={carouselRef}
sx={{
display: 'flex',
overflowX: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
'&::-webkit-scrollbar': {
display: 'none',
},
gap: '16px', // Fixed 16px gap - using string with px unit to ensure it's absolute
padding: '8px 4px',
}}
>
{items?.map((item, index) => (
<Box
key={index}
sx={{
flex: '0 0 auto',
width: {
xs: '260px', // Fixed width at different breakpoints
sm: '280px',
md: '300px'
}
}}
>
{renderItem(item)}
</Box>
))}
</Box>
<IconButton
sx={{
position: 'absolute',
right: -20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
backgroundColor: 'background.paper',
boxShadow: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
visibility: scrollPosition >= maxScroll - 5 ? 'hidden' : 'visible',
}}
onClick={() => scroll('right')}
>
<ArrowForwardIosIcon />
</IconButton>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {
Box,
Avatar,
Typography,
Tooltip
} from "@mui/material";
import PersonIcon from '@mui/icons-material/Person';
import { formatDistanceToNow, format } from "date-fns";
import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent";
interface AuditEventItemProps {
event: AuditEvent;
validatorUser: number;
}
export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) {
return (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
>
<PersonIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
</Typography>
<DateDisplay date={event.Date} />
</Box>
<Typography variant="body2">{auditEventMessage(event)}</Typography>
</Box>
</Box>
);
}
interface DateDisplayProps {
date: number;
}
function DateDisplay({ date }: DateDisplayProps) {
return (
<Typography variant="caption" color="text.secondary">
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
</Typography>
</Tooltip>
</Typography>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import {
Box,
Stack,
} from "@mui/material";
import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent";
import AuditEventItem from './AuditEventItem';
interface AuditEventsTabPanelProps {
activeTab: number;
auditEvents: AuditEvent[];
validatorUser: number;
}
export default function AuditEventsTabPanel({
activeTab,
auditEvents,
validatorUser
}: AuditEventsTabPanelProps) {
const filteredEvents = auditEvents.filter(
event => event.EventType !== AuditEventType.Comment
);
return (
<Box role="tabpanel" hidden={activeTab !== 1}>
{activeTab === 1 && (
<Stack spacing={2}>
{filteredEvents.map((event, index) => (
<AuditEventItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))}
</Stack>
)}
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {
Box,
Avatar,
Typography,
Tooltip
} from "@mui/material";
import PersonIcon from '@mui/icons-material/Person';
import { formatDistanceToNow, format } from "date-fns";
import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent";
interface CommentItemProps {
event: AuditEvent;
validatorUser: number;
}
export default function CommentItem({ event, validatorUser }: CommentItemProps) {
return (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
>
<PersonIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
</Typography>
<DateDisplay date={event.Date} />
</Box>
<Typography variant="body2">{decodeAuditEvent(event)}</Typography>
</Box>
</Box>
);
}
interface DateDisplayProps {
date: number;
}
function DateDisplay({ date }: DateDisplayProps) {
return (
<Typography variant="caption" color="text.secondary">
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
</Typography>
</Tooltip>
</Typography>
);
}

View File

@@ -0,0 +1,65 @@
import React, {useState} from 'react';
import {
Paper,
Box,
Tabs,
Tab,
} from "@mui/material";
import CommentsTabPanel from './CommentsTabPanel';
import AuditEventsTabPanel from './AuditEventsTabPanel';
import { AuditEvent } from "@/app/ts/AuditEvent";
interface CommentsAndAuditSectionProps {
auditEvents: AuditEvent[];
newComment: string;
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
validatorUser: number;
userId: number | null;
}
export default function CommentsAndAuditSection({
auditEvents,
newComment,
setNewComment,
handleCommentSubmit,
validatorUser,
userId,
}: CommentsAndAuditSectionProps) {
const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
};
return (
<Paper sx={{ p: 3, mt: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
aria-label="comments and audit tabs"
>
<Tab label="Comments" />
<Tab label="Audit Events" />
</Tabs>
</Box>
<CommentsTabPanel
activeTab={activeTab}
auditEvents={auditEvents}
validatorUser={validatorUser}
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
/>
<AuditEventsTabPanel
activeTab={activeTab}
auditEvents={auditEvents}
validatorUser={validatorUser}
/>
</Paper>
);
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
import {
Box,
Stack,
Avatar,
TextField,
IconButton
} from "@mui/material";
import SendIcon from '@mui/icons-material/Send';
import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent";
import CommentItem from './CommentItem';
interface CommentsTabPanelProps {
activeTab: number;
auditEvents: AuditEvent[];
validatorUser: number;
newComment: string;
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
}
export default function CommentsTabPanel({
activeTab,
auditEvents,
validatorUser,
newComment,
setNewComment,
handleCommentSubmit,
userId
}: CommentsTabPanelProps) {
const commentEvents = auditEvents.filter(
event => event.EventType === AuditEventType.Comment
);
return (
<Box role="tabpanel" hidden={activeTab !== 0}>
{activeTab === 0 && (
<>
<Stack spacing={2} sx={{ mb: 3 }}>
{commentEvents.map((event, index) => (
<CommentItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))}
</Stack>
<CommentInput
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
/>
</>
)}
</Box>
);
}
interface CommentInputProps {
newComment: string;
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
}
function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) {
return (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar
src={`/thumbnails/user/${userId}`}
/>
<TextField
fullWidth
multiline
rows={2}
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<IconButton
color="primary"
onClick={handleCommentSubmit}
disabled={!newComment.trim()}
>
<SendIcon />
</IconButton>
</Box>
);
}

View File

@@ -2,63 +2,244 @@
import Link from "next/link"
import Image from "next/image";
import "./styles/header.scss"
import { UserInfo } from "@/app/ts/User";
import { useState, useEffect } from "react";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
interface HeaderButton {
name: string,
href: string
name: string;
href: string;
}
const navItems: HeaderButton[] = [
{ name: "Submissions", href: "/submissions" },
{ name: "Mapfixes", href: "/mapfixes" },
{ name: "Maps", href: "/maps" },
];
function HeaderButton(header: HeaderButton) {
return (
<Link href={header.href}>
<button>{header.name}</button>
</Link>
)
return (
<Button color="inherit" component={Link} href={header.href}>
{header.name}
</Button>
);
}
export default function Header() {
const handleLoginClick = () => {
window.location.href = "/auth/oauth2/login?redirect=" + window.location.href;
};
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const [valid, setValid] = useState<boolean>(false)
const [user, setUser] = useState<UserInfo | null>(null)
const handleLoginClick = () => {
window.location.href =
"/auth/oauth2/login?redirect=" + window.location.href;
};
useEffect(() => {
async function getLoginInfo() {
const [validateData, userData] = await Promise.all([
fetch("/api/session/validate").then(validateResponse => validateResponse.json()),
fetch("/api/session/user").then(userResponse => userResponse.json())
]);
setValid(validateData)
setUser(userData)
}
getLoginInfo()
}, [])
const [valid, setValid] = useState<boolean>(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
return (
<header className="header-bar">
<nav className="left">
<HeaderButton name="Submissions" href="/submissions"/>
<HeaderButton name="Mapfixes" href="/mapfixes"/>
<HeaderButton name="Maps" href="/maps"/>
</nav>
<nav className="right">
<HeaderButton name="Submit" href="/submit"/>
{valid && user ? (
<div className="author">
<Link href="/auth">
<Image className="avatar" width={28} height={28} priority={true} src={user.AvatarURL} alt={user.Username}/>
<button>{user.Username}</button>
</Link>
</div>
) : (
<button onClick={handleLoginClick}>Login</button>
)}
</nav>
</header>
)
}
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
useEffect(() => {
async function getLoginInfo() {
try {
const response = await fetch("/api/session/user");
if (!response.ok) {
setValid(false);
setUser(null);
return;
}
const userData = await response.json();
const isLoggedIn = userData && 'UserID' in userData;
setValid(isLoggedIn);
setUser(isLoggedIn ? userData : null);
} catch (error) {
console.error("Error fetching user data:", error);
setValid(false);
setUser(null);
}
}
getLoginInfo();
}, []);
// Mobile navigation drawer content
const drawer = (
<Box onClick={handleDrawerToggle} sx={{ textAlign: 'center' }}>
<List>
{navItems.map((item) => (
<ListItem key={item.name} disablePadding>
<ListItemButton component={Link} href={item.href} sx={{ textAlign: 'center' }}>
<ListItemText primary={item.name} />
</ListItemButton>
</ListItem>
))}
{valid && user && (
<ListItem disablePadding>
<ListItemButton component={Link} href="/submit" sx={{ textAlign: 'center' }}>
<ListItemText primary="Submit Map" sx={{ color: 'success.main' }} />
</ListItemButton>
</ListItem>
)}
{!valid && (
<ListItem disablePadding>
<ListItemButton onClick={handleLoginClick} sx={{ textAlign: 'center' }}>
<ListItemText primary="Login" />
</ListItemButton>
</ListItem>
)}
{valid && user && (
<ListItem disablePadding>
<ListItemButton component={Link} href="/auth" sx={{ textAlign: 'center' }}>
<ListItemText primary="Manage Account" />
</ListItemButton>
</ListItem>
)}
</List>
</Box>
);
return (
<AppBar position="static">
<Toolbar>
{isMobile && (
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
)}
{/* Desktop navigation */}
{!isMobile && (
<Box display="flex" flexGrow={1} gap={2}>
{navItems.map((item) => (
<HeaderButton key={item.name} name={item.name} href={item.href} />
))}
</Box>
)}
{/* Spacer for mobile view */}
{isMobile && <Box sx={{ flexGrow: 1 }} />}
{/* Right side of nav */}
<Box display="flex" gap={2}>
{!isMobile && valid && user && (
<Button variant="outlined" color="success" component={Link} href="/submit">
Submit Map
</Button>
)}
{!isMobile && valid && user ? (
<Box display="flex" alignItems="center">
<Button
onClick={handleMenuOpen}
color="inherit"
size="small"
style={{ textTransform: "none" }}
>
<Image
className="avatar"
width={28}
height={28}
priority={true}
src={user.AvatarURL}
alt={user.Username}
style={{ marginRight: 8 }}
/>
<Typography variant="body1">{user.Username}</Typography>
</Button>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<MenuItem component={Link} href="/auth">
Manage
</MenuItem>
</Menu>
</Box>
) : !isMobile && (
<Button color="inherit" onClick={handleLoginClick}>
Login
</Button>
)}
{/* In mobile view, display just the avatar if logged in */}
{isMobile && valid && user && (
<IconButton
onClick={handleMenuOpen}
color="inherit"
size="small"
>
<Image
className="avatar"
width={28}
height={28}
priority={true}
src={user.AvatarURL}
alt={user.Username}
/>
</IconButton>
)}
</Box>
</Toolbar>
{/* Mobile drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile
}}
sx={{
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: 240 },
}}
>
{drawer}
</Drawer>
</AppBar>
);
}

View File

@@ -1,71 +1,186 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Rating } from "@mui/material";
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Grid, Typography} from "@mui/material";
import {Explore, Person2} from "@mui/icons-material";
import {StatusChip} from "@/app/_components/statusChip";
interface SubmissionCardProps {
interface MapCardProps {
displayName: string;
assetId: number;
authorId: number;
author: string;
rating: number;
id: number;
statusID: number;
gameID: number;
created: number;
type: 'mapfix' | 'submission';
}
export function SubmissionCard(props: SubmissionCardProps) {
return (
<Link href={`/submissions/${props.id}`}>
<div className="submissionCard">
<div className="content">
<div className="map-image">
{/* TODO: Grab image of model */}
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
</div>
<div className="details">
<div className="header">
<span className="displayName">{props.displayName}</span>
<div className="rating">
<Rating value={props.rating} readOnly size="small" />
</div>
</div>
<div className="footer">
<div className="author">
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
<span>{props.author}</span>
</div>
</div>
</div>
</div>
</div>
</Link>
);
}
const CARD_WIDTH = 270;
export function MapfixCard(props: SubmissionCardProps) {
export function MapCard(props: MapCardProps) {
return (
<Link href={`/mapfixes/${props.id}`}>
<div className="MapfixCard">
<div className="content">
<div className="map-image">
{/* TODO: Grab image of model */}
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
</div>
<div className="details">
<div className="header">
<span className="displayName">{props.displayName}</span>
<div className="rating">
<Rating value={props.rating} readOnly size="small" />
</div>
</div>
<div className="footer">
<div className="author">
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
<span>{props.author}</span>
</div>
</div>
</div>
</div>
</div>
</Link>
);
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
<Box sx={{
width: CARD_WIDTH,
mx: 'auto', // Center the card in its grid cell
}}>
<Card sx={{
width: CARD_WIDTH,
height: 340, // Fixed height for all cards
display: 'flex',
flexDirection: 'column',
}}>
<CardActionArea
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch'
}}
href={`/${props.type === 'submission' ? 'submissions' : 'mapfixes'}/${props.id}`}>
<Box sx={{ position: 'relative' }}>
<CardMedia
component="img"
image={`/thumbnails/asset/${props.assetId}`}
alt={props.displayName}
sx={{
height: 160, // Fixed height for all images
objectFit: 'cover',
}}
/>
<Box
sx={{
position: 'absolute',
top: 12,
right: 12,
}}
>
<StatusChip status={props.statusID}/>
</Box>
</Box>
<CardContent sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
p: 2,
width: '100%',
}}>
<Box>
<Typography
variant="subtitle1"
component="div"
sx={{
mb: 1,
fontWeight: 600,
color: '#fff',
lineHeight: '1.3',
// Allow text to wrap
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{props.displayName}
</Typography>
<Box sx={{
display: 'flex',
mb: 1.5,
}}>
<Explore sx={{
mr: 0.75,
mt: 0.25,
color: 'text.secondary',
fontSize: '0.9rem',
flexShrink: 0,
}} />
<Typography
variant="body2"
color="text.secondary"
sx={{
fontWeight: 500,
// Allow text to wrap
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: '1.2',
wordBreak: 'break-word',
}}
>
{props.gameID === 1 ? 'Bhop' : props.gameID === 2 ? 'Surf' : props.gameID === 5 ? 'Fly Trials' : props.gameID === 4 ? 'Deathrun' : 'Unknown'}
</Typography>
</Box>
<Box sx={{
display: 'flex',
mb: 1.5,
}}>
<Person2 sx={{
mr: 0.75,
mt: 0.25,
color: 'text.secondary',
fontSize: '0.9rem',
flexShrink: 0,
}} />
<Typography
variant="body2"
color="text.secondary"
sx={{
fontWeight: 500,
// Allow text to wrap
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: '1.2',
wordBreak: 'break-word',
}}
>
{props.author}
</Typography>
</Box>
</Box>
<Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar
src={`/thumbnails/user/${props.authorId}`}
alt={props.author}
sx={{
width: 24,
height: 24,
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
/>
<Typography
variant="caption"
sx={{
ml: 1,
color: 'text.secondary',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{/*In the future author should be the username of the submitter not the info from the map*/}
{props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Typography>
</Box>
</Box>
</CardContent>
</CardActionArea>
</Card>
</Box>
</Grid>
)
}

View File

@@ -0,0 +1,38 @@
import { Typography, Box, IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
interface CopyableFieldProps {
label: string;
value: string | number | null | undefined;
onCopy: (value: string) => void;
placeholderText?: string;
}
export const CopyableField = ({
label,
value,
onCopy,
placeholderText = "Not assigned"
}: CopyableFieldProps) => {
const displayValue = value?.toString() || placeholderText;
return (
<>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{displayValue}</Typography>
{value && (
<Tooltip title="Copy ID">
<IconButton
size="small"
onClick={() => onCopy(value.toString())}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</>
);
};

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { Button, Stack } from '@mui/material';
import {MapfixInfo } from "@/app/ts/Mapfix";
import {hasRole, Roles, RolesConstants} from "@/app/ts/Roles";
import {SubmissionInfo, SubmissionStatus} from "@/app/ts/Submission";
interface ReviewAction {
name: string,
action: string,
}
interface ReviewButtonsProps {
onClick: (action: string, id: number) => void;
item: (SubmissionInfo | MapfixInfo);
userId: number | null;
roles: Roles;
type: "submission" | "mapfix";
}
const ReviewActions = {
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
BypassSubmit: {name:"Bypass Submit", action:"bypass-submit"} as ReviewAction,
ResetSubmitting: {name:"Reset Submitting",action:"reset-submitting"} as ReviewAction,
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
Reject: {name:"Reject",action:"reject"} as ReviewAction,
Validate: {name:"Validate",action:"retry-validate"} as ReviewAction,
ResetValidating: {name:"Reset Validating",action:"reset-validating"} as ReviewAction,
RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction,
Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction,
ResetUploading: {name:"Reset Uploading",action:"reset-uploading"} as ReviewAction,
}
const ReviewButtons: React.FC<ReviewButtonsProps> = ({
onClick,
item,
userId,
roles,
type,
}) => {
const getVisibleButtons = () => {
if (!item || userId === null) return [];
// Define a type for the button
type ReviewButton = {
action: ReviewAction;
color: "primary" | "error" | "success" | "info" | "warning";
};
const buttons: ReviewButton[] = [];
const is_submitter = userId === item.Submitter;
const status = item.StatusID;
// Helper function to check status regardless of which enum type it is
const statusMatches = (statusValues: number[]) => {
return statusValues.includes(status);
};
// Create status constants that work with both types
const Status = {
UnderConstruction: SubmissionStatus.UnderConstruction,
ChangesRequested: SubmissionStatus.ChangesRequested,
Submitting: SubmissionStatus.Submitting,
Submitted: SubmissionStatus.Submitted,
AcceptedUnvalidated: SubmissionStatus.AcceptedUnvalidated,
Validating: SubmissionStatus.Validating,
Validated: SubmissionStatus.Validated,
Uploading: SubmissionStatus.Uploading,
Uploaded: SubmissionStatus.Uploaded,
Rejected: SubmissionStatus.Rejected,
Release: SubmissionStatus.Released
};
const reviewRole = type === "submission" ? RolesConstants.SubmissionReview : RolesConstants.MapfixReview;
const uploadRole = type === "submission" ? RolesConstants.SubmissionUpload : RolesConstants.MapfixUpload;
if (is_submitter) {
if (statusMatches([Status.UnderConstruction, Status.ChangesRequested])) {
buttons.push({
action: ReviewActions.Submit,
color: "primary"
});
}
if (statusMatches([Status.Submitted, Status.ChangesRequested])) {
buttons.push({
action: ReviewActions.Revoke,
color: "error"
});
}
}
// Buttons for review role
if (hasRole(roles, reviewRole)) {
if (status === Status.Submitted && !is_submitter) {
buttons.push(
{
action: ReviewActions.Accept,
color: "success"
},
{
action: ReviewActions.Reject,
color: "error"
}
);
}
if (status === Status.AcceptedUnvalidated) {
buttons.push({
action: ReviewActions.Validate,
color: "info"
});
}
if (status === Status.Validating) {
buttons.push({
action: ReviewActions.ResetValidating,
color: "warning"
});
}
if (statusMatches([Status.Validated, Status.AcceptedUnvalidated, Status.Submitted]) && !is_submitter) {
buttons.push({
action: ReviewActions.RequestChanges,
color: "warning"
});
}
if (status === Status.ChangesRequested) {
buttons.push({
action: ReviewActions.BypassSubmit,
color: "warning"
});
}
}
// Buttons for upload role
if (hasRole(roles, uploadRole)) {
if (status === Status.Validated) {
buttons.push({
action: ReviewActions.Upload,
color: "success"
});
}
if (status === Status.Uploading) {
buttons.push({
action: ReviewActions.ResetUploading,
color: "warning"
});
}
}
return buttons;
};
return (
<Stack spacing={2} sx={{ mb: 3 }}>
{getVisibleButtons().map((button, index) => (
<Button
key={index}
variant="contained"
color={button.color}
fullWidth
onClick={() => onClick(button.action.action, item.ID)}
>
{button.action.name}
</Button>
))}
</Stack>
);
};
export default ReviewButtons;

View File

@@ -0,0 +1,86 @@
import { Paper, Grid, Typography } from "@mui/material";
import { ReviewItemHeader } from "./ReviewItemHeader";
import { CopyableField } from "@/app/_components/review/CopyableField";
import { SubmissionInfo } from "@/app/ts/Submission";
import { MapfixInfo } from "@/app/ts/Mapfix";
// Define a field configuration for specific types
interface FieldConfig {
key: string;
label: string;
placeholder?: string;
}
type ReviewItemType = SubmissionInfo | MapfixInfo;
interface ReviewItemProps {
item: ReviewItemType;
handleCopyValue: (value: string) => void;
}
export function ReviewItem({
item,
handleCopyValue
}: ReviewItemProps) {
// Type guard to check if item is valid
if (!item) return null;
// Determine the type of item
const isSubmission = 'UploadedAssetID' in item;
const isMapfix = 'TargetAssetID' in item;
// Define static fields based on item type
let fields: FieldConfig[] = [];
if (isSubmission) {
// Fields for Submission
fields = [
{ key: 'Submitter', label: 'Submitter ID' },
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'UploadedAssetID', label: 'Uploaded Asset ID' },
];
} else if (isMapfix) {
// Fields for Mapfix
fields = [
{ key: 'Submitter', label: 'Submitter ID' },
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'TargetAssetID', label: 'Target Asset ID' },
];
}
return (
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 4 }}>
<ReviewItemHeader
displayName={item.DisplayName}
statusId={item.StatusID}
creator={item.Creator}
submitterId={item.Submitter}
/>
{/* Item Details */}
<Grid container spacing={2} sx={{ mt: 2 }}>
{fields.map((field) => (
<Grid item xs={12} sm={6} key={field.key}>
<CopyableField
label={field.label}
value={(item as never)[field.key]}
onCopy={handleCopyValue}
placeholderText={field.placeholder}
/>
</Grid>
))}
</Grid>
{/* Description Section */}
{isMapfix && item.Description && (
<div style={{ marginTop: 24 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Description
</Typography>
<Typography variant="body1">
{item.Description}
</Typography>
</div>
)}
</Paper>
);
}

View File

@@ -0,0 +1,36 @@
import {Typography, Box, Avatar} from "@mui/material";
import { StatusChip } from "@/app/_components/statusChip";
import { SubmissionStatus } from "@/app/ts/Submission";
import { MapfixStatus } from "@/app/ts/Mapfix";
type StatusIdType = SubmissionStatus | MapfixStatus;
interface ReviewItemHeaderProps {
displayName: string;
statusId: StatusIdType;
creator: string | null | undefined;
submitterId: number;
}
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
return (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
{displayName}
</Typography>
<StatusChip status={statusId as number} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar
src={`/thumbnails/user/${submitterId}`}
sx={{ mr: 1, width: 24, height: 24 }}
/>
<Typography variant="body1">
by {creator || "Unknown Creator"}
</Typography>
</Box>
</>
);
};

View File

@@ -0,0 +1,87 @@
import React, {JSX} from "react";
import {Cancel, CheckCircle, Pending} from "@mui/icons-material";
import {Chip} from "@mui/material";
export const StatusChip = ({status}: { status: number }) => {
let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
let icon: JSX.Element = <Pending fontSize="small"/>;
let label: string = 'Unknown';
switch (status) {
case 0:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Under Construction';
break;
case 1:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Changes Requested';
break;
case 2:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Submitting';
break;
case 3:
color = 'warning';
icon = <CheckCircle fontSize="small"/>;
label = 'Under Review';
break;
case 4:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Script Review';
break;
case 5:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Validating';
break;
case 6:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Validated';
break;
case 7:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Uploading';
break;
case 8:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Uploaded';
break;
case 9:
color = 'error';
icon = <Cancel fontSize="small"/>;
label = 'Rejected';
break;
case 10:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Released';
break;
default:
color = 'default';
icon = <Pending fontSize="small"/>;
label = 'Unknown';
break;
}
return (
<Chip
icon={icon}
label={label}
color={color}
size="small"
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
);
};

View File

@@ -1,9 +1,16 @@
'use client';
import "./globals.scss";
import {theme} from "@/app/lib/theme";
import {ThemeProvider} from "@mui/material";
export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
return (
<html lang="en">
<body>{children}</body>
<body>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</body>
</html>
);
}

91
web/src/app/lib/theme.tsx Normal file
View File

@@ -0,0 +1,91 @@
import {createTheme} from "@mui/material";
export const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#90caf9',
},
secondary: {
main: '#f48fb1',
},
background: {
default: '#121212',
paper: '#1e1e1e',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h5: {
fontWeight: 500,
letterSpacing: '0.5px',
},
subtitle1: {
fontWeight: 500,
fontSize: '0.95rem',
},
body2: {
fontSize: '0.875rem',
},
caption: {
fontSize: '0.75rem',
},
},
shape: {
borderRadius: 8,
},
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 8,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.2)',
},
},
},
},
MuiCardMedia: {
styleOverrides: {
root: {
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
padding: 16,
'&:last-child': {
paddingBottom: 16,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
fontWeight: 500,
},
},
},
MuiDivider: {
styleOverrides: {
root: {
borderColor: 'rgba(255, 255, 255, 0.1)',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
},
},
},
});

View File

@@ -1,75 +0,0 @@
@forward "../../_components/styles/mapCard.scss";
@use "../../globals.scss";
a {
color:rgb(255, 255, 255);
&:visited, &:hover, &:focus {
text-decoration: none;
color: rgb(255, 255, 255);
}
&:active {
color: rgb(192, 192, 192)
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-rows: repeat(3, 1fr);
gap: 16px;
max-width: 100%;
margin: 0 auto;
overflow-x: hidden;
box-sizing: border-box;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 0.3rem;
}
.pagination button {
padding: 0.25rem 0.5rem;
font-size: 1.15rem;
border: none;
border-radius: 0.35rem;
background-color: #33333350;
color: #fff;
cursor: pointer;
}
.pagination button:disabled {
background-color: #5555559a;
cursor: not-allowed;
}
.pagination-dots {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
width: 100%;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #bbb;
cursor: pointer;
}
.dot.active {
background-color: #333;
}

View File

@@ -1,19 +0,0 @@
@forward "./page/commentWindow.scss";
@forward "./page/reviewStatus.scss";
@forward "./page/ratingWindow.scss";
@forward "./page/reviewButtons.scss";
@forward "./page/comments.scss";
@forward "./page/review.scss";
@forward "./page/map.scss";
@use "../../../globals.scss";
.map-page-main {
display: flex;
justify-content: center;
width: 100vw;
}
.by-creator {
margin-top: 10px;
}

View File

@@ -1,56 +0,0 @@
@use "../../../../globals.scss";
#comment-text-field {
@include globals.border-with-radius;
resize: none;
width: 100%;
height: 100px;
background-color: var(--comment-area)
}
.leave-comment-window {
@include globals.border-with-radius;
width: 100%;
height: 230px;
margin-top: 35px;
.rating-type {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
gap: 35%;
.rating-right {
display: grid;
> span {
margin: 6px 0 6px 0;
}
}
p {
margin: 15px 0 15px 0;
}
}
header {
display: flex;
align-items: center;
background-color: var(--window-header);
border-bottom: globals.$review-border;
height: 45px;
p {
font-weight: bold;
margin: 0 0 0 20px;
}
}
main {
padding: 20px;
button {
margin-top: 9px;
}
}
}

View File

@@ -1,49 +0,0 @@
$comments-size: 60px;
.comments {
display: grid;
gap: 25px;
margin-top: 20px;
.no-comments {
text-align: center;
margin: 0;
}
.commenter {
display: flex;
height: $comments-size;
//BhopMaptest comment
&[data-highlighted="true"] {
background-color: var(--comment-highlighted);
}
> img {
border-radius: 50%;
}
.name {
font: {
weight: 500;
size: 1.3em;
};
}
.date {
font-size: .8em;
margin: 0 0 0 5px;
color: #646464
}
.details {
display: grid;
margin-left: 10px;
header {
display: flex;
align-items: center;
}
p:not(.date) {
margin: 0;
}
}
}
}

View File

@@ -1,15 +0,0 @@
@use "../../../../globals.scss";
.map-image-area {
@include globals.border-with-radius;
display: flex;
justify-content: center;
align-items: center;
width: 350px;
height: 350px;
> p {
text-align: center;
margin: 0;
}
}

View File

@@ -1,43 +0,0 @@
@use "../../../../globals.scss";
.rating-window {
@include globals.border-with-radius;
width: 100%;
.rating-type {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
gap: 35%;
.rating-right {
display: grid;
> span {
margin: 6px 0 6px 0;
}
}
p {
margin: 15px 0 15px 0;
}
}
header {
display: flex;
align-items: center;
background-color: var(--window-header);
border-bottom: globals.$review-border;
height: 45px;
p {
font-weight: bold;
margin: 0 0 0 20px;
}
}
main {
display: grid;
place-items: center;
}
}

View File

@@ -1,47 +0,0 @@
@use "../../../../globals.scss";
.review-info {
width: 650px;
height: 100%;
> div {
display: flex;
justify-content: space-between;
align-items: center;
}
p, h1 {
color: var(--text-color);
}
h1 {
font: {
weight: 500;
size: 1.8rem
};
margin: 0;
}
a {
color: var(--anchor-link-review);
&:hover {
text-decoration: underline;
}
}
}
.review-section {
display: flex;
gap: 50px;
margin-top: 20px;
}
.review-area {
display: grid;
justify-content: center;
gap: 25px;
img {
width: 100%;
height: 350px;
object-fit: contain
}
}

View File

@@ -1,13 +0,0 @@
@use "../../../../globals.scss";
.review-set {
@include globals.border-with-radius;
display: grid;
align-items: center;
gap: 10px;
padding: 10px;
button {
width: 100%;
}
}

View File

@@ -1,80 +0,0 @@
$UnderConstruction: "0";
$Submitted: "1";
$ChangesRequested: "2";
$Accepted: "3";
$Validating: "4";
$Validated: "5";
$Uploading: "6";
$Uploaded: "7";
$Rejected: "8";
$Released: "9";
.review-status {
border-radius: 5px;
p {
margin: 3px 25px 3px 25px;
font-weight: bold;
}
&[data-review-status="#{$Released}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Rejected}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Uploading}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Uploaded}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Validated}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Validating}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Accepted}"] {
background-color: rgb(2, 162, 2);
p {
color: white;
}
}
&[data-review-status="#{$ChangesRequested}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Submitted}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$UnderConstruction}"] {
background-color: orange;
p {
color: white;
}
}
}

View File

@@ -1,71 +0,0 @@
import type { MapfixInfo } from "@/app/ts/Mapfix";
import { Button } from "@mui/material"
import Window from "./_window";
import SendIcon from '@mui/icons-material/Send';
import Image from "next/image";
interface CommentersProps {
comments_data: CreatorAndReviewStatus
}
interface CreatorAndReviewStatus {
asset_id: MapfixInfo["AssetID"],
creator: MapfixInfo["DisplayName"],
review: MapfixInfo["StatusID"],
submitter: MapfixInfo["Submitter"],
target_asset_id: MapfixInfo["TargetAssetID"],
description: MapfixInfo["Description"],
comments: Comment[],
name: string
}
interface Comment {
picture?: string, //TEMP
comment: string,
date: string,
name: string
}
function AddComment(comment: Comment) {
const IsBhopMaptest = comment.name == "BhopMaptest" //Highlighted commenter
return (
<div className="commenter" data-highlighted={IsBhopMaptest}>
<Image src={comment.picture as string} alt={`${comment.name}'s comment`}/>
<div className="details">
<header>
<p className="name">{comment.name}</p>
<p className="date">{comment.date}</p>
</header>
<p className="comment">{comment.comment}</p>
</div>
</div>
);
}
function LeaveAComment() {
return (
<Window title="Leave a Comment:" className="leave-comment-window">
<textarea name="comment-box" id="comment-text-field"></textarea>
<Button variant="outlined" endIcon={<SendIcon/>}>Submit</Button>
</Window>
)
}
export function Comments(stats: CommentersProps) {
return (<>
<section className="comments">
{stats.comments_data.comments.length===0
&& <p className="no-comments">There are no comments.</p>
|| stats.comments_data.comments.map(comment => (
<AddComment key={comment.name} name={comment.name} date={comment.date} comment={comment.comment}/>
))}
</section>
<LeaveAComment/>
</>)
}
export {
type CreatorAndReviewStatus,
type Comment,
}

View File

@@ -1,31 +0,0 @@
import Image from "next/image";
import { MapfixInfo } from "@/app/ts/Mapfix"
interface AssetID {
id: MapfixInfo["AssetID"]
}
function MapImage({ id }: AssetID) {
if (!id) {
return <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export {
type AssetID,
MapImage
}

View File

@@ -1,169 +0,0 @@
import { Roles, RolesConstants } from "@/app/ts/Roles";
import { MapfixStatus } from "@/app/ts/Mapfix";
import { Button, ButtonOwnProps } from "@mui/material";
import { useState, useEffect } from "react";
interface ReviewAction {
name: string,
action: string,
}
const ReviewActions = {
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction,
ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction,
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
Reject: {name:"Reject",action:"reject"} as ReviewAction,
Validate: {name:"Validate",action:"retry-validate"} as ReviewAction,
ResetValidating: {name:"Reset Validating (fix softlocked status)",action:"reset-validating"} as ReviewAction,
RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction,
Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction,
ResetUploading: {name:"Reset Uploading (fix softlocked status)",action:"reset-uploading"} as ReviewAction,
}
interface ReviewButton {
action: ReviewAction,
mapfixId: string,
color: ButtonOwnProps["color"]
}
interface ReviewId {
mapfixId: string,
mapfixStatus: number,
mapfixSubmitter: number,
}
async function ReviewButtonClicked(action: string, mapfixId: string) {
try {
const response = await fetch(`/api/mapfixes/${mapfixId}/status/${action}`, {
method: "POST",
headers: {
"Content-type": "application/json",
}
});
// Check if the HTTP request was successful
if (!response.ok) {
const errorDetails = await response.text();
// Throw an error with detailed information
throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`);
}
window.location.reload();
} catch (error) {
console.error("Error updating mapfix status:", error);
}
}
function ReviewButton(props: ReviewButton) {
return <Button
color={props.color}
variant="contained"
onClick={() => { ReviewButtonClicked(props.action.action, props.mapfixId) }}>{props.action.name}</Button>
}
export default function ReviewButtons(props: ReviewId) {
// When is each button visible?
// Multiple buttons can be visible at once.
// Action | Role | When Current Status is One of:
// ---------------|-----------|-----------------------
// Submit | Submitter | UnderConstruction, ChangesRequested
// Revoke | Submitter | Submitted, ChangesRequested
// Accept | Reviewer | Submitted
// Validate | Reviewer | Accepted
// ResetValidating| Reviewer | Validating
// Reject | Reviewer | Submitted
// RequestChanges | Reviewer | Validated, Accepted, Submitted
// Upload | MapAdmin | Validated
// ResetUploading | MapAdmin | Uploading
const { mapfixId, mapfixStatus } = props;
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [rolesData, userData] = await Promise.all([
fetch("/api/session/roles").then(rolesResponse => rolesResponse.json()),
fetch("/api/session/user").then(userResponse => userResponse.json())
]);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
}
fetchData();
}, [mapfixId]);
if (loading) return <p>Loading...</p>;
const visibleButtons: ReviewButton[] = [];
const is_submitter = user === props.mapfixSubmitter;
if (is_submitter) {
if ([MapfixStatus.UnderConstruction, MapfixStatus.ChangesRequested].includes(mapfixStatus!)) {
visibleButtons.push({ action: ReviewActions.Submit, color: "info", mapfixId });
}
if ([MapfixStatus.Submitted, MapfixStatus.ChangesRequested].includes(mapfixStatus!)) {
visibleButtons.push({ action: ReviewActions.Revoke, color: "info", mapfixId });
}
if (mapfixStatus === MapfixStatus.Submitting) {
visibleButtons.push({ action: ReviewActions.ResetSubmitting, color: "error", mapfixId });
}
}
if (roles&RolesConstants.MapfixReview) {
// you can force submit a map in ChangesRequested status
if (!is_submitter && mapfixStatus === MapfixStatus.ChangesRequested) {
visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", mapfixId });
}
// you can't review your own mapfix!
// note that this means there needs to be more than one person with MapfixReview
if (!is_submitter && mapfixStatus === MapfixStatus.Submitted) {
visibleButtons.push({ action: ReviewActions.Accept, color: "info", mapfixId });
visibleButtons.push({ action: ReviewActions.Reject, color: "error", mapfixId });
}
if (mapfixStatus === MapfixStatus.AcceptedUnvalidated) {
visibleButtons.push({ action: ReviewActions.Validate, color: "info", mapfixId });
}
if (mapfixStatus === MapfixStatus.Validating) {
visibleButtons.push({ action: ReviewActions.ResetValidating, color: "error", mapfixId });
}
// this button serves the same purpose as Revoke if you are both
// the map submitter and have MapfixReview when status is Submitted
if (
[MapfixStatus.Validated, MapfixStatus.AcceptedUnvalidated].includes(mapfixStatus!)
|| !is_submitter && mapfixStatus == MapfixStatus.Submitted
) {
visibleButtons.push({ action: ReviewActions.RequestChanges, color: "error", mapfixId });
}
}
if (roles&RolesConstants.MapfixUpload) {
if (mapfixStatus === MapfixStatus.Validated) {
visibleButtons.push({ action: ReviewActions.Upload, color: "info", mapfixId });
}
if (mapfixStatus === MapfixStatus.Uploading) {
visibleButtons.push({ action: ReviewActions.ResetUploading, color: "error", mapfixId });
}
}
return (
<section className="review-set">
{visibleButtons.length === 0 ? (
<p>No available actions</p>
) : (
visibleButtons.map((btn) => (
<ReviewButton key={btn.action.action} {...btn} />
))
)}
</section>
);
}

View File

@@ -1,20 +0,0 @@
interface WindowStruct {
className: string,
title: string,
children: React.ReactNode
}
export default function Window(window: WindowStruct) {
return (
<section className={window.className}>
<header>
<p>{window.title}</p>
</header>
<main>{window.children}</main>
</section>
)
}
export {
type WindowStruct
}

View File

@@ -1,127 +1,420 @@
"use client"
"use client";
import { MapfixInfo, MapfixStatusToString } from "@/app/ts/Mapfix";
import type { CreatorAndReviewStatus } from "./_comments";
import { MapImage } from "./_mapImage";
import { useParams } from "next/navigation";
import ReviewButtons from "./_reviewButtons";
import { Comments, Comment } from "./_comments";
import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent";
import {AuditEvent} from "@/app/ts/AuditEvent";
import { Roles, RolesConstants } from "@/app/ts/Roles";
import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation";
import {useState, useEffect, useCallback} from "react";
import Link from "next/link";
import { useState, useEffect } from "react";
import "./(styles)/page.scss";
// MUI Components
import {
Typography,
Box,
Container,
Breadcrumbs,
Paper,
Skeleton,
Grid,
CardMedia,
Snackbar,
Alert,
} from "@mui/material";
interface ReviewId {
mapfixId: string,
mapfixStatus: number,
mapfixSubmitter: number,
mapfixAssetId: number,
mapfixTargetAssetId: number,
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAuditSection";
import {ReviewItem} from "@/app/_components/review/ReviewItem";
import {ErrorDisplay} from "@/app/_components/ErrorDisplay";
import {MapfixInfo} from "@/app/ts/Mapfix";
import ReviewButtons from "@/app/_components/review/ReviewButtons";
// Review action definitions
interface SnackbarState {
open: boolean;
message: string | null;
severity: 'success' | 'error' | 'info' | 'warning';
}
function RatingArea(mapfix: ReviewId) {
return (
<aside className="review-area">
<section className="map-image-area">
<div>
<p className="this-mapfix">This Mapfix:</p>
<MapImage id={mapfix.mapfixAssetId}/>
</div>
<div>
<p className="target-map">Target Map Being Fixed:</p>
<MapImage id={mapfix.mapfixTargetAssetId}/>
</div>
</section>
<ReviewButtons mapfixId={mapfix.mapfixId} mapfixStatus={mapfix.mapfixStatus} mapfixSubmitter={mapfix.mapfixSubmitter}/>
</aside>
)
}
export default function MapfixDetailsPage() {
const { mapfixId } = useParams<{ mapfixId: string }>();
const router = useRouter();
function TitleAndComments(stats: CreatorAndReviewStatus) {
const Review = MapfixStatusToString(stats.review)
const [mapfix, setMapfix] = useState<MapfixInfo | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [showBeforeImage, setShowBeforeImage] = useState(false);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: null,
severity: 'success'
});
const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
setSnackbar({
open: true,
message,
severity
});
};
// TODO: hide status message when status is not "Accepted"
return (
<main className="review-info">
<div>
<h1>{stats.name}</h1>
<aside data-review-status={stats.review} className="review-status">
<p>{Review}</p>
</aside>
</div>
<p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p>
<p className="submitter">Submitter {stats.submitter}</p>
<p className="asset-id">Model Asset ID {stats.asset_id}</p>
<p className="target-asset-id">Target Asset ID {stats.target_asset_id}</p>
<p className="description">Description: {stats.description}</p>
<span className="spacer"></span>
<Comments comments_data={stats}/>
</main>
)
}
const handleCloseSnackbar = () => {
setSnackbar({
...snackbar,
open: false
});
};
export default function MapfixInfoPage() {
const { mapfixId } = useParams < { mapfixId: string } >()
const validatorUser = 9223372036854776000;
const [mapfix, setMapfix] = useState<MapfixInfo | null>(null)
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
const fetchData = useCallback(async (skipLoadingState = false) => {
try {
if (!skipLoadingState) {
setLoading(true);
}
setError(null);
useEffect(() => { // needs to be client sided since server doesn't have a session, nextjs got mad at me for exporting an async function: (https://nextjs.org/docs/messages/no-async-client-component)
async function getMapfix() {
const res = await fetch(`/api/mapfixes/${mapfixId}`)
if (res.ok) {
setMapfix(await res.json())
const [mapfixData, auditData, rolesData, userData] = await Promise.all([
fetch(`/api/mapfixes/${mapfixId}`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch mapfix: ${res.status}`);
return res.json();
}),
fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`);
return res.json();
}),
fetch("/api/session/roles").then(res => {
if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`);
return res.json();
}),
fetch("/api/session/user").then(res => {
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
return res.json();
})
]);
setMapfix(mapfixData);
setAuditEvents(auditData);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
setError(error instanceof Error ? error.message : "Failed to load mapfix details");
} finally {
if (!skipLoadingState) {
setLoading(false);
}
}
async function getAuditEvents() {
const res = await fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`)
if (res.ok) {
setAuditEvents(await res.json())
}, [mapfixId]);
// Fetch mapfix data and audit events
useEffect(() => {
fetchData();
}, [fetchData]);
// Handle review button actions
async function handleReviewAction(action: string, mapfixId: number) {
try {
const response = await fetch(`/api/mapfixes/${mapfixId}/status/${action}`, {
method: "POST",
headers: {
"Content-type": "application/json",
}
});
if (!response.ok) {
const errorDetails = await response.text();
throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`);
}
}
getMapfix()
getAuditEvents()
}, [mapfixId])
const comments:Comment[] = auditEvents.map((auditEvent) => {
let username = auditEvent.Username;
if (auditEvent.User == 9223372036854776000) {
username = "[Validator]";
}
if (username === "" && mapfix && auditEvent.User == mapfix.Submitter) {
username = "[Submitter]";
}
return {
date: auditEvent.CreatedAt,
name: username,
comment: auditEventMessage(auditEvent),
}
})
// Set success message based on the action
showSnackbar(`Successfully completed action: ${action}`, "success");
if (!mapfix) {
return <Webpage>
{/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */}
</Webpage>
// Reload data instead of refreshing the page
fetchData(true);
} catch (error) {
console.error("Error updating mapfix status:", error);
showSnackbar(error instanceof Error ? error.message : "Failed to update mapfix", 'error');
// Reload data instead of refreshing the page
fetchData(true);
}
}
return (
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea mapfixId={mapfixId} mapfixStatus={mapfix.StatusID} mapfixSubmitter={mapfix.Submitter} mapfixAssetId={mapfix.AssetID} mapfixTargetAssetId={mapfix.TargetAssetID} />
<TitleAndComments
name={mapfix.DisplayName}
creator={mapfix.Creator}
review={mapfix.StatusID}
asset_id={mapfix.AssetID}
submitter={mapfix.Submitter}
target_asset_id={mapfix.TargetAssetID}
description={mapfix.Description}
comments={comments}
/>
</section>
</main>
</Webpage>
)
}
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
showSnackbar('ID copied to clipboard', 'success');
};
const handleCommentSubmit = async () => {
if (!newComment.trim()) {
return; // Don't submit empty comments
}
try {
const response = await fetch(`/api/mapfixes/${mapfixId}/comment`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: newComment,
});
if (!response.ok) {
throw new Error(`Failed to post comment: ${response.status}`);
}
// Clear comment input
setNewComment("");
// Refresh audit events to show the new comment
const auditData = await fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`);
if (auditData.ok) {
const updatedAuditEvents = await auditData.json();
setAuditEvents(updatedAuditEvents);
}
} catch (error) {
console.error("Error submitting comment:", error);
setError(error instanceof Error ? error.message : "Failed to submit comment");
}
};
// Loading state
if (loading) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 3 }}>
<Skeleton variant="text" width="60%" height={40} />
</Box>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={300} />
<Box sx={{ mt: 2 }}>
<Skeleton variant="rectangular" height={50} />
</Box>
</Grid>
<Grid item xs={12} md={8}>
<Skeleton variant="text" height={60} />
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" width="30%" />
<Box sx={{ mt: 4 }}>
<Skeleton variant="rectangular" height={200} />
</Box>
</Grid>
</Grid>
</Container>
</Webpage>
);
}
if (error || !mapfix) {
return (
<ErrorDisplay
title="Error Loading Mapfix"
message={error || "Mapfix not found"}
buttonText="Return to Mapfixes"
onButtonClick={() => router.push('/mapfixes')}
/>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/mapfixes" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Mapfixes</Typography>
</Link>
<Typography color="text.secondary">{mapfix.DisplayName}</Typography>
</Breadcrumbs>
<Grid container spacing={4}>
{/* Left Column - Image and Action Buttons */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
<Box sx={{ position: 'relative', width: '100%', aspectRatio: '1/1' }}>
{/* Before/After Images Container */}
<Box sx={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Before Image */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
opacity: showBeforeImage ? 1 : 0,
transition: 'opacity 0.5s ease-in-out'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${mapfix.TargetAssetID}`}
alt="Before Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
{/* After Image */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0,
opacity: showBeforeImage ? 0 : 1,
transition: 'opacity 0.5s ease-in-out'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${mapfix.AssetID}`}
alt="After Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box
sx={{
position: 'absolute',
top: 16,
left: 16,
zIndex: 2,
bgcolor: 'rgba(0,0,0,0.65)',
color: 'white',
padding: '6px 12px',
borderRadius: 3,
backdropFilter: 'blur(4px)',
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: 0.8,
transition: 'transform 0.3s ease-out',
transform: showBeforeImage ? 'translateY(0)' : 'translateY(0)',
}}
>
{showBeforeImage ? (
<>
<Typography variant="subtitle2" component="span" sx={{ fontWeight: 600 }}>
BEFORE
</Typography>
</>
) : (
<>
<Typography variant="subtitle2" component="span" sx={{ fontWeight: 600 }}>
AFTER
</Typography>
</>
)}
</Box>
<Box
sx={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
<Typography
variant="caption"
sx={{
color: 'white',
bgcolor: 'rgba(0,0,0,0.4)',
padding: '2px 8px',
borderRadius: 1,
backdropFilter: 'blur(2px)',
}}
>
Click to compare
</Typography>
</Box>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 3,
cursor: 'pointer',
'&:hover': {
background: 'linear-gradient(rgba(0,0,0,0.02), rgba(0,0,0,0.05))',
},
}}
onClick={() => setShowBeforeImage(!showBeforeImage)}
/>
</Box>
</Box>
</Paper>
{/* Review Buttons */}
<ReviewButtons
onClick={handleReviewAction}
item={mapfix}
userId={user}
roles={roles}
type="mapfix"/>
</Grid>
{/* Right Column - Mapfix Details and Comments */}
<Grid item xs={12} md={8}>
<ReviewItem
item={mapfix}
handleCopyValue={handleCopyId}
/>
{/* Comments Section */}
<CommentsAndAuditSection
auditEvents={auditEvents}
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
/>
</Grid>
</Grid>
<Snackbar
open={snackbar.open}
autoHideDuration={snackbar.severity === 'error' ? 6000 : 3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
onClose={handleCloseSnackbar}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

@@ -2,113 +2,135 @@
import { useState, useEffect } from "react";
import { MapfixList } from "../ts/Mapfix";
import { MapfixCard } from "../_components/mapCard";
import { MapCard } from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
// TODO: MAKE MAPFIX & SUBMISSIONS USE THE SAME COMPONENTS :angry: (currently too lazy)
import "./(styles)/page.scss";
import { ListSortConstants } from "../ts/Sort";
import {
Box,
Breadcrumbs,
CircularProgress,
Container,
Pagination,
Typography
} from "@mui/material";
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
export default function MapfixInfoPage() {
const [mapfixes, setMapfixes] = useState<MapfixList|null>(null)
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
const [isLoading, setIsLoading] = useState(false);
const cardsPerPage = 24;
useEffect(() => {
async function fetchMapfixes() {
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
if (res.ok) {
setMapfixes(await res.json())
const controller = new AbortController();
async function fetchMapFixes() {
setIsLoading(true);
try {
const res = await fetch(
`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`,
{ signal: controller.signal }
);
if (res.ok) {
const data = await res.json();
setMapfixes(data);
} else {
console.error("Failed to fetch mapfixes:", res.status);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching mapfixes:", error);
}
} finally {
setIsLoading(false);
}
}
setTimeout(() => {
fetchMapfixes()
}, 50);
}, [currentPage])
fetchMapFixes();
return () => controller.abort();
}, [currentPage]);
if (!mapfixes) {
return <Webpage>
<main>
Loading...
</main>
</Webpage>
if (isLoading || !mapfixes) {
return (
<Webpage>
<Container sx={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress />
<Typography variant="body1" sx={{ mt: 2 }}>
Loading mapfixes...
</Typography>
</Box>
</Container>
</Webpage>
);
}
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
const currentCards = mapfixes.Mapfixes.slice(
(currentPage - 1) * cardsPerPage,
currentPage * cardsPerPage
);
const nextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
const prevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
if (mapfixes.Total == 0) {
return <Webpage>
<main>
Mapfixes list is empty.
</main>
</Webpage>
}
return (
// TODO: Add filter settings & searchbar & page selector
<Webpage>
<main
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '1rem',
width: '100%',
maxWidth: '100vw',
boxSizing: 'border-box',
overflowX: 'hidden'
}}
>
<div className="pagination-dots">
{Array.from({ length: totalPages }).map((_, index) => (
<span
key={index}
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
onClick={() => setCurrentPage(index+1)}
></span>
))}
</div>
<div className="pagination">
<button onClick={prevPage} disabled={currentPage === 1}>&lt;</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button onClick={nextPage} disabled={currentPage === totalPages}>&gt;</button>
</div>
<div className="grid">
{currentCards.map((mapfix) => (
<MapfixCard
key={mapfix.ID}
id={mapfix.ID}
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
authorId={mapfix.Submitter}
rating={mapfix.StatusID}
/>
))}
</div>
</main>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Box component="main" sx={{ width: '100%', px: 2 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="text.secondary">Mapfixes</Typography>
</Breadcrumbs>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Map Fixes
</Typography>
<Typography variant="subtitle1" color="text.secondary" mb={4}>
Explore all submitted fixes for maps from the community.
</Typography>
<Box
className="grid"
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: 3,
width: '100%',
}}
>
{mapfixes.Mapfixes.map((mapfix) => (
<MapCard
key={mapfix.ID}
id={mapfix.ID}
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
authorId={mapfix.Submitter}
rating={mapfix.StatusID}
statusID={mapfix.StatusID}
gameID={mapfix.GameID}
created={mapfix.CreatedAt}
type="mapfix"
/>
))}
</Box>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" my={4}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
variant="outlined"
shape="rounded"
/>
</Box>
)}
</Box>
</Container>
</Webpage>
)
}
);
}

View File

@@ -1,43 +0,0 @@
.maps-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Allows 4 cards per row */
gap: 20px;
width: 100%;
max-width: 1200px;
padding: 20px;
margin: 0 auto;
}
.map-card {
background: #1e1e1e;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease-in-out;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 200px;
object-fit: cover; /* Ensures the image covers the space without being cut off */
}
.map-info {
padding: 15px;
text-align: center;
h2 {
font-size: 1.2rem;
font-weight: bold;
color: #ffffff;
}
p {
font-size: 1rem;
color: #bbbbbb;
}
}
}

View File

@@ -1,28 +0,0 @@
import Image from "next/image";
import { MapInfo } from "@/app/ts/Map";
interface AssetID {
id: MapInfo["ID"];
}
function MapImage({ id }: AssetID) {
if (!id) {
return <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export { type AssetID, MapImage };

View File

@@ -1,54 +0,0 @@
@use "../../../../globals.scss";
::placeholder {
color: var(--placeholder-text)
}
.form-spacer {
margin-bottom: 20px;
&:last-of-type {
margin-top: 15px;
}
}
#target-asset-radio {
color: var(--text-color);
font-size: globals.$form-label-fontsize;
}
.form-field {
width: 850px;
& label, & input {
color: var(--text-color);
}
& fieldset {
border-color: rgb(100,100,100);
}
& span {
color: white;
}
}
main {
display: grid;
justify-content: center;
align-items: center;
margin-inline: auto;
width: 700px;
}
header h1 {
text-align: center;
color: var(--text-color);
}
form {
display: grid;
gap: 25px;
fieldset {
border: blue
}
}

View File

@@ -1,86 +1,278 @@
"use client"
import { Button, TextField } from "@mui/material"
import React, { useState, useEffect } from "react";
import {
Button,
TextField,
Box,
Container,
Typography,
Breadcrumbs,
CircularProgress,
Paper,
Grid,
Alert
} from "@mui/material";
import SendIcon from '@mui/icons-material/Send';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import Webpage from "@/app/_components/webpage";
import { useParams } from "next/navigation";
import "./(styles)/page.scss"
import Link from "next/link";
import {MapInfo} from "@/app/ts/Map";
interface MapfixPayload {
AssetID: number;
TargetAssetID: number;
Description: string;
}
interface IdResponse {
OperationID: number;
}
// Game ID mapping
const gameTypes: Record<number, string> = {
1: "Bhop",
2: "Surf",
5: "Flytrials"
};
export default function MapfixInfoPage() {
const dynamicId = useParams<{ mapId: string }>();
const { mapId } = useParams<{ mapId: string }>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mapDetails, setMapDetails] = useState<MapInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
const fetchMapDetails = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/maps/${mapId}`);
if (!response.ok) {
throw new Error(`Failed to fetch map details: ${response.statusText}`);
}
const data = await response.json();
setMapDetails(data);
} catch (error) {
console.error("Error fetching map details:", error);
setLoadError(error instanceof Error ? error.message : "Failed to load map details");
} finally {
setIsLoading(false);
}
};
if (mapId) {
fetchMapDetails();
}
}, [mapId]);
// Get game type from game ID
const getGameType = (gameId: number | undefined): string => {
if (!gameId) return "Unknown";
return gameTypes[gameId] || "Unknown";
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
setError(null);
const form = event.currentTarget;
const formData = new FormData(form);
const assetId = formData.get("asset-id") as string;
const description = formData.get("description") as string;
// Validate required fields
if (!assetId || isNaN(Number(assetId))) {
setError("Please enter a valid Asset ID");
setIsSubmitting(false);
return;
}
if (!description) {
setError("Please provide a description for the mapfix");
setIsSubmitting(false);
return;
}
const payload: MapfixPayload = {
AssetID: Number((formData.get("asset-id") as string) ?? "-1"),
TargetAssetID: Number(dynamicId.mapId),
Description: (formData.get("description") as string) ?? "unknown", // TEMPORARY! TODO: Change
AssetID: Number(assetId),
TargetAssetID: Number(mapId),
Description: description,
};
console.log(payload)
console.log(JSON.stringify(payload))
try {
// Send the POST request
const response = await fetch("/api/mapfixes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
// Check if the HTTP request was successful
if (!response.ok) {
const errorDetails = await response.text();
// Throw an error with detailed information
throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`);
}
// Allow any HTTP status
const id_response:IdResponse = await response.json();
// navigate to newly created mapfix
window.location.assign(`/operations/${id_response.OperationID}`)
const { OperationID } = await response.json();
window.location.assign(`/operations/${OperationID}`);
} catch (error) {
console.error("Error submitting data:", error);
setError(error instanceof Error ? error.message : "An unknown error occurred");
setIsSubmitting(false);
}
};
return (
<Webpage>
<main>
<header>
<h1>Submit Mapfix</h1>
<span className="spacer form-spacer"></span>
</header>
<form onSubmit={handleSubmit}>
{/* TODO: Add form data for mapfixes, such as changes they did, and any times that need to be deleted & what styles */}
<TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID" variant="outlined"/>
<TextField className="form-field" id="description" name="description" label="Describe the Mapfix" variant="outlined"/>
<span className="spacer form-spacer"></span>
<Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{
width: "400px",
height: "50px",
marginInline: "auto"
}}>Create Mapfix</Button>
</form>
</main>
<Container maxWidth="lg" sx={{ mt: 4, mb: 8 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
{mapDetails && (
<Link href={`/maps/${mapId}`} passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">{mapDetails.DisplayName}</Typography>
</Link>
)}
<Typography color="text.secondary">Submit Mapfix</Typography>
</Breadcrumbs>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
Submit Mapfix
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Fill out the form below to submit a fix for the selected map
</Typography>
</Box>
<Paper elevation={2} sx={{ p: { xs: 2, md: 4 }, borderRadius: 2 }}>
{loadError && (
<Alert severity="error" sx={{ mb: 3 }}>
{loadError}
</Alert>
)}
{error && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography color="error.dark">{error}</Typography>
</Box>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<CircularProgress />
</Box>
) : (
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
{/* Map details section - disabled prefilled fields */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Map Information
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="map-id"
label="Map ID"
variant="outlined"
fullWidth
value={mapId}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="game-type"
label="Game Type"
variant="outlined"
fullWidth
value={getGameType(mapDetails?.GameID)}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="map-name"
label="Map Name"
variant="outlined"
fullWidth
value={mapDetails?.DisplayName || 'Unknown'}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="map-creator"
label="Creator"
variant="outlined"
fullWidth
value={mapDetails?.Creator || 'Unknown'}
disabled
/>
</Grid>
<Grid item xs={12}>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
Mapfix Details
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
required
id="asset-id"
name="asset-id"
label="New Asset ID"
variant="outlined"
fullWidth
helperText="Enter the unique identifier for your fixed map asset"
/>
</Grid>
<Grid item xs={12}>
<TextField
required
id="description"
name="description"
label="Description"
variant="outlined"
fullWidth
multiline
rows={4}
helperText="Describe the changes made in this mapfix"
/>
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={isSubmitting}
sx={{ width: 400, height: 50 }}
>
{isSubmitting ? <CircularProgress size={24} /> : "Create Mapfix"}
</Button>
</Grid>
</Grid>
</form>
)}
</Paper>
</Container>
</Webpage>
)
}
);
}

View File

@@ -1,103 +1,338 @@
"use client"
"use client";
import { MapInfo } from "@/app/ts/Map";
import { MapImage } from "./_mapImage";
import Webpage from "@/app/_components/webpage";
import { useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Snackbar, Alert } from "@mui/material";
// MUI Components
import {
Typography,
Box,
Button as MuiButton,
Card,
CardContent,
Button,
Container,
Breadcrumbs,
Chip,
Grid,
Divider,
Paper,
Skeleton,
ThemeProvider,
createTheme,
CssBaseline
Stack,
CardMedia,
Tooltip,
IconButton
} from "@mui/material";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import PersonIcon from "@mui/icons-material/Person";
import FlagIcon from "@mui/icons-material/Flag";
import BugReportIcon from "@mui/icons-material/BugReport";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
interface ButtonProps {
name: string;
href: string;
}
function Button({ name, href }: ButtonProps) {
return (
<Link href={href} passHref>
<MuiButton variant="contained" color="primary" sx={{ mt: 2 }}>
{name}
</MuiButton>
</Link>
);
}
const darkTheme = createTheme({
palette: {
mode: "dark",
},
});
export default function Map() {
export default function MapDetails() {
const { mapId } = useParams();
const router = useRouter();
const [map, setMap] = useState<MapInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
useEffect(() => {
async function getMap() {
const res = await fetch(`/api/maps/${mapId}`);
if (res.ok) {
setMap(await res.json());
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/maps/${mapId}`);
if (!res.ok) {
throw new Error(`Failed to fetch map: ${res.status}`);
}
const data = await res.json();
setMap(data);
} catch (error) {
console.error("Error fetching map details:", error);
setError(error instanceof Error ? error.message : "Failed to load map details");
} finally {
setLoading(false);
}
}
getMap();
}, [mapId]);
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Webpage>
{!map ? (
<Card>
<CardContent>
<Skeleton variant="text" width={200} height={40} />
<Skeleton variant="text" width={300} />
<Skeleton variant="rectangular" height={200} />
</CardContent>
</Card>
) : (
<Box display="flex" flexDirection={{ xs: "column", md: "row" }} gap={4}>
<Box flex={1}>
<Card>
<CardContent>
<Typography variant="h5" gutterBottom>
Map Info
</Typography>
<Typography variant="body1"><strong>Map ID:</strong> {mapId}</Typography>
<Typography variant="body1"><strong>Display Name:</strong> {map.DisplayName}</Typography>
<Typography variant="body1"><strong>Creator:</strong> {map.Creator}</Typography>
<Typography variant="body1"><strong>Game ID:</strong> {map.GameID}</Typography>
<Typography variant="body1"><strong>Release Date:</strong> {new Date(map.Date * 1000).toLocaleString()}</Typography>
<Button name="Submit A Mapfix For This Map" href={`/maps/${mapId}/fix`} />
</CardContent>
</Card>
</Box>
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
<Box flex={1}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Map Preview
</Typography>
<MapImage id={map.ID} />
</CardContent>
</Card>
</Box>
</Box>
)}
const getGameInfo = (gameId: number) => {
switch (gameId) {
case 1:
return {
name: "Bhop",
color: "#2196f3" // blue
};
case 2:
return {
name: "Surf",
color: "#4caf50" // green
};
case 5:
return {
name: "Fly Trials",
color: "#ff9800" // orange
};
default:
return {
name: "Unknown",
color: "#9e9e9e" // gray
};
}
};
const handleSubmitMapfix = () => {
router.push(`/maps/${mapId}/fix`);
};
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
setCopySuccess(true);
};
const handleCloseSnackbar = () => {
setCopySuccess(false);
};
if (error) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: 'error.light',
color: 'error.contrastText'
}}
>
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
<Typography variant="body1">{error}</Typography>
<Button
variant="contained"
onClick={() => router.push('/maps')}
sx={{ mt: 3 }}
>
Return to Maps
</Button>
</Paper>
</Container>
</Webpage>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
</Breadcrumbs>
{loading ? (
<Box>
<Box sx={{ mb: 4 }}>
<Skeleton variant="text" width="60%" height={60} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
</Grid>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
</Grid>
</Grid>
</Box>
) : (
map && (
<>
{/* Map Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
{map.DisplayName}
</Typography>
{map.GameID && (
<Chip
label={getGameInfo(map.GameID).name}
sx={{
bgcolor: getGameInfo(map.GameID).color,
color: '#fff',
fontWeight: 'bold',
fontSize: '0.9rem',
height: 32
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
<strong>Created by:</strong> {map.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
{formatDate(map.Date)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">
<strong>ID:</strong> {mapId}
</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
</Box>
<Grid container spacing={3}>
{/* Map Preview Section */}
<Grid item xs={12} md={8}>
<Paper
elevation={3}
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${map.ID}`}
alt={`Preview of map: ${map.DisplayName}`}
sx={{
height: 400,
objectFit: 'cover',
}}
/>
</Paper>
</Grid>
{/* Map Details Section */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>Map Details</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
<Typography variant="body1">{map.DisplayName}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
<Typography variant="body1">{map.Creator}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Typography variant="body1">{formatDate(map.Date)}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{mapId}</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Stack>
</Paper>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Button
fullWidth
variant="contained"
color="primary"
startIcon={<BugReportIcon />}
onClick={handleSubmitMapfix}
size="large"
>
Submit a Mapfix
</Button>
</Paper>
</Grid>
</Grid>
</>
)
)}
<Snackbar
open={copySuccess}
autoHideDuration={3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
</Alert>
</Snackbar>
</Container>
</Webpage>
</ThemeProvider>
);
}
}

View File

@@ -1,60 +1,302 @@
"use client";
import {useState, useEffect} from "react";
import Image from "next/image";
import { useState, useEffect } from "react";
import {useRouter} from "next/navigation";
import Webpage from "@/app/_components/webpage";
import "./(styles)/page.scss";
import {
Box,
Container,
Typography,
Grid,
Card,
CardContent,
CardMedia,
CardActionArea,
TextField,
InputAdornment,
Pagination,
CircularProgress,
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent, Breadcrumbs
} from "@mui/material";
import {Search as SearchIcon} from "@mui/icons-material";
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
interface Map {
ID: number;
DisplayName: string;
Creator: string;
GameID: number;
Date: number;
ID: number;
DisplayName: string;
Creator: string;
GameID: number;
Date: number;
}
// TODO: should rewrite this entire page, just wanted to get a simple page working. This was written by chatgippity
export default function MapsPage() {
const [maps, setMaps] = useState<Map[]>([]);
const router = useRouter();
const [maps, setMaps] = useState<Map[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [gameFilter, setGameFilter] = useState<string>("0"); // 0 means "All Maps"
const mapsPerPage = 12;
const requestPageSize = 100;
useEffect(() => {
const fetchMaps = async () => {
const res = await fetch("/api/maps?Page=1&Limit=100");
const data: Map[] = await res.json();
setMaps(data);
};
useEffect(() => {
const fetchMaps = async () => {
// Just send it and load all maps hoping for the best
try {
setLoading(true);
let allMaps: Map[] = [];
let page = 1;
let hasMore = true;
fetchMaps();
}, []);
while (hasMore) {
const res = await fetch(`/api/maps?Page=${page}&Limit=${requestPageSize}`);
const data: Map[] = await res.json();
allMaps = [...allMaps, ...data];
hasMore = data.length === requestPageSize;
page++;
}
const customLoader = ({ src }: { src: string }) => {
return src;
};
setMaps(allMaps);
} catch (error) {
console.error("Failed to fetch maps:", error);
} finally {
setLoading(false);
}
};
return (
<Webpage>
<div className="maps-container">
{maps.map((map) => (
<div key={map.ID} className="map-card">
<a href={`/maps/${map.ID}`} className="block">
<Image
loader={customLoader}
src={`/thumbnails/maps/${map.ID}`}
alt={map.DisplayName}
width={500}
height={300}
className="w-full h-48 object-cover"
/>
<div className="map-info">
<h2>{map.DisplayName}</h2>
<p>By {map.Creator}</p>
</div>
</a>
</div>
))}
</div>
</Webpage>
);
fetchMaps();
}, []);
const handleGameFilterChange = (event: SelectChangeEvent) => {
setGameFilter(event.target.value);
setCurrentPage(1);
};
// Filter maps based on search query and game filter
const filteredMaps = maps.filter(map => {
const matchesSearch =
map.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
map.Creator.toLowerCase().includes(searchQuery.toLowerCase());
const matchesGameFilter =
gameFilter === "0" || // "All Maps"
map.GameID === parseInt(gameFilter);
return matchesSearch && matchesGameFilter;
});
// Calculate pagination
const totalPages = Math.ceil(filteredMaps.length / mapsPerPage);
const currentMaps = filteredMaps.slice(
(currentPage - 1) * mapsPerPage,
currentPage * mapsPerPage
);
const handlePageChange = (_event: React.ChangeEvent<unknown>, page: number) => {
setCurrentPage(page);
window.scrollTo({top: 0, behavior: 'smooth'});
};
const handleMapClick = (mapId: number) => {
router.push(`/maps/${mapId}`);
};
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getGameName = (gameId: number) => {
switch (gameId) {
case 1:
return "Bhop";
case 2:
return "Surf";
case 5:
return "Fly Trials";
default:
return "Unknown";
}
};
const getGameLabelStyles = (gameId: number) => {
switch (gameId) {
case 1: // Bhop
return {
bgcolor: "info.main",
color: "white",
};
case 2: // Surf
return {
bgcolor: "success.main",
color: "white",
};
case 5: // Fly Trials
return {
bgcolor: "warning.main",
color: "white",
};
default: // Unknown
return {
bgcolor: "grey.500",
color: "white",
};
}
};
return (
<Webpage>
<Container maxWidth="lg" sx={{py: 6}}>
<Box mb={6}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="text.secondary">Maps</Typography>
</Breadcrumbs>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Map Collection
</Typography>
<Typography variant="subtitle1" color="text.secondary" mb={4}>
Browse all community-created maps or find your favorites
</Typography>
<TextField
fullWidth
variant="outlined"
placeholder="Search maps by name or creator..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon/>
</InputAdornment>
),
}}
sx={{mb: 4}}
/>
{loading ? (
<Box display="flex" justifyContent="center" my={8}>
<CircularProgress/>
</Box>
) : (
<>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography>
Showing {filteredMaps.length} {filteredMaps.length === 1 ? 'map' : 'maps'}
</Typography>
<FormControl sx={{minWidth: 200}}>
<InputLabel id="game-filter-label">Filter by Game</InputLabel>
<Select
labelId="game-filter-label"
id="game-filter"
value={gameFilter}
label="Filter by Game"
onChange={handleGameFilterChange}
>
<MenuItem value="0">All Maps</MenuItem>
<MenuItem value="1">Bhop</MenuItem>
<MenuItem value="2">Surf</MenuItem>
<MenuItem value="5">Fly Trials</MenuItem>
</Select>
</FormControl>
</Box>
<Grid container spacing={3}>
{currentMaps.map((map) => (
<Grid item xs={12} sm={6} md={4} key={map.ID}>
<Card
elevation={1}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
}
}}
>
<CardActionArea onClick={() => handleMapClick(map.ID)}>
<CardMedia
component="div"
sx={{
position: 'relative',
height: 180,
backgroundColor: 'rgba(0,0,0,0.05)',
}}
>
<Box
position="absolute"
top={10}
right={10}
px={1}
py={0.5}
borderRadius={1}
fontSize="0.75rem"
fontWeight="bold"
{...getGameLabelStyles(map.GameID)}
>
{getGameName(map.GameID)}
</Box>
<Image
src={`/thumbnails/asset/${map.ID}`}
alt={map.DisplayName}
fill
style={{objectFit: 'cover'}}
/>
</CardMedia>
<CardContent>
<Typography variant="h6" component="h2" noWrap>
{map.DisplayName}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
By {map.Creator}
</Typography>
<Typography variant="caption" color="text.secondary">
Added {formatDate(map.Date)}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" my={4}>
<Pagination
count={totalPages}
page={currentPage}
onChange={handlePageChange}
variant="outlined"
shape="rounded"
/>
</Box>
)}
</>
)}
</Box>
</Container>
</Webpage>
);
}

View File

@@ -1,91 +0,0 @@
.operation-status {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #121212;
color: #e0e0e0;
.operation-card {
width: 400px;
padding: 20px;
background: #1e1e1e;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
h5 {
margin-bottom: 15px;
font-weight: bold;
color: #ffffff;
}
p {
margin: 5px 0;
color: #b0b0b0;
}
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
margin-top: 10px;
&.created {
color: #ffca28;
}
&.completed {
color: #66bb6a;
}
&.failed {
color: #ef5350;
}
.status-icon {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
&.created {
background-color: #ffca28;
}
&.completed {
background-color: #66bb6a;
}
&.failed {
background-color: #ef5350;
}
}
}
.MuiCircularProgress-root {
color: #90caf9;
}
.submission-button {
margin-top: 20px;
display: flex;
justify-content: center;
width: 100%;
button {
width: 100%;
height: 60px;
background-color: #66bb6a;
color: white;
font-size: 20px;
font-weight: bold;
padding: 20px;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s;
&:hover {
background-color: #57a05a;
}
}
}
}

View File

@@ -1,12 +1,27 @@
"use client";
import { useEffect, useState, useRef } from "react";
import {useEffect, useState, useRef, ReactElement} from "react";
import { useParams, useRouter } from "next/navigation";
import { CircularProgress, Typography, Card, CardContent, Button } from "@mui/material";
import {
CircularProgress,
Typography,
Paper,
Box,
Container,
Button,
Chip,
Divider,
Alert,
Collapse,
IconButton
} from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PendingIcon from '@mui/icons-material/Pending';
import Webpage from "@/app/_components/webpage";
import "./(styles)/page.scss";
interface Operation {
OperationID: number;
Status: number;
@@ -14,7 +29,7 @@ interface Operation {
Owner: string;
Date: number;
Path: string;
}
}
export default function OperationStatusPage() {
const router = useRouter();
@@ -23,6 +38,7 @@ export default function OperationStatusPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [operation, setOperation] = useState<Operation | null>(null);
const [expandStatusMessage, setExpandStatusMessage] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -78,45 +94,163 @@ export default function OperationStatusPage() {
}
};
const getStatusClass = (status: number) => getStatusText(status).toLowerCase();
const getStatusColor = (status: number) => {
switch (status) {
case 0:
return "warning";
case 1:
return "success";
case 2:
return "error";
default:
return "default";
}
};
const getStatusIcon = (status: number): ReactElement | undefined => {
switch (status) {
case 0:
return <PendingIcon/>;
case 1:
return <CheckCircleIcon/>;
case 2:
return <ErrorIcon/>;
default:
return undefined;
}
};
// Format the status message for better display
const formatStatusMessage = (message: string) => {
try {
// Check if message is JSON
const parsed = JSON.parse(message);
return JSON.stringify(parsed, null, 2);
} catch {
// Not valid JSON, return as is
return message;
}
};
return (
<Webpage>
<main className="operation-status">
<Container maxWidth="md" sx={{ py: 6 }}>
<Typography variant="h4" component="h1" fontWeight="bold" mb={4}>
Operation Status
</Typography>
{loading ? (
<CircularProgress />
) : error ? (
<Typography color="error">{error}</Typography>
) : operation ? (
<Card className="operation-card">
<CardContent>
<Typography variant="h5">Operation ID: {operation.OperationID}</Typography>
<div className={`status-indicator ${getStatusClass(operation.Status)}`}>
<span className={`status-icon ${getStatusClass(operation.Status)}`} />
{getStatusText(operation.Status)}
</div>
<Typography>Status Message: {operation.StatusMessage}</Typography>
<Typography>Owner: {operation.Owner}</Typography>
<Typography>Date: {new Date(operation.Date * 1000).toLocaleString()}</Typography>
<Typography>Path: {operation.Path}</Typography>
<Box display="flex" flexDirection="column" alignItems="center" my={8}>
<CircularProgress size={60} />
<Typography variant="body1" mt={2}>
Loading operation details...
</Typography>
</Box>
) : error ? (
<Alert severity="error" sx={{ my: 2 }}>
<Typography variant="body1">{error}</Typography>
</Alert>
) : operation ? (
<Paper
elevation={3}
sx={{
p: 3,
borderRadius: 2,
border: 1,
borderColor: 'divider'
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5">
Operation #{operation.OperationID}
</Typography>
<Chip
icon={getStatusIcon(operation.Status)}
label={getStatusText(operation.Status)}
color={getStatusColor(operation.Status) as "success" | "warning" | "error" | "default"}
variant="filled"
sx={{ fontWeight: 'bold', px: 1 }}
/>
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="body1" color="text.secondary" gutterBottom>
<strong>Owner:</strong> {operation.Owner}
</Typography>
<Typography variant="body1" color="text.secondary" gutterBottom>
<strong>Date:</strong> {new Date(operation.Date * 1000).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle1" fontWeight="bold">
Status Message
</Typography>
<IconButton
size="small"
onClick={() => setExpandStatusMessage(!expandStatusMessage)}
aria-label={expandStatusMessage ? "Collapse details" : "Expand details"}
>
{expandStatusMessage ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
<Collapse in={expandStatusMessage}>
<Paper
variant="outlined"
sx={{
mt: 1,
p: 2,
bgcolor: 'background.default',
maxHeight: '300px',
overflow: 'auto'
}}
>
<pre style={{ margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{formatStatusMessage(operation.StatusMessage)}
</pre>
</Paper>
</Collapse>
{!expandStatusMessage && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{operation.StatusMessage.length > 100
? `${operation.StatusMessage.substring(0, 100)}...`
: operation.StatusMessage}
</Typography>
)}
</Box>
{operation.Status === 1 && (
<div className="submission-button">
<Button
variant="contained"
color="success"
onClick={() => router.push(operation.Path)}
>
View Submission
</Button>
</div>
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
variant="contained"
color="primary"
size="large"
onClick={() => router.push(operation.Path)}
startIcon={<CheckCircleIcon />}
>
Next Step
</Button>
</Box>
)}
</CardContent>
</Card>
) : (
<Typography>No operation found.</Typography>
</Paper>
) : (
<Alert severity="info" sx={{ my: 2 }}>
<Typography variant="body1">No operation found with ID: {operationId}</Typography>
</Alert>
)}
</main>
</Container>
</Webpage>
);
}
}

View File

@@ -1,7 +1,228 @@
'use client'
import { useState, useEffect } from "react";
import {MapfixInfo, MapfixList} from "./ts/Mapfix";
import { MapCard } from "./_components/mapCard";
import Webpage from "./_components/webpage";
import { ListSortConstants } from "./ts/Sort";
import {
Box,
Container,
CircularProgress,
Typography,
Paper,
} from "@mui/material";
import Link from "next/link";
import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
import {Carousel} from "@/app/_components/carousel";
export default function Home() {
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
const itemsPerSection: number = 8; // Show more items for the carousel
useEffect(() => {
const mapfixController = new AbortController();
const submissionsController = new AbortController();
async function fetchMapFixes(): Promise<void> {
setIsLoadingMapfixes(true);
try {
const res = await fetch(`/api/mapfixes?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
signal: mapfixController.signal,
});
if (res.ok) {
const data: MapfixList = await res.json();
setMapfixes(data);
}
} catch (error) {
console.error("Failed to fetch mapfixes:", error);
} finally {
setIsLoadingMapfixes(false);
}
}
async function fetchSubmissions(): Promise<void> {
setIsLoadingSubmissions(true);
try {
const res = await fetch(`/api/submissions?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
signal: submissionsController.signal,
});
if (res.ok) {
const data: SubmissionList = await res.json();
setSubmissions(data);
}
} catch (error) {
console.error("Failed to fetch submissions:", error);
} finally {
setIsLoadingSubmissions(false);
}
}
fetchMapFixes();
fetchSubmissions();
return () => {
mapfixController.abort();
submissionsController.abort();
};
}, []);
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
if (isLoading && (!mapfixes || !submissions)) {
return <Webpage>
<main
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress/>
<Typography variant="body1" style={{marginTop: '1rem'}}>
Loading content...
</Typography>
</Box>
</main>
</Webpage>;
}
const renderMapfixCard = (mapfix: MapfixInfo): React.ReactNode => (
<MapCard
key={mapfix.ID}
id={mapfix.ID}
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
authorId={mapfix.Submitter}
rating={mapfix.StatusID}
statusID={mapfix.StatusID}
gameID={mapfix.GameID}
created={mapfix.CreatedAt}
type="mapfix"
/>
);
const renderSubmissionCard = (submission: SubmissionInfo): React.ReactNode => (
<MapCard
key={submission.ID}
id={submission.ID}
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
authorId={submission.Submitter}
rating={submission.StatusID}
statusID={submission.StatusID}
gameID={submission.GameID}
created={submission.CreatedAt}
type="submission"
/>
);
return (
<Webpage></Webpage>
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<main
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '1rem',
width: '100%',
maxWidth: '100vw',
boxSizing: 'border-box',
overflowX: 'hidden'
}}
>
<Typography variant="h3" component="h1" fontWeight="bold" mb={5}>
Welcome to the Maps Service!
</Typography>
<Paper
elevation={2}
sx={{
p: 4,
mb: 6,
borderRadius: 2,
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
color: 'white'
}}
>
<Typography variant="h4" component="h2" gutterBottom>
Contribute to the community
</Typography>
<Typography variant="body1" paragraph>
Help improve maps by submitting fixes or creating new maps submissions for the community.
</Typography>
<Box display="flex" gap={2}>
<Link href="/submit" style={{ textDecoration: 'none' }}>
<Box
component="button"
sx={{
backgroundColor: 'white',
color: '#2196F3',
border: 'none',
borderRadius: 1,
px: 3,
py: 1.5,
fontWeight: 'bold',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}
}}
>
Submit Map
</Box>
</Link>
<Link href="/maps" style={{ textDecoration: 'none' }}>
<Box
component="button"
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: '1px solid white',
borderRadius: 1,
px: 3,
py: 1.5,
fontWeight: 'bold',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
}
}}
>
Create Map Fix
</Box>
</Link>
</Box>
</Paper>
{/* Submissions Carousel */}
{submissions && (
<Carousel<SubmissionInfo>
title="Recent Submissions"
items={submissions.Submissions}
renderItem={renderSubmissionCard}
viewAllLink="/submissions"
/>
)}
{/* Map Fixes Carousel */}
{mapfixes && (
<Carousel<MapfixInfo>
title="Recent Map Fixes"
items={mapfixes.Mapfixes}
renderItem={renderMapfixCard}
viewAllLink="/mapfixes"
/>
)}
</main>
</Container>
</Webpage>
);
}

View File

@@ -1,75 +0,0 @@
@forward "../../_components/styles/mapCard.scss";
@use "../../globals.scss";
a {
color:rgb(255, 255, 255);
&:visited, &:hover, &:focus {
text-decoration: none;
color: rgb(255, 255, 255);
}
&:active {
color: rgb(192, 192, 192)
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-rows: repeat(3, 1fr);
gap: 16px;
max-width: 100%;
margin: 0 auto;
overflow-x: hidden;
box-sizing: border-box;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 0.3rem;
}
.pagination button {
padding: 0.25rem 0.5rem;
font-size: 1.15rem;
border: none;
border-radius: 0.35rem;
background-color: #33333350;
color: #fff;
cursor: pointer;
}
.pagination button:disabled {
background-color: #5555559a;
cursor: not-allowed;
}
.pagination-dots {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
width: 100%;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #bbb;
cursor: pointer;
}
.dot.active {
background-color: #333;
}

View File

@@ -1,19 +0,0 @@
@forward "./page/commentWindow.scss";
@forward "./page/reviewStatus.scss";
@forward "./page/ratingWindow.scss";
@forward "./page/reviewButtons.scss";
@forward "./page/comments.scss";
@forward "./page/review.scss";
@forward "./page/map.scss";
@use "../../../globals.scss";
.map-page-main {
display: flex;
justify-content: center;
width: 100vw;
}
.by-creator {
margin-top: 10px;
}

View File

@@ -1,56 +0,0 @@
@use "../../../../globals.scss";
#comment-text-field {
@include globals.border-with-radius;
resize: none;
width: 100%;
height: 100px;
background-color: var(--comment-area)
}
.leave-comment-window {
@include globals.border-with-radius;
width: 100%;
height: 230px;
margin-top: 35px;
.rating-type {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
gap: 35%;
.rating-right {
display: grid;
> span {
margin: 6px 0 6px 0;
}
}
p {
margin: 15px 0 15px 0;
}
}
header {
display: flex;
align-items: center;
background-color: var(--window-header);
border-bottom: globals.$review-border;
height: 45px;
p {
font-weight: bold;
margin: 0 0 0 20px;
}
}
main {
padding: 20px;
button {
margin-top: 9px;
}
}
}

View File

@@ -1,49 +0,0 @@
$comments-size: 60px;
.comments {
display: grid;
gap: 25px;
margin-top: 20px;
.no-comments {
text-align: center;
margin: 0;
}
.commenter {
display: flex;
height: $comments-size;
//BhopMaptest comment
&[data-highlighted="true"] {
background-color: var(--comment-highlighted);
}
> img {
border-radius: 50%;
}
.name {
font: {
weight: 500;
size: 1.3em;
};
}
.date {
font-size: .8em;
margin: 0 0 0 5px;
color: #646464
}
.details {
display: grid;
margin-left: 10px;
header {
display: flex;
align-items: center;
}
p:not(.date) {
margin: 0;
}
}
}
}

View File

@@ -1,19 +0,0 @@
@use "../../../../globals.scss";
.map-image-area {
@include globals.border-with-radius;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: auto;
margin-left: auto;
margin-right: auto;
border-radius: 12px;
overflow: hidden;
> p {
text-align: center;
margin: 0;
}
}

View File

@@ -1,43 +0,0 @@
@use "../../../../globals.scss";
.rating-window {
@include globals.border-with-radius;
width: 100%;
.rating-type {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
gap: 35%;
.rating-right {
display: grid;
> span {
margin: 6px 0 6px 0;
}
}
p {
margin: 15px 0 15px 0;
}
}
header {
display: flex;
align-items: center;
background-color: var(--window-header);
border-bottom: globals.$review-border;
height: 45px;
p {
font-weight: bold;
margin: 0 0 0 20px;
}
}
main {
display: grid;
place-items: center;
}
}

View File

@@ -1,46 +0,0 @@
@use "../../../../globals.scss";
.review-info {
width: 650px;
height: 100%;
> div {
display: flex;
justify-content: space-between;
align-items: center;
}
p, h1 {
color: var(--text-color);
}
h1 {
font: {
weight: 500;
size: 1.8rem
};
margin: 0;
}
a {
color: var(--anchor-link-review);
&:hover {
text-decoration: underline;
}
}
}
.review-section {
display: flex;
gap: 50px;
margin-top: 20px;
}
.review-area {
display: grid;
justify-content: center;
gap: 25px;
img {
height: 100%;
object-fit: contain
}
}

View File

@@ -1,13 +0,0 @@
@use "../../../../globals.scss";
.review-set {
@include globals.border-with-radius;
display: grid;
align-items: center;
gap: 10px;
padding: 10px;
button {
width: 100%;
}
}

View File

@@ -1,80 +0,0 @@
$UnderConstruction: "0";
$Submitted: "1";
$ChangesRequested: "2";
$Accepted: "3";
$Validating: "4";
$Validated: "5";
$Uploading: "6";
$Uploaded: "7";
$Rejected: "8";
$Released: "9";
.review-status {
border-radius: 5px;
p {
margin: 3px 25px 3px 25px;
font-weight: bold;
}
&[data-review-status="#{$Released}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Rejected}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Uploading}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Uploaded}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Validated}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Validating}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Accepted}"] {
background-color: rgb(2, 162, 2);
p {
color: white;
}
}
&[data-review-status="#{$ChangesRequested}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$Submitted}"] {
background-color: orange;
p {
color: white;
}
}
&[data-review-status="#{$UnderConstruction}"] {
background-color: orange;
p {
color: white;
}
}
}

View File

@@ -1,70 +0,0 @@
import type { SubmissionInfo } from "@/app/ts/Submission";
import { Button } from "@mui/material"
import Window from "./_window";
import SendIcon from '@mui/icons-material/Send';
import Image from "next/image";
interface CommentersProps {
comments_data: CreatorAndReviewStatus
}
interface CreatorAndReviewStatus {
asset_id: SubmissionInfo["AssetID"],
creator: SubmissionInfo["DisplayName"],
review: SubmissionInfo["StatusID"],
submitter: SubmissionInfo["Submitter"],
uploaded_asset_id: SubmissionInfo["UploadedAssetID"],
comments: Comment[],
name: string
}
interface Comment {
picture?: string, //TEMP
comment: string,
date: string,
name: string
}
function AddComment(comment: Comment) {
const IsBhopMaptest = comment.name == "BhopMaptest" //Highlighted commenter
return (
<div className="commenter" data-highlighted={IsBhopMaptest}>
<Image src={comment.picture as string} alt={`${comment.name}'s comment`}/>
<div className="details">
<header>
<p className="name">{comment.name}</p>
<p className="date">{comment.date}</p>
</header>
<p className="comment">{comment.comment}</p>
</div>
</div>
);
}
function LeaveAComment() {
return (
<Window title="Leave a Comment:" className="leave-comment-window">
<textarea name="comment-box" id="comment-text-field"></textarea>
<Button variant="outlined" endIcon={<SendIcon/>}>Submit</Button>
</Window>
)
}
export function Comments(stats: CommentersProps) {
return (<>
<section className="comments">
{stats.comments_data.comments.length===0
&& <p className="no-comments">There are no comments.</p>
|| stats.comments_data.comments.map(comment => (
<AddComment key={comment.name} name={comment.name} date={comment.date} comment={comment.comment}/>
))}
</section>
<LeaveAComment/>
</>)
}
export {
type CreatorAndReviewStatus,
type Comment,
}

View File

@@ -1,28 +0,0 @@
import Image from "next/image";
import { SubmissionInfo } from "@/app/ts/Submission";
interface AssetID {
id: SubmissionInfo["AssetID"];
}
function MapImage({ id }: AssetID) {
if (!id) {
return <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export { type AssetID, MapImage };

View File

@@ -1,172 +0,0 @@
import { Roles, RolesConstants } from "@/app/ts/Roles";
import { SubmissionStatus } from "@/app/ts/Submission";
import { Button, ButtonOwnProps } from "@mui/material";
import { useState, useEffect } from "react";
interface ReviewAction {
name: string,
action: string,
}
const ReviewActions = {
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction,
BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction,
ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction,
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
Reject: {name:"Reject",action:"reject"} as ReviewAction,
Validate: {name:"Validate",action:"retry-validate"} as ReviewAction,
ResetValidating: {name:"Reset Validating (fix softlocked status)",action:"reset-validating"} as ReviewAction,
RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction,
Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction,
ResetUploading: {name:"Reset Uploading (fix softlocked status)",action:"reset-uploading"} as ReviewAction,
}
interface ReviewButton {
action: ReviewAction,
submissionId: string,
color: ButtonOwnProps["color"]
}
interface ReviewId {
submissionId: string,
submissionStatus: number,
submissionSubmitter: number,
}
async function ReviewButtonClicked(action: string, submissionId: string) {
try {
const response = await fetch(`/api/submissions/${submissionId}/status/${action}`, {
method: "POST",
headers: {
"Content-type": "application/json",
}
});
// Check if the HTTP request was successful
if (!response.ok) {
const errorDetails = await response.text();
// Throw an error with detailed information
throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`);
}
window.location.reload();
} catch (error) {
console.error("Error updating submission status:", error);
}
}
function ReviewButton(props: ReviewButton) {
return <Button
color={props.color}
variant="contained"
onClick={() => { ReviewButtonClicked(props.action.action, props.submissionId) }}>{props.action.name}</Button>
}
export default function ReviewButtons(props: ReviewId) {
// When is each button visible?
// Multiple buttons can be visible at once.
// Action | Role | When Current Status is One of:
// ---------------|-----------|-----------------------
// Submit | Submitter | UnderConstruction, ChangesRequested
// Revoke | Submitter | Submitted, ChangesRequested
// Accept | Reviewer | Submitted
// Validate | Reviewer | Accepted
// ResetValidating| Reviewer | Validating
// Reject | Reviewer | Submitted
// RequestChanges | Reviewer | Validated, Accepted, Submitted
// Upload | MapAdmin | Validated
// ResetUploading | MapAdmin | Uploading
const { submissionId, submissionStatus } = props;
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [rolesData, userData] = await Promise.all([
fetch("/api/session/roles").then(rolesResponse => rolesResponse.json()),
fetch("/api/session/user").then(userResponse => userResponse.json())
]);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
}
fetchData();
}, [submissionId]);
if (loading) return <p>Loading...</p>;
const visibleButtons: ReviewButton[] = [];
const is_submitter = user === props.submissionSubmitter;
if (is_submitter) {
if ([SubmissionStatus.UnderConstruction, SubmissionStatus.ChangesRequested].includes(submissionStatus!)) {
visibleButtons.push({ action: ReviewActions.Submit, color: "info", submissionId });
}
if ([SubmissionStatus.Submitted, SubmissionStatus.ChangesRequested].includes(submissionStatus!)) {
visibleButtons.push({ action: ReviewActions.Revoke, color: "info", submissionId });
}
if (submissionStatus === SubmissionStatus.Submitting) {
visibleButtons.push({ action: ReviewActions.ResetSubmitting, color: "error", submissionId });
}
}
if (roles&RolesConstants.SubmissionReview) {
// you can force submit a map in ChangesRequested status
if (!is_submitter && submissionStatus === SubmissionStatus.ChangesRequested) {
visibleButtons.push({ action: ReviewActions.AdminSubmit, color: "error", submissionId });
visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", submissionId });
}
// you can't review your own submission!
// note that this means there needs to be more than one person with SubmissionReview
if (!is_submitter && submissionStatus === SubmissionStatus.Submitted) {
visibleButtons.push({ action: ReviewActions.Accept, color: "info", submissionId });
visibleButtons.push({ action: ReviewActions.Reject, color: "error", submissionId });
}
if (submissionStatus === SubmissionStatus.AcceptedUnvalidated) {
visibleButtons.push({ action: ReviewActions.Validate, color: "info", submissionId });
}
if (submissionStatus === SubmissionStatus.Validating) {
visibleButtons.push({ action: ReviewActions.ResetValidating, color: "error", submissionId });
}
// this button serves the same purpose as Revoke if you are both
// the map submitter and have SubmissionReview when status is Submitted
if (
[SubmissionStatus.Validated, SubmissionStatus.AcceptedUnvalidated].includes(submissionStatus!)
|| !is_submitter && submissionStatus == SubmissionStatus.Submitted
) {
visibleButtons.push({ action: ReviewActions.RequestChanges, color: "error", submissionId });
}
}
if (roles&RolesConstants.SubmissionUpload) {
if (submissionStatus === SubmissionStatus.Validated) {
visibleButtons.push({ action: ReviewActions.Upload, color: "info", submissionId });
}
// TODO: hide Reset buttons for 10 seconds
if (submissionStatus === SubmissionStatus.Uploading) {
visibleButtons.push({ action: ReviewActions.ResetUploading, color: "error", submissionId });
}
}
return (
<section className="review-set">
{visibleButtons.length === 0 ? (
<p>No available actions</p>
) : (
visibleButtons.map((btn) => (
<ReviewButton key={btn.action.action} {...btn} />
))
)}
</section>
);
}

View File

@@ -1,20 +0,0 @@
interface WindowStruct {
className: string,
title: string,
children: React.ReactNode
}
export default function Window(window: WindowStruct) {
return (
<section className={window.className}>
<header>
<p>{window.title}</p>
</header>
<main>{window.children}</main>
</section>
)
}
export {
type WindowStruct
}

View File

@@ -1,109 +1,316 @@
"use client"
"use client";
import { SubmissionInfo, SubmissionStatusToString } from "@/app/ts/Submission";
import type { CreatorAndReviewStatus } from "./_comments";
import { MapImage } from "./_mapImage";
import { useParams } from "next/navigation";
import ReviewButtons from "./_reviewButtons";
import { Comments, Comment } from "./_comments";
import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent";
import { SubmissionInfo } from "@/app/ts/Submission";
import {AuditEvent} from "@/app/ts/AuditEvent";
import { Roles, RolesConstants } from "@/app/ts/Roles";
import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation";
import {useState, useEffect, useCallback} from "react";
import Link from "next/link";
import { useState, useEffect } from "react";
import "./(styles)/page.scss";
// MUI Components
import {
Typography,
Box,
Container,
Breadcrumbs,
Paper,
Skeleton,
Grid,
CardMedia,
Snackbar,
Alert,
} from "@mui/material";
interface ReviewId {
submissionId: string;
assetId: number;
submissionStatus: number;
submissionSubmitter: number,
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAuditSection";
import {ReviewItem} from "@/app/_components/review/ReviewItem";
import {ErrorDisplay} from "@/app/_components/ErrorDisplay";
import ReviewButtons from "@/app/_components/review/ReviewButtons";
interface SnackbarState {
open: boolean;
message: string | null;
severity: 'success' | 'error' | 'info' | 'warning';
}
function RatingArea(submission: ReviewId) {
return (
<aside className="review-area">
<section className="map-image-area">
<MapImage id={submission.assetId}/>
</section>
<ReviewButtons submissionId={submission.submissionId} submissionStatus={submission.submissionStatus} submissionSubmitter={submission.submissionSubmitter}/>
</aside>
)
}
export default function SubmissionDetailsPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const router = useRouter();
function TitleAndComments(stats: CreatorAndReviewStatus) {
const Review = SubmissionStatusToString(stats.review)
const [submission, setSubmission] = useState<SubmissionInfo | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: null,
severity: 'success'
});
const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
setSnackbar({
open: true,
message,
severity
});
};
// TODO: hide status message when status is not "Accepted"
return (
<main className="review-info">
<div>
<h1>{stats.name}</h1>
<aside data-review-status={stats.review} className="review-status">
<p>{Review}</p>
</aside>
</div>
<p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p>
<p className="submitter">Submitter {stats.submitter}</p>
<p className="asset-id">Model Asset ID {stats.asset_id}</p>
<p className="uploaded-asset-id">Uploaded Asset ID {stats.uploaded_asset_id}</p>
<span className="spacer"></span>
<Comments comments_data={stats}/>
</main>
)
}
const handleCloseSnackbar = () => {
setSnackbar({
...snackbar,
open: false
});
};
export default function SubmissionInfoPage() {
const { submissionId } = useParams < { submissionId: string } >()
const [submission, setSubmission] = useState<SubmissionInfo | null>(null)
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
const validatorUser = 9223372036854776000;
useEffect(() => { // needs to be client sided since server doesn't have a session, nextjs got mad at me for exporting an async function: (https://nextjs.org/docs/messages/no-async-client-component)
async function getSubmission() {
const res = await fetch(`/api/submissions/${submissionId}`)
if (res.ok) {
setSubmission(await res.json())
const fetchData = useCallback(async (skipLoadingState = false) => {
try {
if (!skipLoadingState) {
setLoading(true);
}
setError(null);
const [submissionData, auditData, rolesData, userData] = await Promise.all([
fetch(`/api/submissions/${submissionId}`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch submission: ${res.status}`);
return res.json();
}),
fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`);
return res.json();
}),
fetch("/api/session/roles").then(res => {
if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`);
return res.json();
}),
fetch("/api/session/user").then(res => {
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
return res.json();
})
]);
setSubmission(submissionData);
setAuditEvents(auditData);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
setError(error instanceof Error ? error.message : "Failed to load submission details");
} finally {
if (!skipLoadingState) {
setLoading(false);
}
}
async function getAuditEvents() {
const res = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`)
if (res.ok) {
setAuditEvents(await res.json())
}, [submissionId]);
// Fetch submission data and audit events
useEffect(() => {
fetchData();
}, [fetchData]);
// Handle review button actions
async function handleReviewAction(action: string, submissionId: number) {
try {
const response = await fetch(`/api/submissions/${submissionId}/status/${action}`, {
method: "POST",
headers: {
"Content-type": "application/json",
}
});
if (!response.ok) {
const errorDetails = await response.text();
throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`);
}
}
getSubmission()
getAuditEvents()
}, [submissionId])
const comments:Comment[] = auditEvents.map((auditEvent) => {
let username = auditEvent.Username;
if (auditEvent.User == 9223372036854776000) {
username = "[Validator]";
}
if (username === "" && submission && auditEvent.User == submission.Submitter) {
username = "[Submitter]";
}
return {
date: auditEvent.CreatedAt,
name: username,
comment: auditEventMessage(auditEvent),
}
})
// Set success message based on the action
showSnackbar(`Successfully completed action: ${action}`, "success");
if (!submission) {
return <Webpage>
{/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */}
</Webpage>
// Reload data instead of refreshing the page
fetchData(true);
} catch (error) {
console.error("Error updating submission status:", error);
showSnackbar(error instanceof Error ? error.message : "Failed to update submission", 'error');
// Reload data instead of refreshing the page
fetchData(true);
}
}
return (
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea assetId={submission.AssetID} submissionId={submissionId} submissionStatus={submission.StatusID} submissionSubmitter={submission.Submitter}/>
<TitleAndComments name={submission.DisplayName} creator={submission.Creator} review={submission.StatusID} asset_id={submission.AssetID} submitter={submission.Submitter} uploaded_asset_id={submission.UploadedAssetID} comments={comments}/>
</section>
</main>
</Webpage>
)
}
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
showSnackbar('ID copied to clipboard', 'success');
};
const handleCommentSubmit = async () => {
if (!newComment.trim()) {
return; // Don't submit empty comments
}
try {
const response = await fetch(`/api/submissions/${submissionId}/comment`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: newComment,
});
if (!response.ok) {
throw new Error(`Failed to post comment: ${response.status}`);
}
// Clear comment input
setNewComment("");
// Refresh audit events to show the new comment
const auditData = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`);
if (auditData.ok) {
const updatedAuditEvents = await auditData.json();
setAuditEvents(updatedAuditEvents);
}
} catch (error) {
console.error("Error submitting comment:", error);
setError(error instanceof Error ? error.message : "Failed to submit comment");
}
};
// Loading state
if (loading) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 3 }}>
<Skeleton variant="text" width="60%" height={40} />
</Box>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={300} />
<Box sx={{ mt: 2 }}>
<Skeleton variant="rectangular" height={50} />
</Box>
</Grid>
<Grid item xs={12} md={8}>
<Skeleton variant="text" height={60} />
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" width="30%" />
<Box sx={{ mt: 4 }}>
<Skeleton variant="rectangular" height={200} />
</Box>
</Grid>
</Grid>
</Container>
</Webpage>
);
}
if (error || !submission) {
return (
<ErrorDisplay
title="Error Loading Submission"
message={error || "Submission not found"}
buttonText="Return to Submissions"
onButtonClick={() => router.push('/submissions')}
/>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/submissions" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Submissions</Typography>
</Link>
<Typography color="text.secondary">{submission.DisplayName}</Typography>
</Breadcrumbs>
<Grid container spacing={4}>
{/* Left Column - Image and Action Buttons */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
{submission.AssetID ? (
<CardMedia
component="img"
image={`/thumbnails/asset/${submission.AssetID}`}
alt="Map Thumbnail"
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
/>
) : (
<Box
sx={{
width: '100%',
aspectRatio: '1/1',
bgcolor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="body2" color="text.secondary">No image available</Typography>
</Box>
)}
</Paper>
{/* Review Buttons */}
<ReviewButtons
onClick={handleReviewAction}
item={submission}
userId={user}
roles={roles}
type="submission"/>
</Grid>
{/* Right Column - Submission Details and Comments */}
<Grid item xs={12} md={8}>
<ReviewItem
item={submission}
handleCopyValue={handleCopyId}
/>
{/* Comments Section */}
<CommentsAndAuditSection
auditEvents={auditEvents}
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
/>
</Grid>
</Grid>
<Snackbar
open={snackbar.open}
autoHideDuration={snackbar.severity === 'error' ? 6000 : 3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
onClose={handleCloseSnackbar}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

@@ -2,111 +2,147 @@
import { useState, useEffect } from "react";
import { SubmissionList } from "../ts/Submission";
import { SubmissionCard } from "../_components/mapCard";
import { MapCard } from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
import "./(styles)/page.scss";
import { ListSortConstants } from "../ts/Sort";
import {
Box,
Breadcrumbs,
CircularProgress,
Container,
Pagination,
Typography
} from "@mui/material";
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
export default function SubmissionInfoPage() {
const [submissions, setSubmissions] = useState<SubmissionList|null>(null)
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
const [isLoading, setIsLoading] = useState(false);
const cardsPerPage = 24;
useEffect(() => {
const controller = new AbortController();
async function fetchSubmissions() {
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
if (res.ok) {
setSubmissions(await res.json())
setIsLoading(true);
try {
const res = await fetch(
`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`,
{ signal: controller.signal }
);
if (res.ok) {
const data = await res.json();
setSubmissions(data);
} else {
console.error("Failed to fetch submissions:", res.status);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching submissions:", error);
}
} finally {
setIsLoading(false);
}
}
setTimeout(() => {
fetchSubmissions()
}, 50);
}, [currentPage])
fetchSubmissions();
return () => controller.abort();
}, [currentPage]);
if (!submissions) {
return <Webpage>
<main>
Loading...
</main>
</Webpage>
if (isLoading || !submissions) {
return (
<Webpage>
<Container sx={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress />
<Typography variant="body1" sx={{ mt: 2 }}>
Loading submissions...
</Typography>
</Box>
</Container>
</Webpage>
);
}
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
const currentCards = submissions.Submissions.slice(
(currentPage - 1) * cardsPerPage,
currentPage * cardsPerPage
);
const nextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
const prevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
if (submissions.Total == 0) {
return <Webpage>
<main>
Submissions list is empty.
</main>
</Webpage>
if (submissions.Total === 0) {
return (
<Webpage>
<Container sx={{ py: 6 }}>
<Typography variant="body1">
Submissions list is empty.
</Typography>
</Container>
</Webpage>
);
}
return (
// TODO: Add filter settings & searchbar & page selector
<Webpage>
<main
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '1rem',
width: '100%',
maxWidth: '100vw',
boxSizing: 'border-box',
overflowX: 'hidden'
}}
>
<div className="pagination-dots">
{Array.from({ length: totalPages }).map((_, index) => (
<span
key={index}
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
onClick={() => setCurrentPage(index+1)}
></span>
))}
</div>
<div className="pagination">
<button onClick={prevPage} disabled={currentPage === 1}>&lt;</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button onClick={nextPage} disabled={currentPage === totalPages}>&gt;</button>
</div>
<div className="grid">
{currentCards.map((submission) => (
<SubmissionCard
key={submission.ID}
id={submission.ID}
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
authorId={submission.Submitter}
rating={submission.StatusID}
/>
))}
</div>
</main>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Box component="main" sx={{ width: '100%', px: 2 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="text.secondary">Submissions</Typography>
</Breadcrumbs>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Submissions
</Typography>
<Typography variant="subtitle1" color="text.secondary" mb={4}>
Explore all submitted maps from the community.
</Typography>
<Box
className="grid"
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: 3,
width: '100%',
}}
>
{submissions.Submissions.map((submission) => (
<MapCard
key={submission.ID}
id={submission.ID}
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
authorId={submission.Submitter}
rating={submission.StatusID}
statusID={submission.StatusID}
gameID={submission.GameID}
created={submission.CreatedAt}
type="submission"
/>
))}
</Box>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" my={4}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
variant="outlined"
shape="rounded"
/>
</Box>
)}
</Box>
</Container>
</Webpage>
)
}
);
}

View File

@@ -1,54 +0,0 @@
@use "../../globals.scss";
::placeholder {
color: var(--placeholder-text)
}
.form-spacer {
margin-bottom: 20px;
&:last-of-type {
margin-top: 15px;
}
}
#target-asset-radio {
color: var(--text-color);
font-size: globals.$form-label-fontsize;
}
.form-field {
width: 850px;
& label, & input {
color: var(--text-color);
}
& fieldset {
border-color: rgb(100,100,100);
}
& span {
color: white;
}
}
main {
display: grid;
justify-content: center;
align-items: center;
margin-inline: auto;
width: 700px;
}
header h1 {
text-align: center;
color: var(--text-color);
}
form {
display: grid;
gap: 25px;
fieldset {
border: blue
}
}

View File

@@ -1,13 +1,23 @@
"use client"
import { Button, TextField } from "@mui/material"
import GameSelection from "./_game";
import SendIcon from '@mui/icons-material/Send';
import Webpage from "@/app/_components/webpage"
import React, { useState } from "react";
import "./(styles)/page.scss"
import {
Button,
TextField,
Box,
Container,
Typography,
Breadcrumbs,
CircularProgress,
Paper,
Grid,
FormControl
} from "@mui/material";
import SendIcon from '@mui/icons-material/Send';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import Webpage from "@/app/_components/webpage";
import GameSelection from "./_game";
import Link from "next/link";
interface SubmissionPayload {
AssetID: number;
@@ -15,76 +25,156 @@ interface SubmissionPayload {
Creator: string;
GameID: number;
}
interface IdResponse {
OperationID: number;
}
export default function SubmissionInfoPage() {
export default function SubmitPage() {
const [game, setGame] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
setError(null);
const form = event.currentTarget;
const formData = new FormData(form);
const assetId = formData.get("asset-id") as string;
const displayName = formData.get("display-name") as string;
const creator = formData.get("creator") as string;
// Validate required fields
if (!assetId || isNaN(Number(assetId))) {
setError("Please enter a valid Asset ID");
setIsSubmitting(false);
return;
}
const payload: SubmissionPayload = {
DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change
Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change
AssetID: Number(assetId),
DisplayName: displayName || "unknown",
Creator: creator || "unknown",
GameID: game,
AssetID: Number((formData.get("asset-id") as string) ?? "-1"),
};
console.log(payload)
console.log(JSON.stringify(payload))
try {
// Send the POST request
const response = await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
// Check if the HTTP request was successful
if (!response.ok) {
const errorDetails = await response.text();
// Throw an error with detailed information
throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`);
}
// Allow any HTTP status
const id_response:IdResponse = await response.json();
// navigate to newly created submission
window.location.assign(`/operations/${id_response.OperationID}`)
const { OperationID } = await response.json();
window.location.assign(`/operations/${OperationID}`);
} catch (error) {
console.error("Error submitting data:", error);
setError(error instanceof Error ? error.message : "An unknown error occurred");
setIsSubmitting(false);
}
};
return (
<Webpage>
<main>
<header>
<h1>Submit New Map</h1>
<span className="spacer form-spacer"></span>
</header>
<form onSubmit={handleSubmit}>
<TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID (required)" variant="outlined"/>
<TextField className="form-field" id="display-name" name="display-name" label="Display Name" variant="outlined"/>
<TextField className="form-field" id="creator" name="creator" label="Creator" variant="outlined"/>
<GameSelection game={game} setGame={setGame} />
<span className="spacer form-spacer"></span>
<Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{
width: "400px",
height: "50px",
marginInline: "auto"
}}>Create Submission</Button>
</form>
</main>
<Container maxWidth="lg" sx={{ mt: 4, mb: 8 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">Submit Map</Typography>
</Breadcrumbs>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
Submit New Map
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Fill out the form below to submit a new map to the game
</Typography>
</Box>
<Paper elevation={2} sx={{ p: { xs: 2, md: 4 }, borderRadius: 2 }}>
{error && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography color="error.dark">{error}</Typography>
</Box>
)}
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
required
id="asset-id"
name="asset-id"
label="Asset ID"
variant="outlined"
fullWidth
helperText="Enter the unique identifier for your map asset"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="display-name"
name="display-name"
label="Display Name"
variant="outlined"
fullWidth
helperText="The name that will be shown to users"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="creator"
name="creator"
label="Creator"
variant="outlined"
fullWidth
helperText="Name of the map creator or team"
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Select Game Type
</Typography>
<GameSelection game={game} setGame={setGame} />
</FormControl>
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
startIcon={isSubmitting ? <CircularProgress size={20} color="inherit" /> : <SendIcon />}
disabled={isSubmitting}
sx={{
py: 1.5,
px: 4,
minWidth: 200
}}
>
{isSubmitting ? "Submitting..." : "Submit Map"}
</Button>
</Grid>
</Grid>
</form>
</Paper>
</Container>
</Webpage>
)
}
);
}

View File

@@ -54,7 +54,7 @@ export interface AuditEventDataError {
// Full audit event type (mirroring the Go struct)
export interface AuditEvent {
Id: number;
CreatedAt: string; // ISO string, can convert to Date if needed
Date: number;
User: number;
Username: string;
ResourceType: string; // Assuming this is a string enum or similar

View File

@@ -17,7 +17,7 @@ interface MapfixInfo {
readonly DisplayName: string,
readonly Creator: string,
readonly GameID: number,
readonly Date: number,
readonly CreatedAt: number,
readonly Submitter: number,
readonly AssetID: number,
readonly AssetVersion: number,

View File

@@ -17,7 +17,7 @@ interface SubmissionInfo {
readonly DisplayName: string,
readonly Creator: string,
readonly GameID: number,
readonly Date: number,
readonly CreatedAt: number,
readonly Submitter: number,
readonly AssetID: number,
readonly AssetVersion: number,
@@ -48,11 +48,11 @@ function SubmissionStatusToString(submission_status: SubmissionStatus): string {
case SubmissionStatus.Validating:
return "VALIDATING"
case SubmissionStatus.AcceptedUnvalidated:
return "ACCEPTED, NOT VALIDATED"
return "SCRIPT REVIEW"
case SubmissionStatus.ChangesRequested:
return "CHANGES REQUESTED"
case SubmissionStatus.Submitted:
return "SUBMITTED"
return "UNDER REVIEW"
case SubmissionStatus.Submitting:
return "SUBMITTING"
case SubmissionStatus.UnderConstruction: