Ui refactor part 2 #183

Merged
itzaname merged 18 commits from feature/Ui-Rework-Pt2 into staging 2025-06-09 00:33:27 +00:00
56 changed files with 2621 additions and 2304 deletions

View File

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

View File

@@ -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 (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom>{title}</Typography>
<Typography variant="body1">{message}</Typography>
{buttonText && onButtonClick && (
<Button
variant="contained"
onClick={onButtonClick}
sx={{ mt: 3 }}
>
{buttonText}
</Button>
)}
</Paper>
</Container>
</Webpage>
);
}

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
>
<PersonIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
</Typography>
<DateDisplay date={event.Date} />
</Box>
<Typography variant="body2">{auditEventMessage(event)}</Typography>
</Box>
</Box>
);
}
interface DateDisplayProps {
date: number;
}
function DateDisplay({ date }: DateDisplayProps) {
return (
<Typography variant="caption" color="text.secondary">
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
</Typography>
</Tooltip>
</Typography>
);
}

View File

@@ -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 (
<Box role="tabpanel" hidden={activeTab !== 1}>
{activeTab === 1 && (
<Stack spacing={2}>
{filteredEvents.map((event, index) => (
<AuditEventItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))}
</Stack>
)}
</Box>
);
}

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
>
<PersonIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
</Typography>
<DateDisplay date={event.Date} />
</Box>
<Typography variant="body2">{decodeAuditEvent(event)}</Typography>
</Box>
</Box>
);
}
interface DateDisplayProps {
date: number;
}
function DateDisplay({ date }: DateDisplayProps) {
return (
<Typography variant="caption" color="text.secondary">
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
</Typography>
</Tooltip>
</Typography>
);
}

View File

@@ -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 (
<Paper sx={{ p: 3, mt: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
aria-label="comments and audit tabs"
>
<Tab label="Comments" />
<Tab label="Audit Events" />
</Tabs>
</Box>
<CommentsTabPanel
activeTab={activeTab}
auditEvents={auditEvents}
validatorUser={validatorUser}
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
/>
<AuditEventsTabPanel
activeTab={activeTab}
auditEvents={auditEvents}
validatorUser={validatorUser}
/>
</Paper>
);
}

View File

@@ -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 (
<Box role="tabpanel" hidden={activeTab !== 0}>
{activeTab === 0 && (
<>
<Stack spacing={2} sx={{ mb: 3 }}>
{commentEvents.map((event, index) => (
<CommentItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))}
</Stack>
<CommentInput
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
/>
</>
)}
</Box>
);
}
interface CommentInputProps {
newComment: string;
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
}
function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) {
return (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar
src={`/thumbnails/user/${userId}`}
/>
<TextField
fullWidth
multiline
rows={2}
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<IconButton
color="primary"
onClick={handleCommentSubmit}
disabled={!newComment.trim()}
>
<SendIcon />
</IconButton>
</Box>
);
}

View File

@@ -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 (
<Button color="inherit" component={Link} href={header.href}>
@@ -28,6 +42,10 @@ function HeaderButton(header: HeaderButton) {
}
export default function Header() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const handleLoginClick = () => {
window.location.href =
"/auth/oauth2/login?redirect=" + window.location.href;
@@ -45,6 +63,10 @@ export default function Header() {
setAnchorEl(null);
};
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
useEffect(() => {
async function getLoginInfo() {
try {
@@ -71,27 +93,83 @@ export default function Header() {
getLoginInfo();
}, []);
// Mobile navigation drawer content
const drawer = (
<Box onClick={handleDrawerToggle} sx={{ textAlign: 'center' }}>
<List>
{navItems.map((item) => (
<ListItem key={item.name} disablePadding>
<ListItemButton component={Link} href={item.href} sx={{ textAlign: 'center' }}>
<ListItemText primary={item.name} />
</ListItemButton>
</ListItem>
))}
{valid && user && (
<ListItem disablePadding>
<ListItemButton component={Link} href="/submit" sx={{ textAlign: 'center' }}>
<ListItemText primary="Submit Map" sx={{ color: 'success.main' }} />
</ListItemButton>
</ListItem>
)}
{!valid && (
<ListItem disablePadding>
<ListItemButton onClick={handleLoginClick} sx={{ textAlign: 'center' }}>
<ListItemText primary="Login" />
</ListItemButton>
</ListItem>
)}
{valid && user && (
<ListItem disablePadding>
<ListItemButton component={Link} href="/auth" sx={{ textAlign: 'center' }}>
<ListItemText primary="Manage Account" />
</ListItemButton>
</ListItem>
)}
</List>
</Box>
);
return (
<AppBar position="static">
<Toolbar>
<Box display="flex" flexGrow={1} gap={2}>
<HeaderButton name="Submissions" href="/submissions"/>
<HeaderButton name="Mapfixes" href="/mapfixes"/>
<HeaderButton name="Maps" href="/maps"/>
</Box>
{isMobile && (
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
)}
{/* Desktop navigation */}
{!isMobile && (
<Box display="flex" flexGrow={1} gap={2}>
{navItems.map((item) => (
<HeaderButton key={item.name} name={item.name} href={item.href} />
))}
</Box>
)}
{/* Spacer for mobile view */}
{isMobile && <Box sx={{ flexGrow: 1 }} />}
{/* Right side of nav */}
<Box display="flex" gap={2}>
{valid && user && (
{!isMobile && valid && user && (
<Button variant="outlined" color="success" component={Link} href="/submit">
Submit Map
</Button>
)}
{valid && user ? (
{!isMobile && valid && user ? (
<Box display="flex" alignItems="center">
<Button
onClick={handleMenuOpen}
color="inherit"
size="small"
style={{textTransform: "none"}}
style={{ textTransform: "none" }}
>
<Image
className="avatar"
@@ -100,7 +178,7 @@ export default function Header() {
priority={true}
src={user.AvatarURL}
alt={user.Username}
style={{marginRight: 8}}
style={{ marginRight: 8 }}
/>
<Typography variant="body1">{user.Username}</Typography>
</Button>
@@ -122,13 +200,46 @@ export default function Header() {
</MenuItem>
</Menu>
</Box>
) : (
) : !isMobile && (
<Button color="inherit" onClick={handleLoginClick}>
Login
</Button>
)}
{/* In mobile view, display just the avatar if logged in */}
{isMobile && valid && user && (
<IconButton
onClick={handleMenuOpen}
color="inherit"
size="small"
>
<Image
className="avatar"
width={28}
height={28}
priority={true}
src={user.AvatarURL}
alt={user.Username}
/>
</IconButton>
)}
</Box>
</Toolbar>
{/* Mobile drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile
}}
sx={{
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: 240 },
}}
>
{drawer}
</Drawer>
</AppBar>
);
}
}

View File

@@ -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 = <Pending fontSize="small"/>;
let label: string = 'Unknown';
switch (status) {
case 0:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Under Construction';
break;
case 1:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Changes Requested';
break;
case 2:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Submitting';
break;
case 3:
color = 'warning';
icon = <CheckCircle fontSize="small"/>;
label = 'Under Review';
break;
case 4:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Accepted Unvalidated';
break;
case 5:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Validating';
break;
case 6:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Validated';
break;
case 7:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Uploading';
break;
case 8:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Uploaded';
break;
case 9:
color = 'error';
icon = <Cancel fontSize="small"/>;
label = 'Rejected';
break;
case 10:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Released';
break;
default:
color = 'default';
icon = <Pending fontSize="small"/>;
label = 'Unknown';
break;
}
return (
<Chip
icon={icon}
label={label}
color={color}
size="small"
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
);
};
return (
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
<Box sx={{

View File

@@ -0,0 +1,38 @@
import { Typography, Box, IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
interface CopyableFieldProps {
label: string;
value: string | number | null | undefined;
onCopy: (value: string) => void;
placeholderText?: string;
}
export const CopyableField = ({
label,
value,
onCopy,
placeholderText = "Not assigned"
}: CopyableFieldProps) => {
const displayValue = value?.toString() || placeholderText;
return (
<>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{displayValue}</Typography>
{value && (
<Tooltip title="Copy ID">
<IconButton
size="small"
onClick={() => onCopy(value.toString())}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</>
);
};

View File

@@ -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";
itzaname marked this conversation as resolved Outdated

latent ai comment

latent ai comment

I did in fact create the type definition

I did in fact create the type definition
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<ReviewButtonsProps> = ({
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"
});
Quaternions marked this conversation as resolved
Review

SubmissionReview and MapfixReview are separate permissions: the logic and availability of certain actions are different on Mapfixes and Submissions. So Reviewer means SubmissionReview for submissions and MapfixReview for mapfixes, same with Upload permission.

Here's my best shot at a complete table:

When is each button visible?
Multiple buttons can be visible at once.

Action Has Role When Current Status is One of Additional Restructions
Submit Submitter UnderConstruction, ChangesRequested
Reset Submitting Submitter Submitting UpdatedAt is older than 10s
Revoke Submitter Submitted, ChangesRequested
Bypass Submit Review ChangesRequested
Accept Review Submitted
Validate Review Accepted
Reset Validating Review Validating UpdatedAt is older than 10s
Reject Review Submitted
Request Changes Review Validated, Accepted, Submitted
Upload Upload Validated
Reset Uploading Upload Uploading UpdatedAt is older than 10s
Admin Submit Review ChangesRequested Only on Submissions
SubmissionReview and MapfixReview are separate permissions: the logic and availability of certain actions are different on Mapfixes and Submissions. So Reviewer means SubmissionReview for submissions and MapfixReview for mapfixes, same with Upload permission. Here's my best shot at a complete table: When is each button visible? Multiple buttons can be visible at once. Action | Has Role | When Current Status is One of | Additional Restructions ---------------|-----------|-----------------------|--------- Submit | Submitter | UnderConstruction, ChangesRequested Reset Submitting| Submitter | Submitting | UpdatedAt is older than 10s Revoke | Submitter | Submitted, ChangesRequested Bypass Submit | Review | ChangesRequested Accept | Review | Submitted Validate | Review | Accepted Reset Validating| Review | Validating | UpdatedAt is older than 10s Reject | Review | Submitted Request Changes | Review | Validated, Accepted, Submitted Upload | Upload | Validated Reset Uploading | Upload | Uploading | UpdatedAt is older than 10s Admin Submit | Review | ChangesRequested | Only on Submissions
}
}
// 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 (
<Stack spacing={2} sx={{ mb: 3 }}>
{getVisibleButtons().map((button, index) => (
<Button
key={index}
variant="contained"
color={button.color}
fullWidth
onClick={() => onClick(button.action.action, item.ID)}
>
{button.action.name}
</Button>
))}
</Stack>
);
};
export default ReviewButtons;

View File

@@ -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' },
itzaname marked this conversation as resolved Outdated

Inconsistent with submission fields label above label: 'Submitter ID'

Inconsistent with submission fields label above `label: 'Submitter ID'`
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'TargetAssetID', label: 'Target Asset ID' },
];
}
return (
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 4 }}>
<ReviewItemHeader
displayName={item.DisplayName}
statusId={item.StatusID}
creator={item.Creator}
submitterId={item.Submitter}
/>
{/* Item Details */}
<Grid container spacing={2} sx={{ mt: 2 }}>
{fields.map((field) => (
<Grid item xs={12} sm={6} key={field.key}>
<CopyableField
label={field.label}
value={(item as never)[field.key]}
onCopy={handleCopyValue}
placeholderText={field.placeholder}
/>
</Grid>
))}
</Grid>
{/* Description Section */}
{isMapfix && item.Description && (
<div style={{ marginTop: 24 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Description
</Typography>
<Typography variant="body1">
{item.Description}
</Typography>
</div>
)}
</Paper>
);
}

View File

@@ -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 (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
{displayName}
</Typography>
<StatusChip status={statusId as number} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar
src={`/thumbnails/user/${submitterId}`}
sx={{ mr: 1, width: 24, height: 24 }}
/>
<Typography variant="body1">
by {creator || "Unknown Creator"}
</Typography>
</Box>
</>
);
};

View File

@@ -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 = <Pending fontSize="small"/>;
let label: string = 'Unknown';
switch (status) {
case 0:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Under Construction';
break;
case 1:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Changes Requested';
break;
case 2:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Submitting';
break;
case 3:
color = 'warning';
icon = <CheckCircle fontSize="small"/>;
label = 'Under Review';
break;
case 4:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Script Review';
break;
case 5:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Validating';
break;
case 6:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Validated';
break;
case 7:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Uploading';
break;
case 8:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Uploaded';
break;
case 9:
color = 'error';
icon = <Cancel fontSize="small"/>;
label = 'Rejected';
break;
case 10:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Released';
break;
default:
color = 'default';
icon = <Pending fontSize="small"/>;
label = 'Unknown';
break;
}
return (
<Chip
icon={icon}
label={label}
color={color}
size="small"
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="commenter" data-highlighted={IsBhopMaptest}>
<Image src={comment.picture as string} alt={`${comment.name}'s comment`}/>
<div className="details">
<header>
<p className="name">{comment.name}</p>
<p className="date">{comment.date}</p>
</header>
<p className="comment">{comment.comment}</p>
</div>
</div>
);
}
function LeaveAComment() {
return (
<Window title="Leave a Comment:" className="leave-comment-window">
<textarea name="comment-box" id="comment-text-field"></textarea>
<Button variant="outlined" endIcon={<SendIcon/>}>Submit</Button>
</Window>
)
}
export function Comments(stats: CommentersProps) {
return (<>
<section className="comments">
{stats.comments_data.comments.length===0
&& <p className="no-comments">There are no comments.</p>
|| stats.comments_data.comments.map(comment => (
<AddComment key={comment.name} name={comment.name} date={comment.date} comment={comment.comment}/>
))}
</section>
<LeaveAComment/>
</>)
}
export {
type CreatorAndReviewStatus,
type Comment,
}

View File

@@ -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 <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export {
type AssetID,
MapImage
}

View File

@@ -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 <Button
color={props.color}
variant="contained"
onClick={() => { ReviewButtonClicked(props.action.action, props.mapfixId) }}>{props.action.name}</Button>
}
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<number|null>(null);
const [roles, setRoles] = useState<Roles>(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 <p>Loading...</p>;
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 (
<section className="review-set">
{visibleButtons.length === 0 ? (
<p>No available actions</p>
) : (
visibleButtons.map((btn) => (
<ReviewButton key={btn.action.action} {...btn} />
))
)}
</section>
);
}

View File

@@ -1,20 +0,0 @@
interface WindowStruct {
className: string,
title: string,
children: React.ReactNode
}
export default function Window(window: WindowStruct) {
return (
<section className={window.className}>
<header>
<p>{window.title}</p>
</header>
<main>{window.children}</main>
</section>
)
}
export {
type WindowStruct
}

View File

@@ -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 (
<aside className="review-area">
<section className="map-image-area">
<div>
<p className="this-mapfix">This Mapfix:</p>
<MapImage id={mapfix.mapfixAssetId}/>
</div>
<div>
<p className="target-map">Target Map Being Fixed:</p>
<MapImage id={mapfix.mapfixTargetAssetId}/>
</div>
</section>
<ReviewButtons mapfixId={mapfix.mapfixId} mapfixStatus={mapfix.mapfixStatus} mapfixSubmitter={mapfix.mapfixSubmitter}/>
</aside>
)
}
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<MapfixInfo | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [showBeforeImage, setShowBeforeImage] = useState(false);
const [snackbar, setSnackbar] = useState<SnackbarState>({
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 (
<main className="review-info">
<div>
<h1>{stats.name}</h1>
<aside data-review-status={stats.review} className="review-status">
<p>{Review}</p>
</aside>
</div>
<p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p>
<p className="submitter">Submitter {stats.submitter}</p>
<p className="asset-id">Model Asset ID {stats.asset_id}</p>
<p className="target-asset-id">Target Asset ID {stats.target_asset_id}</p>
<p className="description">Description: {stats.description}</p>
<span className="spacer"></span>
<Comments comments_data={stats}/>
</main>
)
}
const handleCloseSnackbar = () => {
setSnackbar({
...snackbar,
open: false
});
};
export default function MapfixInfoPage() {
const { mapfixId } = useParams < { mapfixId: string } >()
const validatorUser = 9223372036854776000;
const [mapfix, setMapfix] = useState<MapfixInfo | null>(null)
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
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 <Webpage>
{/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */}
</Webpage>
// 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 (
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea mapfixId={mapfixId} mapfixStatus={mapfix.StatusID} mapfixSubmitter={mapfix.Submitter} mapfixAssetId={mapfix.AssetID} mapfixTargetAssetId={mapfix.TargetAssetID} />
<TitleAndComments
name={mapfix.DisplayName}
creator={mapfix.Creator}
review={mapfix.StatusID}
asset_id={mapfix.AssetID}
submitter={mapfix.Submitter}
target_asset_id={mapfix.TargetAssetID}
description={mapfix.Description}
comments={comments}
/>
</section>
</main>
</Webpage>
)
}
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 (
<Webpage>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 3 }}>
<Skeleton variant="text" width="60%" height={40} />
</Box>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={300} />
<Box sx={{ mt: 2 }}>
<Skeleton variant="rectangular" height={50} />
</Box>
</Grid>
<Grid item xs={12} md={8}>
<Skeleton variant="text" height={60} />
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" width="30%" />
<Box sx={{ mt: 4 }}>
<Skeleton variant="rectangular" height={200} />
</Box>
</Grid>
</Grid>
</Container>
</Webpage>
);
}
if (error || !mapfix) {
return (
<ErrorDisplay
title="Error Loading Mapfix"
message={error || "Mapfix not found"}
buttonText="Return to Mapfixes"
onButtonClick={() => router.push('/mapfixes')}
/>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/mapfixes" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Mapfixes</Typography>
</Link>
<Typography color="text.secondary">{mapfix.DisplayName}</Typography>
</Breadcrumbs>
<Grid container spacing={4}>
{/* Left Column - Image and Action Buttons */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
<Box sx={{ position: 'relative', width: '100%', aspectRatio: '1/1' }}>
{/* Before/After Images Container */}
<Box sx={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Before Image */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
opacity: showBeforeImage ? 1 : 0,
transition: 'opacity 0.5s ease-in-out'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${mapfix.TargetAssetID}`}
alt="Before Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
{/* After Image */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0,
opacity: showBeforeImage ? 0 : 1,
transition: 'opacity 0.5s ease-in-out'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${mapfix.AssetID}`}
alt="After Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box
sx={{
position: 'absolute',
top: 16,
left: 16,
zIndex: 2,
bgcolor: 'rgba(0,0,0,0.65)',
color: 'white',
padding: '6px 12px',
borderRadius: 3,
backdropFilter: 'blur(4px)',
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: 0.8,
transition: 'transform 0.3s ease-out',
transform: showBeforeImage ? 'translateY(0)' : 'translateY(0)',
}}
>
{showBeforeImage ? (
<>
<Typography variant="subtitle2" component="span" sx={{ fontWeight: 600 }}>
BEFORE
</Typography>
</>
) : (
<>
<Typography variant="subtitle2" component="span" sx={{ fontWeight: 600 }}>
AFTER
</Typography>
</>
)}
</Box>
<Box
sx={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
<Typography
variant="caption"
sx={{
color: 'white',
bgcolor: 'rgba(0,0,0,0.4)',
padding: '2px 8px',
borderRadius: 1,
backdropFilter: 'blur(2px)',
}}
>
Click to compare
</Typography>
</Box>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 3,
cursor: 'pointer',
'&:hover': {
background: 'linear-gradient(rgba(0,0,0,0.02), rgba(0,0,0,0.05))',
},
}}
onClick={() => setShowBeforeImage(!showBeforeImage)}
/>
</Box>
</Box>
</Paper>
{/* Review Buttons */}
<ReviewButtons
onClick={handleReviewAction}
item={mapfix}
userId={user}
roles={roles}
type="mapfix"/>
</Grid>
{/* Right Column - Mapfix Details and Comments */}
<Grid item xs={12} md={8}>
<ReviewItem
item={mapfix}
handleCopyValue={handleCopyId}
/>
{/* Comments Section */}
<CommentsAndAuditSection
auditEvents={auditEvents}
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
/>
</Grid>
</Grid>
<Snackbar
open={snackbar.open}
autoHideDuration={snackbar.severity === 'error' ? 6000 : 3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
onClose={handleCloseSnackbar}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

@@ -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<MapfixList|null>(null)
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
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 <Webpage>
<main
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress/>
<Typography variant="body1" style={{marginTop: '1rem'}}>
Loading mapfixes...
</Typography>
</Box>
</main>
</Webpage>;
return (
<Webpage>
<Container sx={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress />
<Typography variant="body1" sx={{ mt: 2 }}>
Loading mapfixes...
</Typography>
</Box>
</Container>
</Webpage>
);
}
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
const currentCards = mapfixes.Mapfixes;
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<main
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '1rem',
width: '100%',
maxWidth: '100vw',
boxSizing: 'border-box',
overflowX: 'hidden'
}}
>
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
<Box component="main" sx={{ width: '100%', px: 2 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="textPrimary">Mapfixes</Typography>
<Typography color="text.secondary">Mapfixes</Typography>
</Breadcrumbs>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Map Fixes
</Typography>
<Typography variant="subtitle1" color="text.secondary" mb={4}>
Explore all submitted fixes for maps from the community.
</Typography>
<div
<Box
className="grid"
style={{
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
gap: 3,
width: '100%',
}}
>
{currentCards.map((submission) => (
{mapfixes.Mapfixes.map((mapfix) => (
<MapCard
key={submission.ID}
id={submission.ID}
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
authorId={submission.Submitter}
rating={submission.StatusID}
statusID={submission.StatusID}
gameID={submission.GameID}
created={submission.CreatedAt}
key={mapfix.ID}
id={mapfix.ID}
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
authorId={mapfix.Submitter}
rating={mapfix.StatusID}
statusID={mapfix.StatusID}
gameID={mapfix.GameID}
created={mapfix.CreatedAt}
type="mapfix"
/>
))}
</div>
<Box display="flex" justifyContent="center" my={4}>
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
</Box>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" my={4}>
<Pagination
count={totalPages}
page={currentPage}
@@ -122,10 +127,10 @@ export default function MapfixInfoPage() {
variant="outlined"
shape="rounded"
/>
</div>
</Box>
</main>
</Box>
)}
</Box>
</Container>
</Webpage>
)
}
);
}

View File

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

View File

@@ -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 <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export { type AssetID, MapImage };

View File

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

View File

@@ -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<number, string> = {
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<string | null>(null);
const [mapDetails, setMapDetails] = useState<MapInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<Webpage>
<main>
<header>
<h1>Submit Mapfix</h1>
<span className="spacer form-spacer"></span>
</header>
<form onSubmit={handleSubmit}>
{/* TODO: Add form data for mapfixes, such as changes they did, and any times that need to be deleted & what styles */}
<TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID" variant="outlined"/>
<TextField className="form-field" id="description" name="description" label="Describe the Mapfix" variant="outlined"/>
<span className="spacer form-spacer"></span>
<Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{
width: "400px",
height: "50px",
marginInline: "auto"
}}>Create Mapfix</Button>
</form>
</main>
<Container maxWidth="lg" sx={{ mt: 4, mb: 8 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
{mapDetails && (
<Link href={`/maps/${mapId}`} passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">{mapDetails.DisplayName}</Typography>
</Link>
)}
<Typography color="text.secondary">Submit Mapfix</Typography>
</Breadcrumbs>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
Submit Mapfix
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Fill out the form below to submit a fix for the selected map
</Typography>
</Box>
<Paper elevation={2} sx={{ p: { xs: 2, md: 4 }, borderRadius: 2 }}>
{loadError && (
<Alert severity="error" sx={{ mb: 3 }}>
{loadError}
</Alert>
)}
{error && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography color="error.dark">{error}</Typography>
</Box>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<CircularProgress />
</Box>
) : (
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
{/* Map details section - disabled prefilled fields */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Map Information
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="map-id"
label="Map ID"
variant="outlined"
fullWidth
value={mapId}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="game-type"
label="Game Type"
variant="outlined"
fullWidth
value={getGameType(mapDetails?.GameID)}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="map-name"
label="Map Name"
variant="outlined"
fullWidth
value={mapDetails?.DisplayName || 'Unknown'}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="map-creator"
label="Creator"
variant="outlined"
fullWidth
value={mapDetails?.Creator || 'Unknown'}
disabled
/>
</Grid>
<Grid item xs={12}>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
Mapfix Details
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
required
id="asset-id"
name="asset-id"
label="New Asset ID"
variant="outlined"
fullWidth
helperText="Enter the unique identifier for your fixed map asset"
/>
</Grid>
<Grid item xs={12}>
<TextField
required
id="description"
name="description"
label="Description"
variant="outlined"
fullWidth
multiline
rows={4}
helperText="Describe the changes made in this mapfix"
/>
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={isSubmitting}
sx={{ width: 400, height: 50 }}
>
{isSubmitting ? <CircularProgress size={24} /> : "Create Mapfix"}
</Button>
</Grid>
</Grid>
</form>
)}
</Paper>
</Container>
</Webpage>
)
}
);
}

View File

@@ -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 (
<Link href={href} passHref>
<MuiButton variant="contained" color="primary" sx={{ mt: 2 }}>
{name}
</MuiButton>
</Link>
);
}
const darkTheme = createTheme({
palette: {
mode: "dark",
},
});
export default function Map() {
export default function MapDetails() {
const { mapId } = useParams();
const router = useRouter();
const [map, setMap] = useState<MapInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Webpage>
{!map ? (
<Card>
<CardContent>
<Skeleton variant="text" width={200} height={40} />
<Skeleton variant="text" width={300} />
<Skeleton variant="rectangular" height={200} />
</CardContent>
</Card>
) : (
<Box display="flex" flexDirection={{ xs: "column", md: "row" }} gap={4}>
<Box flex={1}>
<Card>
<CardContent>
<Typography variant="h5" gutterBottom>
Map Info
</Typography>
<Typography variant="body1"><strong>Map ID:</strong> {mapId}</Typography>
<Typography variant="body1"><strong>Display Name:</strong> {map.DisplayName}</Typography>
<Typography variant="body1"><strong>Creator:</strong> {map.Creator}</Typography>
<Typography variant="body1"><strong>Game ID:</strong> {map.GameID}</Typography>
<Typography variant="body1"><strong>Release Date:</strong> {new Date(map.Date * 1000).toLocaleString()}</Typography>
<Button name="Submit A Mapfix For This Map" href={`/maps/${mapId}/fix`} />
</CardContent>
</Card>
</Box>
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
<Box flex={1}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Map Preview
</Typography>
<MapImage id={map.ID} />
</CardContent>
</Card>
</Box>
</Box>
)}
const getGameInfo = (gameId: number) => {
switch (gameId) {
case 1:
return {
name: "Bhop",
color: "#2196f3" // blue
};
case 2:
return {
name: "Surf",
color: "#4caf50" // green
};
case 5:
return {
name: "Fly Trials",
color: "#ff9800" // orange
};
default:
return {
name: "Unknown",
color: "#9e9e9e" // gray
};
}
};
const handleSubmitMapfix = () => {
router.push(`/maps/${mapId}/fix`);
};
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
setCopySuccess(true);
};
const handleCloseSnackbar = () => {
setCopySuccess(false);
};
if (error) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: 'error.light',
color: 'error.contrastText'
}}
>
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
<Typography variant="body1">{error}</Typography>
<Button
variant="contained"
onClick={() => router.push('/maps')}
sx={{ mt: 3 }}
>
Return to Maps
</Button>
</Paper>
</Container>
</Webpage>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
</Breadcrumbs>
{loading ? (
<Box>
<Box sx={{ mb: 4 }}>
<Skeleton variant="text" width="60%" height={60} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
</Grid>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
</Grid>
</Grid>
</Box>
) : (
map && (
<>
{/* Map Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
{map.DisplayName}
</Typography>
{map.GameID && (
<Chip
label={getGameInfo(map.GameID).name}
sx={{
bgcolor: getGameInfo(map.GameID).color,
color: '#fff',
fontWeight: 'bold',
fontSize: '0.9rem',
height: 32
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
<strong>Created by:</strong> {map.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
{formatDate(map.Date)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">
<strong>ID:</strong> {mapId}
</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
</Box>
<Grid container spacing={3}>
{/* Map Preview Section */}
<Grid item xs={12} md={8}>
<Paper
elevation={3}
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${map.ID}`}
alt={`Preview of map: ${map.DisplayName}`}
sx={{
height: 400,
objectFit: 'cover',
}}
/>
</Paper>
</Grid>
{/* Map Details Section */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>Map Details</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
<Typography variant="body1">{map.DisplayName}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
<Typography variant="body1">{map.Creator}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Typography variant="body1">{formatDate(map.Date)}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{mapId}</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Stack>
</Paper>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Button
fullWidth
variant="contained"
color="primary"
startIcon={<BugReportIcon />}
onClick={handleSubmitMapfix}
size="large"
>
Submit a Mapfix
</Button>
</Paper>
</Grid>
</Grid>
</>
)
)}
<Snackbar
open={copySuccess}
autoHideDuration={3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
</Alert>
</Snackbar>
</Container>
</Webpage>
</ThemeProvider>
);
}
}

View File

@@ -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() {
<Webpage>
<Container maxWidth="lg" sx={{py: 6}}>
<Box mb={6}>
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="textPrimary">Maps</Typography>
<Typography color="text.secondary">Maps</Typography>
</Breadcrumbs>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Map Collection

View File

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

View File

@@ -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<string | null>(null);
const [operation, setOperation] = useState<Operation | null>(null);
const [expandStatusMessage, setExpandStatusMessage] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(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 <PendingIcon/>;
case 1:
return <CheckCircleIcon/>;
case 2:
return <ErrorIcon/>;
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 (
<Webpage>
<main className="operation-status">
<Container maxWidth="md" sx={{ py: 6 }}>
<Typography variant="h4" component="h1" fontWeight="bold" mb={4}>
Operation Status
</Typography>
{loading ? (
<CircularProgress />
) : error ? (
<Typography color="error">{error}</Typography>
) : operation ? (
<Card className="operation-card">
<CardContent>
<Typography variant="h5">Operation ID: {operation.OperationID}</Typography>
<div className={`status-indicator ${getStatusClass(operation.Status)}`}>
<span className={`status-icon ${getStatusClass(operation.Status)}`} />
{getStatusText(operation.Status)}
</div>
<Typography>Status Message: {operation.StatusMessage}</Typography>
<Typography>Owner: {operation.Owner}</Typography>
<Typography>Date: {new Date(operation.Date * 1000).toLocaleString()}</Typography>
<Typography>Path: {operation.Path}</Typography>
<Box display="flex" flexDirection="column" alignItems="center" my={8}>
<CircularProgress size={60} />
<Typography variant="body1" mt={2}>
Loading operation details...
</Typography>
</Box>
) : error ? (
<Alert severity="error" sx={{ my: 2 }}>
<Typography variant="body1">{error}</Typography>
</Alert>
) : operation ? (
<Paper
elevation={3}
sx={{
p: 3,
borderRadius: 2,
border: 1,
borderColor: 'divider'
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5">
Operation #{operation.OperationID}
</Typography>
<Chip
icon={getStatusIcon(operation.Status)}
label={getStatusText(operation.Status)}
color={getStatusColor(operation.Status) as "success" | "warning" | "error" | "default"}
variant="filled"
sx={{ fontWeight: 'bold', px: 1 }}
/>
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="body1" color="text.secondary" gutterBottom>
<strong>Owner:</strong> {operation.Owner}
</Typography>
<Typography variant="body1" color="text.secondary" gutterBottom>
<strong>Date:</strong> {new Date(operation.Date * 1000).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle1" fontWeight="bold">
Status Message
</Typography>
<IconButton
size="small"
onClick={() => setExpandStatusMessage(!expandStatusMessage)}
aria-label={expandStatusMessage ? "Collapse details" : "Expand details"}
>
{expandStatusMessage ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
<Collapse in={expandStatusMessage}>
<Paper
variant="outlined"
sx={{
mt: 1,
p: 2,
bgcolor: 'background.default',
maxHeight: '300px',
overflow: 'auto'
}}
>
<pre style={{ margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{formatStatusMessage(operation.StatusMessage)}
</pre>
</Paper>
</Collapse>
{!expandStatusMessage && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{operation.StatusMessage.length > 100
? `${operation.StatusMessage.substring(0, 100)}...`
: operation.StatusMessage}
</Typography>
)}
</Box>
{operation.Status === 1 && (
<div className="submission-button">
<Button
variant="contained"
color="success"
onClick={() => router.push(operation.Path)}
>
View Submission
</Button>
</div>
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
variant="contained"
color="primary"
size="large"
onClick={() => router.push(operation.Path)}
startIcon={<CheckCircleIcon />}
>
Next Step
</Button>
</Box>
)}
</CardContent>
</Card>
) : (
<Typography>No operation found.</Typography>
</Paper>
) : (
<Alert severity="info" sx={{ my: 2 }}>
<Typography variant="body1">No operation found with ID: {operationId}</Typography>
</Alert>
)}
</main>
</Container>
</Webpage>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="commenter" data-highlighted={IsBhopMaptest}>
<Image src={comment.picture as string} alt={`${comment.name}'s comment`}/>
<div className="details">
<header>
<p className="name">{comment.name}</p>
<p className="date">{comment.date}</p>
</header>
<p className="comment">{comment.comment}</p>
</div>
</div>
);
}
function LeaveAComment() {
return (
<Window title="Leave a Comment:" className="leave-comment-window">
<textarea name="comment-box" id="comment-text-field"></textarea>
<Button variant="outlined" endIcon={<SendIcon/>}>Submit</Button>
</Window>
)
}
export function Comments(stats: CommentersProps) {
return (<>
<section className="comments">
{stats.comments_data.comments.length===0
&& <p className="no-comments">There are no comments.</p>
|| stats.comments_data.comments.map(comment => (
<AddComment key={comment.name} name={comment.name} date={comment.date} comment={comment.comment}/>
))}
</section>
<LeaveAComment/>
</>)
}
export {
type CreatorAndReviewStatus,
type Comment,
}

View File

@@ -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 <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export { type AssetID, MapImage };

View File

@@ -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 <Button
color={props.color}
variant="contained"
onClick={() => { ReviewButtonClicked(props.action.action, props.submissionId) }}>{props.action.name}</Button>
}
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<number|null>(null);
const [roles, setRoles] = useState<Roles>(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 <p>Loading...</p>;
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 (
<section className="review-set">
{visibleButtons.length === 0 ? (
<p>No available actions</p>
) : (
visibleButtons.map((btn) => (
<ReviewButton key={btn.action.action} {...btn} />
))
)}
</section>
);
}

View File

@@ -1,20 +0,0 @@
interface WindowStruct {
className: string,
title: string,
children: React.ReactNode
}
export default function Window(window: WindowStruct) {
return (
<section className={window.className}>
<header>
<p>{window.title}</p>
</header>
<main>{window.children}</main>
</section>
)
}
export {
type WindowStruct
}

View File

@@ -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 (
<aside className="review-area">
<section className="map-image-area">
<MapImage id={submission.assetId}/>
</section>
<ReviewButtons submissionId={submission.submissionId} submissionStatus={submission.submissionStatus} submissionSubmitter={submission.submissionSubmitter}/>
</aside>
)
}
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<SubmissionInfo | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [snackbar, setSnackbar] = useState<SnackbarState>({
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 (
<main className="review-info">
<div>
<h1>{stats.name}</h1>
<aside data-review-status={stats.review} className="review-status">
<p>{Review}</p>
</aside>
</div>
<p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p>
<p className="submitter">Submitter {stats.submitter}</p>
<p className="asset-id">Model Asset ID {stats.asset_id}</p>
<p className="uploaded-asset-id">Uploaded Asset ID {stats.uploaded_asset_id}</p>
<span className="spacer"></span>
<Comments comments_data={stats}/>
</main>
)
}
const handleCloseSnackbar = () => {
setSnackbar({
...snackbar,
open: false
});
};
export default function SubmissionInfoPage() {
const { submissionId } = useParams < { submissionId: string } >()
const [submission, setSubmission] = useState<SubmissionInfo | null>(null)
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
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 <Webpage>
{/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */}
</Webpage>
// 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 (
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea assetId={submission.AssetID} submissionId={submissionId} submissionStatus={submission.StatusID} submissionSubmitter={submission.Submitter}/>
<TitleAndComments name={submission.DisplayName} creator={submission.Creator} review={submission.StatusID} asset_id={submission.AssetID} submitter={submission.Submitter} uploaded_asset_id={submission.UploadedAssetID} comments={comments}/>
</section>
</main>
</Webpage>
)
}
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 (
<Webpage>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 3 }}>
<Skeleton variant="text" width="60%" height={40} />
</Box>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={300} />
<Box sx={{ mt: 2 }}>
<Skeleton variant="rectangular" height={50} />
</Box>
</Grid>
<Grid item xs={12} md={8}>
<Skeleton variant="text" height={60} />
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" width="30%" />
<Box sx={{ mt: 4 }}>
<Skeleton variant="rectangular" height={200} />
</Box>
</Grid>
</Grid>
</Container>
</Webpage>
);
}
if (error || !submission) {
return (
<ErrorDisplay
title="Error Loading Submission"
message={error || "Submission not found"}
buttonText="Return to Submissions"
onButtonClick={() => router.push('/submissions')}
/>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/submissions" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Submissions</Typography>
</Link>
<Typography color="text.secondary">{submission.DisplayName}</Typography>
</Breadcrumbs>
<Grid container spacing={4}>
{/* Left Column - Image and Action Buttons */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
{submission.AssetID ? (
<CardMedia
component="img"
image={`/thumbnails/asset/${submission.AssetID}`}
alt="Map Thumbnail"
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
/>
) : (
<Box
sx={{
width: '100%',
aspectRatio: '1/1',
bgcolor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="body2" color="text.secondary">No image available</Typography>
</Box>
)}
</Paper>
{/* Review Buttons */}
<ReviewButtons
onClick={handleReviewAction}
item={submission}
userId={user}
roles={roles}
type="submission"/>
</Grid>
{/* Right Column - Submission Details and Comments */}
<Grid item xs={12} md={8}>
<ReviewItem
item={submission}
handleCopyValue={handleCopyId}
/>
{/* Comments Section */}
<CommentsAndAuditSection
auditEvents={auditEvents}
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
/>
</Grid>
</Grid>
<Snackbar
open={snackbar.open}
autoHideDuration={snackbar.severity === 'error' ? 6000 : 3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
onClose={handleCloseSnackbar}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

@@ -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<SubmissionList | null>(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 <Webpage>
<main
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress/>
<Typography variant="body1" style={{marginTop: '1rem'}}>
Loading submissions...
</Typography>
</Box>
</main>
</Webpage>;
return (
<Webpage>
<Container sx={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box display="flex" flexDirection="column" alignItems="center">
<CircularProgress />
<Typography variant="body1" sx={{ mt: 2 }}>
Loading submissions...
</Typography>
</Box>
</Container>
</Webpage>
);
}
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
const currentCards = submissions.Submissions;
if (submissions.Total === 0) {
return <Webpage>
<main>
Submissions list is empty.
</main>
</Webpage>;
return (
<Webpage>
<Container sx={{ py: 6 }}>
<Typography variant="body1">
Submissions list is empty.
</Typography>
</Container>
</Webpage>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<main
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '1rem',
width: '100%',
maxWidth: '100vw',
boxSizing: 'border-box',
overflowX: 'hidden'
}}
>
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
<Box component="main" sx={{ width: '100%', px: 2 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="textPrimary">Submissions</Typography>
<Typography color="text.secondary">Submissions</Typography>
</Breadcrumbs>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Submissions
</Typography>
<Typography variant="subtitle1" color="text.secondary" mb={4}>
Explore all submitted maps from the community.
</Typography>
<div
<Box
className="grid"
style={{
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
gap: 3,
width: '100%',
}}
>
{currentCards.map((submission) => (
{submissions.Submissions.map((submission) => (
<MapCard
key={submission.ID}
id={submission.ID}
@@ -118,9 +128,10 @@ export default function SubmissionInfoPage() {
type="submission"
/>
))}
</div>
<Box display="flex" justifyContent="center" my={4}>
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
</Box>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" my={4}>
<Pagination
count={totalPages}
page={currentPage}
@@ -128,10 +139,10 @@ export default function SubmissionInfoPage() {
variant="outlined"
shape="rounded"
/>
</div>
</Box>
</main>
</Box>
)}
</Box>
</Container>
</Webpage>
)
}
);
}

View File

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

View File

@@ -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<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
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 (
<Webpage>
<main>
<header>
<h1>Submit New Map</h1>
<span className="spacer form-spacer"></span>
</header>
<form onSubmit={handleSubmit}>
<TextField className="form-field" id="asset-id" name="asset-id" label="Asset ID (required)" variant="outlined"/>
<TextField className="form-field" id="display-name" name="display-name" label="Display Name" variant="outlined"/>
<TextField className="form-field" id="creator" name="creator" label="Creator" variant="outlined"/>
<GameSelection game={game} setGame={setGame} />
<span className="spacer form-spacer"></span>
<Button type="submit" variant="contained" startIcon={<SendIcon/>} sx={{
width: "400px",
height: "50px",
marginInline: "auto"
}}>Create Submission</Button>
</form>
</main>
<Container maxWidth="lg" sx={{ mt: 4, mb: 8 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">Submit Map</Typography>
</Breadcrumbs>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
Submit New Map
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Fill out the form below to submit a new map to the game
</Typography>
</Box>
<Paper elevation={2} sx={{ p: { xs: 2, md: 4 }, borderRadius: 2 }}>
{error && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography color="error.dark">{error}</Typography>
</Box>
)}
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
required
id="asset-id"
name="asset-id"
label="Asset ID"
variant="outlined"
fullWidth
helperText="Enter the unique identifier for your map asset"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="display-name"
name="display-name"
label="Display Name"
variant="outlined"
fullWidth
helperText="The name that will be shown to users"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
id="creator"
name="creator"
label="Creator"
variant="outlined"
fullWidth
helperText="Name of the map creator or team"
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Select Game Type
</Typography>
<GameSelection game={game} setGame={setGame} />
</FormControl>
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
startIcon={isSubmitting ? <CircularProgress size={20} color="inherit" /> : <SendIcon />}
disabled={isSubmitting}
sx={{
py: 1.5,
px: 4,
minWidth: 200
}}
>
{isSubmitting ? "Submitting..." : "Submit Map"}
</Button>
</Grid>
</Grid>
</form>
</Paper>
</Container>
</Webpage>
)
}
);
}

View File

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

View File

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