From a1ed57379c5da4100431f2ee6d200a88dd0abc7d Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 23:33:13 -0500 Subject: [PATCH 1/4] Add reviewer/user dashboard --- web/src/App.tsx | 4 + web/src/app/_components/mapCard.tsx | 27 +- web/src/app/page.tsx | 76 ++- web/src/app/reviewer-dashboard/page.tsx | 747 ++++++++++++++++++++++++ web/src/app/ts/Roles.ts | 12 + web/src/app/user-dashboard/page.tsx | 637 ++++++++++++++++++++ 6 files changed, 1498 insertions(+), 5 deletions(-) create mode 100644 web/src/app/reviewer-dashboard/page.tsx create mode 100644 web/src/app/user-dashboard/page.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 10d3676..9afe728 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> diff --git a/web/src/app/_components/mapCard.tsx b/web/src/app/_components/mapCard.tsx index a6cdc29..10fd0cf 100644 --- a/web/src/app/_components/mapCard.tsx +++ b/web/src/app/_components/mapCard.tsx @@ -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 {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Typography, Skeleton, Chip} from "@mui/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 && ( + + {props.type === 'submission' ? : } + + )} (null); const [submissions, setSubmissions] = useState(null); const [stats, setStats] = useState(null); @@ -33,8 +38,34 @@ export default function Home() { const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false); const [isLoadingStats, setIsLoadingStats] = useState(false); const [currentStatIndex, setCurrentStatIndex] = useState(0); + const [userRoles, setUserRoles] = useState(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 + + + + + Loading... + + + + ; + } + + // Show reviewer dashboard if user has review permissions + if (user && userRoles && hasAnyReviewerRole(userRoles)) { + return ; + } + + // Show my contributions page if user is logged in (but doesn't have reviewer role) + if (user) { + return ; + } + return ( diff --git a/web/src/app/reviewer-dashboard/page.tsx b/web/src/app/reviewer-dashboard/page.tsx new file mode 100644 index 0000000..25091f0 --- /dev/null +++ b/web/src/app/reviewer-dashboard/page.tsx @@ -0,0 +1,747 @@ +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 ( + + ); +} + +// 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(null); + const [mapfixes, setMapfixes] = useState(null); + const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false); + const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false); + const [tabValue, setTabValue] = useState(0); + const [userRoles, setUserRoles] = useState(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 { + 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}&Status=${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 + const allowedStatuses = getSubmissionStatusesForRoles(userRoles); + + // Fetch all pages for each status in parallel + const results = await Promise.all( + allowedStatuses.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 { + 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}&Status=${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 + const allowedStatuses = getMapfixStatusesForRoles(userRoles); + + // Fetch all pages for each status in parallel + const results = await Promise.all( + allowedStatuses.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 ( + + + + + + ); + } + + // Check if user is logged in + if (!user) { + return ( + + + + You must be logged in to access the reviewer dashboard. + + + + ); + } + + // Wait for roles to load before checking permissions + if (userRoles === null) { + return ( + + + + + + ); + } + + // 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 ( + + + + You do not have permission to access the reviewer dashboard. This page is only available to users with review permissions. + + + + ); + } + + return ( + + + + + } + aria-label="breadcrumb" + > + + Home + + Reviewer Dashboard + + + + + + Reviewer Dashboard + + + + Manage submissions and map fixes requiring your attention. + + + {/* Summary Cards */} + + {canReviewSubmissions && ( + + + + + + + {isLoadingSubmissions ? ( + + ) : ( + userRoles && submissions + ? groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0) + : 0 + )} + + + Submissions Pending Review + + + + + + )} + + {canReviewMapfixes && ( + + + + + + + {isLoadingMapfixes ? ( + + ) : ( + userRoles && mapfixes + ? groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0) + : 0 + )} + + + Map Fixes Pending Review + + + + + + )} + + + {/* Tabs */} + + + {canReviewSubmissions && ( + + Submissions + {!isLoadingSubmissions && userRoles && submissions && (() => { + const total = groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0); + return total > 0 ? ( + + ) : null; + })()} + + } + id="reviewer-tab-0" + aria-controls="reviewer-tabpanel-0" + /> + )} + {canReviewMapfixes && ( + + Map Fixes + {!isLoadingMapfixes && userRoles && mapfixes && (() => { + const total = groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0); + return total > 0 ? ( + + ) : null; + })()} + + } + id={`reviewer-tab-${canReviewSubmissions ? 1 : 0}`} + aria-controls={`reviewer-tabpanel-${canReviewSubmissions ? 1 : 0}`} + /> + )} + + + + {/* Submissions Tab */} + {canReviewSubmissions && ( + + {userRoles && submissions && groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0) === 0 ? ( + + No submissions currently need your review. Great job! + + ) : isLoadingSubmissions ? ( + + {skeletonCards.map((i) => ( + + + + + + + + + + + + + + + + ))} + + ) : ( + + {userRoles && submissions && groupSubmissionsByStatus(submissions.Submissions, userRoles).map((group, groupIdx) => ( + + + {group.label} ({group.items.length}) + + + {group.items.map((submission) => ( + + ))} + + + ))} + + )} + + )} + + {/* Map Fixes Tab */} + {canReviewMapfixes && ( + + {userRoles && mapfixes && groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0) === 0 ? ( + + No map fixes currently need your review. Great job! + + ) : isLoadingMapfixes ? ( + + {skeletonCards.map((i) => ( + + + + + + + + + + + + + + + + ))} + + ) : ( + + {userRoles && mapfixes && groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).map((group, groupIdx) => ( + + + {group.label} ({group.items.length}) + + + {group.items.map((mapfix) => ( + + ))} + + + ))} + + )} + + )} + + + + ); +} diff --git a/web/src/app/ts/Roles.ts b/web/src/app/ts/Roles.ts index 626dee2..074a5be 100644 --- a/web/src/app/ts/Roles.ts +++ b/web/src/app/ts/Roles.ts @@ -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, }; diff --git a/web/src/app/user-dashboard/page.tsx b/web/src/app/user-dashboard/page.tsx new file mode 100644 index 0000000..370a33b --- /dev/null +++ b/web/src/app/user-dashboard/page.tsx @@ -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: + }); + } + + // 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: + }); + } + + // 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: + }); + } + + // 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: + }); + } + + return groups; +} + +export default function UserDashboardPage() { + useTitle("Dashboard"); + const { user, isLoading: userLoading } = useUser(); + + const [submissions, setSubmissions] = useState(null); + const [mapfixes, setMapfixes] = useState(null); + const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false); + const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false); + const [userRoles, setUserRoles] = useState(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 ( + + + + + + ); + } + + // Check if user is logged in + if (!user) { + return ( + + + + You must be logged in to view your contributions. + + + + ); + } + + // 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 ( + + + + + } + aria-label="breadcrumb" + > + + Home + + Dashboard + + {userRoles && hasAnyReviewerRole(userRoles) && ( + + )} + + + + Dashboard + + + + Welcome back, {user.Username}! Here's the status of all your map submissions and fixes. + + + {/* Overview Stats */} + + + + + Total Contributions + + + {isLoading ? ( + + ) : ( + totalContributions + )} + + + + + + + + Released + + + {isLoading ? ( + + ) : ( + totalReleased + )} + + + + + + + + In Review + + + {isLoading ? ( + + ) : ( + totalContributions - totalReleased - totalActionNeeded - (submissions?.Submissions.filter(s => s.StatusID === SubmissionStatus.Rejected).length || 0) - (mapfixes?.Mapfixes.filter(m => m.StatusID === MapfixStatus.Rejected).length || 0) + )} + + + + + + + + Action Needed + + + {isLoading ? ( + + ) : ( + totalActionNeeded + )} + + + + + + {/* Quick Actions */} + + + + + + {/* All Contributions - Grouped by Status */} + {totalContributions === 0 && !isLoading ? ( + + You haven't made any contributions yet. Start by submitting a map or fixing an existing one! + + ) : isLoading ? ( + + {skeletonCards.map((i) => ( + + + + + + + + + + + + ))} + + ) : hasData ? ( + + {groupAllContributions(submissions.Submissions, mapfixes.Mapfixes).map((group, groupIdx) => ( + + + + + {group.icon} + + + + {group.label} ({group.items.length}) + + + {group.description} + + {group.items.length > 0 && ( + + } + 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' + } + }} + /> + } + 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' + } + }} + /> + + )} + + + + + {group.items.map((item) => ( + + ))} + + + ))} + + ) : null} + + {/* Load More Button */} + {hasData && hasMoreItems && !isLoading && ( + + + + )} + + + + ); +} -- 2.49.1 From ce32f1d0ddc03ab80e42c728447ea9ba5ee53157 Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 23:37:19 -0500 Subject: [PATCH 2/4] Dedupe status --- web/src/app/reviewer-dashboard/page.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/app/reviewer-dashboard/page.tsx b/web/src/app/reviewer-dashboard/page.tsx index 25091f0..0ea8d5e 100644 --- a/web/src/app/reviewer-dashboard/page.tsx +++ b/web/src/app/reviewer-dashboard/page.tsx @@ -242,12 +242,13 @@ export default function ReviewerDashboardPage() { setIsLoadingSubmissions(true); try { - // Get statuses based on user roles + // 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( - allowedStatuses.map(status => fetchAllPagesForStatus(status)) + uniqueStatuses.map(status => fetchAllPagesForStatus(status)) ); // Combine all results @@ -324,12 +325,13 @@ export default function ReviewerDashboardPage() { setIsLoadingMapfixes(true); try { - // Get statuses based on user roles + // 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( - allowedStatuses.map(status => fetchAllPagesForStatus(status)) + uniqueStatuses.map(status => fetchAllPagesForStatus(status)) ); // Combine all results -- 2.49.1 From cf6abe0e0ebe38aa29da39f31091f4467a3d4b86 Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 23:41:20 -0500 Subject: [PATCH 3/4] Use correct status query param --- web/src/app/reviewer-dashboard/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/reviewer-dashboard/page.tsx b/web/src/app/reviewer-dashboard/page.tsx index 0ea8d5e..00a663a 100644 --- a/web/src/app/reviewer-dashboard/page.tsx +++ b/web/src/app/reviewer-dashboard/page.tsx @@ -208,7 +208,7 @@ export default function ReviewerDashboardPage() { while (hasMore) { const res = await fetch( - `/v1/submissions?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&Status=${status}`, + `/v1/submissions?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&StatusID=${status}`, { signal: controller.signal } ); @@ -291,7 +291,7 @@ export default function ReviewerDashboardPage() { while (hasMore) { const res = await fetch( - `/v1/mapfixes?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&Status=${status}`, + `/v1/mapfixes?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&StatusID=${status}`, { signal: controller.signal } ); -- 2.49.1 From 372880be419699237183b42d88afee7f604d0a21 Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 23:50:10 -0500 Subject: [PATCH 4/4] Fix lint --- web/src/app/_components/mapCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/_components/mapCard.tsx b/web/src/app/_components/mapCard.tsx index 10fd0cf..e328689 100644 --- a/web/src/app/_components/mapCard.tsx +++ b/web/src/app/_components/mapCard.tsx @@ -1,4 +1,4 @@ -import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Typography, Skeleton, Chip} from "@mui/material"; +import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Typography, Skeleton} from "@mui/material"; import {Explore, Person2, Assignment, Build} from "@mui/icons-material"; import {StatusChip} from "@/app/_components/statusChip"; import {Link} from "react-router-dom"; -- 2.49.1