Compare commits

..

2 Commits
staging ... cli

Author SHA1 Message Date
3f8a6fdff6 w 2025-06-07 02:30:36 -07:00
c5274ff194 entire cli 2025-06-07 02:23:30 -07:00
25 changed files with 627 additions and 1531 deletions

129
Cargo.lock generated
View File

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

View File

@@ -2,5 +2,6 @@
members = [
"validation",
"validation/api",
"validation/cli",
]
resolver = "2"

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,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())
}

View File

@@ -76,7 +76,7 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,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())
}

View File

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

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

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

View File

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

View File

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

View File

@@ -39,11 +39,11 @@ pub enum Error{
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
Download(crate::download::Error),
ModelFileDecode(ReadDomError),
ApiGetScriptPolicyFromHash(submissions_api::types::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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}>&lt;</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button onClick={nextPage} disabled={currentPage === totalPages}>&gt;</button>
</div>
<div className="grid">
{currentCards.map((mapfix) => (
<MapfixCard
key={mapfix.ID}
id={mapfix.ID}
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
authorId={mapfix.Submitter}
rating={mapfix.StatusID}
/>
))}
</div>
</main>
</Webpage>
)
}

View File

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

View File

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

View File

@@ -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}>&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>
</Webpage>
)
}

View File

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

View File

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