Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70dd8502f4 | |||
| 5b977289e7 | |||
| 7b3af95f3d | |||
|
4d78a9b2c5
|
|||
|
ec59a83379
|
|||
| 54bf3f55a0 | |||
| 14f404ffe3 | |||
|
0e1d2fe50a
|
|||
|
ada8c322da
|
|||
|
84d2bfef20
|
68
openapi.yaml
68
openapi.yaml
@@ -114,6 +114,13 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
description: >
|
||||
Sort order:
|
||||
* `0` - Disabled
|
||||
* `1` - DisplayNameAscending
|
||||
* `2` - DisplayNameDescending
|
||||
* `3` - DateAscending
|
||||
* `4` - DateDescending
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
@@ -178,6 +185,11 @@ paths:
|
||||
format: int32
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
description: >
|
||||
Game ID:
|
||||
* `1` - Bhop
|
||||
* `2` - Surf
|
||||
* `5` - FlyTrials
|
||||
- name: Sort
|
||||
in: query
|
||||
schema:
|
||||
@@ -185,6 +197,13 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
description: >
|
||||
Sort order:
|
||||
* `0` - Disabled
|
||||
* `1` - DisplayNameAscending
|
||||
* `2` - DisplayNameDescending
|
||||
* `3` - DateAscending
|
||||
* `4` - DateDescending
|
||||
- name: Submitter
|
||||
in: query
|
||||
schema:
|
||||
@@ -210,6 +229,24 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 9
|
||||
description: >
|
||||
// Phase: Creation
|
||||
* `0` - UnderConstruction
|
||||
* `1` - ChangesRequested
|
||||
|
||||
// Phase: Review
|
||||
* `2` - Submitting
|
||||
* `3` - Submitted
|
||||
|
||||
// Phase: Testing
|
||||
* `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
* `5` - Validating
|
||||
* `6` - Validated
|
||||
* `7` - Uploading
|
||||
|
||||
// Phase: Final MapfixStatus
|
||||
* `8` - Uploaded // uploaded to the group, but pending release
|
||||
* `9` - Rejected
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
@@ -602,6 +639,11 @@ paths:
|
||||
format: int32
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
description: >
|
||||
Game ID:
|
||||
* `1` - Bhop
|
||||
* `2` - Surf
|
||||
* `5` - FlyTrials
|
||||
- name: Sort
|
||||
in: query
|
||||
schema:
|
||||
@@ -609,6 +651,13 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
description: >
|
||||
Sort order:
|
||||
* `0` - Disabled
|
||||
* `1` - DisplayNameAscending
|
||||
* `2` - DisplayNameDescending
|
||||
* `3` - DateAscending
|
||||
* `4` - DateDescending
|
||||
- name: Submitter
|
||||
in: query
|
||||
schema:
|
||||
@@ -634,6 +683,25 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 10
|
||||
description: >
|
||||
// Phase: Creation
|
||||
* `0` - UnderConstruction
|
||||
* `1` - ChangesRequested
|
||||
|
||||
// Phase: Review
|
||||
* `2` - Submitting
|
||||
* `3` - Submitted
|
||||
|
||||
// Phase: Testing
|
||||
* `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
* `5` - Validating
|
||||
* `6` - Validated
|
||||
* `7` - Uploading
|
||||
* `8` - Uploaded // uploaded to the group, but pending release
|
||||
|
||||
// Phase: Final SubmissionStatus
|
||||
* `9` - Rejected
|
||||
* `10` - Released
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
|
||||
@@ -2879,16 +2879,25 @@ func decodeListMapfixAuditEventsParams(args [1]string, argsEscaped bool, r *http
|
||||
|
||||
// ListMapfixesParams is parameters of listMapfixes operation.
|
||||
type ListMapfixesParams struct {
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
Submitter OptInt64
|
||||
AssetID OptInt64
|
||||
TargetAssetID OptInt64
|
||||
StatusID OptInt32
|
||||
// // Phase: Creation * `0` - UnderConstruction * `1` - ChangesRequested
|
||||
// // Phase: Review * `2` - Submitting * `3` - Submitted
|
||||
// // Phase: Testing * `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
// * `5` - Validating * `6` - Validated * `7` - Uploading
|
||||
// // Phase: Final MapfixStatus * `8` - Uploaded // uploaded to the group, but pending release * `9`
|
||||
// - Rejected.
|
||||
StatusID OptInt32
|
||||
}
|
||||
|
||||
func unpackListMapfixesParams(packed middleware.Parameters) (params ListMapfixesParams) {
|
||||
@@ -3617,7 +3626,9 @@ type ListMapsParams struct {
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Sort OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
}
|
||||
|
||||
func unpackListMapsParams(packed middleware.Parameters) (params ListMapsParams) {
|
||||
@@ -5117,16 +5128,25 @@ func decodeListSubmissionAuditEventsParams(args [1]string, argsEscaped bool, r *
|
||||
|
||||
// ListSubmissionsParams is parameters of listSubmissions operation.
|
||||
type ListSubmissionsParams struct {
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
Submitter OptInt64
|
||||
AssetID OptInt64
|
||||
UploadedAssetID OptInt64
|
||||
StatusID OptInt32
|
||||
// // Phase: Creation * `0` - UnderConstruction * `1` - ChangesRequested
|
||||
// // Phase: Review * `2` - Submitting * `3` - Submitted
|
||||
// // Phase: Testing * `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
// * `5` - Validating * `6` - Validated * `7` - Uploading * `8` - Uploaded // uploaded to the group,
|
||||
// but pending release
|
||||
// // Phase: Final SubmissionStatus * `9` - Rejected * `10` - Released.
|
||||
StatusID OptInt32
|
||||
}
|
||||
|
||||
func unpackListSubmissionsParams(packed middleware.Parameters) (params ListSubmissionsParams) {
|
||||
|
||||
@@ -396,10 +396,42 @@ func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, params api.A
|
||||
return ErrPermissionDeniedNeedRoleMapfixReview
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// transaction
|
||||
target_status := model.MapfixStatusChangesRequested
|
||||
smap := datastore.Optional()
|
||||
smap.Add("status_id", model.MapfixStatusChangesRequested)
|
||||
return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
|
||||
smap.Add("status_id", target_status)
|
||||
err = svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event_data := model.AuditEventDataAction{
|
||||
TargetStatus: uint32(target_status),
|
||||
}
|
||||
|
||||
EventData, err := json.Marshal(event_data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{
|
||||
ID: 0,
|
||||
User: userId,
|
||||
ResourceType: model.ResourceMapfix,
|
||||
ResourceID: params.MapfixID,
|
||||
EventType: model.AuditEventTypeAction,
|
||||
EventData: EventData,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionMapfixRevoke invokes actionMapfixRevoke operation.
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,SingleItemError>{
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,ScriptSingleItemError>{
|
||||
let scripts=self.get_scripts(GetScriptsRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -57,7 +57,7 @@ impl Context{
|
||||
ResourceID:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<scripts.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(scripts));
|
||||
}
|
||||
Ok(scripts.into_iter().next())
|
||||
}
|
||||
@@ -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>,SingleItemError>{
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,ScriptPolicySingleItemError>{
|
||||
let policies=self.get_script_policies(GetScriptPoliciesRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -115,7 +115,7 @@ impl Context{
|
||||
Policy:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<policies.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(policies));
|
||||
}
|
||||
Ok(policies.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,SingleItemError>{
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,ScriptSingleItemError>{
|
||||
let scripts=self.get_scripts(GetScriptsRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -87,7 +87,7 @@ impl Context{
|
||||
ResourceID:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<scripts.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(scripts));
|
||||
}
|
||||
Ok(scripts.into_iter().next())
|
||||
}
|
||||
@@ -126,7 +126,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,ScriptPolicySingleItemError>{
|
||||
let policies=self.get_script_policies(GetScriptPoliciesRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -135,7 +135,7 @@ impl Context{
|
||||
Policy:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<policies.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(policies));
|
||||
}
|
||||
Ok(policies.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -14,16 +14,21 @@ impl std::fmt::Display for Error{
|
||||
impl std::error::Error for Error{}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SingleItemError{
|
||||
DuplicateItems,
|
||||
pub enum SingleItemError<Items>{
|
||||
DuplicateItems(Items),
|
||||
Other(Error),
|
||||
}
|
||||
impl std::fmt::Display for SingleItemError{
|
||||
impl<Items> std::fmt::Display for SingleItemError<Items>
|
||||
where
|
||||
Items:std::fmt::Debug
|
||||
{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
write!(f,"{self:?}")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SingleItemError{}
|
||||
impl<Items> std::error::Error for SingleItemError<Items> where Items:std::fmt::Debug{}
|
||||
pub type ScriptSingleItemError=SingleItemError<Vec<ScriptResponse>>;
|
||||
pub type ScriptPolicySingleItemError=SingleItemError<Vec<ScriptPolicyResponse>>;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashSet,HashMap};
|
||||
use crate::download::download_asset_version;
|
||||
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||
use crate::rbx_util::{get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||
|
||||
use heck::{ToSnakeCase,ToTitleCase};
|
||||
|
||||
@@ -225,27 +225,33 @@ pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_d
|
||||
|
||||
// count objects (default count is 0)
|
||||
let mut counts=Counts::default();
|
||||
for instance in dom.descendants_of(model_instance.referent()){
|
||||
if class_is_a(instance.class.as_str(),"BasePart"){
|
||||
// Zones
|
||||
match instance.name.parse(){
|
||||
Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Spawns & Teleports
|
||||
match instance.name.parse(){
|
||||
Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()),
|
||||
Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Wormholes
|
||||
match instance.name.parse(){
|
||||
Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
|
||||
let db=rbx_reflection_database::get();
|
||||
let base_part=&db.classes["BasePart"];
|
||||
let base_parts=dom.descendants_of(model_instance.referent()).filter(|&instance|
|
||||
db.classes.get(instance.class.as_str()).is_some_and(|class|
|
||||
db.has_superclass(class,base_part)
|
||||
)
|
||||
);
|
||||
for instance in base_parts{
|
||||
// Zones
|
||||
match instance.name.parse(){
|
||||
Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Spawns & Teleports
|
||||
match instance.name.parse(){
|
||||
Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()),
|
||||
Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Wormholes
|
||||
match instance.name.parse(){
|
||||
Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,14 +28,6 @@ pub fn static_ustr(s:&'static str)->rbx_dom_weak::Ustr{
|
||||
rbx_dom_weak::ustr(s)
|
||||
}
|
||||
|
||||
pub fn class_is_a(class:&str,superclass:&str)->bool{
|
||||
let db=rbx_reflection_database::get();
|
||||
let (Some(class),Some(superclass))=(db.classes.get(class),db.classes.get(superclass))else{
|
||||
return false;
|
||||
};
|
||||
db.has_superclass(class,superclass)
|
||||
}
|
||||
|
||||
fn find_first_child_name_and_class<'a>(dom:&'a rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance,name:&str,class:&str)->Option<&'a rbx_dom_weak::Instance>{
|
||||
instance.children().iter().filter_map(|&r|dom.get_by_ref(r)).find(|inst|inst.name==name&&inst.class==class)
|
||||
}
|
||||
|
||||
@@ -39,11 +39,11 @@ pub enum Error{
|
||||
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
|
||||
Download(crate::download::Error),
|
||||
ModelFileDecode(ReadDomError),
|
||||
ApiGetScriptPolicyFromHash(submissions_api::types::SingleItemError),
|
||||
ApiGetScriptPolicyFromHash(submissions_api::types::ScriptPolicySingleItemError),
|
||||
ApiGetScript(submissions_api::Error),
|
||||
ApiCreateScript(submissions_api::Error),
|
||||
ApiCreateScriptPolicy(submissions_api::Error),
|
||||
ApiGetScriptFromHash(submissions_api::types::SingleItemError),
|
||||
ApiGetScriptFromHash(submissions_api::types::ScriptSingleItemError),
|
||||
ApiUpdateMapfixModel(submissions_api::Error),
|
||||
ApiUpdateSubmissionModel(submissions_api::Error),
|
||||
ModelFileRootMustHaveOneChild,
|
||||
|
||||
151
web/src/app/_components/carousel.tsx
Normal file
151
web/src/app/_components/carousel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import {Box, IconButton, Typography} from "@mui/material";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
import {SubmissionInfo} from "@/app/ts/Submission";
|
||||
import {MapfixInfo} from "@/app/ts/Mapfix";
|
||||
|
||||
// Type for the items in the carousel
|
||||
type CarouselItem = SubmissionInfo | MapfixInfo;
|
||||
|
||||
// Props for the Carousel component
|
||||
interface CarouselProps<T extends CarouselItem> {
|
||||
title: string;
|
||||
items: T[] | undefined;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
viewAllLink: string;
|
||||
}
|
||||
|
||||
export function Carousel<T extends CarouselItem>({ title, items, renderItem, viewAllLink }: CarouselProps<T>) {
|
||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState<number>(0);
|
||||
const [maxScroll, setMaxScroll] = useState<number>(0);
|
||||
|
||||
const SCROLL_AMOUNT = 300;
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselRef.current) {
|
||||
const scrollWidth = carouselRef.current.scrollWidth;
|
||||
const clientWidth = carouselRef.current.clientWidth;
|
||||
setMaxScroll(scrollWidth - clientWidth);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right'): void => {
|
||||
if (carouselRef.current) {
|
||||
const scrollAmount = direction === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT;
|
||||
|
||||
carouselRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (carouselRef.current) {
|
||||
setScrollPosition(carouselRef.current.scrollLeft);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (carouselRef.current) {
|
||||
setScrollPosition(carouselRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
const ref = carouselRef.current;
|
||||
if (ref) {
|
||||
ref.addEventListener('scroll', handleScroll);
|
||||
return () => ref.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box mb={6}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4" component="h2" fontWeight="bold">
|
||||
{title}
|
||||
</Typography>
|
||||
<Link href={viewAllLink} style={{textDecoration: 'none'}}>
|
||||
<Typography component="span" color="primary">
|
||||
View All →
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box position="relative">
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
visibility: scrollPosition <= 5 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => scroll('left')}
|
||||
>
|
||||
<ArrowBackIosNewIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
ref={carouselRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
gap: '16px', // Fixed 16px gap - using string with px unit to ensure it's absolute
|
||||
padding: '8px 4px',
|
||||
}}
|
||||
>
|
||||
{items?.map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: {
|
||||
xs: '260px', // Fixed width at different breakpoints
|
||||
sm: '280px',
|
||||
md: '300px'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
visibility: scrollPosition >= maxScroll - 5 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => scroll('right')}
|
||||
>
|
||||
<ArrowForwardIosIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,62 +3,132 @@
|
||||
import Link from "next/link"
|
||||
import Image from "next/image";
|
||||
|
||||
import "./styles/header.scss"
|
||||
import { UserInfo } from "@/app/ts/User";
|
||||
import { useState, useEffect } from "react";
|
||||
import {UserInfo} from "@/app/ts/User";
|
||||
import {useState, useEffect} from "react";
|
||||
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
interface HeaderButton {
|
||||
name: string,
|
||||
href: string
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
function HeaderButton(header: HeaderButton) {
|
||||
return (
|
||||
<Link href={header.href}>
|
||||
<button>{header.name}</button>
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<Button color="inherit" component={Link} href={header.href}>
|
||||
{header.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const handleLoginClick = () => {
|
||||
window.location.href = "/auth/oauth2/login?redirect=" + window.location.href;
|
||||
};
|
||||
const handleLoginClick = () => {
|
||||
window.location.href =
|
||||
"/auth/oauth2/login?redirect=" + window.location.href;
|
||||
};
|
||||
|
||||
const [valid, setValid] = useState<boolean>(false)
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
const [valid, setValid] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getLoginInfo() {
|
||||
const [validateData, userData] = await Promise.all([
|
||||
fetch("/api/session/validate").then(validateResponse => validateResponse.json()),
|
||||
fetch("/api/session/user").then(userResponse => userResponse.json())
|
||||
]);
|
||||
setValid(validateData)
|
||||
setUser(userData)
|
||||
}
|
||||
getLoginInfo()
|
||||
}, [])
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header-bar">
|
||||
<nav className="left">
|
||||
<HeaderButton name="Submissions" href="/submissions"/>
|
||||
<HeaderButton name="Mapfixes" href="/mapfixes"/>
|
||||
<HeaderButton name="Maps" href="/maps"/>
|
||||
</nav>
|
||||
<nav className="right">
|
||||
<HeaderButton name="Submit" href="/submit"/>
|
||||
{valid && user ? (
|
||||
<div className="author">
|
||||
<Link href="/auth">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={user.AvatarURL} alt={user.Username}/>
|
||||
<button>{user.Username}</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleLoginClick}>Login</button>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getLoginInfo() {
|
||||
try {
|
||||
const response = await fetch("/api/session/user");
|
||||
|
||||
if (!response.ok) {
|
||||
setValid(false);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
const isLoggedIn = userData && 'UserID' in userData;
|
||||
|
||||
setValid(isLoggedIn);
|
||||
setUser(isLoggedIn ? userData : null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
setValid(false);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
getLoginInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Box display="flex" flexGrow={1} gap={2}>
|
||||
<HeaderButton name="Submissions" href="/submissions"/>
|
||||
<HeaderButton name="Mapfixes" href="/mapfixes"/>
|
||||
<HeaderButton name="Maps" href="/maps"/>
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
{valid && user && (
|
||||
<Button variant="outlined" color="success" component={Link} href="/submit">
|
||||
Submit Map
|
||||
</Button>
|
||||
)}
|
||||
{valid && user ? (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Button
|
||||
onClick={handleMenuOpen}
|
||||
color="inherit"
|
||||
size="small"
|
||||
style={{textTransform: "none"}}
|
||||
>
|
||||
<Image
|
||||
className="avatar"
|
||||
width={28}
|
||||
height={28}
|
||||
priority={true}
|
||||
src={user.AvatarURL}
|
||||
alt={user.Username}
|
||||
style={{marginRight: 8}}
|
||||
/>
|
||||
<Typography variant="body1">{user.Username}</Typography>
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} href="/auth">
|
||||
Manage
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
) : (
|
||||
<Button color="inherit" onClick={handleLoginClick}>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,268 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Rating } from "@mui/material";
|
||||
import React, {JSX} from "react";
|
||||
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Grid, Typography} from "@mui/material";
|
||||
import {Cancel, CheckCircle, Explore, Pending, Person2} from "@mui/icons-material";
|
||||
|
||||
interface SubmissionCardProps {
|
||||
interface MapCardProps {
|
||||
displayName: string;
|
||||
assetId: number;
|
||||
authorId: number;
|
||||
author: string;
|
||||
rating: number;
|
||||
id: number;
|
||||
statusID: number;
|
||||
gameID: number;
|
||||
created: number;
|
||||
type: 'mapfix' | 'submission';
|
||||
}
|
||||
|
||||
export function SubmissionCard(props: SubmissionCardProps) {
|
||||
return (
|
||||
<Link href={`/submissions/${props.id}`}>
|
||||
<div className="submissionCard">
|
||||
<div className="content">
|
||||
<div className="map-image">
|
||||
{/* TODO: Grab image of model */}
|
||||
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="header">
|
||||
<span className="displayName">{props.displayName}</span>
|
||||
<div className="rating">
|
||||
<Rating value={props.rating} readOnly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="author">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
|
||||
<span>{props.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const CARD_WIDTH = 270;
|
||||
|
||||
export function MapfixCard(props: SubmissionCardProps) {
|
||||
export function MapCard(props: MapCardProps) {
|
||||
const StatusChip = ({status}: { status: number }) => {
|
||||
let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
|
||||
let icon: JSX.Element = <Pending fontSize="small"/>;
|
||||
let label: string = 'Unknown';
|
||||
|
||||
switch (status) {
|
||||
case 0:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Under Construction';
|
||||
break;
|
||||
case 1:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Changes Requested';
|
||||
break;
|
||||
case 2:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Submitting';
|
||||
break;
|
||||
case 3:
|
||||
color = 'warning';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Under Review';
|
||||
break;
|
||||
case 4:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Accepted Unvalidated';
|
||||
break;
|
||||
case 5:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Validating';
|
||||
break;
|
||||
case 6:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Validated';
|
||||
break;
|
||||
case 7:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Uploading';
|
||||
break;
|
||||
case 8:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Uploaded';
|
||||
break;
|
||||
case 9:
|
||||
color = 'error';
|
||||
icon = <Cancel fontSize="small"/>;
|
||||
label = 'Rejected';
|
||||
break;
|
||||
case 10:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Released';
|
||||
break;
|
||||
default:
|
||||
color = 'default';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Unknown';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
color={color}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Link href={`/mapfixes/${props.id}`}>
|
||||
<div className="MapfixCard">
|
||||
<div className="content">
|
||||
<div className="map-image">
|
||||
{/* TODO: Grab image of model */}
|
||||
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="header">
|
||||
<span className="displayName">{props.displayName}</span>
|
||||
<div className="rating">
|
||||
<Rating value={props.rating} readOnly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="author">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
|
||||
<span>{props.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
|
||||
<Box sx={{
|
||||
width: CARD_WIDTH,
|
||||
mx: 'auto', // Center the card in its grid cell
|
||||
}}>
|
||||
<Card sx={{
|
||||
width: CARD_WIDTH,
|
||||
height: 340, // Fixed height for all cards
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
href={`/${props.type === 'submission' ? 'submissions' : 'mapfixes'}/${props.id}`}>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`/thumbnails/asset/${props.assetId}`}
|
||||
alt={props.displayName}
|
||||
sx={{
|
||||
height: 160, // Fixed height for all images
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<StatusChip status={props.statusID}/>
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
width: '100%',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{
|
||||
mb: 1,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
lineHeight: '1.3',
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{props.displayName}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
}}>
|
||||
<Explore sx={{
|
||||
mr: 0.75,
|
||||
mt: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.gameID === 1 ? 'Bhop' : props.gameID === 2 ? 'Surf' : props.gameID === 5 ? 'Fly Trials' : props.gameID === 4 ? 'Deathrun' : 'Unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
}}>
|
||||
<Person2 sx={{
|
||||
mr: 0.75,
|
||||
mt: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.author}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
src={`/thumbnails/user/${props.authorId}`}
|
||||
alt={props.author}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
ml: 1,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/*In the future author should be the username of the submitter not the info from the map*/}
|
||||
{props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
import "./globals.scss";
|
||||
import {theme} from "@/app/lib/theme";
|
||||
import {ThemeProvider} from "@mui/material";
|
||||
|
||||
export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<ThemeProvider theme={theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
91
web/src/app/lib/theme.tsx
Normal file
91
web/src/app/lib/theme.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {createTheme} from "@mui/material";
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#90caf9',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f48fb1',
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
subtitle1: {
|
||||
fontWeight: 500,
|
||||
fontSize: '0.95rem',
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardMedia: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 16,
|
||||
'&:last-child': {
|
||||
paddingBottom: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDivider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,113 +2,130 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MapfixList } from "../ts/Mapfix";
|
||||
import { MapfixCard } from "../_components/mapCard";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
// TODO: MAKE MAPFIX & SUBMISSIONS USE THE SAME COMPONENTS :angry: (currently too lazy)
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
import {Box, Breadcrumbs, CircularProgress, Container, Pagination, Typography} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MapfixInfoPage() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList|null>(null)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMapfixes() {
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchMapFixes() {
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
setMapfixes(await res.json())
|
||||
setMapfixes(await res.json());
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fetchMapfixes()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
fetchMapFixes();
|
||||
|
||||
if (!mapfixes) {
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !mapfixes) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Loading...
|
||||
</main>
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
|
||||
const currentCards = mapfixes.Mapfixes.slice(
|
||||
(currentPage - 1) * cardsPerPage,
|
||||
currentPage * cardsPerPage
|
||||
);
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (mapfixes.Total == 0) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Mapfixes list is empty.
|
||||
</main>
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<div className="pagination-dots">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(index+1)}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<button onClick={prevPage} disabled={currentPage === 1}><</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>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading mapfixes...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
const currentCards = mapfixes.Mapfixes;
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Mapfixes</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Map Fixes
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Explore all submitted fixes for maps from the community.
|
||||
</Typography>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentCards.map((submission) => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="mapfix"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={(_, page) => setCurrentPage(page)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import {useState, useEffect} from "react";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent, Breadcrumbs
|
||||
} from "@mui/material";
|
||||
import {Search as SearchIcon} from "@mui/icons-material";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Map {
|
||||
ID: number;
|
||||
DisplayName: string;
|
||||
Creator: string;
|
||||
GameID: number;
|
||||
Date: number;
|
||||
ID: number;
|
||||
DisplayName: string;
|
||||
Creator: string;
|
||||
GameID: number;
|
||||
Date: number;
|
||||
}
|
||||
|
||||
// TODO: should rewrite this entire page, just wanted to get a simple page working. This was written by chatgippity
|
||||
|
||||
export default function MapsPage() {
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
const router = useRouter();
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [gameFilter, setGameFilter] = useState<string>("0"); // 0 means "All Maps"
|
||||
const mapsPerPage = 12;
|
||||
const requestPageSize = 100;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
const res = await fetch("/api/maps?Page=1&Limit=100");
|
||||
const data: Map[] = await res.json();
|
||||
setMaps(data);
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
// Just send it and load all maps hoping for the best
|
||||
try {
|
||||
setLoading(true);
|
||||
let allMaps: Map[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
fetchMaps();
|
||||
}, []);
|
||||
while (hasMore) {
|
||||
const res = await fetch(`/api/maps?Page=${page}&Limit=${requestPageSize}`);
|
||||
const data: Map[] = await res.json();
|
||||
allMaps = [...allMaps, ...data];
|
||||
hasMore = data.length === requestPageSize;
|
||||
page++;
|
||||
}
|
||||
|
||||
const customLoader = ({ src }: { src: string }) => {
|
||||
return src;
|
||||
};
|
||||
setMaps(allMaps);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch maps:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<div className="maps-container">
|
||||
{maps.map((map) => (
|
||||
<div key={map.ID} className="map-card">
|
||||
<a href={`/maps/${map.ID}`} className="block">
|
||||
<Image
|
||||
loader={customLoader}
|
||||
src={`/thumbnails/maps/${map.ID}`}
|
||||
alt={map.DisplayName}
|
||||
width={500}
|
||||
height={300}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="map-info">
|
||||
<h2>{map.DisplayName}</h2>
|
||||
<p>By {map.Creator}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Webpage>
|
||||
);
|
||||
fetchMaps();
|
||||
}, []);
|
||||
|
||||
const handleGameFilterChange = (event: SelectChangeEvent) => {
|
||||
setGameFilter(event.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Filter maps based on search query and game filter
|
||||
const filteredMaps = maps.filter(map => {
|
||||
const matchesSearch =
|
||||
map.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
map.Creator.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesGameFilter =
|
||||
gameFilter === "0" || // "All Maps"
|
||||
map.GameID === parseInt(gameFilter);
|
||||
|
||||
return matchesSearch && matchesGameFilter;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredMaps.length / mapsPerPage);
|
||||
const currentMaps = filteredMaps.slice(
|
||||
(currentPage - 1) * mapsPerPage,
|
||||
currentPage * mapsPerPage
|
||||
);
|
||||
|
||||
const handlePageChange = (_event: React.ChangeEvent<unknown>, page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
};
|
||||
|
||||
const handleMapClick = (mapId: number) => {
|
||||
router.push(`/maps/${mapId}`);
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getGameName = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1:
|
||||
return "Bhop";
|
||||
case 2:
|
||||
return "Surf";
|
||||
case 5:
|
||||
return "Fly Trials";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getGameLabelStyles = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1: // Bhop
|
||||
return {
|
||||
bgcolor: "info.main",
|
||||
color: "white",
|
||||
};
|
||||
case 2: // Surf
|
||||
return {
|
||||
bgcolor: "success.main",
|
||||
color: "white",
|
||||
};
|
||||
case 5: // Fly Trials
|
||||
return {
|
||||
bgcolor: "warning.main",
|
||||
color: "white",
|
||||
};
|
||||
default: // Unknown
|
||||
return {
|
||||
bgcolor: "grey.500",
|
||||
color: "white",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{py: 6}}>
|
||||
<Box mb={6}>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Maps</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Map Collection
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Browse all community-created maps or find your favorites
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search maps by name or creator..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon/>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{mb: 4}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" my={8}>
|
||||
<CircularProgress/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography>
|
||||
Showing {filteredMaps.length} {filteredMaps.length === 1 ? 'map' : 'maps'}
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{minWidth: 200}}>
|
||||
<InputLabel id="game-filter-label">Filter by Game</InputLabel>
|
||||
<Select
|
||||
labelId="game-filter-label"
|
||||
id="game-filter"
|
||||
value={gameFilter}
|
||||
label="Filter by Game"
|
||||
onChange={handleGameFilterChange}
|
||||
>
|
||||
<MenuItem value="0">All Maps</MenuItem>
|
||||
<MenuItem value="1">Bhop</MenuItem>
|
||||
<MenuItem value="2">Surf</MenuItem>
|
||||
<MenuItem value="5">Fly Trials</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{currentMaps.map((map) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={map.ID}>
|
||||
<Card
|
||||
elevation={1}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 4,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={() => handleMapClick(map.ID)}>
|
||||
<CardMedia
|
||||
component="div"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: 180,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={10}
|
||||
right={10}
|
||||
px={1}
|
||||
py={0.5}
|
||||
borderRadius={1}
|
||||
fontSize="0.75rem"
|
||||
fontWeight="bold"
|
||||
{...getGameLabelStyles(map.GameID)}
|
||||
>
|
||||
{getGameName(map.GameID)}
|
||||
</Box>
|
||||
<Image
|
||||
src={`/thumbnails/asset/${map.ID}`}
|
||||
alt={map.DisplayName}
|
||||
fill
|
||||
style={{objectFit: 'cover'}}
|
||||
/>
|
||||
</CardMedia>
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="h2" noWrap>
|
||||
{map.DisplayName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
By {map.Creator}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Added {formatDate(map.Date)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {MapfixInfo, MapfixList} from "./ts/Mapfix";
|
||||
import { MapCard } from "./_components/mapCard";
|
||||
import Webpage from "./_components/webpage";
|
||||
import { ListSortConstants } from "./ts/Sort";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
|
||||
import {Carousel} from "@/app/_components/carousel";
|
||||
|
||||
export default function Home() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
|
||||
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
|
||||
const itemsPerSection: number = 8; // Show more items for the carousel
|
||||
|
||||
useEffect(() => {
|
||||
const mapfixController = new AbortController();
|
||||
const submissionsController = new AbortController();
|
||||
|
||||
async function fetchMapFixes(): Promise<void> {
|
||||
setIsLoadingMapfixes(true);
|
||||
try {
|
||||
const res = await fetch(`/api/mapfixes?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: mapfixController.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: MapfixList = await res.json();
|
||||
setMapfixes(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch mapfixes:", error);
|
||||
} finally {
|
||||
setIsLoadingMapfixes(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSubmissions(): Promise<void> {
|
||||
setIsLoadingSubmissions(true);
|
||||
try {
|
||||
const res = await fetch(`/api/submissions?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: submissionsController.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: SubmissionList = await res.json();
|
||||
setSubmissions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch submissions:", error);
|
||||
} finally {
|
||||
setIsLoadingSubmissions(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchMapFixes();
|
||||
fetchSubmissions();
|
||||
|
||||
return () => {
|
||||
mapfixController.abort();
|
||||
submissionsController.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
|
||||
|
||||
if (isLoading && (!mapfixes || !submissions)) {
|
||||
return <Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading content...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const renderMapfixCard = (mapfix: MapfixInfo): React.ReactNode => (
|
||||
<MapCard
|
||||
key={mapfix.ID}
|
||||
id={mapfix.ID}
|
||||
assetId={mapfix.AssetID}
|
||||
displayName={mapfix.DisplayName}
|
||||
author={mapfix.Creator}
|
||||
authorId={mapfix.Submitter}
|
||||
rating={mapfix.StatusID}
|
||||
statusID={mapfix.StatusID}
|
||||
gameID={mapfix.GameID}
|
||||
created={mapfix.CreatedAt}
|
||||
type="mapfix"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSubmissionCard = (submission: SubmissionInfo): React.ReactNode => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="submission"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Webpage></Webpage>
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={5}>
|
||||
Welcome to the Maps Service!
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 6,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
Contribute to the community
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Help improve maps by submitting fixes or creating new maps submissions for the community.
|
||||
</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<Link href="/submit" style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="button"
|
||||
sx={{
|
||||
backgroundColor: 'white',
|
||||
color: '#2196F3',
|
||||
border: 'none',
|
||||
borderRadius: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit Map
|
||||
</Box>
|
||||
</Link>
|
||||
<Link href="/maps" style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="button"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid white',
|
||||
borderRadius: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create Map Fix
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Submissions Carousel */}
|
||||
{submissions && (
|
||||
<Carousel<SubmissionInfo>
|
||||
title="Recent Submissions"
|
||||
items={submissions.Submissions}
|
||||
renderItem={renderSubmissionCard}
|
||||
viewAllLink="/submissions"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Map Fixes Carousel */}
|
||||
{mapfixes && (
|
||||
<Carousel<MapfixInfo>
|
||||
title="Recent Map Fixes"
|
||||
items={mapfixes.Mapfixes}
|
||||
renderItem={renderMapfixCard}
|
||||
viewAllLink="/mapfixes"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { SubmissionList } from "../ts/Submission";
|
||||
import { SubmissionCard } from "../_components/mapCard";
|
||||
import {useState, useEffect} from "react";
|
||||
import {SubmissionList} from "../ts/Submission";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
import {ListSortConstants} from "../ts/Sort";
|
||||
import {Breadcrumbs, Pagination, Typography, CircularProgress, Box, Container} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SubmissionInfoPage() {
|
||||
const [submissions, setSubmissions] = useState<SubmissionList|null>(null)
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchSubmissions() {
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
setSubmissions(await res.json())
|
||||
setSubmissions(await res.json());
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fetchSubmissions()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
fetchSubmissions();
|
||||
|
||||
if (!submissions) {
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !submissions) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Loading...
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading submissions...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
|
||||
const currentCards = submissions.Submissions;
|
||||
|
||||
const currentCards = submissions.Submissions.slice(
|
||||
(currentPage - 1) * cardsPerPage,
|
||||
currentPage * cardsPerPage
|
||||
);
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (submissions.Total == 0) {
|
||||
if (submissions.Total === 0) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Submissions list is empty.
|
||||
</main>
|
||||
</Webpage>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div className="pagination-dots">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(index+1)}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<button onClick={prevPage} disabled={currentPage === 1}><</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>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Submissions</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Submissions
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Explore all submitted maps from the community.
|
||||
</Typography>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentCards.map((submission) => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="submission"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={(_, page) => setCurrentPage(page)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface MapfixInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly Date: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
@@ -17,7 +17,7 @@ interface SubmissionInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly Date: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
Reference in New Issue
Block a user