diff --git a/web/src/app/mapfixes/(styles)/page.scss b/web/src/app/mapfixes/(styles)/page.scss new file mode 100644 index 0000000..bf98951 --- /dev/null +++ b/web/src/app/mapfixes/(styles)/page.scss @@ -0,0 +1,75 @@ +@forward "./page/card.scss"; + +@use "../../globals.scss"; + +a { + color:rgb(255, 255, 255); + + &:visited, &:hover, &:focus { + text-decoration: none; + color: rgb(255, 255, 255); + } + &:active { + color: rgb(192, 192, 192) + } +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + grid-template-rows: repeat(3, 1fr); + gap: 16px; + max-width: 100%; + margin: 0 auto; + overflow-x: hidden; + box-sizing: border-box; +} + +@media (max-width: 768px) { + .grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin: 0.3rem; +} + +.pagination button { + padding: 0.25rem 0.5rem; + font-size: 1.15rem; + border: none; + border-radius: 0.35rem; + background-color: #33333350; + color: #fff; + cursor: pointer; +} + +.pagination button:disabled { + background-color: #5555559a; + cursor: not-allowed; +} + +.pagination-dots { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + justify-content: center; + width: 100%; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #bbb; + cursor: pointer; +} + +.dot.active { + background-color: #333; +} \ No newline at end of file diff --git a/web/src/app/mapfixes/(styles)/page/card.scss b/web/src/app/mapfixes/(styles)/page/card.scss new file mode 100644 index 0000000..dce43eb --- /dev/null +++ b/web/src/app/mapfixes/(styles)/page/card.scss @@ -0,0 +1,87 @@ +@use "../../../globals.scss"; + +.submissionCard { + display: flex; + background-color: #2020207c; + border: 1px solid #97979783; + border-radius: 10px; + box-sizing: border-box; + flex-direction: column; + justify-content: space-between; + height: 100%; + min-width: 180px; + max-width: 340px; +} + +.content { + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: space-between; +} + +.details { + padding: 2px 4px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin: 3px 0px; +} + +.footer { + display: flex; + justify-content: space-between; + align-items: center; + margin: 3px 0px; +} + +.map-image { + border-radius: 10px 10px 0 0; + overflow: hidden; + + > img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.displayName { + font-size: 1rem; + font-weight: bold; + overflow: hidden; + max-width: 70%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rating { + flex-shrink: 0; +} + +.author { + display: flex; + align-items: center; + gap: 0.5rem; + flex-grow: 1; + min-width: 0; +} + +.author span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rating { + margin-left: auto; + flex-shrink: 0; +} + +.avatar { + border-radius: 50%; + object-fit: cover; +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page.scss new file mode 100644 index 0000000..4015f24 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page.scss @@ -0,0 +1,19 @@ +@forward "./page/commentWindow.scss"; +@forward "./page/reviewStatus.scss"; +@forward "./page/ratingWindow.scss"; +@forward "./page/reviewButtons.scss"; +@forward "./page/comments.scss"; +@forward "./page/review.scss"; +@forward "./page/map.scss"; + +@use "../../../globals.scss"; + +.map-page-main { + display: flex; + justify-content: center; + width: 100vw; +} + +.by-creator { + margin-top: 10px; +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/commentWindow.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/commentWindow.scss new file mode 100644 index 0000000..d04b140 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/commentWindow.scss @@ -0,0 +1,56 @@ +@use "../../../../globals.scss"; + +#comment-text-field { + @include globals.border-with-radius; + resize: none; + width: 100%; + height: 100px; + background-color: var(--comment-area) +} + +.leave-comment-window { + @include globals.border-with-radius; + width: 100%; + height: 230px; + margin-top: 35px; + + .rating-type { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + gap: 35%; + + .rating-right { + display: grid; + + > span { + margin: 6px 0 6px 0; + } + } + + p { + margin: 15px 0 15px 0; + } + } + + header { + display: flex; + align-items: center; + background-color: var(--window-header); + border-bottom: globals.$review-border; + height: 45px; + + p { + font-weight: bold; + margin: 0 0 0 20px; + } + } + main { + padding: 20px; + + button { + margin-top: 9px; + } + } +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/comments.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/comments.scss new file mode 100644 index 0000000..d74b8a6 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/comments.scss @@ -0,0 +1,49 @@ +$comments-size: 60px; + +.comments { + display: grid; + gap: 25px; + margin-top: 20px; + + .no-comments { + text-align: center; + margin: 0; + } + + .commenter { + display: flex; + height: $comments-size; + + //BhopMaptest comment + &[data-highlighted="true"] { + background-color: var(--comment-highlighted); + } + > img { + border-radius: 50%; + } + + .name { + font: { + weight: 500; + size: 1.3em; + }; + } + .date { + font-size: .8em; + margin: 0 0 0 5px; + color: #646464 + } + .details { + display: grid; + margin-left: 10px; + + header { + display: flex; + align-items: center; + } + p:not(.date) { + margin: 0; + } + } + } +} diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/map.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/map.scss new file mode 100644 index 0000000..ede388e --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/map.scss @@ -0,0 +1,15 @@ +@use "../../../../globals.scss"; + +.map-image-area { + @include globals.border-with-radius; + display: flex; + justify-content: center; + align-items: center; + width: 350px; + height: 350px; + + > p { + text-align: center; + margin: 0; + } +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/ratingWindow.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/ratingWindow.scss new file mode 100644 index 0000000..770fe16 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/ratingWindow.scss @@ -0,0 +1,43 @@ +@use "../../../../globals.scss"; + +.rating-window { + @include globals.border-with-radius; + width: 100%; + + .rating-type { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + gap: 35%; + + .rating-right { + display: grid; + + > span { + margin: 6px 0 6px 0; + } + } + + p { + margin: 15px 0 15px 0; + } + } + + header { + display: flex; + align-items: center; + background-color: var(--window-header); + border-bottom: globals.$review-border; + height: 45px; + + p { + font-weight: bold; + margin: 0 0 0 20px; + } + } + main { + display: grid; + place-items: center; + } +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/review.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/review.scss new file mode 100644 index 0000000..08fc5c0 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/review.scss @@ -0,0 +1,47 @@ +@use "../../../../globals.scss"; + +.review-info { + width: 650px; + height: 100%; + + > div { + display: flex; + justify-content: space-between; + align-items: center; + } + p, h1 { + color: var(--text-color); + } + h1 { + font: { + weight: 500; + size: 1.8rem + }; + margin: 0; + } + a { + color: var(--anchor-link-review); + + &:hover { + text-decoration: underline; + } + } +} + +.review-section { + display: flex; + gap: 50px; + margin-top: 20px; +} + +.review-area { + display: grid; + justify-content: center; + gap: 25px; + + img { + width: 100%; + height: 350px; + object-fit: contain + } +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewButtons.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewButtons.scss new file mode 100644 index 0000000..ccc6ecd --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewButtons.scss @@ -0,0 +1,13 @@ +@use "../../../../globals.scss"; + +.review-set { + @include globals.border-with-radius; + display: grid; + align-items: center; + gap: 10px; + padding: 10px; + + button { + width: 100%; + } +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewStatus.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewStatus.scss new file mode 100644 index 0000000..e64bd43 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewStatus.scss @@ -0,0 +1,80 @@ +$UnderConstruction: "0"; +$Submitted: "1"; +$ChangesRequested: "2"; +$Accepted: "3"; +$Validating: "4"; +$Validated: "5"; +$Uploading: "6"; +$Uploaded: "7"; +$Rejected: "8"; +$Released: "9"; + +.review-status { + border-radius: 5px; + + p { + margin: 3px 25px 3px 25px; + font-weight: bold; + } + + &[data-review-status="#{$Released}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Rejected}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Uploading}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Uploaded}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Validated}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Validating}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Accepted}"] { + background-color: rgb(2, 162, 2); + p { + color: white; + } + } + &[data-review-status="#{$ChangesRequested}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$Submitted}"] { + background-color: orange; + p { + color: white; + } + } + &[data-review-status="#{$UnderConstruction}"] { + background-color: orange; + p { + color: white; + } + } +} diff --git a/web/src/app/mapfixes/[mapfixId]/_comments.tsx b/web/src/app/mapfixes/[mapfixId]/_comments.tsx new file mode 100644 index 0000000..7c5286a --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/_comments.tsx @@ -0,0 +1,68 @@ +import type { MapfixInfo } from "@/app/ts/Mapfix"; +import { Button } from "@mui/material" +import Window from "./_window"; +import SendIcon from '@mui/icons-material/Send'; +import Image from "next/image"; + +interface CommentersProps { + comments_data: CreatorAndReviewStatus +} + +interface CreatorAndReviewStatus { + asset_id: MapfixInfo["AssetID"], + creator: MapfixInfo["DisplayName"], + review: MapfixInfo["StatusID"], + status_message: MapfixInfo["StatusMessage"], + comments: Comment[], + name: string +} + +interface Comment { + picture?: string, //TEMP + comment: string, + date: string, + name: string +} + +function AddComment(comment: Comment) { + const IsBhopMaptest = comment.name == "BhopMaptest" //Highlighted commenter + + return ( + <div className="commenter" data-highlighted={IsBhopMaptest}> + <Image src={comment.picture as string} alt={`${comment.name}'s comment`}/> + <div className="details"> + <header> + <p className="name">{comment.name}</p> + <p className="date">{comment.date}</p> + </header> + <p className="comment">{comment.comment}</p> + </div> + </div> + ); +} + +function LeaveAComment() { + return ( + <Window title="Leave a Comment:" className="leave-comment-window"> + <textarea name="comment-box" id="comment-text-field"></textarea> + <Button variant="outlined" endIcon={<SendIcon/>}>Submit</Button> + </Window> + ) +} + +export default function Comments(stats: CommentersProps) { + return (<> + <section className="comments"> + {stats.comments_data.comments.length===0 + && <p className="no-comments">There are no comments.</p> + || stats.comments_data.comments.map(comment => ( + <AddComment key={comment.name} name={comment.name} date={comment.date} comment={comment.comment}/> + ))} + </section> + <LeaveAComment/> + </>) +} + +export { + type CreatorAndReviewStatus +} diff --git a/web/src/app/mapfixes/[mapfixId]/_map.tsx b/web/src/app/mapfixes/[mapfixId]/_map.tsx new file mode 100644 index 0000000..f515db2 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/_map.tsx @@ -0,0 +1,14 @@ +import { MapfixInfo } from "@/app/ts/Mapfix" + +interface AssetID { + id: MapfixInfo["AssetID"] +} + +function MapImage() { + return <p>Fetching map image...</p> +} + +export { + type AssetID, + MapImage +} diff --git a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx new file mode 100644 index 0000000..24d75c4 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx @@ -0,0 +1,74 @@ +import { Button, ButtonOwnProps } from "@mui/material"; + +type Actions = "Completed" | "Submit" | "Reject" | "Revoke" +type ApiActions = Lowercase<Actions> | "trigger-validate" | "retry-validate" | "trigger-upload" | "reset-uploading" | "reset-validating" +type Review = Actions | "Accept" | "Validate" | "Upload" | "Reset Uploading (fix softlocked status)" | "Reset Validating (fix softlocked status)" | "Request Changes" + +interface ReviewButton { + name: Review, + action: ApiActions, + mapfixId: string, + color: ButtonOwnProps["color"] +} + +interface ReviewId { + mapfixId: string +} + +async function ReviewButtonClicked(action: ApiActions, mapfixId: string) { + try { + const response = await fetch(`/api/mapfixes/${mapfixId}/status/${action}`, { + method: "POST", + headers: { + "Content-type": "application/json", + } + }); + // Check if the HTTP request was successful + if (!response.ok) { + const errorDetails = await response.text(); + + // Throw an error with detailed information + throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`); + } + + window.location.reload(); + } catch (error) { + console.error("Error updating mapfix status:", error); + } +} + +function ReviewButton(props: ReviewButton) { + return <Button + color={props.color} + variant="contained" + onClick={() => { ReviewButtonClicked(props.action, props.mapfixId) }}>{props.name}</Button> +} + +export default function ReviewButtons(props: ReviewId) { + const mapfixId = props.mapfixId + // When is each button visible? + // Multiple buttons can be visible at once. + // Action | Role | When Current Status is One of: + // ---------------|-----------|----------------------- + // Submit | Submitter | UnderConstruction, ChangesRequested + // Revoke | Submitter | Submitted, ChangesRequested + // Accept | Reviewer | Submitted + // Validate | Reviewer | Accepted + // ResetValidating| Reviewer | Validating + // Reject | Reviewer | Submitted + // RequestChanges | Reviewer | Validated, Accepted, Submitted + // Upload | MapAdmin | Validated + // ResetUploading | MapAdmin | Uploading + return ( + <section className="review-set"> + <ReviewButton color="info" name="Submit" action="submit" mapfixId={mapfixId}/> + <ReviewButton color="info" name="Revoke" action="revoke" mapfixId={mapfixId}/> + <ReviewButton color="info" name="Accept" action="trigger-validate" mapfixId={mapfixId}/> + <ReviewButton color="info" name="Validate" action="retry-validate" mapfixId={mapfixId}/> + <ReviewButton color="error" name="Reject" action="reject" mapfixId={mapfixId}/> + <ReviewButton color="info" name="Upload" action="trigger-upload" mapfixId={mapfixId}/> + <ReviewButton color="error" name="Reset Uploading (fix softlocked status)" action="reset-uploading" mapfixId={mapfixId}/> + <ReviewButton color="error" name="Reset Validating (fix softlocked status)" action="reset-validating" mapfixId={mapfixId}/> + </section> + ) +} diff --git a/web/src/app/mapfixes/[mapfixId]/_window.tsx b/web/src/app/mapfixes/[mapfixId]/_window.tsx new file mode 100644 index 0000000..866b5a4 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/_window.tsx @@ -0,0 +1,20 @@ +interface WindowStruct { + className: string, + title: string, + children: React.ReactNode +} + +export default function Window(window: WindowStruct) { + return ( + <section className={window.className}> + <header> + <p>{window.title}</p> + </header> + <main>{window.children}</main> + </section> + ) +} + +export { + type WindowStruct +} \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx new file mode 100644 index 0000000..cc38cb3 --- /dev/null +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -0,0 +1,105 @@ +"use client" + +import { MapfixInfo, MapfixStatusToString } from "@/app/ts/Mapfix"; +import type { CreatorAndReviewStatus } from "./_comments"; +import { MapImage } from "./_map"; +import { useParams } from "next/navigation"; +import ReviewButtons from "./_reviewButtons"; +import { Rating } from "@mui/material"; +import Comments from "./_comments"; +import Webpage from "@/app/_components/webpage"; +import Window from "./_window"; +import Link from "next/link"; +import { useState, useEffect } from "react"; + +import "./(styles)/page.scss"; + +interface ReviewId { + mapfixId: string +} + +function Ratings() { + return ( + <Window className="rating-window" title="Rating"> + <section className="rating-type"> + <aside className="rating-left"> + <p>Quality</p> + <p>Difficulty</p> + <p>Fun</p> + <p>Length</p> + </aside> + <aside className="rating-right"> + <Rating defaultValue={2.5} precision={0.5}/> + <Rating defaultValue={2.5} precision={0.5}/> + <Rating defaultValue={2.5} precision={0.5}/> + <Rating defaultValue={2.5} precision={0.5}/> + </aside> + </section> + </Window> + ) +} + +function RatingArea(mapfix: ReviewId) { + return ( + <aside className="review-area"> + <section className="map-image-area"> + <MapImage/> + </section> + <Ratings/> + <ReviewButtons mapfixId={mapfix.mapfixId}/> + </aside> + ) +} + +function TitleAndComments(stats: CreatorAndReviewStatus) { + const Review = MapfixStatusToString(stats.review) + + // TODO: hide status message when status is not "Accepted" + return ( + <main className="review-info"> + <div> + <h1>{stats.name}</h1> + <aside data-review-status={stats.review} className="review-status"> + <p>{Review}</p> + </aside> + </div> + <p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p> + <p className="asset-id">Model Asset ID {stats.asset_id}</p> + <p className="status-message">Validation Error: {stats.status_message}</p> + <span className="spacer"></span> + <Comments comments_data={stats}/> + </main> + ) +} + +export default function MapfixInfoPage() { + const dynamicId = useParams<{mapfixId: string}>() + + const [mapfix, setMapfix] = useState<MapfixInfo | null>(null) + + useEffect(() => { // needs to be client sided since server doesn't have a session, nextjs got mad at me for exporting an async function: (https://nextjs.org/docs/messages/no-async-client-component) + async function getMapfix() { + const res = await fetch(`/api/mapfixes/${dynamicId.mapfixId}`) + if (res.ok) { + setMapfix(await res.json()) + } + } + getMapfix() + }, [dynamicId.mapfixId]) + + if (!mapfix) { + return <Webpage> + {/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */} + </Webpage> + } + return ( + <Webpage> + <main className="map-page-main"> + <section className="review-section"> + <RatingArea mapfixId={dynamicId.mapfixId}/> + <TitleAndComments name={mapfix.DisplayName} creator={mapfix.Creator} review={mapfix.StatusID} status_message={mapfix.StatusMessage} asset_id={mapfix.AssetID} comments={[]}/> + </section> + </main> + </Webpage> + ) +} diff --git a/web/src/app/mapfixes/_card.tsx b/web/src/app/mapfixes/_card.tsx new file mode 100644 index 0000000..a1c594a --- /dev/null +++ b/web/src/app/mapfixes/_card.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Rating } from "@mui/material"; + +interface SubmissionCardProps { + displayName: string; + assetId: number; + rating: number; + author: string; + id: number; +} + +export default 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 height={200} width={200} priority={true} src="https://api.ic3.space/strafe/map-images/11222350808" style={{ width: `100%` }} 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="https://api.ic3.space/strafe/map-images/11222350808" alt={props.author}/> + <span>{props.author}</span> + </div> + </div> + </div> + </div> + </div> + </Link> + ); +} diff --git a/web/src/app/mapfixes/_window.tsx b/web/src/app/mapfixes/_window.tsx new file mode 100644 index 0000000..b71e7e2 --- /dev/null +++ b/web/src/app/mapfixes/_window.tsx @@ -0,0 +1,18 @@ +interface WindowStruct { + children: React.ReactNode, + className: string, + title: string, +} + +export default function Window(window: WindowStruct) { + return <section className={window.className}> + <header> + <p>{window.title}</p> + </header> + <main>{window.children}</main> + </section> +} + +export { + type WindowStruct +} \ No newline at end of file diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx new file mode 100644 index 0000000..cf6eccb --- /dev/null +++ b/web/src/app/mapfixes/page.tsx @@ -0,0 +1,110 @@ +'use client' + +import React, { useState, useEffect } from "react"; +import { MapfixInfo } from "../ts/Mapfix"; +import MapfixCard from "./_card"; +import Webpage from "@/app/_components/webpage"; + +import "./(styles)/page.scss"; + +export default function MapfixInfoPage() { + const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]) + const [currentPage, setCurrentPage] = useState(0); + const cardsPerPage = 24; // built to fit on a 1920x1080 monitor + + const totalPages = Math.ceil(mapfixes.length / cardsPerPage); + + const currentCards = mapfixes.slice( + currentPage * cardsPerPage, + (currentPage + 1) * cardsPerPage + ); + + const nextPage = () => { + if (currentPage < totalPages - 1) { + setCurrentPage(currentPage + 1); + } + }; + + const prevPage = () => { + if (currentPage > 0) { + setCurrentPage(currentPage - 1); + } + }; + + useEffect(() => { + async function fetchMapfixes() { + const res = await fetch('/api/mapfixes?Page=1&Limit=100') + if (res.ok) { + setMapfixes(await res.json()) + } + } + + setTimeout(() => { + fetchMapfixes() + }, 50); + }, []) + + if (!mapfixes) { + return <Webpage> + <main> + Loading... + </main> + </Webpage> + } + + if (mapfixes && mapfixes.length == 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' + }} + > + <div className="pagination-dots"> + {Array.from({ length: totalPages }).map((_, index) => ( + <span + key={index} + className={`dot ${index === currentPage ? 'active' : ''}`} + onClick={() => setCurrentPage(index)} + ></span> + ))} + </div> + <div className="pagination"> + <button onClick={prevPage} disabled={currentPage === 0}><</button> + <span> + Page {currentPage + 1} of {totalPages} + </span> + <button onClick={nextPage} disabled={currentPage === totalPages - 1}>></button> + </div> + <div className="grid"> + {currentCards.map((mapfix) => ( + <MapfixCard + key={mapfix.ID} + id={mapfix.ID} + assetId={mapfix.AssetID} + displayName={mapfix.DisplayName} + author={mapfix.Creator} + rating={mapfix.StatusID} + /> + ))} + </div> + </main> + </Webpage> + ) +} diff --git a/web/src/app/maps/[mapId]/fix/(styles)/page.scss b/web/src/app/maps/[mapId]/fix/(styles)/page.scss new file mode 100644 index 0000000..8c7dcf8 --- /dev/null +++ b/web/src/app/maps/[mapId]/fix/(styles)/page.scss @@ -0,0 +1,54 @@ +@use "../../../../globals.scss"; + +::placeholder { + color: var(--placeholder-text) +} + +.form-spacer { + margin-bottom: 20px; + + &:last-of-type { + margin-top: 15px; + } +} + +#target-asset-radio { + color: var(--text-color); + font-size: globals.$form-label-fontsize; +} + +.form-field { + width: 850px; + + & label, & input { + color: var(--text-color); + } + & fieldset { + border-color: rgb(100,100,100); + } + & span { + color: white; + } +} + +main { + display: grid; + justify-content: center; + align-items: center; + margin-inline: auto; + width: 700px; +} + +header h1 { + text-align: center; + color: var(--text-color); +} + +form { + display: grid; + gap: 25px; + + fieldset { + border: blue + } +} diff --git a/web/src/app/maps/[mapId]/fix/_game.tsx b/web/src/app/maps/[mapId]/fix/_game.tsx new file mode 100644 index 0000000..e754601 --- /dev/null +++ b/web/src/app/maps/[mapId]/fix/_game.tsx @@ -0,0 +1,65 @@ +import { FormControl, Select, InputLabel, MenuItem } from "@mui/material"; +import { styled } from '@mui/material/styles'; +import InputBase from '@mui/material/InputBase'; +import React from "react"; +import { SelectChangeEvent } from "@mui/material"; + +// TODO: Properly style everything instead of pasting 🤚 + +type GameSelectionProps = { + game: number; + setGame: React.Dispatch<React.SetStateAction<number>>; +}; + +const BootstrapInput = styled(InputBase)(({ theme }) => ({ + 'label + &': { + marginTop: theme.spacing(3), + }, + '& .MuiInputBase-input': { + backgroundColor: '#0000', + color: '#FFF', + border: '1px solid rgba(175, 175, 175, 0.66)', + fontSize: 16, + padding: '10px 26px 10px 12px', + transition: theme.transitions.create(['border-color', 'box-shadow']), + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + ].join(','), + '&:focus': { + borderRadius: 4, + borderColor: '#80bdff', + boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)', + }, + }, + })); + +export default function GameSelection({ game, setGame }: GameSelectionProps) { + const handleChange = (event: SelectChangeEvent) => { + setGame(Number(event.target.value)); // TODO: Change later!! there's 100% a proper way of doing this + }; + + return ( + <FormControl> + <InputLabel sx={{ color: "#646464" }}>Game</InputLabel> + <Select + value={String(game)} + label="Game" + onChange={handleChange} + input={<BootstrapInput />} + > + <MenuItem value={1}>Bhop</MenuItem> + <MenuItem value={2}>Surf</MenuItem> + <MenuItem value={3}>Fly Trials</MenuItem> + </Select> + </FormControl> + ); +} \ No newline at end of file diff --git a/web/src/app/maps/[mapId]/fix/page.tsx b/web/src/app/maps/[mapId]/fix/page.tsx new file mode 100644 index 0000000..5e68374 --- /dev/null +++ b/web/src/app/maps/[mapId]/fix/page.tsx @@ -0,0 +1,98 @@ +"use client" + +import { Button, TextField } from "@mui/material" + +import GameSelection from "./_game"; +import SendIcon from '@mui/icons-material/Send'; +import Webpage from "@/app/_components/webpage"; +import { useParams } from "next/navigation"; +import React, { useState } from "react"; + +import "./(styles)/page.scss" + +interface MapfixPayload { + DisplayName: string; + Creator: string; + GameID: number; + AssetID: number; + AssetVersion: number; + TargetAssetID: number; +} +interface IdResponse { + ID: number; +} + +export default function MapfixInfoPage() { + const [game, setGame] = useState(1); + const dynamicId = useParams<{ mapId: string }>(); + + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + + const form = event.currentTarget; + const formData = new FormData(form); + + const payload: MapfixPayload = { + DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change + Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change + GameID: game, + AssetID: Number((formData.get("asset-id") as string) ?? "0"), + AssetVersion: 0, + TargetAssetID: Number(dynamicId.mapId), + }; + + console.log(payload) + console.log(JSON.stringify(payload)) + + try { + // Send the POST request + const response = await fetch("/api/mapfixes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + // Check if the HTTP request was successful + if (!response.ok) { + const errorDetails = await response.text(); + + // Throw an error with detailed information + throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`); + } + + // Allow any HTTP status + const id_response:IdResponse = await response.json(); + + // navigate to newly created mapfix + window.location.assign(`/mapfixes/${id_response.ID}`) + + } catch (error) { + console.error("Error submitting data:", error); + } + }; + + return ( + <Webpage> + <main> + <header> + <h1>Submit Mapfix</h1> + <span className="spacer form-spacer"></span> + </header> + <form onSubmit={handleSubmit}> + {/* TODO: Add form data for mapfixes, such as changes they did, and any times that need to be deleted & what styles */} + <TextField className="form-field" id="display-name" name="display-name" label="Display Name" variant="outlined"/> + <TextField className="form-field" id="creator" name="creator" label="Creator" variant="outlined"/> + <TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID" variant="outlined"/> + {/* I think this is Quat's job to figure this one out (to be set when someone clicks review(?)) */} {/* <TextField className="form-field" id="asset-version" label="Asset Version" variant="outlined"/> */} + <GameSelection game={game} setGame={setGame} /> + <span className="spacer form-spacer"></span> + <Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{ + width: "400px", + height: "50px", + marginInline: "auto" + }}>Submit</Button> + </form> + </main> + </Webpage> + ) +} diff --git a/web/src/app/submit/page.tsx b/web/src/app/submit/page.tsx index 38cc9e3..b7108e9 100644 --- a/web/src/app/submit/page.tsx +++ b/web/src/app/submit/page.tsx @@ -71,7 +71,7 @@ export default function SubmissionInfoPage() { <Webpage> <main> <header> - <h1>Submit Asset</h1> + <h1>Submit New Map</h1> <span className="spacer form-spacer"></span> </header> <form onSubmit={handleSubmit}> diff --git a/web/src/app/ts/Mapfix.ts b/web/src/app/ts/Mapfix.ts new file mode 100644 index 0000000..0a5bd25 --- /dev/null +++ b/web/src/app/ts/Mapfix.ts @@ -0,0 +1,59 @@ +const enum MapfixStatus { + UnderConstruction = 0, + Submitted = 1, + ChangesRequested = 2, + Accepted = 3, + Validating = 4, + Validated = 5, + Uploading = 6, + Uploaded = 7, + Rejected = 8, +} + +interface MapfixInfo { + readonly ID: number, + readonly DisplayName: string, + readonly Creator: string, + readonly GameID: number, + readonly Date: number, + readonly Submitter: number, + readonly AssetID: number, + readonly AssetVersion: number, + readonly ValidatedAssetID: number, + readonly ValidatedAssetVersion: number, + readonly Completed: boolean, + readonly TargetAssetID: number, + readonly StatusID: MapfixStatus + readonly StatusMessage: string, +} + +function MapfixStatusToString(mapfix_status: MapfixStatus): string { + switch (mapfix_status) { + case MapfixStatus.Rejected: + return "REJECTED" + case MapfixStatus.Uploading: + return "UPLOADING" + case MapfixStatus.Uploaded: + return "UPLOADED" + case MapfixStatus.Validated: + return "VALIDATED" + case MapfixStatus.Validating: + return "VALIDATING" + case MapfixStatus.Accepted: + return "ACCEPTED" + case MapfixStatus.ChangesRequested: + return "CHANGES REQUESTED" + case MapfixStatus.Submitted: + return "SUBMITTED" + case MapfixStatus.UnderConstruction: + return "UNDER CONSTRUCTION" + default: + return "UNKNOWN" + } +} + +export { + MapfixStatus, + MapfixStatusToString, + type MapfixInfo +}