Compare commits

...

11 Commits

Author SHA1 Message Date
fb76be406e submissions: accept CheckList in internal changes requested 2025-06-07 23:53:17 -07:00
9bf991aebb validation: hijack check system to report internal error
This should really use the dedicated error audit event somehow
2025-06-07 23:53:17 -07:00
c108d98c8d validation: plumb out CheckList 2025-06-07 23:53:17 -07:00
70d19ab7c0 submissions-api: update changes requested internal endpoint 2025-06-07 23:53:17 -07:00
a3e80e5048 openapi: generate 2025-06-07 23:53:17 -07:00
0ce9abc954 openapi: send detailed CheckList on internal changes requested 2025-06-07 23:53:17 -07:00
c3aba5a56f submissions: AuditEvent CheckReport 2025-06-07 23:53:17 -07:00
54bf3f55a0 Rework submission/mapfix/maps list views (#173)
Refactored maps/landing/mapfix/submission and navbar

Reviewed-on: StrafesNET/maps-service#173
Reviewed-by: Quaternions <quaternions@noreply@itzana.me>
Co-authored-by: itzaname <me@sliving.io>
Co-committed-by: itzaname <me@sliving.io>
2025-06-08 03:41:36 +00:00
14f404ffe3 Merge pull request 'Openapi: Document Enum Fields' (#177) from openapi-doc into staging
Reviewed-on: StrafesNET/maps-service#177
Reviewed-by: itzaname <itzaname@noreply@itzana.me>
2025-06-08 00:21:21 +00:00
0e1d2fe50a openapi: generate 2025-06-07 17:09:51 -07:00
ada8c322da openapi: add descriptions to enum fields 2025-06-07 17:07:47 -07:00
32 changed files with 2199 additions and 615 deletions

View File

@@ -116,13 +116,12 @@ paths:
- Mapfixes
parameters:
- $ref: '#/components/parameters/MapfixID'
- name: ErrorMessage
in: query
required: true
schema:
type: string
minLength: 0
maxLength: 4096
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CheckList'
responses:
"204":
description: Successful response
@@ -321,13 +320,12 @@ paths:
- Submissions
parameters:
- $ref: '#/components/parameters/SubmissionID'
- name: ErrorMessage
in: query
required: true
schema:
type: string
minLength: 0
maxLength: 4096
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CheckList'
responses:
"204":
description: Successful response
@@ -847,6 +845,28 @@ components:
type: integer
format: int32
minimum: 0
Check:
required:
- Name
- Summary
- Passed
- Details
type: object
properties:
Name:
type: string
maxLength: 128
Summary:
type: string
maxLength: 4096
Passed:
type: boolean
Details:
type: object
CheckList:
type: array
items:
$ref: "#/components/schemas/Check"
Error:
description: Represents error object
type: object

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ type Invoker interface {
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /mapfixes/{MapfixID}/status/validator-request-changes
ActionMapfixRequestChanges(ctx context.Context, params ActionMapfixRequestChangesParams) error
ActionMapfixRequestChanges(ctx context.Context, request CheckList, params ActionMapfixRequestChangesParams) error
// ActionMapfixSubmitted invokes actionMapfixSubmitted operation.
//
// (Internal endpoint) Role Validator changes status from Submitting -> Submitted.
@@ -75,7 +75,7 @@ type Invoker interface {
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /submissions/{SubmissionID}/status/validator-request-changes
ActionSubmissionRequestChanges(ctx context.Context, params ActionSubmissionRequestChangesParams) error
ActionSubmissionRequestChanges(ctx context.Context, request CheckList, params ActionSubmissionRequestChangesParams) error
// ActionSubmissionSubmitted invokes actionSubmissionSubmitted operation.
//
// (Internal endpoint) Role Validator changes status from Submitting -> Submitted.
@@ -311,12 +311,12 @@ func (c *Client) sendActionMapfixAccepted(ctx context.Context, params ActionMapf
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /mapfixes/{MapfixID}/status/validator-request-changes
func (c *Client) ActionMapfixRequestChanges(ctx context.Context, params ActionMapfixRequestChangesParams) error {
_, err := c.sendActionMapfixRequestChanges(ctx, params)
func (c *Client) ActionMapfixRequestChanges(ctx context.Context, request CheckList, params ActionMapfixRequestChangesParams) error {
_, err := c.sendActionMapfixRequestChanges(ctx, request, params)
return err
}
func (c *Client) sendActionMapfixRequestChanges(ctx context.Context, params ActionMapfixRequestChangesParams) (res *ActionMapfixRequestChangesNoContent, err error) {
func (c *Client) sendActionMapfixRequestChanges(ctx context.Context, request CheckList, params ActionMapfixRequestChangesParams) (res *ActionMapfixRequestChangesNoContent, err error) {
otelAttrs := []attribute.KeyValue{
otelogen.OperationID("actionMapfixRequestChanges"),
semconv.HTTPRequestMethodKey.String("POST"),
@@ -375,29 +375,14 @@ func (c *Client) sendActionMapfixRequestChanges(ctx context.Context, params Acti
pathParts[2] = "/status/validator-request-changes"
uri.AddPathParts(u, pathParts[:]...)
stage = "EncodeQueryParams"
q := uri.NewQueryEncoder()
{
// Encode "ErrorMessage" parameter.
cfg := uri.QueryParameterEncodingConfig{
Name: "ErrorMessage",
Style: uri.QueryStyleForm,
Explode: true,
}
if err := q.EncodeParam(cfg, func(e uri.Encoder) error {
return e.EncodeValue(conv.StringToString(params.ErrorMessage))
}); err != nil {
return res, errors.Wrap(err, "encode query")
}
}
u.RawQuery = q.Values().Encode()
stage = "EncodeRequest"
r, err := ht.NewRequest(ctx, "POST", u)
if err != nil {
return res, errors.Wrap(err, "create request")
}
if err := encodeActionMapfixRequestChangesRequest(request, r); err != nil {
return res, errors.Wrap(err, "encode request")
}
stage = "SendRequest"
resp, err := c.cfg.Client.Do(r)
@@ -971,12 +956,12 @@ func (c *Client) sendActionSubmissionAccepted(ctx context.Context, params Action
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /submissions/{SubmissionID}/status/validator-request-changes
func (c *Client) ActionSubmissionRequestChanges(ctx context.Context, params ActionSubmissionRequestChangesParams) error {
_, err := c.sendActionSubmissionRequestChanges(ctx, params)
func (c *Client) ActionSubmissionRequestChanges(ctx context.Context, request CheckList, params ActionSubmissionRequestChangesParams) error {
_, err := c.sendActionSubmissionRequestChanges(ctx, request, params)
return err
}
func (c *Client) sendActionSubmissionRequestChanges(ctx context.Context, params ActionSubmissionRequestChangesParams) (res *ActionSubmissionRequestChangesNoContent, err error) {
func (c *Client) sendActionSubmissionRequestChanges(ctx context.Context, request CheckList, params ActionSubmissionRequestChangesParams) (res *ActionSubmissionRequestChangesNoContent, err error) {
otelAttrs := []attribute.KeyValue{
otelogen.OperationID("actionSubmissionRequestChanges"),
semconv.HTTPRequestMethodKey.String("POST"),
@@ -1035,29 +1020,14 @@ func (c *Client) sendActionSubmissionRequestChanges(ctx context.Context, params
pathParts[2] = "/status/validator-request-changes"
uri.AddPathParts(u, pathParts[:]...)
stage = "EncodeQueryParams"
q := uri.NewQueryEncoder()
{
// Encode "ErrorMessage" parameter.
cfg := uri.QueryParameterEncodingConfig{
Name: "ErrorMessage",
Style: uri.QueryStyleForm,
Explode: true,
}
if err := q.EncodeParam(cfg, func(e uri.Encoder) error {
return e.EncodeValue(conv.StringToString(params.ErrorMessage))
}); err != nil {
return res, errors.Wrap(err, "encode query")
}
}
u.RawQuery = q.Values().Encode()
stage = "EncodeRequest"
r, err := ht.NewRequest(ctx, "POST", u)
if err != nil {
return res, errors.Wrap(err, "create request")
}
if err := encodeActionSubmissionRequestChangesRequest(request, r); err != nil {
return res, errors.Wrap(err, "encode request")
}
stage = "SendRequest"
resp, err := c.cfg.Client.Do(r)

View File

@@ -267,6 +267,21 @@ func (s *Server) handleActionMapfixRequestChangesRequest(args [1]string, argsEsc
s.cfg.ErrorHandler(ctx, w, r, err)
return
}
request, close, err := s.decodeActionMapfixRequestChangesRequest(r)
if err != nil {
err = &ogenerrors.DecodeRequestError{
OperationContext: opErrContext,
Err: err,
}
defer recordError("DecodeRequest", err)
s.cfg.ErrorHandler(ctx, w, r, err)
return
}
defer func() {
if err := close(); err != nil {
recordError("CloseRequest", err)
}
}()
var response *ActionMapfixRequestChangesNoContent
if m := s.cfg.Middleware; m != nil {
@@ -275,22 +290,18 @@ func (s *Server) handleActionMapfixRequestChangesRequest(args [1]string, argsEsc
OperationName: ActionMapfixRequestChangesOperation,
OperationSummary: "(Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested",
OperationID: "actionMapfixRequestChanges",
Body: nil,
Body: request,
Params: middleware.Parameters{
{
Name: "MapfixID",
In: "path",
}: params.MapfixID,
{
Name: "ErrorMessage",
In: "query",
}: params.ErrorMessage,
},
Raw: r,
}
type (
Request = struct{}
Request = CheckList
Params = ActionMapfixRequestChangesParams
Response = *ActionMapfixRequestChangesNoContent
)
@@ -303,12 +314,12 @@ func (s *Server) handleActionMapfixRequestChangesRequest(args [1]string, argsEsc
mreq,
unpackActionMapfixRequestChangesParams,
func(ctx context.Context, request Request, params Params) (response Response, err error) {
err = s.h.ActionMapfixRequestChanges(ctx, params)
err = s.h.ActionMapfixRequestChanges(ctx, request, params)
return response, err
},
)
} else {
err = s.h.ActionMapfixRequestChanges(ctx, params)
err = s.h.ActionMapfixRequestChanges(ctx, request, params)
}
if err != nil {
if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
@@ -1189,6 +1200,21 @@ func (s *Server) handleActionSubmissionRequestChangesRequest(args [1]string, arg
s.cfg.ErrorHandler(ctx, w, r, err)
return
}
request, close, err := s.decodeActionSubmissionRequestChangesRequest(r)
if err != nil {
err = &ogenerrors.DecodeRequestError{
OperationContext: opErrContext,
Err: err,
}
defer recordError("DecodeRequest", err)
s.cfg.ErrorHandler(ctx, w, r, err)
return
}
defer func() {
if err := close(); err != nil {
recordError("CloseRequest", err)
}
}()
var response *ActionSubmissionRequestChangesNoContent
if m := s.cfg.Middleware; m != nil {
@@ -1197,22 +1223,18 @@ func (s *Server) handleActionSubmissionRequestChangesRequest(args [1]string, arg
OperationName: ActionSubmissionRequestChangesOperation,
OperationSummary: "(Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested",
OperationID: "actionSubmissionRequestChanges",
Body: nil,
Body: request,
Params: middleware.Parameters{
{
Name: "SubmissionID",
In: "path",
}: params.SubmissionID,
{
Name: "ErrorMessage",
In: "query",
}: params.ErrorMessage,
},
Raw: r,
}
type (
Request = struct{}
Request = CheckList
Params = ActionSubmissionRequestChangesParams
Response = *ActionSubmissionRequestChangesNoContent
)
@@ -1225,12 +1247,12 @@ func (s *Server) handleActionSubmissionRequestChangesRequest(args [1]string, arg
mreq,
unpackActionSubmissionRequestChangesParams,
func(ctx context.Context, request Request, params Params) (response Response, err error) {
err = s.h.ActionSubmissionRequestChanges(ctx, params)
err = s.h.ActionSubmissionRequestChanges(ctx, request, params)
return response, err
},
)
} else {
err = s.h.ActionSubmissionRequestChanges(ctx, params)
err = s.h.ActionSubmissionRequestChanges(ctx, request, params)
}
if err != nil {
if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {

View File

@@ -12,6 +12,245 @@ import (
"github.com/ogen-go/ogen/validate"
)
// Encode implements json.Marshaler.
func (s *Check) Encode(e *jx.Encoder) {
e.ObjStart()
s.encodeFields(e)
e.ObjEnd()
}
// encodeFields encodes fields.
func (s *Check) encodeFields(e *jx.Encoder) {
{
e.FieldStart("Name")
e.Str(s.Name)
}
{
e.FieldStart("Summary")
e.Str(s.Summary)
}
{
e.FieldStart("Passed")
e.Bool(s.Passed)
}
{
e.FieldStart("Details")
s.Details.Encode(e)
}
}
var jsonFieldsNameOfCheck = [4]string{
0: "Name",
1: "Summary",
2: "Passed",
3: "Details",
}
// Decode decodes Check from json.
func (s *Check) Decode(d *jx.Decoder) error {
if s == nil {
return errors.New("invalid: unable to decode Check to nil")
}
var requiredBitSet [1]uint8
if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
switch string(k) {
case "Name":
requiredBitSet[0] |= 1 << 0
if err := func() error {
v, err := d.Str()
s.Name = string(v)
if err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"Name\"")
}
case "Summary":
requiredBitSet[0] |= 1 << 1
if err := func() error {
v, err := d.Str()
s.Summary = string(v)
if err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"Summary\"")
}
case "Passed":
requiredBitSet[0] |= 1 << 2
if err := func() error {
v, err := d.Bool()
s.Passed = bool(v)
if err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"Passed\"")
}
case "Details":
requiredBitSet[0] |= 1 << 3
if err := func() error {
if err := s.Details.Decode(d); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"Details\"")
}
default:
return d.Skip()
}
return nil
}); err != nil {
return errors.Wrap(err, "decode Check")
}
// Validate required fields.
var failures []validate.FieldError
for i, mask := range [1]uint8{
0b00001111,
} {
if result := (requiredBitSet[i] & mask) ^ mask; result != 0 {
// Mask only required fields and check equality to mask using XOR.
//
// If XOR result is not zero, result is not equal to expected, so some fields are missed.
// Bits of fields which would be set are actually bits of missed fields.
missed := bits.OnesCount8(result)
for bitN := 0; bitN < missed; bitN++ {
bitIdx := bits.TrailingZeros8(result)
fieldIdx := i*8 + bitIdx
var name string
if fieldIdx < len(jsonFieldsNameOfCheck) {
name = jsonFieldsNameOfCheck[fieldIdx]
} else {
name = strconv.Itoa(fieldIdx)
}
failures = append(failures, validate.FieldError{
Name: name,
Error: validate.ErrFieldRequired,
})
// Reset bit.
result &^= 1 << bitIdx
}
}
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s *Check) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *Check) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode implements json.Marshaler.
func (s *CheckDetails) Encode(e *jx.Encoder) {
e.ObjStart()
s.encodeFields(e)
e.ObjEnd()
}
// encodeFields encodes fields.
func (s *CheckDetails) encodeFields(e *jx.Encoder) {
}
var jsonFieldsNameOfCheckDetails = [0]string{}
// Decode decodes CheckDetails from json.
func (s *CheckDetails) Decode(d *jx.Decoder) error {
if s == nil {
return errors.New("invalid: unable to decode CheckDetails to nil")
}
if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
switch string(k) {
default:
return d.Skip()
}
}); err != nil {
return errors.Wrap(err, "decode CheckDetails")
}
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s *CheckDetails) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *CheckDetails) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode encodes CheckList as json.
func (s CheckList) Encode(e *jx.Encoder) {
unwrapped := []Check(s)
e.ArrStart()
for _, elem := range unwrapped {
elem.Encode(e)
}
e.ArrEnd()
}
// Decode decodes CheckList from json.
func (s *CheckList) Decode(d *jx.Decoder) error {
if s == nil {
return errors.New("invalid: unable to decode CheckList to nil")
}
var unwrapped []Check
if err := func() error {
unwrapped = make([]Check, 0)
if err := d.Arr(func(d *jx.Decoder) error {
var elem Check
if err := elem.Decode(d); err != nil {
return err
}
unwrapped = append(unwrapped, elem)
return nil
}); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "alias")
}
*s = CheckList(unwrapped)
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s CheckList) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *CheckList) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode implements json.Marshaler.
func (s *Error) Encode(e *jx.Encoder) {
e.ObjStart()

View File

@@ -162,8 +162,7 @@ func decodeActionMapfixAcceptedParams(args [1]string, argsEscaped bool, r *http.
// ActionMapfixRequestChangesParams is parameters of actionMapfixRequestChanges operation.
type ActionMapfixRequestChangesParams struct {
// The unique identifier for a submission.
MapfixID int64
ErrorMessage string
MapfixID int64
}
func unpackActionMapfixRequestChangesParams(packed middleware.Parameters) (params ActionMapfixRequestChangesParams) {
@@ -174,18 +173,10 @@ func unpackActionMapfixRequestChangesParams(packed middleware.Parameters) (param
}
params.MapfixID = packed[key].(int64)
}
{
key := middleware.ParameterKey{
Name: "ErrorMessage",
In: "query",
}
params.ErrorMessage = packed[key].(string)
}
return params
}
func decodeActionMapfixRequestChangesParams(args [1]string, argsEscaped bool, r *http.Request) (params ActionMapfixRequestChangesParams, _ error) {
q := uri.NewQueryDecoder(r.URL.Query())
// Decode path: MapfixID.
if err := func() error {
param := args[0]
@@ -248,58 +239,6 @@ func decodeActionMapfixRequestChangesParams(args [1]string, argsEscaped bool, r
Err: err,
}
}
// Decode query: ErrorMessage.
if err := func() error {
cfg := uri.QueryParameterDecodingConfig{
Name: "ErrorMessage",
Style: uri.QueryStyleForm,
Explode: true,
}
if err := q.HasParam(cfg); err == nil {
if err := q.DecodeParam(cfg, func(d uri.Decoder) error {
val, err := d.DecodeValue()
if err != nil {
return err
}
c, err := conv.ToString(val)
if err != nil {
return err
}
params.ErrorMessage = c
return nil
}); err != nil {
return err
}
if err := func() error {
if err := (validate.String{
MinLength: 0,
MinLengthSet: true,
MaxLength: 4096,
MaxLengthSet: true,
Email: false,
Hostname: false,
Regex: nil,
}).Validate(string(params.ErrorMessage)); err != nil {
return errors.Wrap(err, "string")
}
return nil
}(); err != nil {
return err
}
} else {
return err
}
return nil
}(); err != nil {
return params, &ogenerrors.DecodeParamError{
Name: "ErrorMessage",
In: "query",
Err: err,
}
}
return params, nil
}
@@ -1087,7 +1026,6 @@ func decodeActionSubmissionAcceptedParams(args [1]string, argsEscaped bool, r *h
type ActionSubmissionRequestChangesParams struct {
// The unique identifier for a submission.
SubmissionID int64
ErrorMessage string
}
func unpackActionSubmissionRequestChangesParams(packed middleware.Parameters) (params ActionSubmissionRequestChangesParams) {
@@ -1098,18 +1036,10 @@ func unpackActionSubmissionRequestChangesParams(packed middleware.Parameters) (p
}
params.SubmissionID = packed[key].(int64)
}
{
key := middleware.ParameterKey{
Name: "ErrorMessage",
In: "query",
}
params.ErrorMessage = packed[key].(string)
}
return params
}
func decodeActionSubmissionRequestChangesParams(args [1]string, argsEscaped bool, r *http.Request) (params ActionSubmissionRequestChangesParams, _ error) {
q := uri.NewQueryDecoder(r.URL.Query())
// Decode path: SubmissionID.
if err := func() error {
param := args[0]
@@ -1172,58 +1102,6 @@ func decodeActionSubmissionRequestChangesParams(args [1]string, argsEscaped bool
Err: err,
}
}
// Decode query: ErrorMessage.
if err := func() error {
cfg := uri.QueryParameterDecodingConfig{
Name: "ErrorMessage",
Style: uri.QueryStyleForm,
Explode: true,
}
if err := q.HasParam(cfg); err == nil {
if err := q.DecodeParam(cfg, func(d uri.Decoder) error {
val, err := d.DecodeValue()
if err != nil {
return err
}
c, err := conv.ToString(val)
if err != nil {
return err
}
params.ErrorMessage = c
return nil
}); err != nil {
return err
}
if err := func() error {
if err := (validate.String{
MinLength: 0,
MinLengthSet: true,
MaxLength: 4096,
MaxLengthSet: true,
Email: false,
Hostname: false,
Regex: nil,
}).Validate(string(params.ErrorMessage)); err != nil {
return errors.Wrap(err, "string")
}
return nil
}(); err != nil {
return err
}
} else {
return err
}
return nil
}(); err != nil {
return params, &ogenerrors.DecodeParamError{
Name: "ErrorMessage",
In: "query",
Err: err,
}
}
return params, nil
}

View File

@@ -14,6 +14,148 @@ import (
"github.com/ogen-go/ogen/validate"
)
func (s *Server) decodeActionMapfixRequestChangesRequest(r *http.Request) (
req CheckList,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
if err != nil {
return req, close, err
}
if len(buf) == 0 {
return req, close, validate.ErrBodyRequired
}
d := jx.DecodeBytes(buf)
var request CheckList
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, close, err
}
if err := func() error {
if err := request.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return req, close, errors.Wrap(err, "validate")
}
return request, close, nil
default:
return req, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeActionSubmissionRequestChangesRequest(r *http.Request) (
req CheckList,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
if err != nil {
return req, close, err
}
if len(buf) == 0 {
return req, close, validate.ErrBodyRequired
}
d := jx.DecodeBytes(buf)
var request CheckList
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, close, err
}
if err := func() error {
if err := request.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return req, close, errors.Wrap(err, "validate")
}
return request, close, nil
default:
return req, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeCreateMapfixRequest(r *http.Request) (
req *MapfixCreate,
close func() error,

View File

@@ -11,6 +11,34 @@ import (
ht "github.com/ogen-go/ogen/http"
)
func encodeActionMapfixRequestChangesRequest(
req CheckList,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeActionSubmissionRequestChangesRequest(
req CheckList,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeCreateMapfixRequest(
req *MapfixCreate,
r *http.Request,

View File

@@ -43,6 +43,58 @@ type ActionSubmissionUploadedNoContent struct{}
// ActionSubmissionValidatedNoContent is response for ActionSubmissionValidated operation.
type ActionSubmissionValidatedNoContent struct{}
// Ref: #/components/schemas/Check
type Check struct {
Name string `json:"Name"`
Summary string `json:"Summary"`
Passed bool `json:"Passed"`
Details CheckDetails `json:"Details"`
}
// GetName returns the value of Name.
func (s *Check) GetName() string {
return s.Name
}
// GetSummary returns the value of Summary.
func (s *Check) GetSummary() string {
return s.Summary
}
// GetPassed returns the value of Passed.
func (s *Check) GetPassed() bool {
return s.Passed
}
// GetDetails returns the value of Details.
func (s *Check) GetDetails() CheckDetails {
return s.Details
}
// SetName sets the value of Name.
func (s *Check) SetName(val string) {
s.Name = val
}
// SetSummary sets the value of Summary.
func (s *Check) SetSummary(val string) {
s.Summary = val
}
// SetPassed sets the value of Passed.
func (s *Check) SetPassed(val bool) {
s.Passed = val
}
// SetDetails sets the value of Details.
func (s *Check) SetDetails(val CheckDetails) {
s.Details = val
}
type CheckDetails struct{}
type CheckList []Check
// Represents error object.
// Ref: #/components/schemas/Error
type Error struct {

View File

@@ -19,7 +19,7 @@ type Handler interface {
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /mapfixes/{MapfixID}/status/validator-request-changes
ActionMapfixRequestChanges(ctx context.Context, params ActionMapfixRequestChangesParams) error
ActionMapfixRequestChanges(ctx context.Context, req CheckList, params ActionMapfixRequestChangesParams) error
// ActionMapfixSubmitted implements actionMapfixSubmitted operation.
//
// (Internal endpoint) Role Validator changes status from Submitting -> Submitted.
@@ -55,7 +55,7 @@ type Handler interface {
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /submissions/{SubmissionID}/status/validator-request-changes
ActionSubmissionRequestChanges(ctx context.Context, params ActionSubmissionRequestChangesParams) error
ActionSubmissionRequestChanges(ctx context.Context, req CheckList, params ActionSubmissionRequestChangesParams) error
// ActionSubmissionSubmitted implements actionSubmissionSubmitted operation.
//
// (Internal endpoint) Role Validator changes status from Submitting -> Submitted.

View File

@@ -27,7 +27,7 @@ func (UnimplementedHandler) ActionMapfixAccepted(ctx context.Context, params Act
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /mapfixes/{MapfixID}/status/validator-request-changes
func (UnimplementedHandler) ActionMapfixRequestChanges(ctx context.Context, params ActionMapfixRequestChangesParams) error {
func (UnimplementedHandler) ActionMapfixRequestChanges(ctx context.Context, req CheckList, params ActionMapfixRequestChangesParams) error {
return ht.ErrNotImplemented
}
@@ -81,7 +81,7 @@ func (UnimplementedHandler) ActionSubmissionAccepted(ctx context.Context, params
// (Internal endpoint) Role Validator changes status from Submitting -> ChangesRequested.
//
// POST /submissions/{SubmissionID}/status/validator-request-changes
func (UnimplementedHandler) ActionSubmissionRequestChanges(ctx context.Context, params ActionSubmissionRequestChangesParams) error {
func (UnimplementedHandler) ActionSubmissionRequestChanges(ctx context.Context, req CheckList, params ActionSubmissionRequestChangesParams) error {
return ht.ErrNotImplemented
}

View File

@@ -3,11 +3,88 @@
package api
import (
"fmt"
"github.com/go-faster/errors"
"github.com/ogen-go/ogen/validate"
)
func (s *Check) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if err := (validate.String{
MinLength: 0,
MinLengthSet: false,
MaxLength: 128,
MaxLengthSet: true,
Email: false,
Hostname: false,
Regex: nil,
}).Validate(string(s.Name)); err != nil {
return errors.Wrap(err, "string")
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "Name",
Error: err,
})
}
if err := func() error {
if err := (validate.String{
MinLength: 0,
MinLengthSet: false,
MaxLength: 4096,
MaxLengthSet: true,
Email: false,
Hostname: false,
Regex: nil,
}).Validate(string(s.Summary)); err != nil {
return errors.Wrap(err, "string")
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "Summary",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s CheckList) Validate() error {
alias := ([]Check)(s)
if alias == nil {
return errors.New("nil is invalid value")
}
var failures []validate.FieldError
for i, elem := range alias {
if err := func() error {
if err := elem.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: fmt.Sprintf("[%d]", i),
Error: err,
})
}
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *Error) Validate() error {
if s == nil {
return validate.ErrNilPointer

View File

@@ -48,6 +48,20 @@ type AuditEventDataError struct {
Error string `json:"error"`
}
type Check struct {
Name string `json:"name"`
Summary string `json:"summary"`
Passed bool `json:"passed"`
Details any `json:"details"`
}
// Validator map checks details
const AuditEventTypeCheckReport AuditEventType = 7
type AuditEventDataCheckReport struct {
CheckList []Check `json:"checklist"`
}
type AuditEvent struct {
ID int64 `gorm:"primaryKey"`
CreatedAt time.Time

View File

@@ -122,7 +122,7 @@ func (svc *Service) ActionMapfixSubmitted(ctx context.Context, params internal.A
// (Internal endpoint) Role Validator changes status from Submitting -> RequestChanges.
//
// POST /mapfixes/{MapfixID}/status/validator-request-changes
func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, params internal.ActionMapfixRequestChangesParams) error {
func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, check_list internal.CheckList, params internal.ActionMapfixRequestChangesParams) error {
// transaction
target_status := model.MapfixStatusChangesRequested
smap := datastore.Optional()
@@ -133,8 +133,18 @@ func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, params inter
}
{
event_data := model.AuditEventDataError{
Error: params.ErrorMessage,
check_list2 := make([]model.Check, len(check_list))
for i, check := range check_list {
check_list2[i] = model.Check{
Name: check.Name,
Summary: check.Summary,
Passed: check.Passed,
Details: check.Details,
}
}
event_data := model.AuditEventDataCheckReport{
CheckList: check_list2,
}
EventData, err := json.Marshal(event_data)

View File

@@ -122,7 +122,7 @@ func (svc *Service) ActionSubmissionSubmitted(ctx context.Context, params intern
// (Internal endpoint) Role Validator changes status from Submitting -> RequestChanges.
//
// POST /submissions/{SubmissionID}/status/validator-request-changes
func (svc *Service) ActionSubmissionRequestChanges(ctx context.Context, params internal.ActionSubmissionRequestChangesParams) error {
func (svc *Service) ActionSubmissionRequestChanges(ctx context.Context, check_list internal.CheckList, params internal.ActionSubmissionRequestChangesParams) error {
// transaction
target_status := model.SubmissionStatusChangesRequested
smap := datastore.Optional()
@@ -134,8 +134,18 @@ func (svc *Service) ActionSubmissionRequestChanges(ctx context.Context, params i
//push an error audit event
{
event_data := model.AuditEventDataError{
Error: params.ErrorMessage,
check_list2 := make([]model.Check, len(check_list))
for i, check := range check_list {
check_list2[i] = model.Check{
Name: check.Name,
Summary: check.Summary,
Passed: check.Passed,
Details: check.Details,
}
}
event_data := model.AuditEventDataCheckReport{
CheckList: check_list2,
}
EventData, err := json.Marshal(event_data)

View File

@@ -161,10 +161,19 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn action_submission_request_changes(&self,config:ActionSubmissionRequestChangesRequest<'_>)->Result<(),Error>{
let url_raw=format!("{}/submissions/{}/status/validator-request-changes",self.0.base_url,config.SubmissionID);
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
let body=serde_json::to_string(&config).map_err(Error::JSON)?;
response_ok(
self.0.post(url,body).await.map_err(Error::Reqwest)?
).await.map_err(Error::Response)?;
Ok(())
}
// simple submission endpoints
action!("submissions",action_submission_request_changes,config,ActionSubmissionRequestChangesRequest,"status/validator-request-changes",config.SubmissionID,
("ErrorMessage",config.ErrorMessage.as_str())
);
action!("submissions",action_submission_submitted,config,ActionSubmissionSubmittedRequest,"status/validator-submitted",config.SubmissionID,
("ModelVersion",config.ModelVersion.to_string().as_str())
("DisplayName",config.DisplayName.as_str())
@@ -193,10 +202,19 @@ impl Context{
).await.map_err(Error::Response)?
.json().await.map_err(Error::ReqwestJson)
}
pub async fn action_mapfix_request_changes(&self,config:ActionMapfixRequestChangesRequest<'_>)->Result<(),Error>{
let url_raw=format!("{}/mapfixes/{}/status/validator-request-changes",self.0.base_url,config.MapfixID);
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
let body=serde_json::to_string(&config).map_err(Error::JSON)?;
response_ok(
self.0.post(url,body).await.map_err(Error::Reqwest)?
).await.map_err(Error::Response)?;
Ok(())
}
// simple mapfixes endpoints
action!("mapfixes",action_mapfix_request_changes,config,ActionMapfixRequestChangesRequest,"status/validator-request-changes",config.MapfixID,
("ErrorMessage",config.ErrorMessage.as_str())
);
action!("mapfixes",action_mapfix_submitted,config,ActionMapfixSubmittedRequest,"status/validator-submitted",config.MapfixID,
("ModelVersion",config.ModelVersion.to_string().as_str())
("DisplayName",config.DisplayName.as_str())

View File

@@ -230,6 +230,15 @@ pub struct UpdateSubmissionModelRequest{
pub ModelVersion:u64,
}
#[allow(nonstandard_style)]
#[derive(Clone,Debug,serde::Serialize)]
pub struct Check{
pub Name:&'static str,
pub Summary:String,
pub Passed:bool,
pub Details:serde_json::Value,
}
#[allow(nonstandard_style)]
#[derive(Clone,Debug)]
pub struct ActionSubmissionSubmittedRequest{
@@ -241,10 +250,10 @@ pub struct ActionSubmissionSubmittedRequest{
}
#[allow(nonstandard_style)]
#[derive(Clone,Debug)]
pub struct ActionSubmissionRequestChangesRequest{
#[derive(Clone,Debug,serde::Serialize)]
pub struct ActionSubmissionRequestChangesRequest<'a>{
pub SubmissionID:i64,
pub ErrorMessage:String,
pub CheckList:&'a [Check],
}
#[allow(nonstandard_style)]
@@ -283,10 +292,10 @@ pub struct ActionMapfixSubmittedRequest{
}
#[allow(nonstandard_style)]
#[derive(Clone,Debug)]
pub struct ActionMapfixRequestChangesRequest{
#[derive(Clone,Debug,serde::Serialize)]
pub struct ActionMapfixRequestChangesRequest<'a>{
pub MapfixID:i64,
pub ErrorMessage:String,
pub CheckList:&'a [Check],
}
#[allow(nonstandard_style)]

View File

@@ -3,6 +3,7 @@ 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 heck::{ToSnakeCase,ToTitleCase};
use submissions_api::types::Check;
#[allow(dead_code)]
#[derive(Debug)]
@@ -585,40 +586,34 @@ impl<D:std::fmt::Display> std::fmt::Display for Duplicates<D>{
}
}
#[derive(serde::Serialize)]
struct CheckSummary{
name:&'static str,
summary:String,
passed:bool,
details:serde_json::Value,
}
impl CheckSummary{
const fn passed(name:&'static str)->Self{
Self{
name,
summary:String::new(),
passed:true,
details:serde_json::Value::Null,
macro_rules! passed{
($name:literal)=>{
Check{
Name:$name,
Summary:String::new(),
Passed:true,
Details:serde_json::Value::Null,
}
}
}
macro_rules! summary{
($name:literal,$summary:expr,$details:expr)=>{
CheckSummary{
name:$name,
summary:$summary,
passed:false,
details:serde_json::to_value($details)?,
Check{
Name:$name,
Summary:$summary,
Passed:false,
Details:serde_json::to_value($details)?,
}
};
}
macro_rules! summary_format{
($name:literal,$fmt:literal,$details:expr)=>{
CheckSummary{
name:$name,
summary:format!($fmt),
passed:false,
details:serde_json::to_value($details)?,
Check{
Name:$name,
Summary:format!($fmt),
Passed:false,
Details:serde_json::to_value($details)?,
}
};
}
@@ -628,15 +623,15 @@ macro_rules! summary_format{
impl MapCheck<'_>{
fn itemize(&self)->Result<MapCheckList,serde_json::Error>{
let model_class=match &self.model_class{
StringCheck(Ok(()))=>CheckSummary::passed("ModelClass"),
StringCheck(Ok(()))=>passed!("ModelClass"),
StringCheck(Err(context))=>summary_format!("ModelClass","Invalid model class: {context}",()),
};
let model_name=match &self.model_name{
StringCheck(Ok(()))=>CheckSummary::passed("ModelName"),
StringCheck(Ok(()))=>passed!("ModelName"),
StringCheck(Err(context))=>summary_format!("ModelName","Model name must have snake_case: {context}",()),
};
let display_name=match &self.display_name{
Ok(Ok(StringCheck(Ok(_))))=>CheckSummary::passed("DisplayName"),
Ok(Ok(StringCheck(Ok(_))))=>passed!("DisplayName"),
Ok(Ok(StringCheck(Err(context))))=>summary_format!("DisplayName","DisplayName must have Title Case: {context}",()),
Ok(Err(context))=>summary_format!("DisplayName","Invalid DisplayName: {context}",()),
Err(StringValueError::ObjectNotFound)=>summary!("DisplayName","Missing DisplayName StringValue".to_owned(),()),
@@ -644,22 +639,22 @@ impl MapCheck<'_>{
Err(StringValueError::NonStringValue)=>summary!("DisplayName","DisplayName Value is not a String".to_owned(),()),
};
let creator=match &self.creator{
Ok(Ok(_))=>CheckSummary::passed("Creator"),
Ok(Ok(_))=>passed!("Creator"),
Ok(Err(context))=>summary_format!("Creator","Invalid Creator: {context}",()),
Err(StringValueError::ObjectNotFound)=>summary!("Creator","Missing Creator StringValue".to_owned(),()),
Err(StringValueError::ValueNotSet)=>summary!("Creator","Creator Value not set".to_owned(),()),
Err(StringValueError::NonStringValue)=>summary!("Creator","Creator Value is not a String".to_owned(),()),
};
let game_id=match &self.game_id{
Ok(_)=>CheckSummary::passed("GameID"),
Ok(_)=>passed!("GameID"),
Err(ParseGameIDError)=>summary!("GameID","Model name must be prefixed with bhop_ surf_ or flytrials_".to_owned(),()),
};
let mapstart=match &self.mapstart{
Ok(Exists)=>CheckSummary::passed("MapStart"),
Ok(Exists)=>passed!("MapStart"),
Err(Absent)=>summary_format!("MapStart","Model has no MapStart",()),
};
let duplicate_start=match &self.mode_start_counts{
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateStart"),
DuplicateCheck(Ok(()))=>passed!("DuplicateStart"),
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
let context=Separated::new(", ",||context.iter().map(|(&mode_id,names)|
Duplicates::new(ModeElement{zone:Zone::Start,mode_id},names.len())
@@ -668,10 +663,10 @@ impl MapCheck<'_>{
}
};
let (extra_finish,missing_finish)=match &self.mode_finish_counts{
SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraFinish"),CheckSummary::passed("MissingFinish")),
SetDifferenceCheck(Ok(()))=>(passed!("ExtraFinish"),passed!("MissingFinish")),
SetDifferenceCheck(Err(context))=>(
if context.extra.is_empty(){
CheckSummary::passed("ExtraFinish")
passed!("ExtraFinish")
}else{
let plural=if context.extra.len()==1{"zone"}else{"zones"};
let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)|
@@ -680,7 +675,7 @@ impl MapCheck<'_>{
summary_format!("ExtraFinish","No matching start zone for finish {plural}: {context}",())
},
if context.missing.is_empty(){
CheckSummary::passed("MissingFinish")
passed!("MissingFinish")
}else{
let plural=if context.missing.len()==1{"zone"}else{"zones"};
let context=Separated::new(", ",||context.missing.iter().map(|&mode_id|
@@ -691,10 +686,10 @@ impl MapCheck<'_>{
),
};
let dangling_anticheat=match &self.mode_anticheat_counts{
SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingAnticheat"),
SetDifferenceCheck(Ok(()))=>passed!("DanglingAnticheat"),
SetDifferenceCheck(Err(context))=>{
if context.extra.is_empty(){
CheckSummary::passed("DanglingAnticheat")
passed!("DanglingAnticheat")
}else{
let plural=if context.extra.len()==1{"zone"}else{"zones"};
let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)|
@@ -705,11 +700,11 @@ impl MapCheck<'_>{
}
};
let spawn1=match &self.spawn1{
Ok(Exists)=>CheckSummary::passed("Spawn1"),
Ok(Exists)=>passed!("Spawn1"),
Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1",()),
};
let dangling_teleport=match &self.teleport_counts{
SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingTeleport"),
SetDifferenceCheck(Ok(()))=>passed!("DanglingTeleport"),
SetDifferenceCheck(Err(context))=>{
let unique_names:HashSet<_>=context.extra.values().flat_map(|names|names.iter().copied()).collect();
let plural=if unique_names.len()==1{"object"}else{"objects"};
@@ -718,7 +713,7 @@ impl MapCheck<'_>{
}
};
let duplicate_spawns=match &self.spawn_counts{
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateSpawn"),
DuplicateCheck(Ok(()))=>passed!("DuplicateSpawn"),
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
let context=Separated::new(", ",||context.iter().map(|(&stage_id,&names)|
Duplicates::new(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id},names as usize)
@@ -727,10 +722,10 @@ impl MapCheck<'_>{
}
};
let (extra_wormhole_in,missing_wormhole_in)=match &self.wormhole_in_counts{
SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraWormholeIn"),CheckSummary::passed("MissingWormholeIn")),
SetDifferenceCheck(Ok(()))=>(passed!("ExtraWormholeIn"),passed!("MissingWormholeIn")),
SetDifferenceCheck(Err(context))=>(
if context.extra.is_empty(){
CheckSummary::passed("ExtraWormholeIn")
passed!("ExtraWormholeIn")
}else{
let context=Separated::new(", ",||context.extra.iter().map(|(&wormhole_id,_names)|
WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id}
@@ -738,7 +733,7 @@ impl MapCheck<'_>{
summary_format!("ExtraWormholeIn","WormholeIn with no matching WormholeOut: {context}",())
},
if context.missing.is_empty(){
CheckSummary::passed("MissingWormholeIn")
passed!("MissingWormholeIn")
}else{
// This counts WormholeIn objects, but
// flipped logic is easier to understand
@@ -750,7 +745,7 @@ impl MapCheck<'_>{
)
};
let duplicate_wormhole_out=match &self.wormhole_out_counts{
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateWormholeOut"),
DuplicateCheck(Ok(()))=>passed!("DuplicateWormholeOut"),
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
let context=Separated::new(", ",||context.iter().map(|(&wormhole_id,&names)|
Duplicates::new(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id},names as usize)
@@ -780,24 +775,12 @@ impl MapCheck<'_>{
}
#[derive(serde::Serialize)]
struct MapCheckList{
checks:Box<[CheckSummary;16]>,
}
impl MapCheckList{
fn summary(&self)->String{
Separated::new("; ",||self.checks.iter().filter_map(|check|
(!check.passed).then_some(check.summary.as_str())
)).to_string()
}
}
pub struct Summary{
pub summary:String,
pub json:serde_json::Value,
pub struct MapCheckList{
pub checks:Box<[Check;16]>,
}
pub struct CheckReportAndVersion{
pub status:Result<MapInfoOwned,Summary>,
pub status:Result<MapInfoOwned,MapCheckList>,
pub version:u64,
}
@@ -836,10 +819,7 @@ impl crate::message_handler::MessageHandler{
// check the report, generate an error message if it fails the check
let status=match map_check.result(){
Ok(map_info)=>Ok(map_info),
Err(Ok(summary))=>Err(Summary{
summary:summary.summary(),
json:serde_json::to_value(&summary).map_err(Error::ToJsonValue)?,
}),
Err(Ok(check_list))=>Err(check_list),
Err(Err(e))=>return Err(Error::ToJsonValue(e)),
};

View File

@@ -31,10 +31,10 @@ impl crate::message_handler::MessageHandler{
}
).await.map_err(Error::ApiActionMapfixCheck)?,
// update the mapfix model status to request changes
Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.action_mapfix_request_changes(
Ok(CheckReportAndVersion{status:Err(check_list),..})=>self.api.action_mapfix_request_changes(
submissions_api::types::ActionMapfixRequestChangesRequest{
MapfixID:mapfix_id,
ErrorMessage:report.summary,
CheckList:check_list.checks.as_slice(),
}
).await.map_err(Error::ApiActionMapfixCheck)?,
// update the mapfix model status to request changes
@@ -45,7 +45,12 @@ impl crate::message_handler::MessageHandler{
self.api.action_mapfix_request_changes(
submissions_api::types::ActionMapfixRequestChangesRequest{
MapfixID:mapfix_id,
ErrorMessage:e.to_string(),
CheckList:&[submissions_api::types::Check{
Name:"InternalError",
Summary:e.to_string(),
Passed:false,
Details:serde_json::Value::Null,
}],
}
).await.map_err(Error::ApiActionMapfixCheck)?;
},

View File

@@ -32,10 +32,10 @@ impl crate::message_handler::MessageHandler{
}
).await.map_err(Error::ApiActionSubmissionCheck)?,
// update the submission model status to request changes
Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.action_submission_request_changes(
Ok(CheckReportAndVersion{status:Err(check_list),..})=>self.api.action_submission_request_changes(
submissions_api::types::ActionSubmissionRequestChangesRequest{
SubmissionID:submission_id,
ErrorMessage:report.summary,
CheckList:check_list.checks.as_slice(),
}
).await.map_err(Error::ApiActionSubmissionCheck)?,
// update the submission model status to request changes
@@ -46,7 +46,12 @@ impl crate::message_handler::MessageHandler{
self.api.action_submission_request_changes(
submissions_api::types::ActionSubmissionRequestChangesRequest{
SubmissionID:submission_id,
ErrorMessage:e.to_string(),
CheckList:&[submissions_api::types::Check{
Name:"InternalError",
Summary:e.to_string(),
Passed:false,
Details:serde_json::Value::Null,
}],
}
).await.map_err(Error::ApiActionSubmissionCheck)?;
},

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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