Compare commits

...

15 Commits

Author SHA1 Message Date
70dd8502f4 Merge pull request 'submissions: add missing audit even when requesting changes for a mapfix' () from pr2 into staging
Reviewed-on: 
2025-06-08 05:49:28 +00:00
5b977289e7 Merge pull request 'validation: include more details in duplicates error' () from dupes into staging
Reviewed-on: 
2025-06-08 05:49:11 +00:00
7b3af95f3d Merge pull request 'Remove class_is_a' () from pr1 into staging
Reviewed-on: 
2025-06-08 05:48:55 +00:00
4d78a9b2c5
submissions: add missing audit even when requesting changes for a mapfix 2025-06-07 22:42:39 -07:00
ec59a83379
validation: include more details in duplicates error 2025-06-07 22:34:57 -07:00
54bf3f55a0 Rework submission/mapfix/maps list views ()
Refactored maps/landing/mapfix/submission and navbar

Reviewed-on: 
Reviewed-by: Quaternions <quaternions@noreply@itzana.me>
Co-authored-by: itzaname <me@sliving.io>
Co-committed-by: itzaname <me@sliving.io>
2025-06-08 03:41:36 +00:00
14f404ffe3 Merge pull request 'Openapi: Document Enum Fields' () from openapi-doc into staging
Reviewed-on: 
Reviewed-by: itzaname <itzaname@noreply@itzana.me>
2025-06-08 00:21:21 +00:00
0e1d2fe50a
openapi: generate 2025-06-07 17:09:51 -07:00
ada8c322da
openapi: add descriptions to enum fields 2025-06-07 17:07:47 -07:00
84d2bfef20
remove class_is_a 2025-06-07 16:29:06 -07:00
170e7c64b6 Merge pull request 'submissions-api: add external delete endpoints' () from pr1 into staging
Reviewed-on: 
2025-06-07 05:38:30 +00:00
b443866dd6 Merge pull request 'update deps' () from deps into staging
Reviewed-on: 
2025-06-07 05:35:45 +00:00
ebe37ad6a2
update deps 2025-06-06 22:29:35 -07:00
131dad7ae0
submissions-api: v0.7.2 script policy delete endpoints 2025-06-06 22:28:07 -07:00
b6d4ce4f80
submissions-api: add external delete endpoints 2025-06-06 17:14:27 -07:00
24 changed files with 1661 additions and 534 deletions

275
Cargo.lock generated

@ -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]]

@ -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

@ -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) {

@ -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.

@ -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"

@ -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"

@ -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
}
}

@ -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(())
}
}

@ -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())
}

@ -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>{

@ -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(_)=>(),
}
}

@ -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)
}

@ -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,

@ -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>
);
}

@ -3,62 +3,132 @@
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 {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";
interface HeaderButton {
name: string,
href: string
name: string;
href: string;
}
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 handleLoginClick = () => {
window.location.href =
"/auth/oauth2/login?redirect=" + window.location.href;
};
const [valid, setValid] = useState<boolean>(false)
const [user, setUser] = useState<UserInfo | null>(null)
const [valid, setValid] = useState<boolean>(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
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 handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
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 handleMenuClose = () => {
setAnchorEl(null);
};
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();
}, []);
return (
<AppBar position="static">
<Toolbar>
<Box display="flex" flexGrow={1} gap={2}>
<HeaderButton name="Submissions" href="/submissions"/>
<HeaderButton name="Mapfixes" href="/mapfixes"/>
<HeaderButton name="Maps" href="/maps"/>
</Box>
<Box display="flex" gap={2}>
{valid && user && (
<Button variant="outlined" color="success" component={Link} href="/submit">
Submit Map
</Button>
)}
{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>
) : (
<Button color="inherit" onClick={handleLoginClick}>
Login
</Button>
)}
</Box>
</Toolbar>
</AppBar>
);
}

@ -1,71 +1,268 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Rating } from "@mui/material";
import React, {JSX} from "react";
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Grid, Typography} from "@mui/material";
import {Cancel, CheckCircle, Explore, Pending, Person2} from "@mui/icons-material";
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) {
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 = 'Accepted Unvalidated';
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)',
}}
/>
);
};
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>
)
}

@ -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

@ -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',
},
},
},
},
});

@ -2,113 +2,130 @@
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";
export default function MapfixInfoPage() {
const [mapfixes, setMapfixes] = useState<MapfixList|null>(null)
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
useEffect(() => {
async function fetchMapfixes() {
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
const controller = new AbortController();
async function fetchMapFixes() {
setIsLoading(true);
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
signal: controller.signal,
});
if (res.ok) {
setMapfixes(await res.json())
setMapfixes(await res.json());
}
setIsLoading(false);
}
setTimeout(() => {
fetchMapfixes()
}, 50);
}, [currentPage])
fetchMapFixes();
if (!mapfixes) {
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
}, [currentPage]);
if (isLoading || !mapfixes) {
return <Webpage>
<main>
Loading...
</main>
</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'
height: '100vh',
}}
>
<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>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress/>
<Typography variant="body1" style={{marginTop: '1rem'}}>
Loading mapfixes...
</Typography>
</Box>
</main>
</Webpage>;
}
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
const currentCards = mapfixes.Mapfixes;
return (
<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'
}}
>
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
</Link>
<Typography color="textPrimary">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>
<div
className="grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
width: '100%',
}}
>
{currentCards.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="mapfix"
/>
))}
</div>
<Box display="flex" justifyContent="center" my={4}>
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
variant="outlined"
shape="rounded"
/>
</div>
</Box>
</main>
</Container>
</Webpage>
)
}

@ -1,60 +1,298 @@
"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";
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="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
</Link>
<Typography color="textPrimary">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>
);
}

@ -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>
);
}

@ -1,112 +1,137 @@
'use client'
import { useState, useEffect } from "react";
import { SubmissionList } from "../ts/Submission";
import { SubmissionCard } from "../_components/mapCard";
import {useState, useEffect} from "react";
import {SubmissionList} from "../ts/Submission";
import {MapCard} from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
import "./(styles)/page.scss";
import { ListSortConstants } from "../ts/Sort";
import {ListSortConstants} from "../ts/Sort";
import {Breadcrumbs, Pagination, Typography, CircularProgress, Box, Container} from "@mui/material";
import Link from "next/link";
export default function SubmissionInfoPage() {
const [submissions, setSubmissions] = useState<SubmissionList|null>(null)
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
useEffect(() => {
const controller = new AbortController();
async function fetchSubmissions() {
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
setIsLoading(true);
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
signal: controller.signal,
});
if (res.ok) {
setSubmissions(await res.json())
setSubmissions(await res.json());
}
setIsLoading(false);
}
setTimeout(() => {
fetchSubmissions()
}, 50);
}, [currentPage])
fetchSubmissions();
if (!submissions) {
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
}, [currentPage]);
if (isLoading || !submissions) {
return <Webpage>
<main>
Loading...
<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 submissions...
</Typography>
</Box>
</main>
</Webpage>
</Webpage>;
}
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
const currentCards = submissions.Submissions;
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) {
if (submissions.Total === 0) {
return <Webpage>
<main>
Submissions list is empty.
</main>
</Webpage>
</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 }}>
<main
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '1rem',
width: '100%',
maxWidth: '100vw',
boxSizing: 'border-box',
overflowX: 'hidden'
}}
>
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
</Link>
<Typography color="textPrimary">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>
<div
className="grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
width: '100%',
}}
>
{currentCards.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"
/>
))}
</div>
<Box display="flex" justifyContent="center" my={4}>
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
variant="outlined"
shape="rounded"
/>
</div>
</Box>
</main>
</Container>
</Webpage>
)
}

@ -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,

@ -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,