Add user/reviewer dashboard #297
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
749
web/src/app/reviewer-dashboard/page.tsx
Normal file
749
web/src/app/reviewer-dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
637
web/src/app/user-dashboard/page.tsx
Normal file
637
web/src/app/user-dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user