diff --git a/web/package.json b/web/package.json index 5342771..3e91a77 100644 --- a/web/package.json +++ b/web/package.json @@ -13,18 +13,19 @@ "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.1.10", "@mui/material": "^6.1.10", + "date-fns": "^4.1.0", "next": "^15.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "sass": "^1.82.0" }, "devDependencies": { - "typescript": "^5.7.2", + "@eslint/eslintrc": "^3.2.0", "@types/node": "^20.17.9", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "eslint": "^9.16.0", "eslint-config-next": "15.1.0", - "@eslint/eslintrc": "^3.2.0" + "typescript": "^5.7.2" } } diff --git a/web/src/app/_components/ErrorDisplay.tsx b/web/src/app/_components/ErrorDisplay.tsx new file mode 100644 index 0000000..af7ea0e --- /dev/null +++ b/web/src/app/_components/ErrorDisplay.tsx @@ -0,0 +1,43 @@ +import { Button, Container, Paper, Typography } from "@mui/material"; +import Webpage from "@/app/_components/webpage"; + +interface ErrorDisplayProps { + title: string; + message: string; + buttonText?: string; + onButtonClick?: () => void; +} + +export function ErrorDisplay({ + title, + message, + buttonText, + onButtonClick + }: ErrorDisplayProps) { + return ( + + + + {title} + {message} + {buttonText && onButtonClick && ( + + )} + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/AuditEventItem.tsx b/web/src/app/_components/comments/AuditEventItem.tsx new file mode 100644 index 0000000..f4a2989 --- /dev/null +++ b/web/src/app/_components/comments/AuditEventItem.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { + Box, + Avatar, + Typography, + Tooltip +} from "@mui/material"; +import PersonIcon from '@mui/icons-material/Person'; +import { formatDistanceToNow, format } from "date-fns"; +import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent"; + +interface AuditEventItemProps { + event: AuditEvent; + validatorUser: number; +} + +export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) { + return ( + + + + + + + + {event.User === validatorUser ? "Validator" : event.Username || "Unknown"} + + + + {auditEventMessage(event)} + + + ); +} + +interface DateDisplayProps { + date: number; +} + +function DateDisplay({ date }: DateDisplayProps) { + return ( + + + + {formatDistanceToNow(new Date(date * 1000), { addSuffix: true })} + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/AuditEventsTabPanel.tsx b/web/src/app/_components/comments/AuditEventsTabPanel.tsx new file mode 100644 index 0000000..d5b68cc --- /dev/null +++ b/web/src/app/_components/comments/AuditEventsTabPanel.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { + Box, + Stack, +} from "@mui/material"; +import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent"; +import AuditEventItem from './AuditEventItem'; + +interface AuditEventsTabPanelProps { + activeTab: number; + auditEvents: AuditEvent[]; + validatorUser: number; +} + +export default function AuditEventsTabPanel({ + activeTab, + auditEvents, + validatorUser + }: AuditEventsTabPanelProps) { + const filteredEvents = auditEvents.filter( + event => event.EventType !== AuditEventType.Comment + ); + + return ( + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/CommentItem.tsx b/web/src/app/_components/comments/CommentItem.tsx new file mode 100644 index 0000000..a385244 --- /dev/null +++ b/web/src/app/_components/comments/CommentItem.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { + Box, + Avatar, + Typography, + Tooltip +} from "@mui/material"; +import PersonIcon from '@mui/icons-material/Person'; +import { formatDistanceToNow, format } from "date-fns"; +import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent"; + +interface CommentItemProps { + event: AuditEvent; + validatorUser: number; +} + +export default function CommentItem({ event, validatorUser }: CommentItemProps) { + return ( + + + + + + + + {event.User === validatorUser ? "Validator" : event.Username || "Unknown"} + + + + {decodeAuditEvent(event)} + + + ); +} + +interface DateDisplayProps { + date: number; +} + +function DateDisplay({ date }: DateDisplayProps) { + return ( + + + + {formatDistanceToNow(new Date(date * 1000), { addSuffix: true })} + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/CommentsAndAuditSection.tsx b/web/src/app/_components/comments/CommentsAndAuditSection.tsx new file mode 100644 index 0000000..274304b --- /dev/null +++ b/web/src/app/_components/comments/CommentsAndAuditSection.tsx @@ -0,0 +1,65 @@ +import React, {useState} from 'react'; +import { + Paper, + Box, + Tabs, + Tab, +} from "@mui/material"; +import CommentsTabPanel from './CommentsTabPanel'; +import AuditEventsTabPanel from './AuditEventsTabPanel'; +import { AuditEvent } from "@/app/ts/AuditEvent"; + +interface CommentsAndAuditSectionProps { + auditEvents: AuditEvent[]; + newComment: string; + setNewComment: (comment: string) => void; + handleCommentSubmit: () => void; + validatorUser: number; + userId: number | null; +} + +export default function CommentsAndAuditSection({ + auditEvents, + newComment, + setNewComment, + handleCommentSubmit, + validatorUser, + userId, + }: CommentsAndAuditSectionProps) { + + const [activeTab, setActiveTab] = useState(0); + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/CommentsTabPanel.tsx b/web/src/app/_components/comments/CommentsTabPanel.tsx new file mode 100644 index 0000000..7318b35 --- /dev/null +++ b/web/src/app/_components/comments/CommentsTabPanel.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { + Box, + Stack, + Avatar, + TextField, + IconButton +} from "@mui/material"; +import SendIcon from '@mui/icons-material/Send'; +import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent"; +import CommentItem from './CommentItem'; + +interface CommentsTabPanelProps { + activeTab: number; + auditEvents: AuditEvent[]; + validatorUser: number; + newComment: string; + setNewComment: (comment: string) => void; + handleCommentSubmit: () => void; + userId: number | null; +} + +export default function CommentsTabPanel({ + activeTab, + auditEvents, + validatorUser, + newComment, + setNewComment, + handleCommentSubmit, + userId + }: CommentsTabPanelProps) { + const commentEvents = auditEvents.filter( + event => event.EventType === AuditEventType.Comment + ); + + return ( + + ); +} + +interface CommentInputProps { + newComment: string; + setNewComment: (comment: string) => void; + handleCommentSubmit: () => void; + userId: number | null; +} + +function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) { + return ( + + + setNewComment(e.target.value)} + /> + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/header.tsx b/web/src/app/_components/header.tsx index 463d62e..0eac6b4 100644 --- a/web/src/app/_components/header.tsx +++ b/web/src/app/_components/header.tsx @@ -2,9 +2,8 @@ import Link from "next/link" import Image from "next/image"; - -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"; @@ -13,12 +12,27 @@ import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; +import IconButton from "@mui/material/IconButton"; +import MenuIcon from "@mui/icons-material/Menu"; +import Drawer from "@mui/material/Drawer"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; interface HeaderButton { name: string; href: string; } +const navItems: HeaderButton[] = [ + { name: "Submissions", href: "/submissions" }, + { name: "Mapfixes", href: "/mapfixes" }, + { name: "Maps", href: "/maps" }, +]; + function HeaderButton(header: HeaderButton) { return ( )} - {valid && user ? ( + {!isMobile && valid && user ? ( @@ -122,13 +200,46 @@ export default function Header() { - ) : ( + ) : !isMobile && ( )} + + {/* In mobile view, display just the avatar if logged in */} + {isMobile && valid && user && ( + + {user.Username} + + )} + + {/* Mobile drawer */} + + {drawer} + ); -} +} \ No newline at end of file diff --git a/web/src/app/_components/mapCard.tsx b/web/src/app/_components/mapCard.tsx index ff46e5d..c54030a 100644 --- a/web/src/app/_components/mapCard.tsx +++ b/web/src/app/_components/mapCard.tsx @@ -1,6 +1,7 @@ -import React, {JSX} from "react"; -import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Grid, Typography} from "@mui/material"; -import {Cancel, CheckCircle, Explore, Pending, Person2} from "@mui/icons-material"; +import React from "react"; +import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Grid, Typography} from "@mui/material"; +import {Explore, Person2} from "@mui/icons-material"; +import {StatusChip} from "@/app/_components/statusChip"; interface MapCardProps { displayName: string; @@ -18,89 +19,6 @@ interface MapCardProps { const CARD_WIDTH = 270; export function MapCard(props: MapCardProps) { - const StatusChip = ({status}: { status: number }) => { - let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default'; - let icon: JSX.Element = ; - let label: string = 'Unknown'; - - switch (status) { - case 0: - color = 'warning'; - icon = ; - label = 'Under Construction'; - break; - case 1: - color = 'warning'; - icon = ; - label = 'Changes Requested'; - break; - case 2: - color = 'info'; - icon = ; - label = 'Submitting'; - break; - case 3: - color = 'warning'; - icon = ; - label = 'Under Review'; - break; - case 4: - color = 'warning'; - icon = ; - label = 'Accepted Unvalidated'; - break; - case 5: - color = 'info'; - icon = ; - label = 'Validating'; - break; - case 6: - color = 'success'; - icon = ; - label = 'Validated'; - break; - case 7: - color = 'info'; - icon = ; - label = 'Uploading'; - break; - case 8: - color = 'success'; - icon = ; - label = 'Uploaded'; - break; - case 9: - color = 'error'; - icon = ; - label = 'Rejected'; - break; - case 10: - color = 'success'; - icon = ; - label = 'Released'; - break; - default: - color = 'default'; - icon = ; - label = 'Unknown'; - break; - } - - return ( - - ); - }; return ( void; + placeholderText?: string; +} + +export const CopyableField = ({ + label, + value, + onCopy, + placeholderText = "Not assigned" + }: CopyableFieldProps) => { + const displayValue = value?.toString() || placeholderText; + + return ( + <> + {label} + + {displayValue} + {value && ( + + onCopy(value.toString())} + sx={{ ml: 1 }} + > + + + + )} + + + ); +}; \ No newline at end of file diff --git a/web/src/app/_components/review/ReviewButtons.tsx b/web/src/app/_components/review/ReviewButtons.tsx new file mode 100644 index 0000000..9f59138 --- /dev/null +++ b/web/src/app/_components/review/ReviewButtons.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { Button, Stack } from '@mui/material'; +import {MapfixInfo } from "@/app/ts/Mapfix"; +import {hasRole, Roles, RolesConstants} from "@/app/ts/Roles"; +import {SubmissionInfo, SubmissionStatus} from "@/app/ts/Submission"; + +interface ReviewAction { + name: string, + action: string, +} + +interface ReviewButtonsProps { + onClick: (action: string, id: number) => void; + item: (SubmissionInfo | MapfixInfo); + userId: number | null; + roles: Roles; + type: "submission" | "mapfix"; +} + +const ReviewActions = { + Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction, + BypassSubmit: {name:"Bypass Submit", action:"bypass-submit"} as ReviewAction, + ResetSubmitting: {name:"Reset Submitting",action:"reset-submitting"} as ReviewAction, + Revoke: {name:"Revoke",action:"revoke"} as ReviewAction, + Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction, + Reject: {name:"Reject",action:"reject"} as ReviewAction, + Validate: {name:"Validate",action:"retry-validate"} as ReviewAction, + ResetValidating: {name:"Reset Validating",action:"reset-validating"} as ReviewAction, + RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction, + Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction, + ResetUploading: {name:"Reset Uploading",action:"reset-uploading"} as ReviewAction, +} + +const ReviewButtons: React.FC = ({ + onClick, + item, + userId, + roles, + type, + }) => { + const getVisibleButtons = () => { + if (!item || userId === null) return []; + + // Define a type for the button + type ReviewButton = { + action: ReviewAction; + color: "primary" | "error" | "success" | "info" | "warning"; + }; + + const buttons: ReviewButton[] = []; + const is_submitter = userId === item.Submitter; + const status = item.StatusID; + + // Helper function to check status regardless of which enum type it is + const statusMatches = (statusValues: number[]) => { + return statusValues.includes(status); + }; + + // Create status constants that work with both types + const Status = { + UnderConstruction: SubmissionStatus.UnderConstruction, + ChangesRequested: SubmissionStatus.ChangesRequested, + Submitting: SubmissionStatus.Submitting, + Submitted: SubmissionStatus.Submitted, + AcceptedUnvalidated: SubmissionStatus.AcceptedUnvalidated, + Validating: SubmissionStatus.Validating, + Validated: SubmissionStatus.Validated, + Uploading: SubmissionStatus.Uploading, + Uploaded: SubmissionStatus.Uploaded, + Rejected: SubmissionStatus.Rejected, + Release: SubmissionStatus.Released + }; + + const reviewRole = type === "submission" ? RolesConstants.SubmissionReview : RolesConstants.MapfixReview; + const uploadRole = type === "submission" ? RolesConstants.SubmissionUpload : RolesConstants.MapfixUpload; + + if (is_submitter) { + if (statusMatches([Status.UnderConstruction, Status.ChangesRequested])) { + buttons.push({ + action: ReviewActions.Submit, + color: "primary" + }); + } + + if (statusMatches([Status.Submitted, Status.ChangesRequested])) { + buttons.push({ + action: ReviewActions.Revoke, + color: "error" + }); + } + } + + // Buttons for review role + if (hasRole(roles, reviewRole)) { + if (status === Status.Submitted && !is_submitter) { + buttons.push( + { + action: ReviewActions.Accept, + color: "success" + }, + { + action: ReviewActions.Reject, + color: "error" + } + ); + } + + if (status === Status.AcceptedUnvalidated) { + buttons.push({ + action: ReviewActions.Validate, + color: "info" + }); + } + + if (status === Status.Validating) { + buttons.push({ + action: ReviewActions.ResetValidating, + color: "warning" + }); + } + + if (statusMatches([Status.Validated, Status.AcceptedUnvalidated, Status.Submitted]) && !is_submitter) { + buttons.push({ + action: ReviewActions.RequestChanges, + color: "warning" + }); + } + + if (status === Status.ChangesRequested) { + buttons.push({ + action: ReviewActions.BypassSubmit, + color: "warning" + }); + } + } + + // Buttons for upload role + if (hasRole(roles, uploadRole)) { + if (status === Status.Validated) { + buttons.push({ + action: ReviewActions.Upload, + color: "success" + }); + } + + if (status === Status.Uploading) { + buttons.push({ + action: ReviewActions.ResetUploading, + color: "warning" + }); + } + } + + return buttons; + }; + + return ( + + {getVisibleButtons().map((button, index) => ( + + ))} + + ); +}; + +export default ReviewButtons; \ No newline at end of file diff --git a/web/src/app/_components/review/ReviewItem.tsx b/web/src/app/_components/review/ReviewItem.tsx new file mode 100644 index 0000000..36fac18 --- /dev/null +++ b/web/src/app/_components/review/ReviewItem.tsx @@ -0,0 +1,86 @@ +import { Paper, Grid, Typography } from "@mui/material"; +import { ReviewItemHeader } from "./ReviewItemHeader"; +import { CopyableField } from "@/app/_components/review/CopyableField"; +import { SubmissionInfo } from "@/app/ts/Submission"; +import { MapfixInfo } from "@/app/ts/Mapfix"; + +// Define a field configuration for specific types +interface FieldConfig { + key: string; + label: string; + placeholder?: string; +} + +type ReviewItemType = SubmissionInfo | MapfixInfo; + +interface ReviewItemProps { + item: ReviewItemType; + handleCopyValue: (value: string) => void; +} + +export function ReviewItem({ + item, + handleCopyValue + }: ReviewItemProps) { + // Type guard to check if item is valid + if (!item) return null; + + // Determine the type of item + const isSubmission = 'UploadedAssetID' in item; + const isMapfix = 'TargetAssetID' in item; + + // Define static fields based on item type + let fields: FieldConfig[] = []; + if (isSubmission) { + // Fields for Submission + fields = [ + { key: 'Submitter', label: 'Submitter ID' }, + { key: 'AssetID', label: 'Asset ID' }, + { key: 'UploadedAssetID', label: 'Uploaded Asset ID' }, + ]; + } else if (isMapfix) { + // Fields for Mapfix + fields = [ + { key: 'Submitter', label: 'Submitter ID' }, + { key: 'AssetID', label: 'Asset ID' }, + { key: 'TargetAssetID', label: 'Target Asset ID' }, + ]; + } + + return ( + + + + {/* Item Details */} + + {fields.map((field) => ( + + + + ))} + + + {/* Description Section */} + {isMapfix && item.Description && ( +
+ + Description + + + {item.Description} + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx new file mode 100644 index 0000000..50ce78f --- /dev/null +++ b/web/src/app/_components/review/ReviewItemHeader.tsx @@ -0,0 +1,36 @@ +import {Typography, Box, Avatar} from "@mui/material"; +import { StatusChip } from "@/app/_components/statusChip"; +import { SubmissionStatus } from "@/app/ts/Submission"; +import { MapfixStatus } from "@/app/ts/Mapfix"; + +type StatusIdType = SubmissionStatus | MapfixStatus; + +interface ReviewItemHeaderProps { + displayName: string; + statusId: StatusIdType; + creator: string | null | undefined; + submitterId: number; +} + +export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => { + return ( + <> + + + {displayName} + + + + + + + + by {creator || "Unknown Creator"} + + + + ); +}; \ No newline at end of file diff --git a/web/src/app/_components/statusChip.tsx b/web/src/app/_components/statusChip.tsx new file mode 100644 index 0000000..05e36ff --- /dev/null +++ b/web/src/app/_components/statusChip.tsx @@ -0,0 +1,87 @@ +import React, {JSX} from "react"; +import {Cancel, CheckCircle, Pending} from "@mui/icons-material"; +import {Chip} from "@mui/material"; + +export const StatusChip = ({status}: { status: number }) => { + let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default'; + let icon: JSX.Element = ; + let label: string = 'Unknown'; + + switch (status) { + case 0: + color = 'warning'; + icon = ; + label = 'Under Construction'; + break; + case 1: + color = 'warning'; + icon = ; + label = 'Changes Requested'; + break; + case 2: + color = 'info'; + icon = ; + label = 'Submitting'; + break; + case 3: + color = 'warning'; + icon = ; + label = 'Under Review'; + break; + case 4: + color = 'warning'; + icon = ; + label = 'Script Review'; + break; + case 5: + color = 'info'; + icon = ; + label = 'Validating'; + break; + case 6: + color = 'success'; + icon = ; + label = 'Validated'; + break; + case 7: + color = 'info'; + icon = ; + label = 'Uploading'; + break; + case 8: + color = 'success'; + icon = ; + label = 'Uploaded'; + break; + case 9: + color = 'error'; + icon = ; + label = 'Rejected'; + break; + case 10: + color = 'success'; + icon = ; + label = 'Released'; + break; + default: + color = 'default'; + icon = ; + label = 'Unknown'; + break; + } + + return ( + + ); +}; \ No newline at end of file diff --git a/web/src/app/mapfixes/(styles)/page.scss b/web/src/app/mapfixes/(styles)/page.scss deleted file mode 100644 index daf65f9..0000000 --- a/web/src/app/mapfixes/(styles)/page.scss +++ /dev/null @@ -1,75 +0,0 @@ -@forward "../../_components/styles/mapCard.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/[mapfixId]/(styles)/page.scss b/web/src/app/mapfixes/[mapfixId]/(styles)/page.scss deleted file mode 100644 index 4015f24..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page.scss +++ /dev/null @@ -1,19 +0,0 @@ -@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 deleted file mode 100644 index d04b140..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/commentWindow.scss +++ /dev/null @@ -1,56 +0,0 @@ -@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 deleted file mode 100644 index d74b8a6..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/comments.scss +++ /dev/null @@ -1,49 +0,0 @@ -$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 deleted file mode 100644 index ede388e..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/map.scss +++ /dev/null @@ -1,15 +0,0 @@ -@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 deleted file mode 100644 index 770fe16..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/ratingWindow.scss +++ /dev/null @@ -1,43 +0,0 @@ -@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 deleted file mode 100644 index 08fc5c0..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/review.scss +++ /dev/null @@ -1,47 +0,0 @@ -@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 deleted file mode 100644 index ccc6ecd..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewButtons.scss +++ /dev/null @@ -1,13 +0,0 @@ -@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 deleted file mode 100644 index e64bd43..0000000 --- a/web/src/app/mapfixes/[mapfixId]/(styles)/page/reviewStatus.scss +++ /dev/null @@ -1,80 +0,0 @@ -$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 deleted file mode 100644 index 091092e..0000000 --- a/web/src/app/mapfixes/[mapfixId]/_comments.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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"], - submitter: MapfixInfo["Submitter"], - target_asset_id: MapfixInfo["TargetAssetID"], - description: MapfixInfo["Description"], - 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 ( -
- {`${comment.name}'s -
-
-

{comment.name}

-

{comment.date}

-
-

{comment.comment}

-
-
- ); -} - -function LeaveAComment() { - return ( - - - - - ) -} - -export function Comments(stats: CommentersProps) { - return (<> -
- {stats.comments_data.comments.length===0 - &&

There are no comments.

- || stats.comments_data.comments.map(comment => ( - - ))} -
- - ) -} - -export { - type CreatorAndReviewStatus, - type Comment, -} diff --git a/web/src/app/mapfixes/[mapfixId]/_mapImage.tsx b/web/src/app/mapfixes/[mapfixId]/_mapImage.tsx deleted file mode 100644 index d82f482..0000000 --- a/web/src/app/mapfixes/[mapfixId]/_mapImage.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Image from "next/image"; -import { MapfixInfo } from "@/app/ts/Mapfix" - -interface AssetID { - id: MapfixInfo["AssetID"] -} - -function MapImage({ id }: AssetID) { - if (!id) { - return

Missing asset ID

; - } - - const imageUrl = `/thumbnails/asset/${id}`; - - return ( - Map Thumbnail - ); -} - -export { - type AssetID, - MapImage -} diff --git a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx deleted file mode 100644 index 70b93fe..0000000 --- a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { Roles, RolesConstants } from "@/app/ts/Roles"; -import { MapfixStatus } from "@/app/ts/Mapfix"; -import { Button, ButtonOwnProps } from "@mui/material"; -import { useState, useEffect } from "react"; - -interface ReviewAction { - name: string, - action: string, -} - -const ReviewActions = { - Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction, - BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction, - ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction, - Revoke: {name:"Revoke",action:"revoke"} as ReviewAction, - Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction, - Reject: {name:"Reject",action:"reject"} as ReviewAction, - Validate: {name:"Validate",action:"retry-validate"} as ReviewAction, - ResetValidating: {name:"Reset Validating (fix softlocked status)",action:"reset-validating"} as ReviewAction, - RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction, - Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction, - ResetUploading: {name:"Reset Uploading (fix softlocked status)",action:"reset-uploading"} as ReviewAction, -} - -interface ReviewButton { - action: ReviewAction, - mapfixId: string, - color: ButtonOwnProps["color"] -} - -interface ReviewId { - mapfixId: string, - mapfixStatus: number, - mapfixSubmitter: number, -} - -async function ReviewButtonClicked(action: string, 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 -} - -export default function ReviewButtons(props: ReviewId) { - // 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 - const { mapfixId, mapfixStatus } = props; - const [user, setUser] = useState(null); - const [roles, setRoles] = useState(RolesConstants.Empty); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchData() { - try { - const [rolesData, userData] = await Promise.all([ - fetch("/api/session/roles").then(rolesResponse => rolesResponse.json()), - fetch("/api/session/user").then(userResponse => userResponse.json()) - ]); - - setRoles(rolesData.Roles); - setUser(userData.UserID); - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - } - - fetchData(); - }, [mapfixId]); - - if (loading) return

Loading...

; - - const visibleButtons: ReviewButton[] = []; - - const is_submitter = user === props.mapfixSubmitter; - if (is_submitter) { - if ([MapfixStatus.UnderConstruction, MapfixStatus.ChangesRequested].includes(mapfixStatus!)) { - visibleButtons.push({ action: ReviewActions.Submit, color: "info", mapfixId }); - } - if ([MapfixStatus.Submitted, MapfixStatus.ChangesRequested].includes(mapfixStatus!)) { - visibleButtons.push({ action: ReviewActions.Revoke, color: "info", mapfixId }); - } - if (mapfixStatus === MapfixStatus.Submitting) { - visibleButtons.push({ action: ReviewActions.ResetSubmitting, color: "error", mapfixId }); - } - } - - if (roles&RolesConstants.MapfixReview) { - // you can force submit a map in ChangesRequested status - if (!is_submitter && mapfixStatus === MapfixStatus.ChangesRequested) { - visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", mapfixId }); - } - // you can't review your own mapfix! - // note that this means there needs to be more than one person with MapfixReview - if (!is_submitter && mapfixStatus === MapfixStatus.Submitted) { - visibleButtons.push({ action: ReviewActions.Accept, color: "info", mapfixId }); - visibleButtons.push({ action: ReviewActions.Reject, color: "error", mapfixId }); - } - if (mapfixStatus === MapfixStatus.AcceptedUnvalidated) { - visibleButtons.push({ action: ReviewActions.Validate, color: "info", mapfixId }); - } - if (mapfixStatus === MapfixStatus.Validating) { - visibleButtons.push({ action: ReviewActions.ResetValidating, color: "error", mapfixId }); - } - // this button serves the same purpose as Revoke if you are both - // the map submitter and have MapfixReview when status is Submitted - if ( - [MapfixStatus.Validated, MapfixStatus.AcceptedUnvalidated].includes(mapfixStatus!) - || !is_submitter && mapfixStatus == MapfixStatus.Submitted - ) { - visibleButtons.push({ action: ReviewActions.RequestChanges, color: "error", mapfixId }); - } - } - - if (roles&RolesConstants.MapfixUpload) { - if (mapfixStatus === MapfixStatus.Validated) { - visibleButtons.push({ action: ReviewActions.Upload, color: "info", mapfixId }); - } - if (mapfixStatus === MapfixStatus.Uploading) { - visibleButtons.push({ action: ReviewActions.ResetUploading, color: "error", mapfixId }); - } - } - - return ( -
- {visibleButtons.length === 0 ? ( -

No available actions

- ) : ( - visibleButtons.map((btn) => ( - - )) - )} -
- ); -} diff --git a/web/src/app/mapfixes/[mapfixId]/_window.tsx b/web/src/app/mapfixes/[mapfixId]/_window.tsx deleted file mode 100644 index 866b5a4..0000000 --- a/web/src/app/mapfixes/[mapfixId]/_window.tsx +++ /dev/null @@ -1,20 +0,0 @@ -interface WindowStruct { - className: string, - title: string, - children: React.ReactNode -} - -export default function Window(window: WindowStruct) { - return ( -
-
-

{window.title}

-
-
{window.children}
-
- ) -} - -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 index c7e5047..4af5e69 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -1,127 +1,420 @@ -"use client" +"use client"; -import { MapfixInfo, MapfixStatusToString } from "@/app/ts/Mapfix"; -import type { CreatorAndReviewStatus } from "./_comments"; -import { MapImage } from "./_mapImage"; -import { useParams } from "next/navigation"; -import ReviewButtons from "./_reviewButtons"; -import { Comments, Comment } from "./_comments"; -import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent"; +import {AuditEvent} from "@/app/ts/AuditEvent"; +import { Roles, RolesConstants } from "@/app/ts/Roles"; import Webpage from "@/app/_components/webpage"; +import { useParams, useRouter } from "next/navigation"; +import {useState, useEffect, useCallback} from "react"; import Link from "next/link"; -import { useState, useEffect } from "react"; -import "./(styles)/page.scss"; +// MUI Components +import { + Typography, + Box, + Container, + Breadcrumbs, + Paper, + Skeleton, + Grid, + CardMedia, + Snackbar, + Alert, +} from "@mui/material"; -interface ReviewId { - mapfixId: string, - mapfixStatus: number, - mapfixSubmitter: number, - mapfixAssetId: number, - mapfixTargetAssetId: number, +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAuditSection"; +import {ReviewItem} from "@/app/_components/review/ReviewItem"; +import {ErrorDisplay} from "@/app/_components/ErrorDisplay"; +import {MapfixInfo} from "@/app/ts/Mapfix"; +import ReviewButtons from "@/app/_components/review/ReviewButtons"; + +// Review action definitions + +interface SnackbarState { + open: boolean; + message: string | null; + severity: 'success' | 'error' | 'info' | 'warning'; } -function RatingArea(mapfix: ReviewId) { - return ( - - ) -} +export default function MapfixDetailsPage() { + const { mapfixId } = useParams<{ mapfixId: string }>(); + const router = useRouter(); -function TitleAndComments(stats: CreatorAndReviewStatus) { - const Review = MapfixStatusToString(stats.review) + const [mapfix, setMapfix] = useState(null); + const [auditEvents, setAuditEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newComment, setNewComment] = useState(""); + const [user, setUser] = useState(null); + const [roles, setRoles] = useState(RolesConstants.Empty); + const [showBeforeImage, setShowBeforeImage] = useState(false); + const [snackbar, setSnackbar] = useState({ + open: false, + message: null, + severity: 'success' + }); + const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { + setSnackbar({ + open: true, + message, + severity + }); + }; - // TODO: hide status message when status is not "Accepted" - return ( -
-
-

{stats.name}

- -
-

by {stats.creator}

-

Submitter {stats.submitter}

-

Model Asset ID {stats.asset_id}

-

Target Asset ID {stats.target_asset_id}

-

Description: {stats.description}

- - -
- ) -} + const handleCloseSnackbar = () => { + setSnackbar({ + ...snackbar, + open: false + }); + }; -export default function MapfixInfoPage() { - const { mapfixId } = useParams < { mapfixId: string } >() + const validatorUser = 9223372036854776000; - const [mapfix, setMapfix] = useState(null) - const [auditEvents, setAuditEvents] = useState([]) + const fetchData = useCallback(async (skipLoadingState = false) => { + try { + if (!skipLoadingState) { + setLoading(true); + } + setError(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/${mapfixId}`) - if (res.ok) { - setMapfix(await res.json()) + const [mapfixData, auditData, rolesData, userData] = await Promise.all([ + fetch(`/api/mapfixes/${mapfixId}`).then(res => { + if (!res.ok) throw new Error(`Failed to fetch mapfix: ${res.status}`); + return res.json(); + }), + fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`).then(res => { + if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`); + return res.json(); + }), + fetch("/api/session/roles").then(res => { + if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`); + return res.json(); + }), + fetch("/api/session/user").then(res => { + if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); + return res.json(); + }) + ]); + + setMapfix(mapfixData); + setAuditEvents(auditData); + setRoles(rolesData.Roles); + setUser(userData.UserID); + } catch (error) { + console.error("Error fetching data:", error); + setError(error instanceof Error ? error.message : "Failed to load mapfix details"); + } finally { + if (!skipLoadingState) { + setLoading(false); } } - async function getAuditEvents() { - const res = await fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`) - if (res.ok) { - setAuditEvents(await res.json()) + }, [mapfixId]); + + // Fetch mapfix data and audit events + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Handle review button actions + async function handleReviewAction(action: string, mapfixId: number) { + try { + const response = await fetch(`/api/mapfixes/${mapfixId}/status/${action}`, { + method: "POST", + headers: { + "Content-type": "application/json", + } + }); + + if (!response.ok) { + const errorDetails = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`); } - } - getMapfix() - getAuditEvents() - }, [mapfixId]) - const comments:Comment[] = auditEvents.map((auditEvent) => { - let username = auditEvent.Username; - if (auditEvent.User == 9223372036854776000) { - username = "[Validator]"; - } - if (username === "" && mapfix && auditEvent.User == mapfix.Submitter) { - username = "[Submitter]"; - } - return { - date: auditEvent.CreatedAt, - name: username, - comment: auditEventMessage(auditEvent), - } - }) + // Set success message based on the action + showSnackbar(`Successfully completed action: ${action}`, "success"); - if (!mapfix) { - return - {/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */} - + // Reload data instead of refreshing the page + fetchData(true); + } catch (error) { + console.error("Error updating mapfix status:", error); + showSnackbar(error instanceof Error ? error.message : "Failed to update mapfix", 'error'); + + + // Reload data instead of refreshing the page + fetchData(true); + } } - return ( - -
-
- - -
-
-
- ) -} + + const handleCopyId = (idToCopy: string) => { + navigator.clipboard.writeText(idToCopy); + showSnackbar('ID copied to clipboard', 'success'); + + }; + + const handleCommentSubmit = async () => { + if (!newComment.trim()) { + return; // Don't submit empty comments + } + + try { + const response = await fetch(`/api/mapfixes/${mapfixId}/comment`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: newComment, + }); + + if (!response.ok) { + throw new Error(`Failed to post comment: ${response.status}`); + } + + // Clear comment input + setNewComment(""); + + // Refresh audit events to show the new comment + const auditData = await fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`); + if (auditData.ok) { + const updatedAuditEvents = await auditData.json(); + setAuditEvents(updatedAuditEvents); + } + } catch (error) { + console.error("Error submitting comment:", error); + setError(error instanceof Error ? error.message : "Failed to submit comment"); + } + }; + + // Loading state + if (loading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + if (error || !mapfix) { + return ( + router.push('/mapfixes')} + /> + ); + } + return ( + + + {/* Breadcrumbs Navigation */} + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Mapfixes + + {mapfix.DisplayName} + + + + {/* Left Column - Image and Action Buttons */} + + + + {/* Before/After Images Container */} + + {/* Before Image */} + + + + + {/* After Image */} + + + + + {showBeforeImage ? ( + <> + + BEFORE + + + ) : ( + <> + + AFTER + + + )} + + + + + Click to compare + + + + setShowBeforeImage(!showBeforeImage)} + /> + + + + + + {/* Review Buttons */} + + + + {/* Right Column - Mapfix Details and Comments */} + + + + {/* Comments Section */} + + + + + + + {snackbar.message} + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index 7eba041..dccdc63 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -2,119 +2,124 @@ import { useState, useEffect } from "react"; import { MapfixList } from "../ts/Mapfix"; -import {MapCard} 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 { + Box, + Breadcrumbs, + CircularProgress, + Container, + Pagination, + Typography +} from "@mui/material"; import Link from "next/link"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; export default function MapfixInfoPage() { - const [mapfixes, setMapfixes] = useState(null) + const [mapfixes, setMapfixes] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); - const cardsPerPage = 24; // built to fit on a 1920x1080 monitor + const cardsPerPage = 24; useEffect(() => { const controller = new AbortController(); async function fetchMapFixes() { setIsLoading(true); - const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, { - signal: controller.signal, - }); - if (res.ok) { - setMapfixes(await res.json()); + try { + const res = await fetch( + `/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, + { signal: controller.signal } + ); + + if (res.ok) { + const data = await res.json(); + setMapfixes(data); + } else { + console.error("Failed to fetch mapfixes:", res.status); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + console.error("Error fetching mapfixes:", error); + } + } finally { + setIsLoading(false); } - setIsLoading(false); } fetchMapFixes(); - - return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes + return () => controller.abort(); }, [currentPage]); if (isLoading || !mapfixes) { - return -
- - - - Loading mapfixes... - - -
-
; + return ( + + + + + + Loading mapfixes... + + + + + ); } const totalPages = Math.ceil(mapfixes.Total / cardsPerPage); - const currentCards = mapfixes.Mapfixes; return ( -
- - - Home + + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home - Mapfixes + Mapfixes + Map Fixes + Explore all submitted fixes for maps from the community. -
- {currentCards.map((submission) => ( + {mapfixes.Mapfixes.map((mapfix) => ( ))} -
- -
+ + + {totalPages > 1 && ( + -
-
-
+
+ )} + - ) -} + ); +} \ No newline at end of file diff --git a/web/src/app/maps/(styles)/page.scss b/web/src/app/maps/(styles)/page.scss deleted file mode 100644 index 052bcda..0000000 --- a/web/src/app/maps/(styles)/page.scss +++ /dev/null @@ -1,43 +0,0 @@ -.maps-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Allows 4 cards per row */ - gap: 20px; - width: 100%; - max-width: 1200px; - padding: 20px; - margin: 0 auto; -} - -.map-card { - background: #1e1e1e; - border-radius: 10px; - overflow: hidden; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - transition: transform 0.2s ease-in-out; - - &:hover { - transform: scale(1.05); - } - - img { - width: 100%; - height: 200px; - object-fit: cover; /* Ensures the image covers the space without being cut off */ - } - - .map-info { - padding: 15px; - text-align: center; - - h2 { - font-size: 1.2rem; - font-weight: bold; - color: #ffffff; - } - - p { - font-size: 1rem; - color: #bbbbbb; - } - } -} diff --git a/web/src/app/maps/[mapId]/_mapImage.tsx b/web/src/app/maps/[mapId]/_mapImage.tsx deleted file mode 100644 index 84bbb10..0000000 --- a/web/src/app/maps/[mapId]/_mapImage.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Image from "next/image"; -import { MapInfo } from "@/app/ts/Map"; - -interface AssetID { - id: MapInfo["ID"]; -} - -function MapImage({ id }: AssetID) { - if (!id) { - return

Missing asset ID

; - } - - const imageUrl = `/thumbnails/asset/${id}`; - - return ( - Map Thumbnail - ); -} - -export { type AssetID, MapImage }; diff --git a/web/src/app/maps/[mapId]/fix/(styles)/page.scss b/web/src/app/maps/[mapId]/fix/(styles)/page.scss deleted file mode 100644 index 8c7dcf8..0000000 --- a/web/src/app/maps/[mapId]/fix/(styles)/page.scss +++ /dev/null @@ -1,54 +0,0 @@ -@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/page.tsx b/web/src/app/maps/[mapId]/fix/page.tsx index e5e7389..84c5d47 100644 --- a/web/src/app/maps/[mapId]/fix/page.tsx +++ b/web/src/app/maps/[mapId]/fix/page.tsx @@ -1,86 +1,278 @@ "use client" -import { Button, TextField } from "@mui/material" - +import React, { useState, useEffect } from "react"; +import { + Button, + TextField, + Box, + Container, + Typography, + Breadcrumbs, + CircularProgress, + Paper, + Grid, + Alert +} from "@mui/material"; import SendIcon from '@mui/icons-material/Send'; +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import Webpage from "@/app/_components/webpage"; import { useParams } from "next/navigation"; - -import "./(styles)/page.scss" +import Link from "next/link"; +import {MapInfo} from "@/app/ts/Map"; interface MapfixPayload { AssetID: number; TargetAssetID: number; Description: string; } -interface IdResponse { - OperationID: number; -} + +// Game ID mapping +const gameTypes: Record = { + 1: "Bhop", + 2: "Surf", + 5: "Flytrials" +}; export default function MapfixInfoPage() { - const dynamicId = useParams<{ mapId: string }>(); + const { mapId } = useParams<{ mapId: string }>(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [mapDetails, setMapDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + const fetchMapDetails = async () => { + try { + setIsLoading(true); + const response = await fetch(`/api/maps/${mapId}`); + + if (!response.ok) { + throw new Error(`Failed to fetch map details: ${response.statusText}`); + } + + const data = await response.json(); + setMapDetails(data); + } catch (error) { + console.error("Error fetching map details:", error); + setLoadError(error instanceof Error ? error.message : "Failed to load map details"); + } finally { + setIsLoading(false); + } + }; + + if (mapId) { + fetchMapDetails(); + } + }, [mapId]); + + // Get game type from game ID + const getGameType = (gameId: number | undefined): string => { + if (!gameId) return "Unknown"; + return gameTypes[gameId] || "Unknown"; + }; const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + setIsSubmitting(true); + setError(null); const form = event.currentTarget; const formData = new FormData(form); + const assetId = formData.get("asset-id") as string; + const description = formData.get("description") as string; + + // Validate required fields + if (!assetId || isNaN(Number(assetId))) { + setError("Please enter a valid Asset ID"); + setIsSubmitting(false); + return; + } + + if (!description) { + setError("Please provide a description for the mapfix"); + setIsSubmitting(false); + return; + } + const payload: MapfixPayload = { - AssetID: Number((formData.get("asset-id") as string) ?? "-1"), - TargetAssetID: Number(dynamicId.mapId), - Description: (formData.get("description") as string) ?? "unknown", // TEMPORARY! TODO: Change + AssetID: Number(assetId), + TargetAssetID: Number(mapId), + Description: description, }; - 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(`/operations/${id_response.OperationID}`) - + const { OperationID } = await response.json(); + window.location.assign(`/operations/${OperationID}`); } catch (error) { console.error("Error submitting data:", error); + setError(error instanceof Error ? error.message : "An unknown error occurred"); + setIsSubmitting(false); } }; return ( -
-
-

Submit Mapfix

- -
-
- {/* TODO: Add form data for mapfixes, such as changes they did, and any times that need to be deleted & what styles */} - - - - - -
+ + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Maps + + {mapDetails && ( + + {mapDetails.DisplayName} + + )} + Submit Mapfix + + + + + Submit Mapfix + + + Fill out the form below to submit a fix for the selected map + + + + + {loadError && ( + + {loadError} + + )} + + {error && ( + + {error} + + )} + + {isLoading ? ( + + + + ) : ( +
+ + {/* Map details section - disabled prefilled fields */} + + + Map Information + + + + + + + + + + + + + + + + + + + + + + Mapfix Details + + + + + + + + + + + + + + + +
+ )} +
+
- ) -} + ); +} \ No newline at end of file diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 6afe389..7892efd 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -1,103 +1,338 @@ -"use client" +"use client"; import { MapInfo } from "@/app/ts/Map"; -import { MapImage } from "./_mapImage"; import Webpage from "@/app/_components/webpage"; -import { useParams } from "next/navigation"; -import { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import React, { useState, useEffect } from "react"; import Link from "next/link"; +import { Snackbar, Alert } from "@mui/material"; // MUI Components import { Typography, Box, - Button as MuiButton, - Card, - CardContent, + Button, + Container, + Breadcrumbs, + Chip, + Grid, + Divider, + Paper, Skeleton, - ThemeProvider, - createTheme, - CssBaseline + Stack, + CardMedia, + Tooltip, + IconButton } from "@mui/material"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import PersonIcon from "@mui/icons-material/Person"; +import FlagIcon from "@mui/icons-material/Flag"; +import BugReportIcon from "@mui/icons-material/BugReport"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -interface ButtonProps { - name: string; - href: string; -} - -function Button({ name, href }: ButtonProps) { - return ( - - - {name} - - - ); -} - -const darkTheme = createTheme({ - palette: { - mode: "dark", - }, -}); - -export default function Map() { +export default function MapDetails() { const { mapId } = useParams(); + const router = useRouter(); const [map, setMap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); useEffect(() => { async function getMap() { - const res = await fetch(`/api/maps/${mapId}`); - if (res.ok) { - setMap(await res.json()); + try { + setLoading(true); + setError(null); + const res = await fetch(`/api/maps/${mapId}`); + if (!res.ok) { + throw new Error(`Failed to fetch map: ${res.status}`); + } + const data = await res.json(); + setMap(data); + } catch (error) { + console.error("Error fetching map details:", error); + setError(error instanceof Error ? error.message : "Failed to load map details"); + } finally { + setLoading(false); } } getMap(); }, [mapId]); - return ( - - - - {!map ? ( - - - - - - - - ) : ( - - - - - - Map Info - - Map ID: {mapId} - Display Name: {map.DisplayName} - Creator: {map.Creator} - Game ID: {map.GameID} - Release Date: {new Date(map.Date * 1000).toLocaleString()} - + + + + ); + } + + return ( + + + {/* Breadcrumbs Navigation */} + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Maps + + {loading ? "Loading..." : map?.DisplayName || "Map Details"} + + {loading ? ( + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + map && ( + <> + {/* Map Header */} + + + + {map.DisplayName} + + + {map.GameID && ( + + )} + + + + + + + Created by: {map.Creator} + + + + + + + {formatDate(map.Date)} + + + + + + + + ID: {mapId} + + + handleCopyId(mapId as string)} + sx={{ ml: 1 }} + > + + + + + + + + + + {/* Map Preview Section */} + + + + + + + {/* Map Details Section */} + + + Map Details + + + + + Display Name + {map.DisplayName} + + + + Creator + {map.Creator} + + + + Game Type + {getGameInfo(map.GameID).name} + + + + Release Date + {formatDate(map.Date)} + + + + Map ID + + {mapId} + + handleCopyId(mapId as string)} + sx={{ ml: 1 }} + > + + + + + + + + + + + + + + + ) + )} + + + + Map ID copied to clipboard! + + + - ); -} +} \ No newline at end of file diff --git a/web/src/app/maps/page.tsx b/web/src/app/maps/page.tsx index b93aa66..737e9c9 100644 --- a/web/src/app/maps/page.tsx +++ b/web/src/app/maps/page.tsx @@ -25,6 +25,7 @@ import { } from "@mui/material"; import {Search as SearchIcon} from "@mui/icons-material"; import Link from "next/link"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; interface Map { ID: number; @@ -156,12 +157,15 @@ export default function MapsPage() { - - - Home + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home - Maps + Maps Map Collection diff --git a/web/src/app/operations/[operationId]/(styles)/page.scss b/web/src/app/operations/[operationId]/(styles)/page.scss deleted file mode 100644 index aefa235..0000000 --- a/web/src/app/operations/[operationId]/(styles)/page.scss +++ /dev/null @@ -1,91 +0,0 @@ -.operation-status { - display: flex; - justify-content: center; - align-items: center; - height: 100vh; - background-color: #121212; - color: #e0e0e0; - - .operation-card { - width: 400px; - padding: 20px; - background: #1e1e1e; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); - border-radius: 8px; - - h5 { - margin-bottom: 15px; - font-weight: bold; - color: #ffffff; - } - - p { - margin: 5px 0; - color: #b0b0b0; - } - } - - .status-indicator { - display: flex; - align-items: center; - gap: 8px; - font-weight: bold; - margin-top: 10px; - - &.created { - color: #ffca28; - } - &.completed { - color: #66bb6a; - } - &.failed { - color: #ef5350; - } - - .status-icon { - width: 12px; - height: 12px; - border-radius: 50%; - display: inline-block; - - &.created { - background-color: #ffca28; - } - &.completed { - background-color: #66bb6a; - } - &.failed { - background-color: #ef5350; - } - } - } - - .MuiCircularProgress-root { - color: #90caf9; - } - - .submission-button { - margin-top: 20px; - display: flex; - justify-content: center; - width: 100%; - - button { - width: 100%; - height: 60px; - background-color: #66bb6a; - color: white; - font-size: 20px; - font-weight: bold; - padding: 20px; - border: none; - border-radius: 5px; - cursor: pointer; - transition: background 0.3s; - - &:hover { - background-color: #57a05a; - } - } - } -} \ No newline at end of file diff --git a/web/src/app/operations/[operationId]/page.tsx b/web/src/app/operations/[operationId]/page.tsx index 28181a9..050305d 100644 --- a/web/src/app/operations/[operationId]/page.tsx +++ b/web/src/app/operations/[operationId]/page.tsx @@ -1,12 +1,27 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import {useEffect, useState, useRef, ReactElement} from "react"; import { useParams, useRouter } from "next/navigation"; -import { CircularProgress, Typography, Card, CardContent, Button } from "@mui/material"; +import { + CircularProgress, + Typography, + Paper, + Box, + Container, + Button, + Chip, + Divider, + Alert, + Collapse, + IconButton +} from "@mui/material"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import PendingIcon from '@mui/icons-material/Pending'; import Webpage from "@/app/_components/webpage"; -import "./(styles)/page.scss"; - interface Operation { OperationID: number; Status: number; @@ -14,7 +29,7 @@ interface Operation { Owner: string; Date: number; Path: string; - } +} export default function OperationStatusPage() { const router = useRouter(); @@ -23,6 +38,7 @@ export default function OperationStatusPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [operation, setOperation] = useState(null); + const [expandStatusMessage, setExpandStatusMessage] = useState(false); const intervalRef = useRef(null); @@ -78,45 +94,163 @@ export default function OperationStatusPage() { } }; - const getStatusClass = (status: number) => getStatusText(status).toLowerCase(); + const getStatusColor = (status: number) => { + switch (status) { + case 0: + return "warning"; + case 1: + return "success"; + case 2: + return "error"; + default: + return "default"; + } + }; + + const getStatusIcon = (status: number): ReactElement | undefined => { + switch (status) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return undefined; + } + }; + + // Format the status message for better display + const formatStatusMessage = (message: string) => { + try { + // Check if message is JSON + const parsed = JSON.parse(message); + return JSON.stringify(parsed, null, 2); + } catch { + // Not valid JSON, return as is + return message; + } + }; return ( -
+ + + Operation Status + + {loading ? ( - - ) : error ? ( - {error} - ) : operation ? ( - - - Operation ID: {operation.OperationID} -
- - {getStatusText(operation.Status)} -
- Status Message: {operation.StatusMessage} - Owner: {operation.Owner} - Date: {new Date(operation.Date * 1000).toLocaleString()} - Path: {operation.Path} + + + + Loading operation details... + + + ) : error ? ( + + {error} + + ) : operation ? ( + + + + Operation #{operation.OperationID} + + + + + + + + + Owner: {operation.Owner} + + + Date: {new Date(operation.Date * 1000).toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + })} + + + + + + + Status Message + + setExpandStatusMessage(!expandStatusMessage)} + aria-label={expandStatusMessage ? "Collapse details" : "Expand details"} + > + {expandStatusMessage ? : } + + + + + +
+                    {formatStatusMessage(operation.StatusMessage)}
+                  
+
+
+ + {!expandStatusMessage && ( + + {operation.StatusMessage.length > 100 + ? `${operation.StatusMessage.substring(0, 100)}...` + : operation.StatusMessage} + + )} +
{operation.Status === 1 && ( -
- -
+ + + )} -
-
- ) : ( - No operation found. + + ) : ( + + No operation found with ID: {operationId} + )} -
+
); -} +} \ No newline at end of file diff --git a/web/src/app/submissions/(styles)/page.scss b/web/src/app/submissions/(styles)/page.scss deleted file mode 100644 index d54fd38..0000000 --- a/web/src/app/submissions/(styles)/page.scss +++ /dev/null @@ -1,75 +0,0 @@ -@forward "../../_components/styles/mapCard.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(220px, 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/submissions/[submissionId]/(styles)/page.scss b/web/src/app/submissions/[submissionId]/(styles)/page.scss deleted file mode 100644 index 4015f24..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page.scss +++ /dev/null @@ -1,19 +0,0 @@ -@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/submissions/[submissionId]/(styles)/page/commentWindow.scss b/web/src/app/submissions/[submissionId]/(styles)/page/commentWindow.scss deleted file mode 100644 index d04b140..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/commentWindow.scss +++ /dev/null @@ -1,56 +0,0 @@ -@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/submissions/[submissionId]/(styles)/page/comments.scss b/web/src/app/submissions/[submissionId]/(styles)/page/comments.scss deleted file mode 100644 index d74b8a6..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/comments.scss +++ /dev/null @@ -1,49 +0,0 @@ -$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/submissions/[submissionId]/(styles)/page/map.scss b/web/src/app/submissions/[submissionId]/(styles)/page/map.scss deleted file mode 100644 index 12469f0..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/map.scss +++ /dev/null @@ -1,19 +0,0 @@ -@use "../../../../globals.scss"; - -.map-image-area { - @include globals.border-with-radius; - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: auto; - margin-left: auto; - margin-right: auto; - border-radius: 12px; - overflow: hidden; - - > p { - text-align: center; - margin: 0; - } -} \ No newline at end of file diff --git a/web/src/app/submissions/[submissionId]/(styles)/page/ratingWindow.scss b/web/src/app/submissions/[submissionId]/(styles)/page/ratingWindow.scss deleted file mode 100644 index 770fe16..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/ratingWindow.scss +++ /dev/null @@ -1,43 +0,0 @@ -@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/submissions/[submissionId]/(styles)/page/review.scss b/web/src/app/submissions/[submissionId]/(styles)/page/review.scss deleted file mode 100644 index 2ca4539..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/review.scss +++ /dev/null @@ -1,46 +0,0 @@ -@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 { - height: 100%; - object-fit: contain - } -} \ No newline at end of file diff --git a/web/src/app/submissions/[submissionId]/(styles)/page/reviewButtons.scss b/web/src/app/submissions/[submissionId]/(styles)/page/reviewButtons.scss deleted file mode 100644 index ccc6ecd..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/reviewButtons.scss +++ /dev/null @@ -1,13 +0,0 @@ -@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/submissions/[submissionId]/(styles)/page/reviewStatus.scss b/web/src/app/submissions/[submissionId]/(styles)/page/reviewStatus.scss deleted file mode 100644 index e64bd43..0000000 --- a/web/src/app/submissions/[submissionId]/(styles)/page/reviewStatus.scss +++ /dev/null @@ -1,80 +0,0 @@ -$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/submissions/[submissionId]/_comments.tsx b/web/src/app/submissions/[submissionId]/_comments.tsx deleted file mode 100644 index 722194c..0000000 --- a/web/src/app/submissions/[submissionId]/_comments.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { SubmissionInfo } from "@/app/ts/Submission"; -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: SubmissionInfo["AssetID"], - creator: SubmissionInfo["DisplayName"], - review: SubmissionInfo["StatusID"], - submitter: SubmissionInfo["Submitter"], - uploaded_asset_id: SubmissionInfo["UploadedAssetID"], - 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 ( -
- {`${comment.name}'s -
-
-

{comment.name}

-

{comment.date}

-
-

{comment.comment}

-
-
- ); -} - -function LeaveAComment() { - return ( - - - - - ) -} - -export function Comments(stats: CommentersProps) { - return (<> -
- {stats.comments_data.comments.length===0 - &&

There are no comments.

- || stats.comments_data.comments.map(comment => ( - - ))} -
- - ) -} - -export { - type CreatorAndReviewStatus, - type Comment, -} diff --git a/web/src/app/submissions/[submissionId]/_mapImage.tsx b/web/src/app/submissions/[submissionId]/_mapImage.tsx deleted file mode 100644 index 96f9059..0000000 --- a/web/src/app/submissions/[submissionId]/_mapImage.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Image from "next/image"; -import { SubmissionInfo } from "@/app/ts/Submission"; - -interface AssetID { - id: SubmissionInfo["AssetID"]; -} - -function MapImage({ id }: AssetID) { - if (!id) { - return

Missing asset ID

; - } - - const imageUrl = `/thumbnails/asset/${id}`; - - return ( - Map Thumbnail - ); -} - -export { type AssetID, MapImage }; diff --git a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx deleted file mode 100644 index b388cf1..0000000 --- a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { Roles, RolesConstants } from "@/app/ts/Roles"; -import { SubmissionStatus } from "@/app/ts/Submission"; -import { Button, ButtonOwnProps } from "@mui/material"; -import { useState, useEffect } from "react"; - -interface ReviewAction { - name: string, - action: string, -} - -const ReviewActions = { - Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction, - AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction, - BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction, - ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction, - Revoke: {name:"Revoke",action:"revoke"} as ReviewAction, - Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction, - Reject: {name:"Reject",action:"reject"} as ReviewAction, - Validate: {name:"Validate",action:"retry-validate"} as ReviewAction, - ResetValidating: {name:"Reset Validating (fix softlocked status)",action:"reset-validating"} as ReviewAction, - RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction, - Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction, - ResetUploading: {name:"Reset Uploading (fix softlocked status)",action:"reset-uploading"} as ReviewAction, -} - -interface ReviewButton { - action: ReviewAction, - submissionId: string, - color: ButtonOwnProps["color"] -} - -interface ReviewId { - submissionId: string, - submissionStatus: number, - submissionSubmitter: number, -} - -async function ReviewButtonClicked(action: string, submissionId: string) { - try { - const response = await fetch(`/api/submissions/${submissionId}/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 submission status:", error); - } -} - -function ReviewButton(props: ReviewButton) { - return -} - -export default function ReviewButtons(props: ReviewId) { - // 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 - const { submissionId, submissionStatus } = props; - const [user, setUser] = useState(null); - const [roles, setRoles] = useState(RolesConstants.Empty); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchData() { - try { - const [rolesData, userData] = await Promise.all([ - fetch("/api/session/roles").then(rolesResponse => rolesResponse.json()), - fetch("/api/session/user").then(userResponse => userResponse.json()) - ]); - - setRoles(rolesData.Roles); - setUser(userData.UserID); - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - } - - fetchData(); - }, [submissionId]); - - if (loading) return

Loading...

; - - const visibleButtons: ReviewButton[] = []; - - const is_submitter = user === props.submissionSubmitter; - if (is_submitter) { - if ([SubmissionStatus.UnderConstruction, SubmissionStatus.ChangesRequested].includes(submissionStatus!)) { - visibleButtons.push({ action: ReviewActions.Submit, color: "info", submissionId }); - } - if ([SubmissionStatus.Submitted, SubmissionStatus.ChangesRequested].includes(submissionStatus!)) { - visibleButtons.push({ action: ReviewActions.Revoke, color: "info", submissionId }); - } - if (submissionStatus === SubmissionStatus.Submitting) { - visibleButtons.push({ action: ReviewActions.ResetSubmitting, color: "error", submissionId }); - } - } - - if (roles&RolesConstants.SubmissionReview) { - // you can force submit a map in ChangesRequested status - if (!is_submitter && submissionStatus === SubmissionStatus.ChangesRequested) { - visibleButtons.push({ action: ReviewActions.AdminSubmit, color: "error", submissionId }); - visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", submissionId }); - } - // you can't review your own submission! - // note that this means there needs to be more than one person with SubmissionReview - if (!is_submitter && submissionStatus === SubmissionStatus.Submitted) { - visibleButtons.push({ action: ReviewActions.Accept, color: "info", submissionId }); - visibleButtons.push({ action: ReviewActions.Reject, color: "error", submissionId }); - } - if (submissionStatus === SubmissionStatus.AcceptedUnvalidated) { - visibleButtons.push({ action: ReviewActions.Validate, color: "info", submissionId }); - } - if (submissionStatus === SubmissionStatus.Validating) { - visibleButtons.push({ action: ReviewActions.ResetValidating, color: "error", submissionId }); - } - // this button serves the same purpose as Revoke if you are both - // the map submitter and have SubmissionReview when status is Submitted - if ( - [SubmissionStatus.Validated, SubmissionStatus.AcceptedUnvalidated].includes(submissionStatus!) - || !is_submitter && submissionStatus == SubmissionStatus.Submitted - ) { - visibleButtons.push({ action: ReviewActions.RequestChanges, color: "error", submissionId }); - } - } - - if (roles&RolesConstants.SubmissionUpload) { - if (submissionStatus === SubmissionStatus.Validated) { - visibleButtons.push({ action: ReviewActions.Upload, color: "info", submissionId }); - } - // TODO: hide Reset buttons for 10 seconds - if (submissionStatus === SubmissionStatus.Uploading) { - visibleButtons.push({ action: ReviewActions.ResetUploading, color: "error", submissionId }); - } - } - - return ( -
- {visibleButtons.length === 0 ? ( -

No available actions

- ) : ( - visibleButtons.map((btn) => ( - - )) - )} -
- ); -} diff --git a/web/src/app/submissions/[submissionId]/_window.tsx b/web/src/app/submissions/[submissionId]/_window.tsx deleted file mode 100644 index 866b5a4..0000000 --- a/web/src/app/submissions/[submissionId]/_window.tsx +++ /dev/null @@ -1,20 +0,0 @@ -interface WindowStruct { - className: string, - title: string, - children: React.ReactNode -} - -export default function Window(window: WindowStruct) { - return ( -
-
-

{window.title}

-
-
{window.children}
-
- ) -} - -export { - type WindowStruct -} \ No newline at end of file diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index e2097d5..ec202a3 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -1,109 +1,316 @@ -"use client" +"use client"; -import { SubmissionInfo, SubmissionStatusToString } from "@/app/ts/Submission"; -import type { CreatorAndReviewStatus } from "./_comments"; -import { MapImage } from "./_mapImage"; -import { useParams } from "next/navigation"; -import ReviewButtons from "./_reviewButtons"; -import { Comments, Comment } from "./_comments"; -import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent"; +import { SubmissionInfo } from "@/app/ts/Submission"; +import {AuditEvent} from "@/app/ts/AuditEvent"; +import { Roles, RolesConstants } from "@/app/ts/Roles"; import Webpage from "@/app/_components/webpage"; +import { useParams, useRouter } from "next/navigation"; +import {useState, useEffect, useCallback} from "react"; import Link from "next/link"; -import { useState, useEffect } from "react"; -import "./(styles)/page.scss"; +// MUI Components +import { + Typography, + Box, + Container, + Breadcrumbs, + Paper, + Skeleton, + Grid, + CardMedia, + Snackbar, + Alert, +} from "@mui/material"; -interface ReviewId { - submissionId: string; - assetId: number; - submissionStatus: number; - submissionSubmitter: number, +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAuditSection"; +import {ReviewItem} from "@/app/_components/review/ReviewItem"; +import {ErrorDisplay} from "@/app/_components/ErrorDisplay"; +import ReviewButtons from "@/app/_components/review/ReviewButtons"; + +interface SnackbarState { + open: boolean; + message: string | null; + severity: 'success' | 'error' | 'info' | 'warning'; } -function RatingArea(submission: ReviewId) { - return ( - - ) -} +export default function SubmissionDetailsPage() { + const { submissionId } = useParams<{ submissionId: string }>(); + const router = useRouter(); -function TitleAndComments(stats: CreatorAndReviewStatus) { - const Review = SubmissionStatusToString(stats.review) + const [submission, setSubmission] = useState(null); + const [auditEvents, setAuditEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newComment, setNewComment] = useState(""); + const [user, setUser] = useState(null); + const [roles, setRoles] = useState(RolesConstants.Empty); + const [snackbar, setSnackbar] = useState({ + open: false, + message: null, + severity: 'success' + }); + const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { + setSnackbar({ + open: true, + message, + severity + }); + }; - // TODO: hide status message when status is not "Accepted" - return ( -
-
-

{stats.name}

- -
-

by {stats.creator}

-

Submitter {stats.submitter}

-

Model Asset ID {stats.asset_id}

-

Uploaded Asset ID {stats.uploaded_asset_id}

- - -
- ) -} + const handleCloseSnackbar = () => { + setSnackbar({ + ...snackbar, + open: false + }); + }; -export default function SubmissionInfoPage() { - const { submissionId } = useParams < { submissionId: string } >() - const [submission, setSubmission] = useState(null) - const [auditEvents, setAuditEvents] = useState([]) + const validatorUser = 9223372036854776000; - 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 getSubmission() { - const res = await fetch(`/api/submissions/${submissionId}`) - if (res.ok) { - setSubmission(await res.json()) + const fetchData = useCallback(async (skipLoadingState = false) => { + try { + if (!skipLoadingState) { + setLoading(true); + } + setError(null); + + const [submissionData, auditData, rolesData, userData] = await Promise.all([ + fetch(`/api/submissions/${submissionId}`).then(res => { + if (!res.ok) throw new Error(`Failed to fetch submission: ${res.status}`); + return res.json(); + }), + fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`).then(res => { + if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`); + return res.json(); + }), + fetch("/api/session/roles").then(res => { + if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`); + return res.json(); + }), + fetch("/api/session/user").then(res => { + if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); + return res.json(); + }) + ]); + + setSubmission(submissionData); + setAuditEvents(auditData); + setRoles(rolesData.Roles); + setUser(userData.UserID); + } catch (error) { + console.error("Error fetching data:", error); + setError(error instanceof Error ? error.message : "Failed to load submission details"); + } finally { + if (!skipLoadingState) { + setLoading(false); } } - async function getAuditEvents() { - const res = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`) - if (res.ok) { - setAuditEvents(await res.json()) + }, [submissionId]); + + // Fetch submission data and audit events + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Handle review button actions + async function handleReviewAction(action: string, submissionId: number) { + try { + const response = await fetch(`/api/submissions/${submissionId}/status/${action}`, { + method: "POST", + headers: { + "Content-type": "application/json", + } + }); + + if (!response.ok) { + const errorDetails = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`); } - } - getSubmission() - getAuditEvents() - }, [submissionId]) - const comments:Comment[] = auditEvents.map((auditEvent) => { - let username = auditEvent.Username; - if (auditEvent.User == 9223372036854776000) { - username = "[Validator]"; - } - if (username === "" && submission && auditEvent.User == submission.Submitter) { - username = "[Submitter]"; - } - return { - date: auditEvent.CreatedAt, - name: username, - comment: auditEventMessage(auditEvent), - } - }) + // Set success message based on the action + showSnackbar(`Successfully completed action: ${action}`, "success"); - if (!submission) { - return - {/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */} - + // Reload data instead of refreshing the page + fetchData(true); + } catch (error) { + console.error("Error updating submission status:", error); + showSnackbar(error instanceof Error ? error.message : "Failed to update submission", 'error'); + + + // Reload data instead of refreshing the page + fetchData(true); + } } - return ( - -
-
- - -
-
-
- ) -} + + const handleCopyId = (idToCopy: string) => { + navigator.clipboard.writeText(idToCopy); + showSnackbar('ID copied to clipboard', 'success'); + + }; + + const handleCommentSubmit = async () => { + if (!newComment.trim()) { + return; // Don't submit empty comments + } + + try { + const response = await fetch(`/api/submissions/${submissionId}/comment`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: newComment, + }); + + if (!response.ok) { + throw new Error(`Failed to post comment: ${response.status}`); + } + + // Clear comment input + setNewComment(""); + + // Refresh audit events to show the new comment + const auditData = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`); + if (auditData.ok) { + const updatedAuditEvents = await auditData.json(); + setAuditEvents(updatedAuditEvents); + } + } catch (error) { + console.error("Error submitting comment:", error); + setError(error instanceof Error ? error.message : "Failed to submit comment"); + } + }; + + // Loading state + if (loading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + if (error || !submission) { + return ( + router.push('/submissions')} + /> + ); + } + return ( + + + {/* Breadcrumbs Navigation */} + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Submissions + + {submission.DisplayName} + + + + {/* Left Column - Image and Action Buttons */} + + + {submission.AssetID ? ( + + ) : ( + + No image available + + )} + + + {/* Review Buttons */} + + + + {/* Right Column - Submission Details and Comments */} + + + + {/* Comments Section */} + + + + + + + {snackbar.message} + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index c81e090..4c226f9 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -1,109 +1,119 @@ 'use client' -import {useState, useEffect} from "react"; -import {SubmissionList} from "../ts/Submission"; -import {MapCard} from "../_components/mapCard"; +import { useState, useEffect } from "react"; +import { SubmissionList } from "../ts/Submission"; +import { MapCard } from "../_components/mapCard"; import Webpage from "@/app/_components/webpage"; - -import "./(styles)/page.scss"; -import {ListSortConstants} from "../ts/Sort"; -import {Breadcrumbs, Pagination, Typography, CircularProgress, Box, Container} from "@mui/material"; +import { ListSortConstants } from "../ts/Sort"; +import { + Box, + Breadcrumbs, + CircularProgress, + Container, + Pagination, + Typography +} from "@mui/material"; import Link from "next/link"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; export default function SubmissionInfoPage() { const [submissions, setSubmissions] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); - const cardsPerPage = 24; // built to fit on a 1920x1080 monitor + const cardsPerPage = 24; useEffect(() => { const controller = new AbortController(); async function fetchSubmissions() { setIsLoading(true); - const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, { - signal: controller.signal, - }); - if (res.ok) { - setSubmissions(await res.json()); + try { + const res = await fetch( + `/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, + { signal: controller.signal } + ); + + if (res.ok) { + const data = await res.json(); + setSubmissions(data); + } else { + console.error("Failed to fetch submissions:", res.status); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + console.error("Error fetching submissions:", error); + } + } finally { + setIsLoading(false); } - setIsLoading(false); } fetchSubmissions(); - - return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes + return () => controller.abort(); }, [currentPage]); if (isLoading || !submissions) { - return -
- - - - Loading submissions... - - -
-
; + return ( + + + + + + Loading submissions... + + + + + ); } const totalPages = Math.ceil(submissions.Total / cardsPerPage); - const currentCards = submissions.Submissions; if (submissions.Total === 0) { - return -
- Submissions list is empty. -
-
; + return ( + + + + Submissions list is empty. + + + + ); } return ( -
- - - Home + + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home - Submissions + Submissions + Submissions + Explore all submitted maps from the community. -
- {currentCards.map((submission) => ( + {submissions.Submissions.map((submission) => ( ))} -
- -
+ + + {totalPages > 1 && ( + -
-
-
+ + )} +
- ) -} + ); +} \ No newline at end of file diff --git a/web/src/app/submit/(styles)/page.scss b/web/src/app/submit/(styles)/page.scss deleted file mode 100644 index 23bf59a..0000000 --- a/web/src/app/submit/(styles)/page.scss +++ /dev/null @@ -1,54 +0,0 @@ -@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/submit/page.tsx b/web/src/app/submit/page.tsx index 99afdae..51c2963 100644 --- a/web/src/app/submit/page.tsx +++ b/web/src/app/submit/page.tsx @@ -1,13 +1,23 @@ "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 React, { useState } from "react"; - -import "./(styles)/page.scss" +import { + Button, + TextField, + Box, + Container, + Typography, + Breadcrumbs, + CircularProgress, + Paper, + Grid, + FormControl +} from "@mui/material"; +import SendIcon from '@mui/icons-material/Send'; +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import Webpage from "@/app/_components/webpage"; +import GameSelection from "./_game"; +import Link from "next/link"; interface SubmissionPayload { AssetID: number; @@ -15,76 +25,156 @@ interface SubmissionPayload { Creator: string; GameID: number; } -interface IdResponse { - OperationID: number; -} -export default function SubmissionInfoPage() { +export default function SubmitPage() { const [game, setGame] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + setIsSubmitting(true); + setError(null); const form = event.currentTarget; const formData = new FormData(form); + const assetId = formData.get("asset-id") as string; + const displayName = formData.get("display-name") as string; + const creator = formData.get("creator") as string; + + // Validate required fields + if (!assetId || isNaN(Number(assetId))) { + setError("Please enter a valid Asset ID"); + setIsSubmitting(false); + return; + } + const payload: SubmissionPayload = { - DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change - Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change + AssetID: Number(assetId), + DisplayName: displayName || "unknown", + Creator: creator || "unknown", GameID: game, - AssetID: Number((formData.get("asset-id") as string) ?? "-1"), }; - console.log(payload) - console.log(JSON.stringify(payload)) - try { - // Send the POST request const response = await fetch("/api/submissions", { 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 submission - window.location.assign(`/operations/${id_response.OperationID}`) - + const { OperationID } = await response.json(); + window.location.assign(`/operations/${OperationID}`); } catch (error) { console.error("Error submitting data:", error); + setError(error instanceof Error ? error.message : "An unknown error occurred"); + setIsSubmitting(false); } }; return ( -
-
-

Submit New Map

- -
-
- - - - - - - -
+ + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Maps + + Submit Map + + + + Submit New Map + + + Fill out the form below to submit a new map to the game + + + + {error && ( + + {error} + + )} + +
+ + + + + + + + + + + + + + + + + Select Game Type + + + + + + + + + +
+
+
- ) -} + ); +} \ No newline at end of file diff --git a/web/src/app/ts/AuditEvent.ts b/web/src/app/ts/AuditEvent.ts index f9882a1..d7b506f 100644 --- a/web/src/app/ts/AuditEvent.ts +++ b/web/src/app/ts/AuditEvent.ts @@ -54,7 +54,7 @@ export interface AuditEventDataError { // Full audit event type (mirroring the Go struct) export interface AuditEvent { Id: number; - CreatedAt: string; // ISO string, can convert to Date if needed + Date: number; User: number; Username: string; ResourceType: string; // Assuming this is a string enum or similar diff --git a/web/src/app/ts/Submission.ts b/web/src/app/ts/Submission.ts index 89d9cd8..4d7b18d 100644 --- a/web/src/app/ts/Submission.ts +++ b/web/src/app/ts/Submission.ts @@ -48,11 +48,11 @@ function SubmissionStatusToString(submission_status: SubmissionStatus): string { case SubmissionStatus.Validating: return "VALIDATING" case SubmissionStatus.AcceptedUnvalidated: - return "ACCEPTED, NOT VALIDATED" + return "SCRIPT REVIEW" case SubmissionStatus.ChangesRequested: return "CHANGES REQUESTED" case SubmissionStatus.Submitted: - return "SUBMITTED" + return "UNDER REVIEW" case SubmissionStatus.Submitting: return "SUBMITTING" case SubmissionStatus.UnderConstruction: