forked from StrafesNET/maps-service
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3f8a6fdff6
|
|||
|
c5274ff194
|
129
Cargo.lock
generated
129
Cargo.lock
generated
@@ -54,6 +54,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -234,6 +284,61 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
|
||||
|
||||
[[package]]
|
||||
name = "cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"maps-validation",
|
||||
"rbx_binary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@@ -897,6 +1002,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -1138,6 +1249,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
@@ -1901,6 +2018,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "submissions-api"
|
||||
version = "0.7.2"
|
||||
@@ -2273,6 +2396,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
members = [
|
||||
"validation",
|
||||
"validation/api",
|
||||
"validation/cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
68
openapi.yaml
68
openapi.yaml
@@ -114,13 +114,6 @@ 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
|
||||
@@ -185,11 +178,6 @@ paths:
|
||||
format: int32
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
description: >
|
||||
Game ID:
|
||||
* `1` - Bhop
|
||||
* `2` - Surf
|
||||
* `5` - FlyTrials
|
||||
- name: Sort
|
||||
in: query
|
||||
schema:
|
||||
@@ -197,13 +185,6 @@ 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:
|
||||
@@ -229,24 +210,6 @@ 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
|
||||
@@ -639,11 +602,6 @@ paths:
|
||||
format: int32
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
description: >
|
||||
Game ID:
|
||||
* `1` - Bhop
|
||||
* `2` - Surf
|
||||
* `5` - FlyTrials
|
||||
- name: Sort
|
||||
in: query
|
||||
schema:
|
||||
@@ -651,13 +609,6 @@ 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:
|
||||
@@ -683,25 +634,6 @@ 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,25 +2879,16 @@ 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
|
||||
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Sort OptInt32
|
||||
Submitter OptInt64
|
||||
AssetID OptInt64
|
||||
TargetAssetID OptInt64
|
||||
// // 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
|
||||
StatusID OptInt32
|
||||
}
|
||||
|
||||
func unpackListMapfixesParams(packed middleware.Parameters) (params ListMapfixesParams) {
|
||||
@@ -3626,9 +3617,7 @@ type ListMapsParams struct {
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
Sort OptInt32
|
||||
}
|
||||
|
||||
func unpackListMapsParams(packed middleware.Parameters) (params ListMapsParams) {
|
||||
@@ -5128,25 +5117,16 @@ 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
|
||||
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Sort OptInt32
|
||||
Submitter OptInt64
|
||||
AssetID OptInt64
|
||||
UploadedAssetID OptInt64
|
||||
// // 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
|
||||
StatusID OptInt32
|
||||
}
|
||||
|
||||
func unpackListSubmissionsParams(packed middleware.Parameters) (params ListSubmissionsParams) {
|
||||
|
||||
@@ -396,42 +396,10 @@ 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", 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
|
||||
smap.Add("status_id", model.MapfixStatusChangesRequested)
|
||||
return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
|
||||
}
|
||||
|
||||
// ActionMapfixRevoke invokes actionMapfixRevoke operation.
|
||||
|
||||
@@ -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>,ScriptSingleItemError>{
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,SingleItemError>{
|
||||
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(scripts));
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
}
|
||||
Ok(scripts.into_iter().next())
|
||||
}
|
||||
@@ -106,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>,ScriptPolicySingleItemError>{
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
|
||||
let policies=self.get_script_policies(GetScriptPoliciesRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -115,7 +115,7 @@ impl Context{
|
||||
Policy:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<policies.len(){
|
||||
return Err(SingleItemError::DuplicateItems(policies));
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
}
|
||||
Ok(policies.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -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>,ScriptSingleItemError>{
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,SingleItemError>{
|
||||
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(scripts));
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
}
|
||||
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>,ScriptPolicySingleItemError>{
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
|
||||
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(policies));
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
}
|
||||
Ok(policies.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -14,21 +14,16 @@ impl std::fmt::Display for Error{
|
||||
impl std::error::Error for Error{}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SingleItemError<Items>{
|
||||
DuplicateItems(Items),
|
||||
pub enum SingleItemError{
|
||||
DuplicateItems,
|
||||
Other(Error),
|
||||
}
|
||||
impl<Items> std::fmt::Display for SingleItemError<Items>
|
||||
where
|
||||
Items:std::fmt::Debug
|
||||
{
|
||||
impl std::fmt::Display for SingleItemError{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
write!(f,"{self:?}")
|
||||
}
|
||||
}
|
||||
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>>;
|
||||
impl std::error::Error for SingleItemError{}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
|
||||
9
validation/cli/Cargo.toml
Normal file
9
validation/cli/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.39", features = ["derive"] }
|
||||
maps-validation = { path = ".." }
|
||||
rbx_binary = "1.0.0"
|
||||
73
validation/cli/src/main.rs
Normal file
73
validation/cli/src/main.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use clap::{Args,Parser,Subcommand};
|
||||
use std::path::{Path,PathBuf};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author,version,about,long_about=None)]
|
||||
#[command(propagate_version=true)]
|
||||
struct Cli{
|
||||
#[command(subcommand)]
|
||||
command:Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands{
|
||||
Check(CheckCommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct CheckCommand{
|
||||
files:Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn main(){
|
||||
let cli=Cli::parse();
|
||||
match cli.command{
|
||||
Commands::Check(command)=>command.run().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
enum CheckError{
|
||||
Io(std::io::Error),
|
||||
Binary(maps_validation::rbx_util::ReadDomError),
|
||||
CheckDom(maps_validation::rbx_util::GetRootInstanceError),
|
||||
}
|
||||
|
||||
fn check(path:&Path)->Result<(),CheckError>{
|
||||
let name=path.file_name().unwrap_or_default().to_str().unwrap_or_default();
|
||||
let file=std::fs::read(path).map_err(CheckError::Io)?;
|
||||
|
||||
let dom=maps_validation::rbx_util::read_dom(file.as_slice()).map_err(CheckError::Binary)?;
|
||||
|
||||
let check=maps_validation::message_handler::MessageHandler::check_dom(&dom).map_err(CheckError::CheckDom)?;
|
||||
match check.result(){
|
||||
Ok(_map_info)=>(),//println!("good {}",map_info.display_name),
|
||||
Err(Ok(check_list))=>println!("bad {name} Error: {}",check_list.summary()),
|
||||
Err(Err(e))=>println!("ugly {name} Error: {e}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl CheckCommand{
|
||||
fn run(self)->Result<(),()>{
|
||||
let mut handles=Vec::new();
|
||||
for path in self.files{
|
||||
if path.is_file(){
|
||||
handles.push(std::thread::spawn(move||{
|
||||
if let Err(e)=check(path.as_path()){
|
||||
let name=path.file_name().unwrap_or_default().to_str().unwrap_or_default();
|
||||
println!("ugly {name} Error: {e:?}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
for handle in handles{
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashSet,HashMap};
|
||||
use crate::download::download_asset_version;
|
||||
use crate::rbx_util::{get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||
|
||||
use heck::{ToSnakeCase,ToTitleCase};
|
||||
|
||||
@@ -225,33 +225,27 @@ 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();
|
||||
|
||||
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(_)=>(),
|
||||
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(_)=>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +371,7 @@ impl<ID:Copy+Eq+std::hash::Hash,T> SetDifferenceCheckContextAtLeastOne<ID,T>{
|
||||
}
|
||||
|
||||
/// Info lifted out of a fully compliant map
|
||||
#[derive(Debug)]
|
||||
pub struct MapInfoOwned{
|
||||
pub display_name:String,
|
||||
pub creator:String,
|
||||
@@ -388,7 +383,7 @@ struct Exists;
|
||||
struct Absent;
|
||||
|
||||
/// The result of every map check.
|
||||
struct MapCheck<'a>{
|
||||
pub struct MapCheck<'a>{
|
||||
// === METADATA CHECKS ===
|
||||
// The root must be of class Model
|
||||
model_class:StringCheck<'a,(),&'static str>,
|
||||
@@ -517,7 +512,7 @@ impl<'a> ModelInfo<'a>{
|
||||
}
|
||||
|
||||
impl MapCheck<'_>{
|
||||
fn result(self)->Result<MapInfoOwned,Result<MapCheckList,serde_json::Error>>{
|
||||
pub fn result(self)->Result<MapInfoOwned,Result<MapCheckList,serde_json::Error>>{
|
||||
match self{
|
||||
MapCheck{
|
||||
model_class:StringCheck(Ok(())),
|
||||
@@ -592,10 +587,10 @@ impl<D:std::fmt::Display> std::fmt::Display for Duplicates<D>{
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct CheckSummary{
|
||||
pub struct CheckSummary{
|
||||
name:&'static str,
|
||||
summary:String,
|
||||
passed:bool,
|
||||
pub passed:bool,
|
||||
details:serde_json::Value,
|
||||
}
|
||||
impl CheckSummary{
|
||||
@@ -786,17 +781,18 @@ impl MapCheck<'_>{
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct MapCheckList{
|
||||
checks:Box<[CheckSummary;16]>,
|
||||
pub struct MapCheckList{
|
||||
pub checks:Box<[CheckSummary;16]>,
|
||||
}
|
||||
impl MapCheckList{
|
||||
fn summary(&self)->String{
|
||||
pub fn summary(&self)->String{
|
||||
Separated::new("; ",||self.checks.iter().filter_map(|check|
|
||||
(!check.passed).then_some(check.summary.as_str())
|
||||
)).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Summary{
|
||||
pub summary:String,
|
||||
pub json:serde_json::Value,
|
||||
@@ -830,14 +826,7 @@ impl crate::message_handler::MessageHandler{
|
||||
// decode dom (slow!)
|
||||
let dom=maybe_gzip.read_with(read_dom,read_dom).map_err(Error::ModelFileDecode)?;
|
||||
|
||||
// extract the root instance
|
||||
let model_instance=get_root_instance(&dom).map_err(Error::GetRootInstance)?;
|
||||
|
||||
// extract information from the model
|
||||
let model_info=get_model_info(&dom,model_instance);
|
||||
|
||||
// convert the model information into a structured report
|
||||
let map_check=model_info.check();
|
||||
let map_check=Self::check_dom(&dom).map_err(Error::GetRootInstance)?;
|
||||
|
||||
// check the report, generate an error message if it fails the check
|
||||
let status=match map_check.result(){
|
||||
@@ -851,4 +840,16 @@ impl crate::message_handler::MessageHandler{
|
||||
|
||||
Ok(CheckReportAndVersion{status,version})
|
||||
}
|
||||
pub fn check_dom(dom:&rbx_dom_weak::WeakDom)->Result<MapCheck,GetRootInstanceError>{
|
||||
// extract the root instance
|
||||
let model_instance=get_root_instance(&dom)?;
|
||||
|
||||
// extract information from the model
|
||||
let model_info=get_model_info(&dom,model_instance);
|
||||
|
||||
// convert the model information into a structured report
|
||||
let map_check=model_info.check();
|
||||
|
||||
Ok(map_check)
|
||||
}
|
||||
}
|
||||
|
||||
16
validation/src/lib.rs
Normal file
16
validation/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub mod rbx_util;
|
||||
pub mod message_handler;
|
||||
pub mod nats_types;
|
||||
pub mod types;
|
||||
pub mod download;
|
||||
pub mod check;
|
||||
pub mod check_mapfix;
|
||||
pub mod check_submission;
|
||||
pub mod create;
|
||||
pub mod create_mapfix;
|
||||
pub mod create_submission;
|
||||
pub mod upload_mapfix;
|
||||
pub mod upload_submission;
|
||||
pub mod validator;
|
||||
pub mod validate_mapfix;
|
||||
pub mod validate_submission;
|
||||
@@ -28,10 +28,19 @@ 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)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum GameID{
|
||||
Bhop=1,
|
||||
Surf=2,
|
||||
|
||||
@@ -39,11 +39,11 @@ pub enum Error{
|
||||
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
|
||||
Download(crate::download::Error),
|
||||
ModelFileDecode(ReadDomError),
|
||||
ApiGetScriptPolicyFromHash(submissions_api::types::ScriptPolicySingleItemError),
|
||||
ApiGetScriptPolicyFromHash(submissions_api::types::SingleItemError),
|
||||
ApiGetScript(submissions_api::Error),
|
||||
ApiCreateScript(submissions_api::Error),
|
||||
ApiCreateScriptPolicy(submissions_api::Error),
|
||||
ApiGetScriptFromHash(submissions_api::types::ScriptSingleItemError),
|
||||
ApiGetScriptFromHash(submissions_api::types::SingleItemError),
|
||||
ApiUpdateMapfixModel(submissions_api::Error),
|
||||
ApiUpdateSubmissionModel(submissions_api::Error),
|
||||
ModelFileRootMustHaveOneChild,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
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,132 +3,62 @@
|
||||
import Link from "next/link"
|
||||
import Image from "next/image";
|
||||
|
||||
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 "./styles/header.scss"
|
||||
import { UserInfo } from "@/app/ts/User";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface HeaderButton {
|
||||
name: string;
|
||||
href: string;
|
||||
name: string,
|
||||
href: string
|
||||
}
|
||||
|
||||
function HeaderButton(header: HeaderButton) {
|
||||
return (
|
||||
<Button color="inherit" component={Link} href={header.href}>
|
||||
{header.name}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Link href={header.href}>
|
||||
<button>{header.name}</button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [valid, setValid] = useState<boolean>(false)
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
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 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>
|
||||
);
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,268 +1,71 @@
|
||||
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";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Rating } from "@mui/material";
|
||||
|
||||
interface MapCardProps {
|
||||
interface SubmissionCardProps {
|
||||
displayName: string;
|
||||
assetId: number;
|
||||
authorId: number;
|
||||
author: string;
|
||||
rating: number;
|
||||
id: number;
|
||||
statusID: number;
|
||||
gameID: number;
|
||||
created: number;
|
||||
type: 'mapfix' | 'submission';
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 270;
|
||||
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export function SubmissionCard(props: SubmissionCardProps) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapfixCard(props: SubmissionCardProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
'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>
|
||||
<ThemeProvider theme={theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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,130 +2,113 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MapfixList } from "../ts/Mapfix";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import { MapfixCard } 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(() => {
|
||||
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,
|
||||
});
|
||||
async function fetchMapfixes() {
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
if (res.ok) {
|
||||
setMapfixes(await res.json());
|
||||
setMapfixes(await res.json())
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchMapFixes();
|
||||
setTimeout(() => {
|
||||
fetchMapfixes()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !mapfixes) {
|
||||
if (!mapfixes) {
|
||||
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 mapfixes...
|
||||
</Typography>
|
||||
</Box>
|
||||
<main>
|
||||
Loading...
|
||||
</main>
|
||||
</Webpage>;
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
const currentCards = mapfixes.Mapfixes;
|
||||
|
||||
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>
|
||||
<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>
|
||||
<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}><</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button onClick={nextPage} disabled={currentPage === totalPages}>></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>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,298 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import {useState, useEffect} from "react";
|
||||
import Image from "next/image";
|
||||
import {useRouter} from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
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 "./(styles)/page.scss";
|
||||
|
||||
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 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;
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
|
||||
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;
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
const res = await fetch("/api/maps?Page=1&Limit=100");
|
||||
const data: Map[] = await res.json();
|
||||
setMaps(data);
|
||||
};
|
||||
|
||||
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++;
|
||||
}
|
||||
fetchMaps();
|
||||
}, []);
|
||||
|
||||
setMaps(allMaps);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch maps:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const customLoader = ({ src }: { src: string }) => {
|
||||
return src;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +1,7 @@
|
||||
'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>
|
||||
<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>
|
||||
<Webpage></Webpage>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import {useState, useEffect} from "react";
|
||||
import {SubmissionList} from "../ts/Submission";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import { useState, useEffect } from "react";
|
||||
import { SubmissionList } from "../ts/Submission";
|
||||
import { SubmissionCard } from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import {ListSortConstants} from "../ts/Sort";
|
||||
import {Breadcrumbs, Pagination, Typography, CircularProgress, Box, Container} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
|
||||
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() {
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
if (res.ok) {
|
||||
setSubmissions(await res.json());
|
||||
setSubmissions(await res.json())
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchSubmissions();
|
||||
setTimeout(() => {
|
||||
fetchSubmissions()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !submissions) {
|
||||
if (!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 submissions...
|
||||
</Typography>
|
||||
</Box>
|
||||
<main>
|
||||
Loading...
|
||||
</main>
|
||||
</Webpage>;
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
|
||||
const currentCards = submissions.Submissions;
|
||||
|
||||
if (submissions.Total === 0) {
|
||||
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>;
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<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">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>
|
||||
<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}><</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button onClick={nextPage} disabled={currentPage === totalPages}>></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>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface MapfixInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Date: 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 CreatedAt: number,
|
||||
readonly Date: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
Reference in New Issue
Block a user