Ui refactor part 2 #183
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
43
web/src/app/_components/ErrorDisplay.tsx
Normal file
43
web/src/app/_components/ErrorDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/app/_components/comments/AuditEventItem.tsx
Normal file
52
web/src/app/_components/comments/AuditEventItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
web/src/app/_components/comments/AuditEventsTabPanel.tsx
Normal file
39
web/src/app/_components/comments/AuditEventsTabPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/app/_components/comments/CommentItem.tsx
Normal file
52
web/src/app/_components/comments/CommentItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
web/src/app/_components/comments/CommentsAndAuditSection.tsx
Normal file
65
web/src/app/_components/comments/CommentsAndAuditSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
web/src/app/_components/comments/CommentsTabPanel.tsx
Normal file
92
web/src/app/_components/comments/CommentsTabPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
38
web/src/app/_components/review/CopyableField.tsx
Normal file
38
web/src/app/_components/review/CopyableField.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
174
web/src/app/_components/review/ReviewButtons.tsx
Normal file
174
web/src/app/_components/review/ReviewButtons.tsx
Normal 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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
Quaternions
commented
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?
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;
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
86
web/src/app/_components/review/ReviewItem.tsx
Normal file
86
web/src/app/_components/review/ReviewItem.tsx
Normal 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
Quaternions
commented
Inconsistent with submission fields label above 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>
|
||||
);
|
||||
}
|
||||
36
web/src/app/_components/review/ReviewItemHeader.tsx
Normal file
36
web/src/app/_components/review/ReviewItemHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
web/src/app/_components/statusChip.tsx
Normal file
87
web/src/app/_components/statusChip.tsx
Normal 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)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user
latent ai comment
I did in fact create the type definition