diff --git a/web/src/app/_components/comments/AuditEventItem.tsx b/web/src/app/_components/comments/AuditEventItem.tsx
index f4a2989..c5930dd 100644
--- a/web/src/app/_components/comments/AuditEventItem.tsx
+++ b/web/src/app/_components/comments/AuditEventItem.tsx
@@ -12,13 +12,15 @@ import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/Audi
interface AuditEventItemProps {
event: AuditEvent;
validatorUser: number;
+ userAvatarUrl?: string;
}
-export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) {
+export default function AuditEventItem({ event, validatorUser, userAvatarUrl }: AuditEventItemProps) {
return (
diff --git a/web/src/app/_components/comments/AuditEventsTabPanel.tsx b/web/src/app/_components/comments/AuditEventsTabPanel.tsx
index d5b68cc..250d6bd 100644
--- a/web/src/app/_components/comments/AuditEventsTabPanel.tsx
+++ b/web/src/app/_components/comments/AuditEventsTabPanel.tsx
@@ -10,12 +10,14 @@ interface AuditEventsTabPanelProps {
activeTab: number;
auditEvents: AuditEvent[];
validatorUser: number;
+ auditEventUserAvatarUrls?: Record;
}
export default function AuditEventsTabPanel({
activeTab,
auditEvents,
- validatorUser
+ validatorUser,
+ auditEventUserAvatarUrls
}: AuditEventsTabPanelProps) {
const filteredEvents = auditEvents.filter(
event => event.EventType !== AuditEventType.Comment
@@ -30,6 +32,7 @@ export default function AuditEventsTabPanel({
key={index}
event={event}
validatorUser={validatorUser}
+ userAvatarUrl={auditEventUserAvatarUrls?.[event.User]}
/>
))}
diff --git a/web/src/app/_components/comments/CommentItem.tsx b/web/src/app/_components/comments/CommentItem.tsx
index a385244..2f7e116 100644
--- a/web/src/app/_components/comments/CommentItem.tsx
+++ b/web/src/app/_components/comments/CommentItem.tsx
@@ -12,13 +12,15 @@ import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent";
interface CommentItemProps {
event: AuditEvent;
validatorUser: number;
+ userAvatarUrl?: string;
}
-export default function CommentItem({ event, validatorUser }: CommentItemProps) {
+export default function CommentItem({ event, validatorUser, userAvatarUrl }: CommentItemProps) {
return (
diff --git a/web/src/app/_components/comments/CommentsAndAuditSection.tsx b/web/src/app/_components/comments/CommentsAndAuditSection.tsx
index 274304b..eb8908a 100644
--- a/web/src/app/_components/comments/CommentsAndAuditSection.tsx
+++ b/web/src/app/_components/comments/CommentsAndAuditSection.tsx
@@ -16,17 +16,20 @@ interface CommentsAndAuditSectionProps {
handleCommentSubmit: () => void;
validatorUser: number;
userId: number | null;
+ commentUserAvatarUrls: Record;
+ auditEventUserAvatarUrls?: Record;
}
export default function CommentsAndAuditSection({
- auditEvents,
- newComment,
- setNewComment,
- handleCommentSubmit,
- validatorUser,
- userId,
- }: CommentsAndAuditSectionProps) {
-
+ auditEvents,
+ newComment,
+ setNewComment,
+ handleCommentSubmit,
+ validatorUser,
+ userId,
+ commentUserAvatarUrls,
+ auditEventUserAvatarUrls
+}: CommentsAndAuditSectionProps) {
const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
@@ -53,12 +56,14 @@ export default function CommentsAndAuditSection({
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
+ commentUserAvatarUrls={commentUserAvatarUrls}
/>
);
diff --git a/web/src/app/_components/comments/CommentsTabPanel.tsx b/web/src/app/_components/comments/CommentsTabPanel.tsx
index d8d9ace..2fc9c4c 100644
--- a/web/src/app/_components/comments/CommentsTabPanel.tsx
+++ b/web/src/app/_components/comments/CommentsTabPanel.tsx
@@ -18,6 +18,8 @@ interface CommentsTabPanelProps {
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
+ userAvatarUrl?: string;
+ commentUserAvatarUrls?: Record;
}
export default function CommentsTabPanel({
@@ -27,7 +29,9 @@ export default function CommentsTabPanel({
newComment,
setNewComment,
handleCommentSubmit,
- userId
+ userId,
+ userAvatarUrl,
+ commentUserAvatarUrls
}: CommentsTabPanelProps) {
const commentEvents = auditEvents.filter(
event => event.EventType === AuditEventType.Comment
@@ -44,6 +48,7 @@ export default function CommentsTabPanel({
key={index}
event={event}
validatorUser={validatorUser}
+ userAvatarUrl={commentUserAvatarUrls?.[event.User]}
/>
))
) : (
@@ -59,6 +64,7 @@ export default function CommentsTabPanel({
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
+ userAvatarUrl={userAvatarUrl}
/>
)}
>
@@ -72,13 +78,15 @@ interface CommentInputProps {
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
+ userAvatarUrl?: string;
}
-function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) {
+function CommentInput({ newComment, setNewComment, handleCommentSubmit, userAvatarUrl }: CommentInputProps) {
return (
-
+ {props.thumbnailUrl ? (
+
+ ) : (
+
+
+
+ )}
-
-
-
-
-
- {/*In the future author should be the username of the submitter not the info from the map*/}
- {props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- })}
-
-
+
+
+
+
+ {props.submitterUsername} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })}
+
diff --git a/web/src/app/_components/review/ReviewItem.tsx b/web/src/app/_components/review/ReviewItem.tsx
index 1ce1940..22aad2e 100644
--- a/web/src/app/_components/review/ReviewItem.tsx
+++ b/web/src/app/_components/review/ReviewItem.tsx
@@ -16,13 +16,16 @@ type ReviewItemType = SubmissionInfo | MapfixInfo;
interface ReviewItemProps {
item: ReviewItemType;
handleCopyValue: (value: string) => void;
+ submitterAvatarUrl?: string;
+ submitterUsername?: string;
}
export function ReviewItem({
- item,
- handleCopyValue
- }: ReviewItemProps) {
- // Type guard to check if item is valid
+ item,
+ handleCopyValue,
+ submitterAvatarUrl,
+ submitterUsername
+}: ReviewItemProps) {
if (!item) return null;
// Determine the type of item
@@ -53,6 +56,8 @@ export function ReviewItem({
statusId={item.StatusID}
creator={item.Creator}
submitterId={item.Submitter}
+ submitterAvatarUrl={submitterAvatarUrl}
+ submitterUsername={submitterUsername}
/>
{/* Item Details */}
diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx
index 3fd975d..56d2d7e 100644
--- a/web/src/app/_components/review/ReviewItemHeader.tsx
+++ b/web/src/app/_components/review/ReviewItemHeader.tsx
@@ -3,52 +3,20 @@ import { StatusChip } from "@/app/_components/statusChip";
import { SubmissionStatus } from "@/app/ts/Submission";
import { MapfixStatus } from "@/app/ts/Mapfix";
import {Status, StatusMatches} from "@/app/ts/Status";
-import { useState, useEffect } from "react";
import Link from "next/link";
import LaunchIcon from '@mui/icons-material/Launch';
-function SubmitterName({ submitterId }: { submitterId: number }) {
- const [name, setName] = useState(null);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- if (!submitterId) return;
- const fetchUserName = async () => {
- try {
- setLoading(true);
- const response = await fetch(`/proxy/users/${submitterId}`);
- if (!response.ok) throw new Error('Failed to fetch user');
- const data = await response.json();
- setName(`@${data.name}`);
- } catch {
- setName(String(submitterId));
- } finally {
- setLoading(false);
- }
- };
- fetchUserName();
- }, [submitterId]);
-
- if (loading) return Loading...;
- return
-
-
- {name || submitterId}
-
-
-
-
-}
-
interface ReviewItemHeaderProps {
displayName: string;
assetId: number | null | undefined,
statusId: SubmissionStatus | MapfixStatus;
creator: string | null | undefined;
submitterId: number;
+ submitterAvatarUrl?: string;
+ submitterUsername?: string;
}
-export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
+export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, submitterId, submitterAvatarUrl, submitterUsername }: ReviewItemHeaderProps) => {
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
const pulse = keyframes`
0%, 100% { opacity: 0.2; transform: scale(0.8); }
@@ -112,11 +80,18 @@ export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, subm
-
+
+
+
+ {submitterUsername ? `@${submitterUsername}` : submitterId}
+
+
+
+
>
);
-};
\ No newline at end of file
+};
diff --git a/web/src/app/hooks/useBatchThumbnails.ts b/web/src/app/hooks/useBatchThumbnails.ts
new file mode 100644
index 0000000..e860413
--- /dev/null
+++ b/web/src/app/hooks/useBatchThumbnails.ts
@@ -0,0 +1,61 @@
+import { useState, useEffect } from "react";
+
+function chunkArray(arr: T[], size: number): T[][] {
+ const res: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) {
+ res.push(arr.slice(i, i + size));
+ }
+ return res;
+}
+
+/**
+ * Fetches thumbnail URLs for a batch of asset IDs using the unified /thumbnails/batch endpoint.
+ * Handles loading and error state. Returns a mapping of assetId to URL.
+ */
+export function useBatchThumbnails(assetIds: (number | string)[] | undefined) {
+ const [thumbnails, setThumbnails] = useState>({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!assetIds || assetIds.length === 0) {
+ setThumbnails({});
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ const filteredIds = assetIds.filter(Boolean);
+ if (filteredIds.length === 0) {
+ setThumbnails({});
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const chunks = chunkArray(filteredIds, 50);
+ Promise.all(
+ chunks.map(chunk =>
+ fetch(`/thumbnails/batch?type=asset&ids=${chunk.join(",")}&type=asset`)
+ .then(res => {
+ if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`);
+ return res.json();
+ })
+ )
+ )
+ .then(datas => {
+ const result: Record = {};
+ for (const data of datas) {
+ for (const [id, url] of Object.entries(data)) {
+ if (url) result[Number(id)] = url as string;
+ }
+ }
+ setThumbnails(result);
+ })
+ .catch(err => setError(err))
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [assetIds && assetIds.filter(Boolean).join(",")]);
+
+ return { thumbnails, loading, error };
+}
diff --git a/web/src/app/hooks/useBatchUserAvatars.ts b/web/src/app/hooks/useBatchUserAvatars.ts
new file mode 100644
index 0000000..84bc42c
--- /dev/null
+++ b/web/src/app/hooks/useBatchUserAvatars.ts
@@ -0,0 +1,61 @@
+import { useState, useEffect } from "react";
+
+function chunkArray(arr: T[], size: number): T[][] {
+ const res: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) {
+ res.push(arr.slice(i, i + size));
+ }
+ return res;
+}
+
+/**
+ * Fetches avatar URLs for a batch of user IDs using the unified /thumbnails/batch?type=user endpoint.
+ * Returns a mapping of userId to avatar URL.
+ */
+export function useBatchUserAvatars(userIds: (number | string)[] | undefined) {
+ const [avatars, setAvatars] = useState>({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!userIds || userIds.length === 0) {
+ setAvatars({});
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ const filteredIds = userIds.filter(Boolean);
+ if (filteredIds.length === 0) {
+ setAvatars({});
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const chunks = chunkArray(filteredIds, 50);
+ Promise.all(
+ chunks.map(chunk =>
+ fetch(`/thumbnails/batch?type=user&ids=${chunk.join(",")}`)
+ .then(res => {
+ if (!res.ok) throw new Error(`Failed to fetch user avatars: ${res.status}`);
+ return res.json();
+ })
+ )
+ )
+ .then(datas => {
+ const result: Record = {};
+ for (const data of datas) {
+ for (const [id, url] of Object.entries(data)) {
+ if (url) result[Number(id)] = url as string;
+ }
+ }
+ setAvatars(result);
+ })
+ .catch(err => setError(err))
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userIds && userIds.filter(Boolean).join(",")]);
+
+ return { avatars, loading, error };
+}
diff --git a/web/src/app/hooks/useBatchUsernames.ts b/web/src/app/hooks/useBatchUsernames.ts
new file mode 100644
index 0000000..cc8e90c
--- /dev/null
+++ b/web/src/app/hooks/useBatchUsernames.ts
@@ -0,0 +1,63 @@
+import { useState, useEffect } from "react";
+
+function chunkArray(arr: T[], size: number): T[][] {
+ const res: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) {
+ res.push(arr.slice(i, i + size));
+ }
+ return res;
+}
+
+/**
+ * Fetches usernames for a batch of user IDs using the /proxy/users/batch?ids=... endpoint.
+ * Returns a mapping of userId to username (or userId as string if not found).
+ */
+export function useBatchUsernames(userIds: (number | string)[] | undefined) {
+ const [usernames, setUsernames] = useState>({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!userIds || userIds.length === 0) {
+ setUsernames({});
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ const filteredIds = userIds.filter(Boolean);
+ if (filteredIds.length === 0) {
+ setUsernames({});
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const chunks = chunkArray(filteredIds, 50);
+ Promise.all(
+ chunks.map(chunk =>
+ fetch(`/proxy/users/batch?ids=${chunk.join(",")}`)
+ .then(res => {
+ if (!res.ok) throw new Error(`Failed to fetch usernames: ${res.status}`);
+ return res.json();
+ })
+ )
+ )
+ .then(datas => {
+ const result: Record = {};
+ for (const data of datas) {
+ if (Array.isArray(data.data)) {
+ for (const user of data.data) {
+ result[user.id] = user.name || String(user.id);
+ }
+ }
+ }
+ setUsernames(result);
+ })
+ .catch(err => setError(err))
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userIds && userIds.filter(Boolean).join(",")]);
+
+ return { usernames, loading, error };
+}
diff --git a/web/src/app/lib/thumbnailLoader.ts b/web/src/app/lib/thumbnailLoader.ts
deleted file mode 100644
index 6e45a87..0000000
--- a/web/src/app/lib/thumbnailLoader.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const thumbnailLoader = ({ src, width, quality }: { src: string, width: number, quality?: number }) => {
- return `${src}?w=${width}&q=${quality || 75}`;
-};
\ No newline at end of file
diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx
index bc05191..87ee9ed 100644
--- a/web/src/app/mapfixes/[mapfixId]/page.tsx
+++ b/web/src/app/mapfixes/[mapfixId]/page.tsx
@@ -2,7 +2,7 @@
import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation";
-import {useState} from "react";
+import { useState } from "react";
import Link from "next/link";
// MUI Components
@@ -17,6 +17,7 @@ import {
CardMedia,
Snackbar,
Alert,
+ CircularProgress,
} from "@mui/material";
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
@@ -27,6 +28,9 @@ import ReviewButtons from "@/app/_components/review/ReviewButtons";
import {useReviewData} from "@/app/hooks/useReviewData";
import {MapfixInfo} from "@/app/ts/Mapfix";
import {useTitle} from "@/app/hooks/useTitle";
+import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
+import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
+import { useBatchUsernames } from "@/app/hooks/useBatchUsernames";
interface SnackbarState {
open: boolean;
@@ -44,6 +48,7 @@ export default function MapfixDetailsPage() {
message: null,
severity: 'success'
});
+
const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
setSnackbar({
open: true,
@@ -76,6 +81,43 @@ export default function MapfixDetailsPage() {
useTitle(mapfix ? `${mapfix.DisplayName} Mapfix` : 'Loading Mapfix...');
+ // Fetch thumbnails for mapfix images using the hook
+ const assetIds = [mapfix?.TargetAssetID, mapfix?.AssetID].filter(Boolean);
+ const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
+
+ // Gather all user IDs: submitter, commenters, audit event actors
+ const commentUserIds = (auditEvents || [])
+ .filter(ev => ev.User && ev.User > 0)
+ .map(ev => ev.User);
+ const submitterId = mapfix?.Submitter;
+ const allUserIds = Array.from(new Set([
+ submitterId,
+ ...(commentUserIds || [])
+ ])).filter(Boolean);
+
+ // Batch fetch avatars and submitter username only
+ const { avatars: userAvatars } = useBatchUserAvatars(allUserIds);
+ const { usernames: userUsernames } = useBatchUsernames([submitterId].filter(Boolean));
+
+ // Prepare avatar/username props for ReviewItem
+ const submitterAvatarUrl = submitterId ? userAvatars[submitterId] : undefined;
+ const submitterUsername = submitterId ? userUsernames[submitterId] : undefined;
+
+ // Prepare avatar map for CommentsAndAuditSection (comments)
+ const commentUserAvatarUrls: Record = {};
+ for (const uid of commentUserIds) {
+ if (userAvatars[uid]) commentUserAvatarUrls[uid] = userAvatars[uid];
+ }
+
+ // Prepare avatar map for CommentsAndAuditSection (audit events)
+ const auditEventUserIds = (auditEvents || [])
+ .filter(ev => ev.User && ev.User > 0)
+ .map(ev => ev.User);
+ const auditEventUserAvatarUrls: Record = {};
+ for (const uid of auditEventUserIds) {
+ if (userAvatars[uid]) auditEventUserAvatarUrls[uid] = userAvatars[uid];
+ }
+
// Handle review button actions
async function handleReviewAction(action: string, mapfixId: number) {
try {
@@ -220,12 +262,18 @@ export default function MapfixDetailsPage() {
transition: 'opacity 0.5s ease-in-out'
}}
>
-
+ {thumbnailUrls[mapfix.TargetAssetID] ? (
+
+ ) : (
+
+
+
+ )}
{/* After Image */}
@@ -241,12 +289,18 @@ export default function MapfixDetailsPage() {
transition: 'opacity 0.5s ease-in-out'
}}
>
-
+ {thumbnailUrls[mapfix.AssetID] ? (
+
+ ) : (
+
+
+
+ )}
{/* Comments Section */}
@@ -353,6 +409,8 @@ export default function MapfixDetailsPage() {
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
+ commentUserAvatarUrls={commentUserAvatarUrls}
+ auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/>
diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx
index b45446f..eb7ae4b 100644
--- a/web/src/app/mapfixes/page.tsx
+++ b/web/src/app/mapfixes/page.tsx
@@ -16,6 +16,9 @@ import {
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle";
+import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
+import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
+import { useBatchUsernames } from "@/app/hooks/useBatchUsernames";
export default function MapfixInfoPage() {
useTitle("Map Fixes");
@@ -55,6 +58,14 @@ export default function MapfixInfoPage() {
return () => controller.abort();
}, [currentPage]);
+ const assetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? [];
+ const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
+
+ // Collect unique submitter IDs for avatar and username fetching
+ const submitterIds = mapfixes ? Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))) : [];
+ const { avatars: avatarUrls } = useBatchUserAvatars(submitterIds);
+ const { usernames: submitterUsernames } = useBatchUsernames(submitterIds);
+
if (isLoading || !mapfixes) {
return (
@@ -111,12 +122,15 @@ export default function MapfixInfoPage() {
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
- authorId={mapfix.Submitter}
+ submitterId={mapfix.Submitter}
+ submitterUsername={submitterUsernames[mapfix.Submitter] || String(mapfix.Submitter)}
rating={mapfix.StatusID}
statusID={mapfix.StatusID}
gameID={mapfix.GameID}
created={mapfix.CreatedAt}
type="mapfix"
+ thumbnailUrl={thumbnailUrls[mapfix.AssetID]}
+ authorAvatarUrl={avatarUrls[mapfix.Submitter]}
/>
))}
diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx
index 1989152..5f37a87 100644
--- a/web/src/app/maps/[mapId]/page.tsx
+++ b/web/src/app/maps/[mapId]/page.tsx
@@ -6,25 +6,26 @@ import { useParams, useRouter } from "next/navigation";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Snackbar, Alert } from "@mui/material";
+import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix";
-import LaunchIcon from '@mui/icons-material/Launch';
// MUI Components
import {
- Typography,
- Box,
- Button,
- Container,
- Breadcrumbs,
- Chip,
- Grid,
- Divider,
- Paper,
- Skeleton,
- Stack,
- CardMedia,
- Tooltip,
- IconButton
+ Typography,
+ Box,
+ Button,
+ Container,
+ Breadcrumbs,
+ Chip,
+ Grid,
+ Divider,
+ Paper,
+ Skeleton,
+ Stack,
+ CardMedia,
+ Tooltip,
+ IconButton,
+ CircularProgress
} from "@mui/material";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
@@ -34,435 +35,446 @@ 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 LaunchIcon from '@mui/icons-material/Launch';
import {hasRole, RolesConstants} from "@/app/ts/Roles";
import {useTitle} from "@/app/hooks/useTitle";
export default function MapDetails() {
- const { mapId } = useParams();
- const router = useRouter();
- const [map, setMap] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [copySuccess, setCopySuccess] = useState(false);
- const [roles, setRoles] = useState(RolesConstants.Empty);
- const [downloading, setDownloading] = useState(false);
- const [mapfixes, setMapfixes] = useState([]);
+ const { mapId } = useParams();
+ const router = useRouter();
+ const [map, setMap] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [copySuccess, setCopySuccess] = useState(false);
+ const [roles, setRoles] = useState(RolesConstants.Empty);
+ const [downloading, setDownloading] = useState(false);
+ const [mapfixes, setMapfixes] = useState([]);
- useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
+ useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
- useEffect(() => {
- async function getMap() {
- try {
- setLoading(true);
- setError(null);
- const res = await fetch(`/api/maps/${mapId}`);
- if (!res.ok) {
- throw new Error(`Failed to fetch map: ${res.status}`);
- }
- const data = await res.json();
- setMap(data);
- } catch (error) {
- console.error("Error fetching map details:", error);
- setError(error instanceof Error ? error.message : "Failed to load map details");
- } finally {
- setLoading(false);
- }
- }
- getMap();
- }, [mapId]);
-
- useEffect(() => {
- async function getRoles() {
- try {
- const rolesResponse = await fetch("/api/session/roles");
- if (rolesResponse.ok) {
- const rolesData = await rolesResponse.json();
- setRoles(rolesData.Roles);
- } else {
- console.warn(`Failed to fetch roles: ${rolesResponse.status}`);
- setRoles(RolesConstants.Empty);
- }
- } catch (error) {
- console.warn("Error fetching roles data:", error);
- setRoles(RolesConstants.Empty);
+ useEffect(() => {
+ async function getMap() {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await fetch(`/api/maps/${mapId}`);
+ if (!res.ok) {
+ throw new Error(`Failed to fetch map: ${res.status}`);
+ }
+ const data = await res.json();
+ setMap(data);
+ } catch (error) {
+ console.error("Error fetching map details:", error);
+ setError(error instanceof Error ? error.message : "Failed to load map details");
+ } finally {
+ setLoading(false);
+ }
}
+ getMap();
+ }, [mapId]);
+
+ useEffect(() => {
+ if (!map) return;
+ const targetAssetId = map.ID;
+ async function fetchMapfixes() {
+ try {
+ const limit = 100;
+ let page = 1;
+ let allMapfixes: MapfixInfo[] = [];
+ let total = 0;
+ do {
+ const res = await fetch(`/api/mapfixes?Page=${page}&Limit=${limit}&TargetAssetID=${targetAssetId}`);
+ if (!res.ok) break;
+ const data = await res.json();
+ if (page === 1) total = data.Total;
+ 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);
+ } catch {
+ setMapfixes([]);
+ }
+ }
+ fetchMapfixes();
+ }, [map]);
+
+ useEffect(() => {
+ async function getRoles() {
+ try {
+ const rolesResponse = await fetch("/api/session/roles");
+ if (rolesResponse.ok) {
+ const rolesData = await rolesResponse.json();
+ setRoles(rolesData.Roles);
+ } else {
+ console.warn(`Failed to fetch roles: ${rolesResponse.status}`);
+ setRoles(RolesConstants.Empty);
+ }
+ } catch (error) {
+ console.warn("Error fetching roles data:", error);
+ setRoles(RolesConstants.Empty);
+ }
+ }
+ getRoles()
+ }, [mapId]);
+
+ // Use useBatchThumbnails for the map thumbnail
+ const assetIds = map?.ID ? [map.ID] : [];
+ const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
+
+ const formatDate = (timestamp: number) => {
+ return new Date(timestamp * 1000).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ };
+
+ const getGameInfo = (gameId: number) => {
+ switch (gameId) {
+ case 1:
+ return {
+ name: "Bhop",
+ color: "#2196f3" // blue
+ };
+ case 2:
+ return {
+ name: "Surf",
+ color: "#4caf50" // green
+ };
+ case 5:
+ return {
+ name: "Fly Trials",
+ color: "#ff9800" // orange
+ };
+ default:
+ return {
+ name: "Unknown",
+ color: "#9e9e9e" // gray
+ };
+ }
+ };
+
+ const handleSubmitMapfix = () => {
+ router.push(`/maps/${mapId}/fix`);
+ };
+
+ const handleCopyId = (idToCopy: string) => {
+ navigator.clipboard.writeText(idToCopy);
+ setCopySuccess(true);
+ };
+
+
+ const handleDownload = async () => {
+ setDownloading(true);
+ try {
+ // Fetch the download URL
+ const res = await fetch(`/api/maps/${mapId}/location`);
+ if (!res.ok) throw new Error('Failed to fetch download location');
+
+ const location = await res.text();
+
+ // open in new window
+ window.open(location.trim(), '_blank');
+
+ } catch (err) {
+ console.error('Download error:', err);
+ // Optional: Show user-friendly error message
+ alert('Download failed. Please try again.');
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ const handleCloseSnackbar = () => {
+ setCopySuccess(false);
+ };
+
+ if (error) {
+ return (
+
+
+
+ Error Loading Map
+ {error}
+
+
+
+
+ );
}
- getRoles()
- }, [mapId]);
- useEffect(() => {
- if (!map) return;
- const targetAssetId = map.ID;
- async function fetchMapfixes() {
- try {
- const limit = 100;
- let page = 1;
- let allMapfixes: MapfixInfo[] = [];
- let total = 0;
- do {
- const res = await fetch(`/api/mapfixes?Page=${page}&Limit=${limit}&TargetAssetID=${targetAssetId}`);
- if (!res.ok) break;
- const data = await res.json();
- if (page === 1) total = data.Total;
- 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);
- } catch {
- setMapfixes([]);
- }
- }
- fetchMapfixes();
- }, [map]);
+ return (
+
+
+ {/* Breadcrumbs Navigation */}
+ }
+ aria-label="breadcrumb"
+ sx={{ mb: 3 }}
+ >
+
+ Home
+
+
+ Maps
+
+ {loading ? "Loading..." : map?.DisplayName || "Map Details"}
+
+ {loading ? (
+
+
+
+
+
+
+
+
+
- const formatDate = (timestamp: number) => {
- return new Date(timestamp * 1000).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- };
+
+
+
+
- const getGameInfo = (gameId: number) => {
- switch (gameId) {
- case 1:
- return {
- name: "Bhop",
- color: "#2196f3" // blue
- };
- case 2:
- return {
- name: "Surf",
- color: "#4caf50" // green
- };
- case 5:
- return {
- name: "Fly Trials",
- color: "#ff9800" // orange
- };
- default:
- return {
- name: "Unknown",
- color: "#9e9e9e" // gray
- };
- }
- };
+
+
+
+
+
+
+
+
+
+ ) : (
+ map && (
+ <>
+ {/* Map Header */}
+
+
+
+ {map.DisplayName}
+
- const handleSubmitMapfix = () => {
- router.push(`/maps/${mapId}/fix`);
- };
+ {map.GameID && (
+
+ )}
+
- const handleCopyId = (idToCopy: string) => {
- navigator.clipboard.writeText(idToCopy);
- setCopySuccess(true);
- };
+
+
+
+
+ Created by: {map.Creator}
+
+
+
+
+
+ {formatDate(map.Date)}
+
+
- const handleDownload = async () => {
- setDownloading(true);
- try {
- // Fetch the download URL
- const res = await fetch(`/api/maps/${mapId}/location`);
- if (!res.ok) throw new Error('Failed to fetch download location');
+
+
+
+
+ ID: {mapId}
+
+
+ handleCopyId(mapId as string)}
+ sx={{ ml: 1 }}
+ >
+
+
+
+
+
+ {!loading && hasRole(roles,RolesConstants.MapDownload) && (
+
+
+
+ Download
+
+
+
+
+
+
+
+ )}
+
+
- const location = await res.text();
+
+ {/* Map Preview Section */}
+
+
+ {thumbnailUrls[map.ID] ? (
+
+ ) : (
+
+
+
+ )}
+
+
- // open in new window
- window.open(location.trim(), '_blank');
+ {/* Map Details Section */}
+
+
+ Map Details
+
- } catch (err) {
- console.error('Download error:', err);
- // Optional: Show user-friendly error message
- alert('Download failed. Please try again.');
- } finally {
- setDownloading(false);
- }
- };
+
+
+ Display Name
+ {map.DisplayName}
+
- const handleCloseSnackbar = () => {
- setCopySuccess(false);
- };
+
+ Creator
+ {map.Creator}
+
- if (error) {
- return (
-
-
-
- Error Loading Map
- {error}
-
-
-
-
- );
- }
+
+ Game Type
+ {getGameInfo(map.GameID).name}
+
- return (
-
-
- {/* Breadcrumbs Navigation */}
- }
- aria-label="breadcrumb"
- sx={{ mb: 3 }}
- >
-
- Home
-
-
- Maps
-
- {loading ? "Loading..." : map?.DisplayName || "Map Details"}
-
- {loading ? (
-
-
-
-
-
-
-
-
-
+
+ Release Date
+ {formatDate(map.Date)}
+
-
-
-
-
+
+ Map ID
+
+ {mapId}
+
+ handleCopyId(mapId as string)}
+ sx={{ ml: 1, p: 0 }}
+ >
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- ) : (
- map && (
- <>
- {/* Map Header */}
-
-
-
- {map.DisplayName}
-
+ {/* 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}
+
+
+
+
+ );
+ })()}
+
+
- {map.GameID && (
-
- )}
-
+
+ }
+ onClick={handleSubmitMapfix}
+ size="large"
+ >
+ Submit a Mapfix
+
+
+
+
+ >
+ )
+ )}
-
-
-
-
- Created by: {map.Creator}
-
-
-
-
-
-
- {formatDate(map.Date)}
-
-
-
-
-
-
-
- ID: {mapId}
-
-
- handleCopyId(mapId as string)}
- sx={{ ml: 1 }}
- >
-
-
-
-
-
- {!loading && hasRole(roles,RolesConstants.MapDownload) && (
-
-
-
- Download
-
-
-
-
-
-
-
- )}
-
-
-
-
- {/* Map Preview Section */}
-
-
-
-
-
-
- {/* Map Details Section */}
-
-
- Map Details
-
-
-
-
- Display Name
- {map.DisplayName}
-
-
-
- Creator
- {map.Creator}
-
-
-
- Game Type
- {getGameInfo(map.GameID).name}
-
-
-
- Release Date
- {formatDate(map.Date)}
-
-
-
- Map ID
-
- {mapId}
-
- handleCopyId(mapId as string)}
- sx={{ ml: 1, p: 0 }}
- >
-
-
-
-
-
-
- {/* 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}
-
-
-
-
- );
- })()}
-
-
-
-
- }
- onClick={handleSubmitMapfix}
- size="large"
- >
- Submit a Mapfix
-
-
-
-
- >
- )
- )}
-
-
-
- Map ID copied to clipboard!
-
-
-
-
- );
+
+
+ Map ID copied to clipboard!
+
+
+
+
+ );
}
diff --git a/web/src/app/maps/page.tsx b/web/src/app/maps/page.tsx
index 1a63514..d20a65f 100644
--- a/web/src/app/maps/page.tsx
+++ b/web/src/app/maps/page.tsx
@@ -27,7 +27,7 @@ import {Search as SearchIcon} from "@mui/icons-material";
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle";
-import {thumbnailLoader} from '@/app/lib/thumbnailLoader';
+import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
interface Map {
ID: number;
@@ -77,11 +77,6 @@ export default function MapsPage() {
fetchMaps();
}, []);
- const handleGameFilterChange = (event: SelectChangeEvent) => {
- setGameFilter(event.target.value);
- setCurrentPage(1);
- };
-
// Filter maps based on search query and game filter
const filteredMaps = maps.filter(map => {
const matchesSearch =
@@ -101,6 +96,13 @@ export default function MapsPage() {
(currentPage - 1) * mapsPerPage,
currentPage * mapsPerPage
);
+ const currentMapIdsArr = currentMaps.map(m => m.ID);
+ const { thumbnails } = useBatchThumbnails(currentMapIdsArr);
+
+ const handleGameFilterChange = (event: SelectChangeEvent) => {
+ setGameFilter(event.target.value);
+ setCurrentPage(1);
+ };
const handlePageChange = (_event: React.ChangeEvent, page: number) => {
setCurrentPage(page);
@@ -262,13 +264,19 @@ export default function MapsPage() {
>
{getGameName(map.GameID)}
-
+ {thumbnails[map.ID] ? (
+
+ ) : (
+
+
+
+ )}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index 0ad41a6..2b59bfa 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -16,6 +16,9 @@ import Link from "next/link";
import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
import {Carousel} from "@/app/_components/carousel";
import {useTitle} from "@/app/hooks/useTitle";
+import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
+import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
+import { useBatchUsernames } from "@/app/hooks/useBatchUsernames";
export default function Home() {
useTitle("Home");
@@ -73,6 +76,19 @@ export default function Home() {
};
}, []);
+ const submissionAssetIds = submissions?.Submissions.map(s => s.AssetID) ?? [];
+ const mapfixAssetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? [];
+ const { thumbnails: submissionThumbnails } = useBatchThumbnails(submissionAssetIds);
+ const { thumbnails: mapfixThumbnails } = useBatchThumbnails(mapfixAssetIds);
+
+ // Collect unique submitter IDs for avatar and username fetching
+ const submissionAuthorIds = submissions ? Array.from(new Set(submissions.Submissions.map(s => s.Submitter))) : [];
+ const mapfixAuthorIds = mapfixes ? Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))) : [];
+ const { avatars: submissionAvatars } = useBatchUserAvatars(submissionAuthorIds);
+ const { avatars: mapfixAvatars } = useBatchUserAvatars(mapfixAuthorIds);
+ const { usernames: submissionUsernames } = useBatchUsernames(submissionAuthorIds);
+ const { usernames: mapfixUsernames } = useBatchUsernames(mapfixAuthorIds);
+
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
if (isLoading && (!mapfixes || !submissions)) {
@@ -102,12 +118,15 @@ export default function Home() {
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
- authorId={mapfix.Submitter}
+ submitterId={mapfix.Submitter}
+ submitterUsername={mapfixUsernames[mapfix.Submitter] || String(mapfix.Submitter)}
rating={mapfix.StatusID}
statusID={mapfix.StatusID}
gameID={mapfix.GameID}
created={mapfix.CreatedAt}
type="mapfix"
+ thumbnailUrl={mapfixThumbnails[mapfix.AssetID]}
+ authorAvatarUrl={mapfixAvatars[mapfix.Submitter]}
/>
);
@@ -118,12 +137,15 @@ export default function Home() {
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
- authorId={submission.Submitter}
+ submitterId={submission.Submitter}
+ submitterUsername={submissionUsernames[submission.Submitter] || String(submission.Submitter)}
rating={submission.StatusID}
statusID={submission.StatusID}
gameID={submission.GameID}
created={submission.CreatedAt}
type="submission"
+ thumbnailUrl={submissionThumbnails[submission.AssetID]}
+ authorAvatarUrl={submissionAvatars[submission.Submitter]}
/>
);
diff --git a/web/src/app/proxy/users/[userId]/route.ts b/web/src/app/proxy/users/[userId]/route.ts
deleted file mode 100644
index b745db2..0000000
--- a/web/src/app/proxy/users/[userId]/route.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { NextResponse } from 'next/server';
-
-export async function GET(
- request: Request,
- { params }: { params: Promise<{ userId: string }> }
-) {
- const { userId } = await params;
-
- if (!userId) {
- return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
- }
-
- try {
- const apiResponse = await fetch(`https://users.roblox.com/v1/users/${userId}`);
-
- if (!apiResponse.ok) {
- const errorData = await apiResponse.text();
- return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status });
- }
-
- const data = await apiResponse.json();
-
- // Add caching headers to the response
- const headers = new Headers();
- headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); // Cache for 1 hour
-
- return NextResponse.json(data, { headers });
- } catch {
- return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
- }
-}
diff --git a/web/src/app/proxy/users/batch/RobloxUserInfo.ts b/web/src/app/proxy/users/batch/RobloxUserInfo.ts
new file mode 100644
index 0000000..b422665
--- /dev/null
+++ b/web/src/app/proxy/users/batch/RobloxUserInfo.ts
@@ -0,0 +1,6 @@
+// Roblox user info type for batch endpoint
+export interface RobloxUserInfo {
+ id: number;
+ name: string;
+ displayName: string;
+}
\ No newline at end of file
diff --git a/web/src/app/proxy/users/batch/route.ts b/web/src/app/proxy/users/batch/route.ts
new file mode 100644
index 0000000..603ee5c
--- /dev/null
+++ b/web/src/app/proxy/users/batch/route.ts
@@ -0,0 +1,99 @@
+// NOTE: This API endpoint proxies Roblox user info in batch and implements in-memory rate limiting.
+// For production, this logic should be moved to a dedicated backend API server (not serverless/edge)
+// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota.
+//
+// If you are behind a CDN/proxy, ensure you trust the IP headers.
+// Consider using Redis or another distributed store for rate limiting in production.
+
+import { checkRateLimit } from '@/lib/rateLimit';
+import { NextResponse } from 'next/server';
+import { getClientIp } from '@/lib/getClientIp';
+import { createGlobalRateLimiter } from '@/lib/globalRateLimit';
+import type { RobloxUserInfo } from './RobloxUserInfo';
+
+const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); // 500 per 5 min
+
+const VALIDATOR_USER_ID = 9223372036854776000;
+
+const USER_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
+const userInfoCache = new Map();
+let lastUserCacheCleanup = 0;
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const idsParam = url.searchParams.get('ids');
+ const ip = getClientIp(request);
+ if (!checkRateLimit(ip)) {
+ return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 });
+ }
+ if (!checkGlobalRateLimit()) {
+ return NextResponse.json({ error: 'Server busy. Please try again later.' }, { status: 429 });
+ }
+ if (!idsParam) {
+ return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 });
+ }
+ let userIds = idsParam
+ .split(',')
+ .map(Number)
+ .filter(id => Number.isInteger(id) && id > 0 && id !== VALIDATOR_USER_ID);
+ // De-duplicate
+ userIds = Array.from(new Set(userIds));
+ if (userIds.length === 0) {
+ return NextResponse.json({ error: 'No valid user IDs provided' }, { status: 400 });
+ }
+ if (userIds.length > 50) {
+ return NextResponse.json({ error: 'Too many user IDs in batch (max 50)' }, { status: 400 });
+ }
+
+ const now = Date.now();
+ // Cleanup expired cache entries
+ if (now - lastUserCacheCleanup > USER_CACHE_TTL) {
+ for (const [id, entry] of userInfoCache.entries()) {
+ if (entry.expires <= now) userInfoCache.delete(id);
+ }
+ lastUserCacheCleanup = now;
+ }
+
+ const result: RobloxUserInfo[] = [];
+ const idsToFetch: number[] = [];
+ const cachedMap: Record = {};
+ for (const id of userIds) {
+ const cached = userInfoCache.get(id);
+ if (cached && cached.expires > now) {
+ cachedMap[id] = cached.info;
+ result.push(cached.info);
+ } else {
+ idsToFetch.push(id);
+ }
+ }
+
+ if (idsToFetch.length > 0) {
+ try {
+ const apiResponse = await fetch('https://users.roblox.com/v1/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userIds: idsToFetch }),
+ });
+ if (!apiResponse.ok) {
+ const errorData = await apiResponse.text();
+ return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status });
+ }
+ const data = await apiResponse.json();
+ for (const user of data.data || []) {
+ userInfoCache.set(user.id, { info: user, expires: now + USER_CACHE_TTL });
+ result.push(user);
+ }
+ } catch {
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+ }
+
+ // Ensure result order matches input order
+ const ordered = userIds.map(id => {
+ return userInfoCache.get(id)?.info || cachedMap[id] || null;
+ });
+
+ const headers = new Headers();
+ headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
+ return NextResponse.json({ data: ordered }, { headers });
+}
\ No newline at end of file
diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx
index 5654f7f..52b7add 100644
--- a/web/src/app/submissions/[submissionId]/page.tsx
+++ b/web/src/app/submissions/[submissionId]/page.tsx
@@ -1,7 +1,7 @@
"use client";
import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation";
-import {useState} from "react";
+import { useState } from "react";
import Link from "next/link";
// MUI Components
@@ -14,6 +14,7 @@ import {
Skeleton,
Grid,
CardMedia,
+ CircularProgress,
Snackbar,
Alert,
} from "@mui/material";
@@ -26,6 +27,9 @@ import ReviewButtons from "@/app/_components/review/ReviewButtons";
import {useReviewData} from "@/app/hooks/useReviewData";
import {SubmissionInfo} from "@/app/ts/Submission";
import {useTitle} from "@/app/hooks/useTitle";
+import {useBatchThumbnails} from "@/app/hooks/useBatchThumbnails";
+import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
+import { useBatchUsernames } from "@/app/hooks/useBatchUsernames";
interface SnackbarState {
open: boolean;
@@ -42,22 +46,6 @@ export default function SubmissionDetailsPage() {
message: null,
severity: 'success'
});
- const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
- setSnackbar({
- open: true,
- message,
- severity
- });
- };
-
- const handleCloseSnackbar = () => {
- setSnackbar({
- ...snackbar,
- open: false
- });
- };
-
-
const validatorUser = 9223372036854776000;
const {
@@ -76,6 +64,45 @@ export default function SubmissionDetailsPage() {
useTitle(submission ? `${submission.DisplayName} Submission` : 'Loading Submission...');
+ // Gather all user IDs and asset IDs needed for batch requests
+ const submitterId = submission?.Submitter;
+ const commentUserIds = auditEvents ? Array.from(new Set(auditEvents.map(ev => ev.User))) : [];
+ const allUserIds = [submitterId, ...commentUserIds].filter(Boolean);
+ const assetIds = submission?.AssetID ? [submission.AssetID] : [];
+
+ // Batch fetch at the page level
+ const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
+ const { avatars: avatarUrls } = useBatchUserAvatars(allUserIds);
+ const { usernames: usernameMap } = useBatchUsernames(allUserIds);
+
+ // Prepare avatar map for CommentsAndAuditSection (comments)
+ const commentUserAvatarUrls: Record = {};
+ for (const uid of commentUserIds) {
+ if (avatarUrls[uid]) commentUserAvatarUrls[uid] = avatarUrls[uid];
+ }
+
+ // Prepare avatar map for CommentsAndAuditSection (audit events)
+ const auditEventUserIds = auditEvents ? Array.from(new Set(auditEvents.map(ev => ev.User))) : [];
+ const auditEventUserAvatarUrls: Record = {};
+ for (const uid of auditEventUserIds) {
+ if (avatarUrls[uid]) auditEventUserAvatarUrls[uid] = avatarUrls[uid];
+ }
+
+ const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
+ setSnackbar({
+ open: true,
+ message,
+ severity
+ });
+ };
+
+ const handleCloseSnackbar = () => {
+ setSnackbar({
+ ...snackbar,
+ open: false
+ });
+ };
+
// Handle review button actions
async function handleReviewAction(action: string, submissionId: number) {
try {
@@ -204,12 +231,27 @@ export default function SubmissionDetailsPage() {
{submission.AssetID ? (
-
+ thumbnailUrls[submission.AssetID] ? (
+
+ ) : (
+
+
+
+ )
) : (
+ >
No image available
)}
@@ -234,14 +276,14 @@ export default function SubmissionDetailsPage() {
roles={roles}
type="submission"/>
-
{/* Right Column - Submission Details and Comments */}
-
{/* Comments Section */}
diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx
index 877cb43..97051b9 100644
--- a/web/src/app/submissions/page.tsx
+++ b/web/src/app/submissions/page.tsx
@@ -16,6 +16,9 @@ import {
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle";
+import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
+import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
+import { useBatchUsernames } from "@/app/hooks/useBatchUsernames";
export default function SubmissionInfoPage() {
useTitle("Submissions");
@@ -55,6 +58,14 @@ export default function SubmissionInfoPage() {
return () => controller.abort();
}, [currentPage]);
+ const assetIds = submissions?.Submissions.map(s => s.AssetID) ?? [];
+ const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
+
+ // Collect submitter user IDs and fetch their avatars
+ const submitterIds = submissions?.Submissions.map(s => s.Submitter) ?? [];
+ const { avatars: submitterAvatars } = useBatchUserAvatars(submitterIds);
+ const { usernames: submitterUsernames } = useBatchUsernames(submitterIds);
+
if (isLoading || !submissions) {
return (
@@ -123,12 +134,15 @@ export default function SubmissionInfoPage() {
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
- authorId={submission.Submitter}
+ submitterId={submission.Submitter}
+ submitterUsername={submitterUsernames[submission.Submitter] || String(submission.Submitter)}
rating={submission.StatusID}
statusID={submission.StatusID}
gameID={submission.GameID}
created={submission.CreatedAt}
type="submission"
+ thumbnailUrl={thumbnailUrls[submission.AssetID]}
+ authorAvatarUrl={submitterAvatars[submission.Submitter]}
/>
))}
diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx
deleted file mode 100644
index 0c76487..0000000
--- a/web/src/app/thumbnails/asset/[assetId]/route.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { errorImageResponse } from '@/app/lib/errorImageResponse';
-
-export async function GET(
- request: NextRequest,
- context: { params: Promise<{ assetId: number }> }
-): Promise {
- const { assetId } = await context.params;
-
- if (!assetId) {
- return errorImageResponse(400, {
- message: "Missing asset ID",
- })
- }
-
- let finalAssetId = assetId;
-
- try {
- const mediaResponse = await fetch(
- `https://publish.roblox.com/v1/assets/${assetId}/media` // NOTE: This allows users to add custom images(their own thumbnail if they'd like) to their maps
- );
- if (mediaResponse.ok) {
- const mediaData = await mediaResponse.json();
- if (mediaData.data && mediaData.data.length > 0) {
- finalAssetId = mediaData.data[0].toString();
- }
- }
- } catch { }
-
- try {
- const response = await fetch(
- `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalAssetId}`
- );
-
- if (!response.ok) {
- throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`)
- }
-
- const data = await response.json();
-
- const imageUrl = data.data[0]?.imageUrl;
- if (!imageUrl) {
- return errorImageResponse(404, {
- message: "No image URL found in the response",
- })
- }
-
- // Redirect to the actual image URL instead of proxying
- return NextResponse.redirect(imageUrl);
- } catch (err) {
- return errorImageResponse(500, {
- message: `Failed to fetch thumbnail URL: ${err}`,
- })
- }
-}
\ No newline at end of file
diff --git a/web/src/app/thumbnails/batch/route.ts b/web/src/app/thumbnails/batch/route.ts
new file mode 100644
index 0000000..4b3e2f1
--- /dev/null
+++ b/web/src/app/thumbnails/batch/route.ts
@@ -0,0 +1,133 @@
+// NOTE: This API endpoint proxies Roblox asset and user avatar thumbnails and implements in-memory rate limiting.
+// For production, this logic should be moved to a dedicated backend API server (not serverless/edge)
+// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota.
+//
+// If you are behind a CDN/proxy, ensure you trust the IP headers.
+//
+// Consider using Redis or another distributed store for rate limiting in production.
+
+import { NextRequest, NextResponse } from 'next/server';
+import { checkRateLimit } from '@/lib/rateLimit';
+import { getClientIp } from '@/lib/getClientIp';
+import { createGlobalRateLimiter } from '@/lib/globalRateLimit';
+
+const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
+const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
+const assetImageCache = new Map();
+const userImageCache = new Map();
+
+// Cleanup state
+let lastCacheCleanup = 0;
+
+// Global rate limiting
+const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); // 500 per 5 min
+
+const VALIDATOR_USER_ID = 9223372036854776000;
+
+type RobloxThumbnailData = {
+ targetId: number;
+ imageUrl?: string;
+};
+
+export async function GET(request: NextRequest) {
+ const ip = getClientIp(request);
+ if (!checkRateLimit(ip)) {
+ return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 });
+ }
+ if (!checkGlobalRateLimit()) {
+ return NextResponse.json({ error: 'Server busy. Please try again later.' }, { status: 429 });
+ }
+
+ const now = Date.now();
+ // Cleanup cache if needed
+ if (now - lastCacheCleanup > CACHE_CLEANUP_INTERVAL) {
+ for (const [id, entry] of assetImageCache.entries()) {
+ if (entry.expires <= now) assetImageCache.delete(id);
+ }
+ for (const [id, entry] of userImageCache.entries()) {
+ if (entry.expires <= now) userImageCache.delete(id);
+ }
+ lastCacheCleanup = now;
+ }
+
+ const url = new URL(request.url);
+ const idsParam = url.searchParams.get('ids');
+ const type = url.searchParams.get('type') || 'asset';
+ if (!idsParam) {
+ return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 });
+ }
+ let ids = idsParam
+ .split(',')
+ .map(Number)
+ .filter(id => Number.isInteger(id) && id > 0 && id !== VALIDATOR_USER_ID);
+ // De-duplicate
+ ids = Array.from(new Set(ids));
+ if (ids.length === 0) {
+ return NextResponse.json({ error: 'No valid IDs provided' }, { status: 400 });
+ }
+ if (ids.length > 50) {
+ return NextResponse.json({ error: 'Too many IDs in batch (max 50)' }, { status: 400 });
+ }
+ const result: Record = {};
+ const idsToFetch: number[] = [];
+
+ const cache = type === 'user' ? userImageCache : assetImageCache;
+
+ for (const id of ids) {
+ const cached = cache.get(id);
+ if (cached && cached.expires > now) {
+ result[id] = cached.url;
+ } else {
+ idsToFetch.push(id);
+ }
+ }
+
+ for (let i = 0; i < idsToFetch.length; i += 50) {
+ const batch = idsToFetch.slice(i, i + 50);
+ let robloxUrl = '';
+ let finalBatch = batch;
+ if (type === 'asset') {
+ finalBatch = [];
+ for (const assetId of batch) {
+ let finalAssetId = assetId;
+ try {
+ const mediaResponse = await fetch(
+ `https://publish.roblox.com/v1/assets/${assetId}/media`
+ );
+ if (mediaResponse.ok) {
+ const mediaData = await mediaResponse.json();
+ if (mediaData.data && mediaData.data.length > 0) {
+ finalAssetId = Number(mediaData.data[0].id || mediaData.data[0]);
+ }
+ }
+ } catch {}
+ finalBatch.push(finalAssetId);
+ }
+ robloxUrl = `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalBatch.join(',')}`;
+ } else {
+ robloxUrl = `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=100x100&format=Png&isCircular=false`;
+ }
+ const response = await fetch(robloxUrl);
+ if (!response.ok) {
+ for (const id of batch) {
+ result[id] = null;
+ }
+ continue;
+ }
+ const data = await response.json();
+ for (let j = 0; j < batch.length; j++) {
+ const id = batch[j];
+ const lookupId = type === 'asset' ? finalBatch[j] : id;
+ const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(lookupId));
+ const imageUrl = found?.imageUrl || null;
+ if (imageUrl) {
+ cache.set(id, { url: imageUrl, expires: now + CACHE_TTL });
+ result[id] = imageUrl;
+ } else {
+ result[id] = null;
+ }
+ }
+ }
+
+ return NextResponse.json(result);
+}
\ No newline at end of file
diff --git a/web/src/app/thumbnails/maps/[mapId]/route.tsx b/web/src/app/thumbnails/maps/[mapId]/route.tsx
deleted file mode 100644
index a6b21d3..0000000
--- a/web/src/app/thumbnails/maps/[mapId]/route.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { NextRequest, NextResponse } from "next/server"
-
-export async function GET(
- request: NextRequest,
- context: { params: Promise<{ mapId: string }> }
-): Promise {
- // TODO: implement this, we need a cdn for in-game map thumbnails...
-
- if (!process.env.API_HOST) {
- throw new Error('env variable "API_HOST" is not set')
- }
-
- const { mapId } = await context.params
-
- const apiHost = process.env.API_HOST.replace(/\/api\/?$/, "")
- const redirectPath = `/thumbnails/asset/${mapId}`
- const redirectUrl = `${apiHost}${redirectPath}`
-
- return NextResponse.redirect(redirectUrl)
-}
\ No newline at end of file
diff --git a/web/src/app/thumbnails/user/[userId]/route.tsx b/web/src/app/thumbnails/user/[userId]/route.tsx
deleted file mode 100644
index 1e08966..0000000
--- a/web/src/app/thumbnails/user/[userId]/route.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-
-export async function GET(
- request: NextRequest,
- context: { params: Promise<{ userId: number }> }
-): Promise {
- const { userId } = await context.params; // Await params to access userId
-
- if (!userId) {
- return NextResponse.json(
- { error: 'Missing userId parameter' },
- { status: 400 }
- );
- }
-
- try {
- const response = await fetch(
- `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${userId}&size=420x420&format=Png&isCircular=false`
- );
-
- if (!response.ok) {
- throw new Error('Failed to fetch avatar headshot JSON');
- }
-
- const data = await response.json();
-
- const imageUrl = data.data[0]?.imageUrl;
- if (!imageUrl) {
- return NextResponse.json(
- { error: 'No image URL found in the response' },
- { status: 404 }
- );
- }
-
- // Redirect to the image URL instead of proxying
- return NextResponse.redirect(imageUrl);
-
- } catch {
- return NextResponse.json(
- { error: 'Failed to fetch avatar headshot URL' },
- { status: 500 }
- );
- }
-}
\ No newline at end of file
diff --git a/web/src/lib/getClientIp.ts b/web/src/lib/getClientIp.ts
new file mode 100644
index 0000000..754df3b
--- /dev/null
+++ b/web/src/lib/getClientIp.ts
@@ -0,0 +1,16 @@
+import { NextRequest } from 'next/server';
+
+/**
+ * Extracts the client IP address from a Next.js request, trusting only proxy headers.
+ * Only use this if you are behind a trusted proxy (e.g., nginx).
+ */
+export function getClientIp(request: NextRequest | Request): string {
+ // X-Forwarded-For may be a comma-separated list. The left-most is the original client.
+ const xff = request.headers.get('x-forwarded-for');
+ if (xff) {
+ return xff.split(',')[0].trim();
+ }
+ const xRealIp = request.headers.get('x-real-ip');
+ if (xRealIp) return xRealIp.trim();
+ return 'unknown';
+}
diff --git a/web/src/lib/globalRateLimit.ts b/web/src/lib/globalRateLimit.ts
new file mode 100644
index 0000000..e1ccd2f
--- /dev/null
+++ b/web/src/lib/globalRateLimit.ts
@@ -0,0 +1,18 @@
+/**
+ * Returns a global rate limiter function with its own state.
+ * Usage: const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000);
+ */
+export function createGlobalRateLimiter(limit: number, windowMs: number) {
+ let count = 0;
+ let lastReset = Date.now();
+ return function checkGlobalRateLimit() {
+ const now = Date.now();
+ if (now - lastReset > windowMs) {
+ count = 0;
+ lastReset = now;
+ }
+ if (count >= limit) return false;
+ count++;
+ return true;
+ };
+}
\ No newline at end of file
diff --git a/web/src/lib/rateLimit.ts b/web/src/lib/rateLimit.ts
new file mode 100644
index 0000000..f025759
--- /dev/null
+++ b/web/src/lib/rateLimit.ts
@@ -0,0 +1,32 @@
+// NOTE: This file is used as a shared in-memory per-IP rate limiter for all Next.js API routes that need it.
+// Not for production-scale, but good for basic abuse prevention.
+//
+// For production, use a distributed store (e.g., Redis) and import this from a shared location.
+const RATE_LIMIT_WINDOW_MS = 60 * 1000;
+const RATE_LIMIT_MAX = 30;
+
+// Map
+const ipRateLimitMap = new Map();
+
+let lastIpRateLimitCleanup = 0;
+
+export function checkRateLimit(ip: string): boolean {
+ const now = Date.now();
+ // Cleanup expired entries if needed
+ if (now - lastIpRateLimitCleanup > RATE_LIMIT_WINDOW_MS) {
+ for (const [ip, entry] of ipRateLimitMap.entries()) {
+ if (entry.expires < now) ipRateLimitMap.delete(ip);
+ }
+ lastIpRateLimitCleanup = now;
+ }
+ const entry = ipRateLimitMap.get(ip);
+ if (!entry || entry.expires < now) {
+ ipRateLimitMap.set(ip, { count: 1, expires: now + RATE_LIMIT_WINDOW_MS });
+ return true;
+ }
+ if (entry.count >= RATE_LIMIT_MAX) {
+ return false;
+ }
+ entry.count++;
+ return true;
+}
\ No newline at end of file