Rework submission view

This commit is contained in:
2025-06-08 14:57:17 -04:00
parent 6cdab0e5cd
commit 269fbd7bbf
15 changed files with 1007 additions and 179 deletions

View File

@@ -13,18 +13,19 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"date-fns": "^4.1.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.82.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^20.17.9",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0"
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,44 @@
// In a new file src/app/_components/ErrorDisplay.tsx
import { Button, Container, Paper, Typography } from "@mui/material";
import Webpage from "@/app/_components/webpage";
interface ErrorDisplayProps {
title: string;
message: string;
buttonText?: string;
onButtonClick?: () => void;
}
export function ErrorDisplay({
title,
message,
buttonText,
onButtonClick
}: ErrorDisplayProps) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom>{title}</Typography>
<Typography variant="body1">{message}</Typography>
{buttonText && onButtonClick && (
<Button
variant="contained"
onClick={onButtonClick}
sx={{ mt: 3 }}
>
{buttonText}
</Button>
)}
</Paper>
</Container>
</Webpage>
);
}

View File

@@ -0,0 +1,53 @@
// AuditEventItem.tsx
import React from 'react';
import {
Box,
Avatar,
Typography,
Tooltip
} from "@mui/material";
import PersonIcon from '@mui/icons-material/Person';
import { formatDistanceToNow, format } from "date-fns";
import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent";
interface AuditEventItemProps {
event: AuditEvent;
validatorUser: number;
}
export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) {
return (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
>
<PersonIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
</Typography>
<DateDisplay date={event.Date} />
</Box>
<Typography variant="body2">{auditEventMessage(event)}</Typography>
</Box>
</Box>
);
}
interface DateDisplayProps {
date: number;
}
function DateDisplay({ date }: DateDisplayProps) {
return (
<Typography variant="caption" color="text.secondary">
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
</Typography>
</Tooltip>
</Typography>
);
}

View File

@@ -0,0 +1,40 @@
// AuditEventsTabPanel.tsx
import React from 'react';
import {
Box,
Stack,
} from "@mui/material";
import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent";
import AuditEventItem from './AuditEventItem';
interface AuditEventsTabPanelProps {
activeTab: number;
auditEvents: AuditEvent[];
validatorUser: number;
}
export default function AuditEventsTabPanel({
activeTab,
auditEvents,
validatorUser
}: AuditEventsTabPanelProps) {
const filteredEvents = auditEvents.filter(
event => event.EventType !== AuditEventType.Comment
);
return (
<Box role="tabpanel" hidden={activeTab !== 1}>
{activeTab === 1 && (
<Stack spacing={2}>
{filteredEvents.map((event, index) => (
<AuditEventItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))}
</Stack>
)}
</Box>
);
}

View File

@@ -0,0 +1,53 @@
// CommentItem.tsx
import React from 'react';
import {
Box,
Avatar,
Typography,
Tooltip
} from "@mui/material";
import PersonIcon from '@mui/icons-material/Person';
import { formatDistanceToNow, format } from "date-fns";
import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent";
interface CommentItemProps {
event: AuditEvent;
validatorUser: number;
}
export default function CommentItem({ event, validatorUser }: CommentItemProps) {
return (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
>
<PersonIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
</Typography>
<DateDisplay date={event.Date} />
</Box>
<Typography variant="body2">{decodeAuditEvent(event)}</Typography>
</Box>
</Box>
);
}
interface DateDisplayProps {
date: number;
}
function DateDisplay({ date }: DateDisplayProps) {
return (
<Typography variant="caption" color="text.secondary">
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
<Typography variant="caption" color="text.secondary">
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
</Typography>
</Tooltip>
</Typography>
);
}

View File

@@ -0,0 +1,63 @@
// CommentsAndAuditSection.tsx
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;
}
export default function CommentsAndAuditSection({
auditEvents,
newComment,
setNewComment,
handleCommentSubmit,
validatorUser
}: 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}
/>
<AuditEventsTabPanel
activeTab={activeTab}
auditEvents={auditEvents}
validatorUser={validatorUser}
/>
</Paper>
);
}

View File

@@ -0,0 +1,90 @@
// CommentsTabPanel.tsx
import React from 'react';
import {
Box,
Stack,
Avatar,
TextField,
IconButton
} from "@mui/material";
import PersonIcon from '@mui/icons-material/Person';
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;
}
export default function CommentsTabPanel({
activeTab,
auditEvents,
validatorUser,
newComment,
setNewComment,
handleCommentSubmit
}: 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}
/>
</>
)}
</Box>
);
}
interface CommentInputProps {
newComment: string;
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
}
function CommentInput({ newComment, setNewComment, handleCommentSubmit }: CommentInputProps) {
return (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar>
<PersonIcon />
</Avatar>
<TextField
fullWidth
multiline
rows={2}
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<IconButton
color="primary"
onClick={handleCommentSubmit}
disabled={!newComment.trim()}
>
<SendIcon />
</IconButton>
</Box>
);
}

View File

@@ -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 {StatusChip} from "@/app/_components/statusChip";
interface MapCardProps {
displayName: string;
@@ -18,89 +19,6 @@ interface MapCardProps {
const CARD_WIDTH = 270;
export function MapCard(props: MapCardProps) {
const StatusChip = ({status}: { status: number }) => {
let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
let icon: JSX.Element = <Pending fontSize="small"/>;
let label: string = 'Unknown';
switch (status) {
case 0:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Under Construction';
break;
case 1:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Changes Requested';
break;
case 2:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Submitting';
break;
case 3:
color = 'warning';
icon = <CheckCircle fontSize="small"/>;
label = 'Under Review';
break;
case 4:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Accepted Unvalidated';
break;
case 5:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Validating';
break;
case 6:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Validated';
break;
case 7:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Uploading';
break;
case 8:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Uploaded';
break;
case 9:
color = 'error';
icon = <Cancel fontSize="small"/>;
label = 'Rejected';
break;
case 10:
color = 'success';
icon = <CheckCircle fontSize="small"/>;
label = 'Released';
break;
default:
color = 'default';
icon = <Pending fontSize="small"/>;
label = 'Unknown';
break;
}
return (
<Chip
icon={icon}
label={label}
color={color}
size="small"
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/>
);
};
return (
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
<Box sx={{

View File

@@ -0,0 +1,42 @@
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;
const handleCopy = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
};
return (
<>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{displayValue}</Typography>
{value && (
<Tooltip title="Copy ID">
<IconButton
size="small"
onClick={() => onCopy(value.toString())}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</>
);
};

View File

@@ -0,0 +1,85 @@
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' },
{ 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}
/>
{/* 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 any)[field.key]}
onCopy={handleCopyValue}
placeholderText={field.placeholder}
/>
</Grid>
))}
</Grid>
{/* Description Section */}
{isMapfix && item.Description && (
<div style={{ marginTop: 24 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Description
</Typography>
<Typography variant="body1">
{item.Description}
</Typography>
</div>
)}
</Paper>
);
}

View File

@@ -0,0 +1,33 @@
import { Typography, Box } from "@mui/material";
import { StatusChip } from "@/app/_components/statusChip";
import PersonIcon from '@mui/icons-material/Person';
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;
}
export const ReviewItemHeader = ({ displayName, statusId, creator }: 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 }}>
<PersonIcon sx={{ mr: 1, color: 'text.secondary' }} />
<Typography variant="body1">
by {creator || "Unknown Creator"}
</Typography>
</Box>
</>
);
};

View File

@@ -0,0 +1,87 @@
import React, {JSX} from "react";
import {Cancel, CheckCircle, Pending} from "@mui/icons-material";
import {Chip} from "@mui/material";
export const StatusChip = ({status}: { status: number }) => {
let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
let icon: JSX.Element = <Pending fontSize="small"/>;
let label: string = 'Unknown';
switch (status) {
case 0:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Under Construction';
break;
case 1:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = 'Changes Requested';
break;
case 2:
color = 'info';
icon = <Pending fontSize="small"/>;
label = 'Submitting';
break;
case 3:
color = 'warning';
icon = <CheckCircle fontSize="small"/>;
label = 'Under Review';
break;
case 4:
color = 'warning';
icon = <Pending fontSize="small"/>;
label = '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)',
}}
/>
);
};

View File

@@ -1,109 +1,428 @@
"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, SubmissionStatus } from "@/app/ts/Submission";
import {AuditEvent} from "@/app/ts/AuditEvent";
import { Roles, RolesConstants, hasRole } from "@/app/ts/Roles";
import Webpage from "@/app/_components/webpage";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import Link from "next/link";
import "./(styles)/page.scss";
// MUI Components
import {
Typography,
Box,
Button,
Container,
Breadcrumbs,
Paper,
Skeleton,
Grid,
Stack,
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";
// Review action definitions
interface ReviewAction {
name: string,
action: string,
}
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>
)
interface SnackbarState {
open: boolean;
message: string | null;
severity: 'success' | 'error' | 'info' | 'warning';
}
function TitleAndComments(stats: CreatorAndReviewStatus) {
const Review = SubmissionStatusToString(stats.review)
// TODO: hide status message when status is not "Accepted"
return (
<main className="review-info">
<div>
<h1>{stats.name}</h1>
<aside data-review-status={stats.review} className="review-status">
<p>{Review}</p>
</aside>
</div>
<p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p>
<p className="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 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",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,
}
export default function SubmissionInfoPage() {
const { submissionId } = useParams < { submissionId: string } >()
export default function SubmissionDetailsPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const router = useRouter();
const [submission, setSubmission] = useState<SubmissionInfo | null>(null)
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
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
});
};
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 handleCloseSnackbar = () => {
setSnackbar({
...snackbar,
open: false
});
};
const validatorUser = 9223372036854776000;
async function fetchData(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())
}
}
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),
}
})
if (!submission) {
return <Webpage>
{/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */}
</Webpage>
}
return (
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea 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>
)
}
// Fetch submission data and audit events
useEffect(() => {
fetchData();
}, [submissionId]);
// Handle review button actions
async function handleReviewAction(action: string, submissionId: string) {
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}`);
}
// Set success message based on the action
showSnackbar(`Successfully completed action: ${action}`, "success");
// 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);
}
}
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");
}
};
// Determine which review buttons to show based on user roles and submission status
const getVisibleButtons = () => {
if (!submission || user === null) return [];
// Define a type for the button
type ReviewButton = {
action: ReviewAction;
color: "primary" | "error" | "success" | "info" | "warning";
};
const buttons: ReviewButton[] = [];
const is_submitter = user === submission.Submitter;
if (is_submitter) {
if ([SubmissionStatus.UnderConstruction, SubmissionStatus.ChangesRequested].includes(submission.StatusID)) {
buttons.push({
action: ReviewActions.Submit,
color: "primary"
});
}
if ([SubmissionStatus.Submitted, SubmissionStatus.ChangesRequested].includes(submission.StatusID)) {
buttons.push({
action: ReviewActions.Revoke,
color: "error"
});
}
}
if (hasRole(roles, RolesConstants.SubmissionReview)) {
if (submission.StatusID === SubmissionStatus.Submitted) {
buttons.push(
{
action: ReviewActions.Accept,
color: "success"
},
{
action: ReviewActions.Reject,
color: "error"
}
);
}
if (submission.StatusID === SubmissionStatus.AcceptedUnvalidated) {
buttons.push({
action: ReviewActions.Validate,
color: "info"
});
}
if (submission.StatusID === SubmissionStatus.Validating) {
buttons.push({
action: ReviewActions.ResetValidating,
color: "warning"
});
}
if ([SubmissionStatus.Validated, SubmissionStatus.AcceptedUnvalidated, SubmissionStatus.Submitted].includes(submission.StatusID)) {
buttons.push({
action: ReviewActions.RequestChanges,
color: "warning"
});
}
}
if (hasRole(roles, RolesConstants.SubmissionUpload)) {
if (submission.StatusID === SubmissionStatus.Validated) {
buttons.push({
action: ReviewActions.Upload,
color: "success"
});
}
if (submission.StatusID === SubmissionStatus.Uploading) {
buttons.push({
action: ReviewActions.ResetUploading,
color: "warning"
});
}
}
return buttons;
};
// 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 */}
<Stack spacing={2} sx={{ mb: 3 }}>
{getVisibleButtons().map((button, index) => (
<Button
key={index}
variant="contained"
color={button.color}
fullWidth
onClick={() => handleReviewAction(button.action.action, submissionId as string)}
>
{button.action.name}
</Button>
))}
</Stack>
</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}
/>
</Grid>
</Grid>
<Snackbar
open={snackbar.open}
autoHideDuration={snackbar.severity === 'error' ? 6000 : 3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
onClose={handleCloseSnackbar}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

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

0
web/src/app/ts/Game.ts Normal file
View File