From 57bca99109e6d39e82ed3cfb1d43090f9e10951b Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 19:42:36 -0500 Subject: [PATCH 1/7] Fix overflow --- web/src/app/mapfixes/page.tsx | 6 ++++-- web/src/app/submissions/page.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index 0ebc2d3..1b149b3 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -77,9 +77,10 @@ export default function MapfixInfoPage() { display: 'flex', justifyContent: 'center', py: 6, - px: 2 + px: 2, + boxSizing: 'border-box' }}> - + } aria-label="breadcrumb" @@ -111,6 +112,7 @@ export default function MapfixInfoPage() { }, gap: 3, width: '100%', + minWidth: 0, }} > {!mapfixes || isLoading ? ( diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index ff91722..5ec99a6 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -77,9 +77,10 @@ export default function SubmissionInfoPage() { display: 'flex', justifyContent: 'center', py: 6, - px: 2 + px: 2, + boxSizing: 'border-box' }}> - + } aria-label="breadcrumb" @@ -111,6 +112,7 @@ export default function SubmissionInfoPage() { }, gap: 3, width: '100%', + minWidth: 0, }} > {!submissions || isLoading ? ( -- 2.49.1 From a19bc4d3800c445602a400c981e85407eef7670d Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 20:32:55 -0500 Subject: [PATCH 2/7] Add mapfix history on maps page --- web/src/app/maps/[mapId]/page.tsx | 290 ++++++++++++++++++++++++++---- web/src/app/ts/Mapfix.ts | 39 ++++ 2 files changed, 290 insertions(+), 39 deletions(-) diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index d39c483..f519217 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -4,7 +4,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; import { Snackbar, Alert } from "@mui/material"; -import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix"; +import { MapfixStatus, type MapfixInfo, getMapfixStatusInfo } from "@/app/ts/Mapfix"; import LaunchIcon from '@mui/icons-material/Launch'; import { useAssetThumbnail } from "@/app/hooks/useThumbnails"; @@ -23,7 +23,11 @@ import { Stack, CardMedia, Tooltip, - IconButton + IconButton, + List, + ListItem, + ListItemIcon, + Pagination } from "@mui/material"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; @@ -33,6 +37,11 @@ import BugReportIcon from "@mui/icons-material/BugReport"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import DownloadIcon from '@mui/icons-material/Download'; +import HistoryIcon from '@mui/icons-material/History'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CancelIcon from '@mui/icons-material/Cancel'; +import BuildIcon from '@mui/icons-material/Build'; +import PendingIcon from '@mui/icons-material/Pending'; import {hasRole, RolesConstants} from "@/app/ts/Roles"; import {useTitle} from "@/app/hooks/useTitle"; @@ -45,6 +54,7 @@ export default function MapDetails() { const [copySuccess, setCopySuccess] = useState(false); const [roles, setRoles] = useState(RolesConstants.Empty); const [mapfixes, setMapfixes] = useState([]); + const [fixesPage, setFixesPage] = useState(1); useTitle(map ? `${map.DisplayName}` : 'Loading Map...'); @@ -111,9 +121,8 @@ export default function MapDetails() { allMapfixes = allMapfixes.concat(data.Mapfixes); page++; } while (allMapfixes.length < total); - // Filter out rejected, uploading, uploaded (StatusID > 7) - const active = allMapfixes.filter((fix: MapfixInfo) => fix.StatusID <= MapfixStatus.Validated); - setMapfixes(active); + // Store all mapfixes for history display + setMapfixes(allMapfixes); } catch { setMapfixes([]); } @@ -154,6 +163,16 @@ export default function MapDetails() { } }; + const getStatusIcon = (iconName: string) => { + switch (iconName) { + case "Build": return BuildIcon; + case "Pending": return PendingIcon; + case "CheckCircle": return CheckCircleIcon; + case "Cancel": return CancelIcon; + default: return PendingIcon; + } + }; + const handleSubmitMapfix = () => { navigate(`/maps/${mapId}/fix`); }; @@ -324,7 +343,8 @@ export default function MapDetails() { sx={{ borderRadius: 2, overflow: 'hidden', - position: 'relative' + position: 'relative', + mb: 3 }} > @@ -355,6 +375,231 @@ export default function MapDetails() { /> + + {/* Mapfix Section - Active + History */} + {mapfixes.length > 0 && (() => { + const activeFix = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated); + const releasedFixes = mapfixes.filter(fix => fix.StatusID === MapfixStatus.Released); + const hasContent = activeFix || releasedFixes.length > 0; + + if (!hasContent) return null; + + // Pagination for released fixes + const fixesPerPage = 5; + const totalPages = Math.ceil(releasedFixes.length / fixesPerPage); + const startIndex = (fixesPage - 1) * fixesPerPage; + const endIndex = startIndex + fixesPerPage; + const paginatedFixes = releasedFixes + .sort((a, b) => b.CreatedAt - a.CreatedAt) + .slice(startIndex, endIndex); + + return ( + + + + + Mapfixes + + + + + + {/* Active Mapfix - shown first with special styling */} + {activeFix && ( + + 0 ? 2 : 0, + '&:hover': { + backgroundColor: 'rgba(25, 118, 210, 0.12)', + transform: 'translateX(4px)' + }, + textDecoration: 'none', + color: 'inherit', + display: 'block' + }} + > + + + {(() => { + const statusInfo = getMapfixStatusInfo(activeFix.StatusID); + const StatusIcon = getStatusIcon(statusInfo.iconName); + return ( + + ); + })()} + + + + + {activeFix.Description} + + + + + + + + + + + + {activeFix.Creator} + + + + + + {formatDate(activeFix.CreatedAt)} + + + + + + + + + + )} + + {/* Released Fixes History */} + {releasedFixes.length > 0 && ( + <> + {activeFix && ( + + + + + + )} + {paginatedFixes.map((fix, index) => { + const statusInfo = getMapfixStatusInfo(fix.StatusID); + const StatusIcon = getStatusIcon(statusInfo.iconName); + + return ( + + + + + + + + + + {fix.Description} + + + + + + + {fix.Creator} + + + + + + {formatDate(fix.CreatedAt)} + + + + + + + + + {index < paginatedFixes.length - 1 && } + + ); + })} + + {/* Pagination */} + {totalPages > 1 && ( + + setFixesPage(page)} + color="primary" + size="medium" + /> + + )} + + )} + + + ); + })()} {/* Map Details Section */} @@ -399,39 +644,6 @@ export default function MapDetails() { - - {/* Active Mapfix in Map Details */} - {mapfixes.length > 0 && (() => { - const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated); - const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b)); - const showFix = active || latest; - return ( - - - Active Mapfix - - - - {showFix.Description} - - - - - ); - })()} diff --git a/web/src/app/ts/Mapfix.ts b/web/src/app/ts/Mapfix.ts index 2fabdb1..f62a9b1 100644 --- a/web/src/app/ts/Mapfix.ts +++ b/web/src/app/ts/Mapfix.ts @@ -66,9 +66,48 @@ function MapfixStatusToString(mapfix_status: MapfixStatus): string { } } +interface MapfixStatusInfo { + label: string; + color: 'default' | 'error' | 'warning' | 'success' | 'primary' | 'info'; + iconName: string; +} + +function getMapfixStatusInfo(statusId: MapfixStatus): MapfixStatusInfo { + switch (statusId) { + case MapfixStatus.UnderConstruction: + return { label: "Under Construction", color: "default", iconName: "Build" }; + case MapfixStatus.ChangesRequested: + return { label: "Changes Requested", color: "warning", iconName: "Pending" }; + case MapfixStatus.Submitting: + return { label: "Submitting", color: "info", iconName: "Pending" }; + case MapfixStatus.Submitted: + return { label: "Submitted", color: "info", iconName: "CheckCircle" }; + case MapfixStatus.AcceptedUnvalidated: + return { label: "Accepted (Unvalidated)", color: "primary", iconName: "CheckCircle" }; + case MapfixStatus.Validating: + return { label: "Validating", color: "info", iconName: "Pending" }; + case MapfixStatus.Validated: + return { label: "Validated", color: "success", iconName: "CheckCircle" }; + case MapfixStatus.Uploading: + return { label: "Uploading", color: "info", iconName: "Pending" }; + case MapfixStatus.Uploaded: + return { label: "Uploaded", color: "success", iconName: "CheckCircle" }; + case MapfixStatus.Rejected: + return { label: "Rejected", color: "error", iconName: "Cancel" }; + case MapfixStatus.Released: + return { label: "Released", color: "success", iconName: "CheckCircle" }; + case MapfixStatus.Releasing: + return { label: "Releasing", color: "info", iconName: "Pending" }; + default: + return { label: "Unknown", color: "default", iconName: "Pending" }; + } +} + export { MapfixStatus, MapfixStatusToString, + getMapfixStatusInfo, type MapfixInfo, type MapfixList, + type MapfixStatusInfo, } -- 2.49.1 From 01cfe678480d154fcade97899edb50d0ee4c1cb0 Mon Sep 17 00:00:00 2001 From: itzaname Date: Fri, 26 Dec 2025 20:38:18 -0500 Subject: [PATCH 3/7] Just exclude rejected and released for active list --- web/src/app/maps/[mapId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index f519217..935b0fe 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -378,7 +378,7 @@ export default function MapDetails() { {/* Mapfix Section - Active + History */} {mapfixes.length > 0 && (() => { - const activeFix = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated); + const activeFix = mapfixes.find(fix => fix.StatusID !== MapfixStatus.Rejected && fix.StatusID !== MapfixStatus.Released); const releasedFixes = mapfixes.filter(fix => fix.StatusID === MapfixStatus.Released); const hasContent = activeFix || releasedFixes.length > 0; -- 2.49.1 From 5a1fe60a7b344afc312a41099d8532358b8ec360 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Fri, 26 Dec 2025 19:02:14 -0800 Subject: [PATCH 4/7] fix quat docker --- compose.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index 50ed183..ee07f03 100644 --- a/compose.yaml +++ b/compose.yaml @@ -34,7 +34,7 @@ services: "--data-rpc-host","dataservice:9000", ] env_file: - - ~/auth-compose/strafesnet_staging.env + - /home/quat/auth-compose/strafesnet_staging.env depends_on: - authrpc - nats @@ -59,7 +59,7 @@ services: maptest-validator container_name: validation env_file: - - ~/auth-compose/strafesnet_staging.env + - /home/quat/auth-compose/strafesnet_staging.env environment: - ROBLOX_GROUP_ID=17032139 # "None" is special case string value - API_HOST_INTERNAL=http://submissions:8083/v1 @@ -105,7 +105,7 @@ services: - REDIS_ADDR=authredis:6379 - RBX_GROUP_ID=17032139 env_file: - - ~/auth-compose/auth-service.env + - /home/quat/auth-compose/auth-service.env depends_on: - authredis networks: @@ -119,7 +119,7 @@ services: environment: - REDIS_ADDR=authredis:6379 env_file: - - ~/auth-compose/auth-service.env + - /home/quat/auth-compose/auth-service.env depends_on: - authredis networks: -- 2.49.1 From 58706a5687c4dd18ef62915c179bfe2dd24da7da Mon Sep 17 00:00:00 2001 From: itzaname Date: Sat, 27 Dec 2025 05:20:45 +0000 Subject: [PATCH 5/7] Add user/reviewer dashboard (#297) Adds "at a glance" dashboard so life is less painful. ![image.png](/attachments/43e83777-7196-4274-9adc-e1268e43bc0f) ![image.png](/attachments/1cbe99ab-50b8-443a-aa48-ad9107ccfb1e) Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/297 Reviewed-by: Rhys Lloyd Co-authored-by: itzaname Co-committed-by: itzaname --- web/src/App.tsx | 4 + web/src/app/_components/mapCard.tsx | 25 +- web/src/app/page.tsx | 76 ++- web/src/app/reviewer-dashboard/page.tsx | 749 ++++++++++++++++++++++++ web/src/app/ts/Roles.ts | 12 + web/src/app/user-dashboard/page.tsx | 637 ++++++++++++++++++++ 6 files changed, 1499 insertions(+), 4 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..e328689 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 {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..00a663a --- /dev/null +++ b/web/src/app/reviewer-dashboard/page.tsx @@ -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 ( + + ); +} + +// 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}&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 { + 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 ( + + + + + + ); + } + + // 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 ea65794255ab372a0595ac0fc34a5bc939d697d5 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Sat, 27 Dec 2025 05:26:04 +0000 Subject: [PATCH 6/7] Cycle before and after images every 1.5 seconds (#295) The images should auto cycle now that the thumbnails are working. I don't know how to test this! This is what I tried: ``` bun install bun run build VITE_API_HOST=https://maps.staging.strafes.net/v1 bun run preview ``` but the mapfixes page won't load the mapfixes. Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/295 Reviewed-by: itzaname Co-authored-by: Rhys Lloyd Co-committed-by: Rhys Lloyd --- web/src/app/mapfixes/[mapfixId]/page.tsx | 39 ++++++------------------ 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx index b295be8..4923c4e 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -1,6 +1,6 @@ import Webpage from "@/app/_components/webpage"; import { useParams, useNavigate } from "react-router-dom"; -import {useState} from "react"; +import {useState, useEffect} from "react"; import { Link } from "react-router-dom"; import { useAssetThumbnail } from "@/app/hooks/useThumbnails"; @@ -121,6 +121,15 @@ export default function MapfixDetailsPage() { }; + // cycle before and after images every 2 seconds + useEffect(() => { + const interval = setInterval(() => { + setShowBeforeImage((prev) => !prev); + }, 2000); + + return () => clearInterval(interval); + }, []); + const handleCommentSubmit = async () => { if (!newComment.trim()) { return; // Don't submit empty comments @@ -323,33 +332,6 @@ export default function MapfixDetailsPage() { )} - - - Click to compare - - - setShowBeforeImage(!showBeforeImage)} /> -- 2.49.1 From 74565e567a94c7994936c8a39c73fa8e4506a7d0 Mon Sep 17 00:00:00 2001 From: itzaname Date: Sat, 27 Dec 2025 05:39:33 +0000 Subject: [PATCH 7/7] Fix "0" displaying in "Review Dashboard" button on user dashboard (#298) The review dashboard link only shows when the user has the correct roles. A normal user would not see the button but instead the text "0". Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/298 Reviewed-by: Rhys Lloyd Co-authored-by: itzaname Co-committed-by: itzaname --- web/src/app/user-dashboard/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/user-dashboard/page.tsx b/web/src/app/user-dashboard/page.tsx index 370a33b..4eb5549 100644 --- a/web/src/app/user-dashboard/page.tsx +++ b/web/src/app/user-dashboard/page.tsx @@ -379,7 +379,7 @@ export default function UserDashboardPage() { Dashboard - {userRoles && hasAnyReviewerRole(userRoles) && ( + {userRoles !== null && hasAnyReviewerRole(userRoles) && (