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 && ( - - )} - + + + + + + + ) + )} - - - - - 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} - - - - - ); - })()} - - - - - - - - - - ) - )} - - - - 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)} - {map.DisplayName} + {thumbnails[map.ID] ? ( + {map.DisplayName} + ) : ( + + + + )} 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