Deploy dashboard update #299

Merged
Quaternions merged 9 commits from staging into master 2025-12-27 05:41:53 +00:00
12 changed files with 1811 additions and 80 deletions

View File

@@ -34,7 +34,7 @@ services:
"--data-rpc-host","dataservice:9000",
]
env_file:
- ~/auth-compose/strafesnet_staging.env
- /home/quat/auth-compose/strafesnet_staging.env
depends_on:
- authrpc
- nats
@@ -59,7 +59,7 @@ services:
maptest-validator
container_name: validation
env_file:
- ~/auth-compose/strafesnet_staging.env
- /home/quat/auth-compose/strafesnet_staging.env
environment:
- ROBLOX_GROUP_ID=17032139 # "None" is special case string value
- API_HOST_INTERNAL=http://submissions:8083/v1
@@ -105,7 +105,7 @@ services:
- REDIS_ADDR=authredis:6379
- RBX_GROUP_ID=17032139
env_file:
- ~/auth-compose/auth-service.env
- /home/quat/auth-compose/auth-service.env
depends_on:
- authredis
networks:
@@ -119,7 +119,7 @@ services:
environment:
- REDIS_ADDR=authredis:6379
env_file:
- ~/auth-compose/auth-service.env
- /home/quat/auth-compose/auth-service.env
depends_on:
- authredis
networks:

View File

@@ -14,6 +14,8 @@ import SubmissionDetailPage from '@/app/submissions/[submissionId]/page'
import SubmitPage from '@/app/submit/page'
import AdminSubmitPage from '@/app/admin-submit/page'
import OperationPage from '@/app/operations/[operationId]/page'
import ReviewerDashboardPage from '@/app/reviewer-dashboard/page'
import UserDashboardPage from '@/app/user-dashboard/page'
import NotFound from '@/app/not-found/page'
function App() {
@@ -31,6 +33,8 @@ function App() {
<Route path="/submit" element={<SubmitPage />} />
<Route path="/admin-submit" element={<AdminSubmitPage />} />
<Route path="/operations/:operationId" element={<OperationPage />} />
<Route path="/review" element={<ReviewerDashboardPage />} />
<Route path="/dashboard" element={<UserDashboardPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ThemeProvider>

View File

@@ -1,5 +1,5 @@
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Typography, Skeleton} from "@mui/material";
import {Explore, Person2} from "@mui/icons-material";
import {Explore, Person2, Assignment, Build} from "@mui/icons-material";
import {StatusChip} from "@/app/_components/statusChip";
import {Link} from "react-router-dom";
import {useAssetThumbnail, useUserThumbnail} from "@/app/hooks/useThumbnails";
@@ -16,6 +16,7 @@ interface MapCardProps {
gameID: number;
created: number;
type: 'mapfix' | 'submission';
showTypeBadge?: boolean;
}
export function MapCard(props: MapCardProps) {
@@ -57,6 +58,28 @@ export function MapCard(props: MapCardProps) {
},
}}
/>
{props.showTypeBadge && (
<Box
sx={{
position: 'absolute',
top: 12,
left: 12,
opacity: assetLoading ? 0 : 1,
transition: 'opacity 0.3s ease-in-out 0.1s',
bgcolor: props.type === 'submission' ? 'primary.main' : 'secondary.main',
color: 'white',
borderRadius: '50%',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 2
}}
>
{props.type === 'submission' ? <Assignment sx={{ fontSize: '1.1rem' }} /> : <Build sx={{ fontSize: '1.1rem' }} />}
</Box>
)}
<Box
sx={{
position: 'absolute',

View File

@@ -1,6 +1,6 @@
import Webpage from "@/app/_components/webpage";
import { useParams, useNavigate } from "react-router-dom";
import {useState} from "react";
import {useState, useEffect} from "react";
import { Link } from "react-router-dom";
import { useAssetThumbnail } from "@/app/hooks/useThumbnails";
@@ -121,6 +121,15 @@ export default function MapfixDetailsPage() {
};
// cycle before and after images every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
setShowBeforeImage((prev) => !prev);
}, 2000);
return () => clearInterval(interval);
}, []);
const handleCommentSubmit = async () => {
if (!newComment.trim()) {
return; // Don't submit empty comments
@@ -323,33 +332,6 @@ export default function MapfixDetailsPage() {
)}
</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',
@@ -363,7 +345,6 @@ export default function MapfixDetailsPage() {
background: 'linear-gradient(rgba(0,0,0,0.02), rgba(0,0,0,0.05))',
},
}}
onClick={() => setShowBeforeImage(!showBeforeImage)}
/>
</Box>
</Box>

View File

@@ -77,9 +77,10 @@ export default function MapfixInfoPage() {
display: 'flex',
justifyContent: 'center',
py: 6,
px: 2
px: 2,
boxSizing: 'border-box'
}}>
<Box sx={{ width: '100%', maxWidth: '1200px' }}>
<Box sx={{ width: '100%', maxWidth: '1200px', minWidth: 0 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
@@ -111,6 +112,7 @@ export default function MapfixInfoPage() {
},
gap: 3,
width: '100%',
minWidth: 0,
}}
>
{!mapfixes || isLoading ? (

View File

@@ -4,7 +4,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { Snackbar, Alert } from "@mui/material";
import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix";
import { MapfixStatus, type MapfixInfo, getMapfixStatusInfo } from "@/app/ts/Mapfix";
import LaunchIcon from '@mui/icons-material/Launch';
import { useAssetThumbnail } from "@/app/hooks/useThumbnails";
@@ -23,7 +23,11 @@ import {
Stack,
CardMedia,
Tooltip,
IconButton
IconButton,
List,
ListItem,
ListItemIcon,
Pagination
} from "@mui/material";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
@@ -33,6 +37,11 @@ import BugReportIcon from "@mui/icons-material/BugReport";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import PendingIcon from '@mui/icons-material/Pending';
import {hasRole, RolesConstants} from "@/app/ts/Roles";
import {useTitle} from "@/app/hooks/useTitle";
@@ -45,6 +54,7 @@ export default function MapDetails() {
const [copySuccess, setCopySuccess] = useState(false);
const [roles, setRoles] = useState(RolesConstants.Empty);
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
const [fixesPage, setFixesPage] = useState(1);
useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
@@ -111,9 +121,8 @@ export default function MapDetails() {
allMapfixes = allMapfixes.concat(data.Mapfixes);
page++;
} while (allMapfixes.length < total);
// Filter out rejected, uploading, uploaded (StatusID > 7)
const active = allMapfixes.filter((fix: MapfixInfo) => fix.StatusID <= MapfixStatus.Validated);
setMapfixes(active);
// Store all mapfixes for history display
setMapfixes(allMapfixes);
} catch {
setMapfixes([]);
}
@@ -154,6 +163,16 @@ export default function MapDetails() {
}
};
const getStatusIcon = (iconName: string) => {
switch (iconName) {
case "Build": return BuildIcon;
case "Pending": return PendingIcon;
case "CheckCircle": return CheckCircleIcon;
case "Cancel": return CancelIcon;
default: return PendingIcon;
}
};
const handleSubmitMapfix = () => {
navigate(`/maps/${mapId}/fix`);
};
@@ -324,7 +343,8 @@ export default function MapDetails() {
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
position: 'relative',
mb: 3
}}
>
<Box sx={{ position: 'relative', overflow: 'hidden' }}>
@@ -355,6 +375,231 @@ export default function MapDetails() {
/>
</Box>
</Paper>
{/* Mapfix Section - Active + History */}
{mapfixes.length > 0 && (() => {
const activeFix = mapfixes.find(fix => fix.StatusID !== MapfixStatus.Rejected && fix.StatusID !== MapfixStatus.Released);
const releasedFixes = mapfixes.filter(fix => fix.StatusID === MapfixStatus.Released);
const hasContent = activeFix || releasedFixes.length > 0;
if (!hasContent) return null;
// Pagination for released fixes
const fixesPerPage = 5;
const totalPages = Math.ceil(releasedFixes.length / fixesPerPage);
const startIndex = (fixesPage - 1) * fixesPerPage;
const endIndex = startIndex + fixesPerPage;
const paginatedFixes = releasedFixes
.sort((a, b) => b.CreatedAt - a.CreatedAt)
.slice(startIndex, endIndex);
return (
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<HistoryIcon sx={{ mr: 1.5, color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>
Mapfixes
</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<List sx={{ width: '100%' }}>
{/* Active Mapfix - shown first with special styling */}
{activeFix && (
<Box key={activeFix.ID}>
<ListItem
component={Link}
to={`/mapfixes/${activeFix.ID}`}
sx={{
py: 2,
px: 2,
borderRadius: 1,
transition: 'all 0.2s',
backgroundColor: 'rgba(25, 118, 210, 0.08)',
borderLeft: '4px solid',
borderColor: 'primary.main',
mb: releasedFixes.length > 0 ? 2 : 0,
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.12)',
transform: 'translateX(4px)'
},
textDecoration: 'none',
color: 'inherit',
display: 'block'
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<ListItemIcon sx={{ minWidth: 36, mt: 0.5 }}>
{(() => {
const statusInfo = getMapfixStatusInfo(activeFix.StatusID);
const StatusIcon = getStatusIcon(statusInfo.iconName);
return (
<StatusIcon
sx={{
fontSize: 24,
color: statusInfo.color === 'default' ? 'text.secondary' :
statusInfo.color === 'error' ? 'error.main' :
statusInfo.color === 'warning' ? 'warning.main' :
statusInfo.color === 'success' ? 'success.main' :
statusInfo.color === 'primary' ? 'primary.main' : 'info.main'
}}
/>
);
})()}
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body1"
component="div"
sx={{
fontWeight: 'bold',
mb: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{activeFix.Description}
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label="Active"
size="small"
color="primary"
sx={{ fontWeight: 'bold' }}
/>
<Chip
label={getMapfixStatusInfo(activeFix.StatusID).label}
size="small"
color={getMapfixStatusInfo(activeFix.StatusID).color as any}
sx={{ fontWeight: 'medium' }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', color: 'text.secondary' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PersonIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">
{activeFix.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">
{formatDate(activeFix.CreatedAt)}
</Typography>
</Box>
</Box>
</Box>
<LaunchIcon sx={{ color: 'primary.main', fontSize: 18, mt: 0.5, flexShrink: 0 }} />
</Box>
</ListItem>
</Box>
)}
{/* Released Fixes History */}
{releasedFixes.length > 0 && (
<>
{activeFix && (
<Box sx={{ mb: 2, mt: 2 }}>
<Divider>
<Chip label={`${releasedFixes.length} Previous Fix${releasedFixes.length !== 1 ? 'es' : ''}`} size="small" />
</Divider>
</Box>
)}
{paginatedFixes.map((fix, index) => {
const statusInfo = getMapfixStatusInfo(fix.StatusID);
const StatusIcon = getStatusIcon(statusInfo.iconName);
return (
<Box key={fix.ID}>
<ListItem
component={Link}
to={`/mapfixes/${fix.ID}`}
sx={{
py: 2,
px: 2,
borderRadius: 1,
transition: 'all 0.2s',
'&:hover': {
backgroundColor: 'action.hover',
transform: 'translateX(4px)'
},
textDecoration: 'none',
color: 'inherit',
display: 'block'
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<ListItemIcon sx={{ minWidth: 36, mt: 0.5 }}>
<StatusIcon
sx={{
fontSize: 24,
color: 'success.main'
}}
/>
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body1"
component="div"
sx={{
fontWeight: 'bold',
mb: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{fix.Description}
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', color: 'text.secondary' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PersonIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">
{fix.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">
{formatDate(fix.CreatedAt)}
</Typography>
</Box>
</Box>
</Box>
<LaunchIcon sx={{ color: 'primary.main', fontSize: 18, mt: 0.5, flexShrink: 0 }} />
</Box>
</ListItem>
{index < paginatedFixes.length - 1 && <Divider sx={{ my: 1 }} />}
</Box>
);
})}
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={fixesPage}
onChange={(_, page) => setFixesPage(page)}
color="primary"
size="medium"
/>
</Box>
)}
</>
)}
</List>
</Paper>
);
})()}
</Grid>
{/* Map Details Section */}
@@ -399,39 +644,6 @@ export default function MapDetails() {
</Tooltip>
</Box>
</Box>
{/* Active Mapfix in Map Details */}
{mapfixes.length > 0 && (() => {
const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated);
const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b));
const showFix = active || latest;
return (
<Box>
<Typography variant="subtitle2" color="text.secondary">
Active Mapfix
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="body2"
component={Link}
to={`/mapfixes/${showFix.ID}`}
sx={{
textDecoration: 'underline',
cursor: 'pointer',
color: 'primary.main',
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 0.5
}}
>
{showFix.Description}
<LaunchIcon sx={{ fontSize: '1rem', ml: 0.5 }} />
</Typography>
</Box>
</Box>
);
})()}
</Stack>
</Paper>

View File

@@ -16,16 +16,21 @@ import {Carousel} from "@/app/_components/carousel";
import {useTitle} from "@/app/hooks/useTitle";
import {usePrefetchThumbnails} from "@/app/hooks/useThumbnails";
import {Stats} from "@/app/ts/Stats";
import ReviewerDashboardPage from "@/app/reviewer-dashboard/page";
import UserDashboardPage from "@/app/user-dashboard/page";
import BuildIcon from "@mui/icons-material/Build";
import RateReviewIcon from "@mui/icons-material/RateReview";
import ListIcon from "@mui/icons-material/List";
import MapIcon from "@mui/icons-material/Map";
import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
import EmojiEventsIcon from "@mui/icons-material/EmojiEvents";
import { useUser } from "@/app/hooks/useUser";
import { hasAnyReviewerRole } from "@/app/ts/Roles";
export default function Home() {
useTitle("Home");
const { user, isLoading: isUserLoading } = useUser();
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
@@ -33,8 +38,34 @@ export default function Home() {
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
const [isLoadingStats, setIsLoadingStats] = useState<boolean>(false);
const [currentStatIndex, setCurrentStatIndex] = useState<number>(0);
const [userRoles, setUserRoles] = useState<number | null>(null);
const itemsPerSection: number = 8;
// Fetch user roles
useEffect(() => {
const controller = new AbortController();
async function fetchRoles() {
try {
const res = await fetch('/v1/session/roles', { signal: controller.signal });
if (res.ok) {
const data = await res.json();
setUserRoles(data.Roles);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching roles:", error);
}
}
}
if (user) {
fetchRoles();
}
return () => controller.abort();
}, [user]);
useEffect(() => {
const mapfixController = new AbortController();
const submissionsController = new AbortController();
@@ -51,7 +82,9 @@ export default function Home() {
setMapfixes(data);
}
} catch (error) {
console.error("Failed to fetch mapfixes:", error);
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Failed to fetch mapfixes:", error);
}
} finally {
setIsLoadingMapfixes(false);
}
@@ -68,7 +101,9 @@ export default function Home() {
setSubmissions(data);
}
} catch (error) {
console.error("Failed to fetch submissions:", error);
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Failed to fetch submissions:", error);
}
} finally {
setIsLoadingSubmissions(false);
}
@@ -85,7 +120,9 @@ export default function Home() {
setStats(data);
}
} catch (error) {
console.error("Failed to fetch stats:", error);
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Failed to fetch stats:", error);
}
} finally {
setIsLoadingStats(false);
}
@@ -233,6 +270,39 @@ export default function Home() {
const currentStat = allStats[currentStatIndex];
// Wait for user to load, and if user exists, wait for roles to load
const isLoadingAuth = isUserLoading || (user && userRoles === null);
if (isLoadingAuth) {
return <Webpage>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 'calc(100vh - 200px)',
}}
>
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
<CircularProgress size={60} thickness={4} sx={{ color: 'primary.main' }}/>
<Typography variant="h6" color="text.secondary">
Loading...
</Typography>
</Box>
</Box>
</Webpage>;
}
// Show reviewer dashboard if user has review permissions
if (user && userRoles && hasAnyReviewerRole(userRoles)) {
return <ReviewerDashboardPage />;
}
// Show my contributions page if user is logged in (but doesn't have reviewer role)
if (user) {
return <UserDashboardPage />;
}
return (
<Webpage>
<Box sx={{ width: '100%', bgcolor: 'background.default' }}>

View File

@@ -0,0 +1,749 @@
import { useState, useEffect } from "react";
import { SubmissionList, SubmissionStatus } from "../ts/Submission";
import { MapfixList, MapfixStatus } from "../ts/Mapfix";
import { MapCard } from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
import { ListSortConstants } from "../ts/Sort";
import { RolesConstants, hasRole, hasAnyReviewerRole } from "../ts/Roles";
import { useUser } from "@/app/hooks/useUser";
import {
Box,
Breadcrumbs,
Card,
CardContent,
Container,
Skeleton,
Typography,
Tabs,
Tab,
Alert,
CircularProgress,
Chip,
Button
} from "@mui/material";
import { Link } from "react-router-dom";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import { useTitle } from "@/app/hooks/useTitle";
import AssignmentIcon from "@mui/icons-material/Assignment";
import BuildIcon from "@mui/icons-material/Build";
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`reviewer-tabpanel-${index}`}
aria-labelledby={`reviewer-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
// Helper function to get submission statuses based on user roles
function getSubmissionStatusesForRoles(roles: number): SubmissionStatus[] {
const statuses: SubmissionStatus[] = [];
if (hasRole(roles, RolesConstants.SubmissionUpload)) {
statuses.push(SubmissionStatus.Validated);
}
if (hasRole(roles, RolesConstants.SubmissionReview)) {
statuses.push(SubmissionStatus.Submitted);
}
if (hasRole(roles, RolesConstants.SubmissionRelease)) {
statuses.push(SubmissionStatus.Uploaded);
}
if (hasRole(roles, RolesConstants.ScriptWrite)) {
statuses.push(SubmissionStatus.AcceptedUnvalidated);
}
return statuses;
}
// Helper function to get mapfix statuses based on user roles
function getMapfixStatusesForRoles(roles: number): MapfixStatus[] {
const statuses: MapfixStatus[] = [];
if (hasRole(roles, RolesConstants.ScriptWrite)) {
statuses.push(MapfixStatus.AcceptedUnvalidated);
}
if (hasRole(roles, RolesConstants.MapfixUpload)) {
statuses.push(MapfixStatus.Validated);
statuses.push(MapfixStatus.Uploaded);
}
if (hasRole(roles, RolesConstants.MapfixReview)) {
statuses.push(MapfixStatus.Submitted);
}
return statuses;
}
// Group submissions by status with priority ordering
// Priority order: ScriptWrite > SubmissionRelease > SubmissionUpload > SubmissionReview
function groupSubmissionsByStatus(submissions: any[], roles: number) {
const groups: { status: SubmissionStatus; label: string; items: any[]; priority: number }[] = [];
// Add groups in priority order based on user's roles
if (hasRole(roles, RolesConstants.ScriptWrite)) {
const items = submissions.filter(s => s.StatusID === SubmissionStatus.AcceptedUnvalidated);
if (items.length > 0) {
groups.push({ status: SubmissionStatus.AcceptedUnvalidated, label: 'Script Review', items, priority: 1 });
}
}
if (hasRole(roles, RolesConstants.SubmissionRelease)) {
const items = submissions.filter(s => s.StatusID === SubmissionStatus.Uploaded);
if (items.length > 0) {
groups.push({ status: SubmissionStatus.Uploaded, label: 'Ready to Release', items, priority: 2 });
}
}
if (hasRole(roles, RolesConstants.SubmissionUpload)) {
const items = submissions.filter(s => s.StatusID === SubmissionStatus.Validated);
if (items.length > 0) {
groups.push({ status: SubmissionStatus.Validated, label: 'Ready to Upload', items, priority: 3 });
}
}
if (hasRole(roles, RolesConstants.SubmissionReview)) {
const items = submissions.filter(s => s.StatusID === SubmissionStatus.Submitted);
if (items.length > 0) {
groups.push({ status: SubmissionStatus.Submitted, label: 'Pending Review', items, priority: 4 });
}
}
return groups;
}
// Group mapfixes by status with priority ordering
// Priority order: ScriptWrite > MapfixUpload > MapfixReview
function groupMapfixesByStatus(mapfixes: any[], roles: number) {
const groups: { status: MapfixStatus; label: string; items: any[]; priority: number }[] = [];
// Add groups in priority order based on user's roles
if (hasRole(roles, RolesConstants.ScriptWrite)) {
const items = mapfixes.filter(m => m.StatusID === MapfixStatus.AcceptedUnvalidated);
if (items.length > 0) {
groups.push({ status: MapfixStatus.AcceptedUnvalidated, label: 'Script Review', items, priority: 1 });
}
}
if (hasRole(roles, RolesConstants.MapfixUpload)) {
const validated = mapfixes.filter(m => m.StatusID === MapfixStatus.Validated);
const uploaded = mapfixes.filter(m => m.StatusID === MapfixStatus.Uploaded);
if (validated.length > 0) {
groups.push({ status: MapfixStatus.Validated, label: 'Ready to Upload', items: validated, priority: 2 });
}
if (uploaded.length > 0) {
groups.push({ status: MapfixStatus.Uploaded, label: 'Ready to Release', items: uploaded, priority: 3 });
}
}
if (hasRole(roles, RolesConstants.MapfixReview)) {
const items = mapfixes.filter(m => m.StatusID === MapfixStatus.Submitted);
if (items.length > 0) {
groups.push({ status: MapfixStatus.Submitted, label: 'Pending Review', items, priority: 4 });
}
}
return groups;
}
export default function ReviewerDashboardPage() {
useTitle("Reviewer Dashboard");
const { user, isLoading: userLoading } = useUser();
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false);
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false);
const [tabValue, setTabValue] = useState(0);
const [userRoles, setUserRoles] = useState<number | null>(null);
// Fetch user roles
useEffect(() => {
// Fetch roles from API
const controller = new AbortController();
async function fetchRoles() {
try {
const res = await fetch('/v1/session/roles', { signal: controller.signal });
if (res.ok) {
const data = await res.json();
setUserRoles(data.Roles);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching roles:", error);
}
}
}
if (user) {
fetchRoles();
}
return () => controller.abort();
}, [user]);
// Fetch submissions needing review
useEffect(() => {
const controller = new AbortController();
async function fetchAllPagesForStatus(status: SubmissionStatus): Promise<any[]> {
const allItems: any[] = [];
let page = 1;
let hasMore = true;
let totalCount = 0;
while (hasMore) {
const res = await fetch(
`/v1/submissions?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&StatusID=${status}`,
{ signal: controller.signal }
);
if (!res.ok) {
console.error(`Failed to fetch submissions for status ${status}, page ${page}:`, res.status);
break;
}
const data = await res.json();
// Store the total count from the first response
if (page === 1) {
totalCount = data.Total || 0;
}
if (data.Submissions && data.Submissions.length > 0) {
allItems.push(...data.Submissions);
// Check if there are more pages based on the total count
hasMore = allItems.length < totalCount;
page++;
} else {
hasMore = false;
}
}
return allItems;
}
async function fetchSubmissions() {
if (!userRoles) return;
setIsLoadingSubmissions(true);
try {
// Get statuses based on user roles and deduplicate
const allowedStatuses = getSubmissionStatusesForRoles(userRoles);
const uniqueStatuses = Array.from(new Set(allowedStatuses));
// Fetch all pages for each status in parallel
const results = await Promise.all(
uniqueStatuses.map(status => fetchAllPagesForStatus(status))
);
// Combine all results
const allSubmissions = results.flat();
setSubmissions({
Submissions: allSubmissions,
Total: allSubmissions.length
});
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching submissions:", error);
}
} finally {
setIsLoadingSubmissions(false);
}
}
if (userRoles && (
hasRole(userRoles, RolesConstants.SubmissionReview) ||
hasRole(userRoles, RolesConstants.SubmissionUpload) ||
hasRole(userRoles, RolesConstants.SubmissionRelease) ||
hasRole(userRoles, RolesConstants.ScriptWrite)
)) {
fetchSubmissions();
}
return () => controller.abort();
}, [userRoles]);
// Fetch mapfixes needing review
useEffect(() => {
const controller = new AbortController();
async function fetchAllPagesForStatus(status: MapfixStatus): Promise<any[]> {
const allItems: any[] = [];
let page = 1;
let hasMore = true;
let totalCount = 0;
while (hasMore) {
const res = await fetch(
`/v1/mapfixes?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&StatusID=${status}`,
{ signal: controller.signal }
);
if (!res.ok) {
console.error(`Failed to fetch mapfixes for status ${status}, page ${page}:`, res.status);
break;
}
const data = await res.json();
// Store the total count from the first response
if (page === 1) {
totalCount = data.Total || 0;
}
if (data.Mapfixes && data.Mapfixes.length > 0) {
allItems.push(...data.Mapfixes);
// Check if there are more pages based on the total count
hasMore = allItems.length < totalCount;
page++;
} else {
hasMore = false;
}
}
return allItems;
}
async function fetchMapfixes() {
if (!userRoles) return;
setIsLoadingMapfixes(true);
try {
// Get statuses based on user roles and deduplicate
const allowedStatuses = getMapfixStatusesForRoles(userRoles);
const uniqueStatuses = Array.from(new Set(allowedStatuses));
// Fetch all pages for each status in parallel
const results = await Promise.all(
uniqueStatuses.map(status => fetchAllPagesForStatus(status))
);
// Combine all results
const allMapfixes = results.flat();
setMapfixes({
Mapfixes: allMapfixes,
Total: allMapfixes.length
});
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching mapfixes:", error);
}
} finally {
setIsLoadingMapfixes(false);
}
}
if (userRoles && (
hasRole(userRoles, RolesConstants.MapfixReview) ||
hasRole(userRoles, RolesConstants.MapfixUpload) ||
hasRole(userRoles, RolesConstants.ScriptWrite)
)) {
fetchMapfixes();
}
return () => controller.abort();
}, [userRoles]);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
const skeletonCards = Array.from({ length: 12 }, (_, i) => i);
// Check if user is loading
if (userLoading) {
return (
<Webpage>
<Container sx={{ py: 6, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Container>
</Webpage>
);
}
// Check if user is logged in
if (!user) {
return (
<Webpage>
<Container sx={{ py: 6 }}>
<Alert severity="warning">
You must be logged in to access the reviewer dashboard.
</Alert>
</Container>
</Webpage>
);
}
// Wait for roles to load before checking permissions
if (userRoles === null) {
return (
<Webpage>
<Container sx={{ py: 6, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Container>
</Webpage>
);
}
// Check if user has any reviewer permissions
const canReviewSubmissions = (
hasRole(userRoles, RolesConstants.SubmissionReview) ||
hasRole(userRoles, RolesConstants.SubmissionUpload) ||
hasRole(userRoles, RolesConstants.SubmissionRelease) ||
hasRole(userRoles, RolesConstants.ScriptWrite)
);
const canReviewMapfixes = (
hasRole(userRoles, RolesConstants.MapfixReview) ||
hasRole(userRoles, RolesConstants.MapfixUpload) ||
hasRole(userRoles, RolesConstants.ScriptWrite)
);
if (!hasAnyReviewerRole(userRoles)) {
return (
<Webpage>
<Container sx={{ py: 6 }}>
<Alert severity="error">
You do not have permission to access the reviewer dashboard. This page is only available to users with review permissions.
</Alert>
</Container>
</Webpage>
);
}
return (
<Webpage>
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
py: 6,
px: 2,
boxSizing: 'border-box'
}}>
<Box sx={{ width: '100%', maxWidth: '1200px', minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
<Link to="/" style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="text.secondary">Reviewer Dashboard</Typography>
</Breadcrumbs>
<Button
component={Link}
to="/dashboard"
variant="outlined"
size="small"
startIcon={<AssignmentIcon />}
>
User Dashboard
</Button>
</Box>
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
Reviewer Dashboard
</Typography>
<Typography variant="subtitle1" color="text.secondary" mb={4}>
Manage submissions and map fixes requiring your attention.
</Typography>
{/* Summary Cards */}
<Box sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 3,
mb: 4
}}>
{canReviewSubmissions && (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<AssignmentIcon sx={{ fontSize: 40, color: 'primary.main' }} />
<Box>
<Typography variant="h4" fontWeight="bold">
{isLoadingSubmissions ? (
<Skeleton width={50} />
) : (
userRoles && submissions
? groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0)
: 0
)}
</Typography>
<Typography variant="body2" color="text.secondary">
Submissions Pending Review
</Typography>
</Box>
</Box>
</CardContent>
</Card>
)}
{canReviewMapfixes && (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<BuildIcon sx={{ fontSize: 40, color: 'secondary.main' }} />
<Box>
<Typography variant="h4" fontWeight="bold">
{isLoadingMapfixes ? (
<Skeleton width={50} />
) : (
userRoles && mapfixes
? groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0)
: 0
)}
</Typography>
<Typography variant="body2" color="text.secondary">
Map Fixes Pending Review
</Typography>
</Box>
</Box>
</CardContent>
</Card>
)}
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="reviewer tabs">
{canReviewSubmissions && (
<Tab
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Submissions
{!isLoadingSubmissions && userRoles && submissions && (() => {
const total = groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0);
return total > 0 ? (
<Chip
label={total}
size="small"
color="primary"
/>
) : null;
})()}
</Box>
}
id="reviewer-tab-0"
aria-controls="reviewer-tabpanel-0"
/>
)}
{canReviewMapfixes && (
<Tab
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Map Fixes
{!isLoadingMapfixes && userRoles && mapfixes && (() => {
const total = groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0);
return total > 0 ? (
<Chip
label={total}
size="small"
color="secondary"
/>
) : null;
})()}
</Box>
}
id={`reviewer-tab-${canReviewSubmissions ? 1 : 0}`}
aria-controls={`reviewer-tabpanel-${canReviewSubmissions ? 1 : 0}`}
/>
)}
</Tabs>
</Box>
{/* Submissions Tab */}
{canReviewSubmissions && (
<TabPanel value={tabValue} index={0}>
{userRoles && submissions && groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0) === 0 ? (
<Alert severity="success">
No submissions currently need your review. Great job!
</Alert>
) : isLoadingSubmissions ? (
<Box
className="grid"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
width: '100%',
minWidth: 0,
}}
>
{skeletonCards.map((i) => (
<Card key={i} sx={{ height: '100%' }}>
<Skeleton variant="rectangular" height={180} />
<CardContent>
<Skeleton variant="text" width="80%" height={32} sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Skeleton variant="text" width={80} />
<Skeleton variant="text" width={100} />
</Box>
<Skeleton variant="text" width="60%" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<Skeleton variant="circular" width={28} height={28} />
<Skeleton variant="text" width={100} />
</Box>
</CardContent>
</Card>
))}
</Box>
) : (
<Box>
{userRoles && submissions && groupSubmissionsByStatus(submissions.Submissions, userRoles).map((group, groupIdx) => (
<Box key={groupIdx} sx={{ mb: groupIdx < groupSubmissionsByStatus(submissions.Submissions, userRoles).length - 1 ? 4 : 0 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2 }}>
{group.label} ({group.items.length})
</Typography>
<Box
className="grid"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
width: '100%',
minWidth: 0,
}}
>
{group.items.map((submission) => (
<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}
type="submission"
/>
))}
</Box>
</Box>
))}
</Box>
)}
</TabPanel>
)}
{/* Map Fixes Tab */}
{canReviewMapfixes && (
<TabPanel value={tabValue} index={canReviewSubmissions ? 1 : 0}>
{userRoles && mapfixes && groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0) === 0 ? (
<Alert severity="success">
No map fixes currently need your review. Great job!
</Alert>
) : isLoadingMapfixes ? (
<Box
className="grid"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
width: '100%',
minWidth: 0,
}}
>
{skeletonCards.map((i) => (
<Card key={i} sx={{ height: '100%' }}>
<Skeleton variant="rectangular" height={180} />
<CardContent>
<Skeleton variant="text" width="80%" height={32} sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Skeleton variant="text" width={80} />
<Skeleton variant="text" width={100} />
</Box>
<Skeleton variant="text" width="60%" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<Skeleton variant="circular" width={28} height={28} />
<Skeleton variant="text" width={100} />
</Box>
</CardContent>
</Card>
))}
</Box>
) : (
<Box>
{userRoles && mapfixes && groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).map((group, groupIdx) => (
<Box key={groupIdx} sx={{ mb: groupIdx < groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).length - 1 ? 4 : 0 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2 }}>
{group.label} ({group.items.length})
</Typography>
<Box
className="grid"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
width: '100%',
minWidth: 0,
}}
>
{group.items.map((mapfix) => (
<MapCard
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"
/>
))}
</Box>
</Box>
))}
</Box>
)}
</TabPanel>
)}
</Box>
</Box>
</Webpage>
);
}

View File

@@ -77,9 +77,10 @@ export default function SubmissionInfoPage() {
display: 'flex',
justifyContent: 'center',
py: 6,
px: 2
px: 2,
boxSizing: 'border-box'
}}>
<Box sx={{ width: '100%', maxWidth: '1200px' }}>
<Box sx={{ width: '100%', maxWidth: '1200px', minWidth: 0 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
@@ -111,6 +112,7 @@ export default function SubmissionInfoPage() {
},
gap: 3,
width: '100%',
minWidth: 0,
}}
>
{!submissions || isLoading ? (

View File

@@ -66,9 +66,48 @@ function MapfixStatusToString(mapfix_status: MapfixStatus): string {
}
}
interface MapfixStatusInfo {
label: string;
color: 'default' | 'error' | 'warning' | 'success' | 'primary' | 'info';
iconName: string;
}
function getMapfixStatusInfo(statusId: MapfixStatus): MapfixStatusInfo {
switch (statusId) {
case MapfixStatus.UnderConstruction:
return { label: "Under Construction", color: "default", iconName: "Build" };
case MapfixStatus.ChangesRequested:
return { label: "Changes Requested", color: "warning", iconName: "Pending" };
case MapfixStatus.Submitting:
return { label: "Submitting", color: "info", iconName: "Pending" };
case MapfixStatus.Submitted:
return { label: "Submitted", color: "info", iconName: "CheckCircle" };
case MapfixStatus.AcceptedUnvalidated:
return { label: "Accepted (Unvalidated)", color: "primary", iconName: "CheckCircle" };
case MapfixStatus.Validating:
return { label: "Validating", color: "info", iconName: "Pending" };
case MapfixStatus.Validated:
return { label: "Validated", color: "success", iconName: "CheckCircle" };
case MapfixStatus.Uploading:
return { label: "Uploading", color: "info", iconName: "Pending" };
case MapfixStatus.Uploaded:
return { label: "Uploaded", color: "success", iconName: "CheckCircle" };
case MapfixStatus.Rejected:
return { label: "Rejected", color: "error", iconName: "Cancel" };
case MapfixStatus.Released:
return { label: "Released", color: "success", iconName: "CheckCircle" };
case MapfixStatus.Releasing:
return { label: "Releasing", color: "info", iconName: "Pending" };
default:
return { label: "Unknown", color: "default", iconName: "Pending" };
}
}
export {
MapfixStatus,
MapfixStatusToString,
getMapfixStatusInfo,
type MapfixInfo,
type MapfixList,
type MapfixStatusInfo,
}

View File

@@ -19,8 +19,20 @@ function hasRole(flags: Roles, role: Roles): boolean {
return (flags & role) === role;
}
function hasAnyReviewerRole(flags: Roles): boolean {
return (
hasRole(flags, RolesConstants.SubmissionReview) ||
hasRole(flags, RolesConstants.SubmissionUpload) ||
hasRole(flags, RolesConstants.SubmissionRelease) ||
hasRole(flags, RolesConstants.MapfixReview) ||
hasRole(flags, RolesConstants.MapfixUpload) ||
hasRole(flags, RolesConstants.ScriptWrite)
);
}
export {
type Roles,
RolesConstants,
hasRole,
hasAnyReviewerRole,
};

View File

@@ -0,0 +1,637 @@
import { useState, useEffect } from "react";
import { SubmissionList, SubmissionStatus } from "../ts/Submission";
import { MapfixList, MapfixStatus } from "../ts/Mapfix";
import { MapCard } from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
import { ListSortConstants } from "../ts/Sort";
import { hasAnyReviewerRole } from "../ts/Roles";
import { useUser } from "@/app/hooks/useUser";
import {
Box,
Breadcrumbs,
Card,
CardContent,
Container,
Skeleton,
Typography,
Alert,
CircularProgress,
Chip,
Button,
Paper
} from "@mui/material";
import { Link } from "react-router-dom";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import { useTitle } from "@/app/hooks/useTitle";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import PendingIcon from "@mui/icons-material/Pending";
import EditIcon from "@mui/icons-material/Edit";
import CancelIcon from "@mui/icons-material/Cancel";
import AddIcon from "@mui/icons-material/Add";
import Assignment from "@mui/icons-material/Assignment";
import Build from "@mui/icons-material/Build";
type ContributionItem = {
id: number;
displayName: string;
author: string;
authorId: number;
statusID: number;
gameID: number;
created: number;
assetId: number;
type: 'submission' | 'mapfix';
};
// Unified grouping for all contributions
function groupAllContributions(submissions: any[], mapfixes: any[]) {
const groups: {
label: string;
description: string;
items: ContributionItem[];
priority: number;
color: 'warning' | 'info' | 'success' | 'error';
icon: React.ReactNode;
}[] = [];
// Convert to unified format
const submissionItems: ContributionItem[] = submissions.map(s => ({
id: s.ID,
displayName: s.DisplayName,
author: s.Creator,
authorId: s.Submitter,
statusID: s.StatusID,
gameID: s.GameID,
created: s.CreatedAt,
assetId: s.AssetID,
type: 'submission' as const
}));
const mapfixItems: ContributionItem[] = mapfixes.map(m => ({
id: m.ID,
displayName: m.DisplayName,
author: m.Creator,
authorId: m.Submitter,
statusID: m.StatusID,
gameID: m.GameID,
created: m.CreatedAt,
assetId: m.AssetID,
type: 'mapfix' as const
}));
// Action Needed
const actionNeeded = [
...submissionItems.filter(s =>
s.statusID === SubmissionStatus.UnderConstruction ||
s.statusID === SubmissionStatus.ChangesRequested
),
...mapfixItems.filter(m =>
m.statusID === MapfixStatus.UnderConstruction ||
m.statusID === MapfixStatus.ChangesRequested
)
].sort((a, b) => b.created - a.created); // Newest first
if (actionNeeded.length > 0) {
groups.push({
label: 'Action Needed',
description: 'These items need your attention before they can be reviewed',
items: actionNeeded,
priority: 1,
color: 'warning',
icon: <EditIcon />
});
}
// Waiting for Review
const waiting = [
...submissionItems.filter(s =>
s.statusID === SubmissionStatus.Submitting ||
s.statusID === SubmissionStatus.Submitted ||
s.statusID === SubmissionStatus.AcceptedUnvalidated ||
s.statusID === SubmissionStatus.Validating ||
s.statusID === SubmissionStatus.Validated ||
s.statusID === SubmissionStatus.Uploading ||
s.statusID === SubmissionStatus.Uploaded
),
...mapfixItems.filter(m =>
m.statusID === MapfixStatus.Submitting ||
m.statusID === MapfixStatus.Submitted ||
m.statusID === MapfixStatus.AcceptedUnvalidated ||
m.statusID === MapfixStatus.Validating ||
m.statusID === MapfixStatus.Validated ||
m.statusID === MapfixStatus.Uploading ||
m.statusID === MapfixStatus.Uploaded ||
m.statusID === MapfixStatus.Releasing
)
].sort((a, b) => b.created - a.created);
if (waiting.length > 0) {
groups.push({
label: 'Waiting for Review',
description: 'Our team is processing these items',
items: waiting,
priority: 2,
color: 'info',
icon: <PendingIcon />
});
}
// Released
const released = [
...submissionItems.filter(s => s.statusID === SubmissionStatus.Released),
...mapfixItems.filter(m => m.statusID === MapfixStatus.Released)
].sort((a, b) => b.created - a.created);
if (released.length > 0) {
groups.push({
label: 'Released',
description: 'Your contributions are live!',
items: released,
priority: 3,
color: 'success',
icon: <CheckCircleIcon />
});
}
// Rejected
const rejected = [
...submissionItems.filter(s => s.statusID === SubmissionStatus.Rejected),
...mapfixItems.filter(m => m.statusID === MapfixStatus.Rejected)
].sort((a, b) => b.created - a.created);
if (rejected.length > 0) {
groups.push({
label: 'Rejected',
description: 'These items did not meet the requirements',
items: rejected,
priority: 4,
color: 'error',
icon: <CancelIcon />
});
}
return groups;
}
export default function UserDashboardPage() {
useTitle("Dashboard");
const { user, isLoading: userLoading } = useUser();
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false);
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false);
const [userRoles, setUserRoles] = useState<number | null>(null);
const [submissionsPage, setSubmissionsPage] = useState(1);
const [mapfixesPage, setMapfixesPage] = useState(1);
const ITEMS_PER_PAGE = 100;
// Fetch user roles
useEffect(() => {
const controller = new AbortController();
async function fetchRoles() {
try {
const res = await fetch('/v1/session/roles', { signal: controller.signal });
if (res.ok) {
const data = await res.json();
setUserRoles(data.Roles);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error("Error fetching roles:", error);
}
}
}
if (user) {
fetchRoles();
}
return () => controller.abort();
}, [user]);
// Fetch user's submissions
useEffect(() => {
const controller = new AbortController();
async function fetchSubmissions() {
if (!user) return;
setIsLoadingSubmissions(true);
try {
const res = await fetch(
`/v1/submissions?Page=${submissionsPage}&Limit=${ITEMS_PER_PAGE}&Sort=${ListSortConstants.ListSortDateDescending}&Submitter=${user.UserID}`,
{ signal: controller.signal }
);
if (res.ok) {
const data = await res.json();
if (submissionsPage === 1) {
setSubmissions(data);
} else {
// Append to existing submissions
setSubmissions(prev => prev ? {
...data,
Submissions: [...prev.Submissions, ...data.Submissions]
} : 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 {
setIsLoadingSubmissions(false);
}
}
if (user) {
fetchSubmissions();
}
return () => controller.abort();
}, [user, submissionsPage, ITEMS_PER_PAGE]);
// Fetch user's mapfixes
useEffect(() => {
const controller = new AbortController();
async function fetchMapfixes() {
if (!user) return;
setIsLoadingMapfixes(true);
try {
const res = await fetch(
`/v1/mapfixes?Page=${mapfixesPage}&Limit=${ITEMS_PER_PAGE}&Sort=${ListSortConstants.ListSortDateDescending}&Submitter=${user.UserID}`,
{ signal: controller.signal }
);
if (res.ok) {
const data = await res.json();
if (mapfixesPage === 1) {
setMapfixes(data);
} else {
// Append to existing mapfixes
setMapfixes(prev => prev ? {
...data,
Mapfixes: [...prev.Mapfixes, ...data.Mapfixes]
} : 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 {
setIsLoadingMapfixes(false);
}
}
if (user) {
fetchMapfixes();
}
return () => controller.abort();
}, [user, mapfixesPage, ITEMS_PER_PAGE]);
const skeletonCards = Array.from({ length: 8 }, (_, i) => i);
// Check if user is loading
if (userLoading) {
return (
<Webpage>
<Container sx={{ py: 6, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Container>
</Webpage>
);
}
// Check if user is logged in
if (!user) {
return (
<Webpage>
<Container sx={{ py: 6 }}>
<Alert severity="warning">
You must be logged in to view your contributions.
</Alert>
</Container>
</Webpage>
);
}
// Calculate stats
const totalContributions = (submissions?.Total || 0) + (mapfixes?.Total || 0);
const releasedSubmissions = submissions?.Submissions.filter(s => s.StatusID === SubmissionStatus.Released).length || 0;
const releasedMapfixes = mapfixes?.Mapfixes.filter(m => m.StatusID === MapfixStatus.Released).length || 0;
const totalReleased = releasedSubmissions + releasedMapfixes;
const actionNeededSubmissions = submissions?.Submissions.filter(s =>
s.StatusID === SubmissionStatus.UnderConstruction ||
s.StatusID === SubmissionStatus.ChangesRequested
).length || 0;
const actionNeededMapfixes = mapfixes?.Mapfixes.filter(m =>
m.StatusID === MapfixStatus.UnderConstruction ||
m.StatusID === MapfixStatus.ChangesRequested
).length || 0;
const totalActionNeeded = actionNeededSubmissions + actionNeededMapfixes;
const isLoading = isLoadingSubmissions || isLoadingMapfixes;
const hasData = submissions && mapfixes;
// Check if there are more items to load
const hasMoreSubmissions = submissions && submissions.Submissions.length < submissions.Total;
const hasMoreMapfixes = mapfixes && mapfixes.Mapfixes.length < mapfixes.Total;
const hasMoreItems = hasMoreSubmissions || hasMoreMapfixes;
const handleLoadMore = () => {
if (hasMoreSubmissions) {
setSubmissionsPage(prev => prev + 1);
}
if (hasMoreMapfixes) {
setMapfixesPage(prev => prev + 1);
}
};
return (
<Webpage>
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
py: 6,
px: 2,
boxSizing: 'border-box'
}}>
<Box sx={{ width: '100%', maxWidth: '1200px', minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
<Link to="/" style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Typography color="text.secondary">Dashboard</Typography>
</Breadcrumbs>
{userRoles !== null && hasAnyReviewerRole(userRoles) && (
<Button
component={Link}
to="/review"
variant="outlined"
size="small"
startIcon={<Build />}
>
Reviewer Dashboard
</Button>
)}
</Box>
<Typography variant="h3" component="h1" fontWeight="bold" mb={1}>
Dashboard
</Typography>
<Typography variant="body1" color="text.secondary" mb={4}>
Welcome back, {user.Username}! Here's the status of all your map submissions and fixes.
</Typography>
{/* Overview Stats */}
<Box sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
gap: 2,
mb: 4
}}>
<Card sx={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<CardContent>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
Total Contributions
</Typography>
<Typography variant="h3" fontWeight="bold" sx={{ color: 'white' }}>
{isLoading ? (
<Skeleton width={60} sx={{ bgcolor: 'rgba(255,255,255,0.2)' }} />
) : (
totalContributions
)}
</Typography>
</CardContent>
</Card>
<Card sx={{ background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }}>
<CardContent>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
Released
</Typography>
<Typography variant="h3" fontWeight="bold" sx={{ color: 'white' }}>
{isLoading ? (
<Skeleton width={60} sx={{ bgcolor: 'rgba(255,255,255,0.2)' }} />
) : (
totalReleased
)}
</Typography>
</CardContent>
</Card>
<Card sx={{ background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }}>
<CardContent>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
In Review
</Typography>
<Typography variant="h3" fontWeight="bold" sx={{ color: 'white' }}>
{isLoading ? (
<Skeleton width={60} sx={{ bgcolor: 'rgba(255,255,255,0.2)' }} />
) : (
totalContributions - totalReleased - totalActionNeeded - (submissions?.Submissions.filter(s => s.StatusID === SubmissionStatus.Rejected).length || 0) - (mapfixes?.Mapfixes.filter(m => m.StatusID === MapfixStatus.Rejected).length || 0)
)}
</Typography>
</CardContent>
</Card>
<Card sx={{ background: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' }}>
<CardContent>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
Action Needed
</Typography>
<Typography variant="h3" fontWeight="bold" sx={{ color: 'white' }}>
{isLoading ? (
<Skeleton width={60} sx={{ bgcolor: 'rgba(255,255,255,0.2)' }} />
) : (
totalActionNeeded
)}
</Typography>
</CardContent>
</Card>
</Box>
{/* Quick Actions */}
<Box sx={{ mb: 4, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
component={Link}
to="/submit"
variant="contained"
startIcon={<AddIcon />}
size="large"
>
Submit New Map
</Button>
<Button
component={Link}
to="/maps"
variant="outlined"
size="large"
>
Browse Maps to Fix
</Button>
</Box>
{/* All Contributions - Grouped by Status */}
{totalContributions === 0 && !isLoading ? (
<Alert severity="info">
You haven't made any contributions yet. Start by submitting a map or fixing an existing one!
</Alert>
) : isLoading ? (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
}}
>
{skeletonCards.map((i) => (
<Card key={i} sx={{ height: '100%' }}>
<Skeleton variant="rectangular" height={180} />
<CardContent>
<Skeleton variant="text" width="80%" height={32} sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Skeleton variant="text" width={80} />
<Skeleton variant="text" width={100} />
</Box>
<Skeleton variant="text" width="60%" />
</CardContent>
</Card>
))}
</Box>
) : hasData ? (
<Box>
{groupAllContributions(submissions.Submissions, mapfixes.Mapfixes).map((group, groupIdx) => (
<Box key={groupIdx} sx={{ mb: 4 }}>
<Paper
sx={{
p: 2,
mb: 2,
borderLeft: 4,
borderColor: `${group.color}.main`,
bgcolor: `${group.color}.lighter` || 'background.paper'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ color: `${group.color}.main`, display: 'flex' }}>
{group.icon}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h5" fontWeight="bold">
{group.label} ({group.items.length})
</Typography>
<Typography variant="body2" color="text.secondary">
{group.description}
</Typography>
{group.items.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Chip
icon={<Assignment sx={{ fontSize: '1rem' }} />}
label={`${group.items.filter(i => i.type === 'submission').length} Submission${group.items.filter(i => i.type === 'submission').length !== 1 ? 's' : ''}`}
size="small"
sx={{
bgcolor: 'primary.main',
color: 'white',
'& .MuiChip-icon': {
color: 'white'
}
}}
/>
<Chip
icon={<Build sx={{ fontSize: '1rem' }} />}
label={`${group.items.filter(i => i.type === 'mapfix').length} Fix${group.items.filter(i => i.type === 'mapfix').length !== 1 ? 'es' : ''}`}
size="small"
sx={{
bgcolor: 'secondary.main',
color: 'white',
'& .MuiChip-icon': {
color: 'white'
}
}}
/>
</Box>
)}
</Box>
</Box>
</Paper>
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 3,
}}
>
{group.items.map((item) => (
<MapCard
key={`${item.type}-${item.id}`}
id={item.id}
assetId={item.assetId}
displayName={item.displayName}
author={item.author}
authorId={item.authorId}
rating={item.statusID}
statusID={item.statusID}
gameID={item.gameID}
created={item.created}
type={item.type}
showTypeBadge={true}
/>
))}
</Box>
</Box>
))}
</Box>
) : null}
{/* Load More Button */}
{hasData && hasMoreItems && !isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Button
variant="outlined"
size="large"
onClick={handleLoadMore}
disabled={isLoadingSubmissions || isLoadingMapfixes}
>
{isLoadingSubmissions || isLoadingMapfixes ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Loading...
</>
) : (
`Load More (${(hasMoreSubmissions ? submissions.Total - submissions.Submissions.length : 0) + (hasMoreMapfixes ? mapfixes.Total - mapfixes.Mapfixes.length : 0)} remaining)`
)}
</Button>
</Box>
)}
</Box>
</Box>
</Webpage>
);
}