Add user/reviewer dashboard #297

Merged
itzaname merged 4 commits from feature/dashboard into staging 2025-12-27 05:20:46 +00:00
6 changed files with 1499 additions and 4 deletions

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

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

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