Fix thumbnails, reduce network activity, rate limiting, caching, script review page #222

Closed
ic3w0lf22 wants to merge 13 commits from thumbnail-cache-batch into staging
29 changed files with 1246 additions and 731 deletions

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
src={event.User === validatorUser ? undefined : userAvatarUrl}
sx={{ border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
>
<PersonIcon />
</Avatar>

View File

@@ -10,12 +10,14 @@ interface AuditEventsTabPanelProps {
activeTab: number;
auditEvents: AuditEvent[];
validatorUser: number;
auditEventUserAvatarUrls?: Record<number, string>;
}
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]}
/>
))}
</Stack>

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
src={event.User === validatorUser ? undefined : userAvatarUrl}
sx={{ border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
>
<PersonIcon />
</Avatar>

View File

@@ -16,17 +16,20 @@ interface CommentsAndAuditSectionProps {
handleCommentSubmit: () => void;
validatorUser: number;
userId: number | null;
commentUserAvatarUrls: Record<number, string>;
auditEventUserAvatarUrls?: Record<number, string>;
}
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}
/>
<AuditEventsTabPanel
activeTab={activeTab}
auditEvents={auditEvents}
validatorUser={validatorUser}
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/>
</Paper>
);

View File

@@ -18,6 +18,8 @@ interface CommentsTabPanelProps {
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
userAvatarUrl?: string;
commentUserAvatarUrls?: Record<number, string>;
}
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 (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar
src={`/thumbnails/user/${userId}`}
src={userAvatarUrl}
sx={{ border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
/>
<TextField
fullWidth

View File

@@ -1,12 +1,13 @@
import React from "react";
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Grid, Typography} from "@mui/material";
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, CircularProgress, Divider, Grid, Typography} from "@mui/material";
import {Explore, Person2} from "@mui/icons-material";
import {StatusChip} from "@/app/_components/statusChip";
interface MapCardProps {
displayName: string;
assetId: number;
authorId: number;
submitterId: number;
submitterUsername: string;
author: string;
rating: number;
id: number;
@@ -14,6 +15,8 @@ interface MapCardProps {
gameID: number;
created: number;
type: 'mapfix' | 'submission';
thumbnailUrl?: string;
authorAvatarUrl?: string;
}
const CARD_WIDTH = 270;
@@ -40,15 +43,21 @@ export function MapCard(props: MapCardProps) {
}}
href={`/${props.type === 'submission' ? 'submissions' : 'mapfixes'}/${props.id}`}>
<Box sx={{ position: 'relative' }}>
<CardMedia
component="img"
image={`/thumbnails/asset/${props.assetId}`}
alt={props.displayName}
sx={{
height: 160, // Fixed height for all images
objectFit: 'cover',
}}
/>
{props.thumbnailUrl ? (
<CardMedia
component="img"
image={props.thumbnailUrl}
alt={props.displayName}
sx={{
height: 160, // Fixed height for all images
objectFit: 'cover',
}}
/>
) : (
<Box sx={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={32} />
</Box>
)}
<Box
sx={{
position: 'absolute',
@@ -145,37 +154,35 @@ export function MapCard(props: MapCardProps) {
</Typography>
</Box>
</Box>
<Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar
src={`/thumbnails/user/${props.authorId}`}
alt={props.author}
sx={{
width: 24,
height: 24,
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
/>
<Typography
variant="caption"
sx={{
ml: 1,
color: 'text.secondary',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{/*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'
})}
</Typography>
</Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar
src={props.authorAvatarUrl}
alt={props.submitterUsername}
sx={{
width: 24,
height: 24,
border: '1px solid rgba(255, 255, 255, 0.1)',
bgcolor: 'grey.900'
}}
/>
<Typography
variant="caption"
sx={{
ml: 1,
color: 'text.secondary',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{props.submitterUsername} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Typography>
</Box>
</CardContent>
</CardActionArea>

View File

@@ -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 */}

View File

@@ -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<string | null>(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 <Typography variant="body1">Loading...</Typography>;
return <Link href={`https://www.roblox.com/users/${submitterId}/profile`} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none', color: 'inherit' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, '&:hover': { textDecoration: 'underline' } }}>
<Typography>
{name || submitterId}
</Typography>
<LaunchIcon sx={{ fontSize: '1rem', color: 'text.secondary' }} />
</Box>
</Link>
}
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
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar
src={`/thumbnails/user/${submitterId}`}
sx={{ mr: 1, width: 24, height: 24 }}
src={submitterAvatarUrl}
sx={{ mr: 1, width: 24, height: 24, border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
/>
<SubmitterName submitterId={submitterId} />
<Link href={`https://www.roblox.com/users/${submitterId}/profile`} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none', color: 'inherit' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, '&:hover': { textDecoration: 'underline' } }}>
<Typography>
{submitterUsername ? `@${submitterUsername}` : submitterId}
</Typography>
<LaunchIcon sx={{ fontSize: '1rem', color: 'text.secondary' }} />
</Box>
</Link>
</Box>
</>
);
};
};

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
function chunkArray<T>(arr: T[], size: number): T[][] {
Review

This is cursed, surely it can be done in chunks without allocating the chunks ahead of time

This is cursed, surely it can be done in chunks without allocating the chunks ahead of time
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<Record<number, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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<number, string> = {};
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(",")]);
Review

is this joining an array of ids into a string and then parsing it out again later?

is this joining an array of ids into a string and then parsing it out again later?
return { thumbnails, loading, error };
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
function chunkArray<T>(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<Record<number, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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<number, string> = {};
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 };
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
function chunkArray<T>(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<Record<number, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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<number, string> = {};
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 };
}

View File

@@ -1,3 +0,0 @@
export const thumbnailLoader = ({ src, width, quality }: { src: string, width: number, quality?: number }) => {
return `${src}?w=${width}&q=${quality || 75}`;
};

View File

@@ -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<number, string> = {};
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<number, string> = {};
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'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${mapfix.TargetAssetID}`}
alt="Before Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{thumbnailUrls[mapfix.TargetAssetID] ? (
<CardMedia
component="img"
image={thumbnailUrls[mapfix.TargetAssetID]}
alt="Before Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={32} />
</Box>
)}
</Box>
{/* After Image */}
@@ -241,12 +289,18 @@ export default function MapfixDetailsPage() {
transition: 'opacity 0.5s ease-in-out'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${mapfix.AssetID}`}
alt="After Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{thumbnailUrls[mapfix.AssetID] ? (
<CardMedia
component="img"
image={thumbnailUrls[mapfix.AssetID]}
alt="After Map Thumbnail"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={32} />
</Box>
)}
</Box>
<Box
sx={{
@@ -343,6 +397,8 @@ export default function MapfixDetailsPage() {
<ReviewItem
item={mapfix}
handleCopyValue={handleCopyId}
submitterAvatarUrl={submitterAvatarUrl}
submitterUsername={submitterUsername}
/>
{/* Comments Section */}
@@ -353,6 +409,8 @@ export default function MapfixDetailsPage() {
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
commentUserAvatarUrls={commentUserAvatarUrls}
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/>
</Grid>
</Grid>

View File

@@ -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 (
<Webpage>
@@ -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]}
/>
))}
</Box>

View File

@@ -6,25 +6,26 @@ import { useParams, useRouter } from "next/navigation";
import React, { useState, useEffect } from "react";
Review

This entire file is extremely difficult to review because of the formatting changes. the green and red code essentially doubles the entire file and is interspersed randomly, making it impossible to spot actual changes

This entire file is extremely difficult to review because of the formatting changes. the green and red code essentially doubles the entire file and is interspersed randomly, making it impossible to spot actual changes
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<MapInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [roles, setRoles] = useState(RolesConstants.Empty);
const [downloading, setDownloading] = useState(false);
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
const { mapId } = useParams();
const router = useRouter();
const [map, setMap] = useState<MapInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [roles, setRoles] = useState(RolesConstants.Empty);
const [downloading, setDownloading] = useState(false);
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
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 (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: 'error.light',
color: 'error.contrastText'
}}
>
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
<Typography variant="body1">{error}</Typography>
<Button
variant="contained"
onClick={() => router.push('/maps')}
sx={{ mt: 3 }}
>
Return to Maps
</Button>
</Paper>
</Container>
</Webpage>
);
}
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 (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
</Breadcrumbs>
{loading ? (
<Box>
<Box sx={{ mb: 4 }}>
<Skeleton variant="text" width="60%" height={60} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
</Box>
</Box>
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
</Grid>
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
};
}
};
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
</Grid>
</Grid>
</Box>
) : (
map && (
<>
{/* Map Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
{map.DisplayName}
</Typography>
const handleSubmitMapfix = () => {
router.push(`/maps/${mapId}/fix`);
};
{map.GameID && (
<Chip
label={getGameInfo(map.GameID).name}
sx={{
bgcolor: getGameInfo(map.GameID).color,
color: '#fff',
fontWeight: 'bold',
fontSize: '0.9rem',
height: 32
}}
/>
)}
</Box>
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
setCopySuccess(true);
};
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
<strong>Created by:</strong> {map.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
{formatDate(map.Date)}
</Typography>
</Box>
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');
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">
<strong>ID:</strong> {mapId}
</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{!loading && hasRole(roles,RolesConstants.MapDownload) && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<InsertDriveFileIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
Download
</Typography>
<Tooltip title="File extension must be changed to .rbxm manually">
<IconButton
size="small"
onClick={handleDownload}
sx={{ ml: 1 }}
disabled={downloading}
>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Box>
const location = await res.text();
<Grid container spacing={3}>
{/* Map Preview Section */}
<Grid item xs={12} md={8}>
<Paper
elevation={3}
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
}}
>
{thumbnailUrls[map.ID] ? (
<CardMedia
component="img"
image={thumbnailUrls[map.ID]}
alt={`Preview of map: ${map?.DisplayName}`}
sx={{
height: 400,
objectFit: 'cover',
}}
/>
) : (
<Box sx={{ width: '100%', height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={40} />
</Box>
)}
</Paper>
</Grid>
// open in new window
window.open(location.trim(), '_blank');
{/* Map Details Section */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>Map Details</Typography>
<Divider sx={{ mb: 2 }} />
} catch (err) {
console.error('Download error:', err);
// Optional: Show user-friendly error message
alert('Download failed. Please try again.');
} finally {
setDownloading(false);
}
};
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
<Typography variant="body1">{map.DisplayName}</Typography>
</Box>
const handleCloseSnackbar = () => {
setCopySuccess(false);
};
<Box>
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
<Typography variant="body1">{map.Creator}</Typography>
</Box>
if (error) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: 'error.light',
color: 'error.contrastText'
}}
>
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
<Typography variant="body1">{error}</Typography>
<Button
variant="contained"
onClick={() => router.push('/maps')}
sx={{ mt: 3 }}
>
Return to Maps
</Button>
</Paper>
</Container>
</Webpage>
);
}
<Box>
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
</Box>
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
</Breadcrumbs>
{loading ? (
<Box>
<Box sx={{ mb: 4 }}>
<Skeleton variant="text" width="60%" height={60} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Typography variant="body1">{formatDate(map.Date)}</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
</Grid>
<Box>
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{mapId}</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1, p: 0 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
</Grid>
</Grid>
</Box>
) : (
map && (
<>
{/* Map Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
{map.DisplayName}
</Typography>
{/* 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 (
<Box>
<Typography variant="subtitle2" color="text.secondary">
Active Mapfix
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="body2"
component={Link}
href={`/mapfixes/${showFix.ID}`}
sx={{
textDecoration: 'underline',
cursor: 'pointer',
color: 'primary.main',
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 0.5
}}
>
{showFix.Description}
<LaunchIcon sx={{ fontSize: '1rem', ml: 0.5 }} />
</Typography>
</Box>
</Box>
);
})()}
</Stack>
</Paper>
{map.GameID && (
<Chip
label={getGameInfo(map.GameID).name}
sx={{
bgcolor: getGameInfo(map.GameID).color,
color: '#fff',
fontWeight: 'bold',
fontSize: '0.9rem',
height: 32
}}
/>
)}
</Box>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Button
fullWidth
variant="contained"
color="primary"
startIcon={<BugReportIcon />}
onClick={handleSubmitMapfix}
size="large"
>
Submit a Mapfix
</Button>
</Paper>
</Grid>
</Grid>
</>
)
)}
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
<strong>Created by:</strong> {map.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
{formatDate(map.Date)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">
<strong>ID:</strong> {mapId}
</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{!loading && hasRole(roles,RolesConstants.MapDownload) && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<InsertDriveFileIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
Download
</Typography>
<Tooltip title="File extension must be changed to .rbxm manually">
<IconButton
size="small"
onClick={handleDownload}
sx={{ ml: 1 }}
disabled={downloading}
>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Box>
<Grid container spacing={3}>
{/* Map Preview Section */}
<Grid item xs={12} md={8}>
<Paper
elevation={3}
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
}}
>
<CardMedia
component="img"
image={`/thumbnails/asset/${map.ID}`}
alt={`Preview of map: ${map.DisplayName}`}
sx={{
height: 400,
objectFit: 'cover',
}}
/>
</Paper>
</Grid>
{/* Map Details Section */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>Map Details</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
<Typography variant="body1">{map.DisplayName}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
<Typography variant="body1">{map.Creator}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Typography variant="body1">{formatDate(map.Date)}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{mapId}</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1, p: 0 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* 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 (
<Box>
<Typography variant="subtitle2" color="text.secondary">
Active Mapfix
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="body2"
component={Link}
href={`/mapfixes/${showFix.ID}`}
sx={{
textDecoration: 'underline',
cursor: 'pointer',
color: 'primary.main',
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 0.5
}}
>
{showFix.Description}
<LaunchIcon sx={{ fontSize: '1rem', ml: 0.5 }} />
</Typography>
</Box>
</Box>
);
})()}
</Stack>
</Paper>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Button
fullWidth
variant="contained"
color="primary"
startIcon={<BugReportIcon />}
onClick={handleSubmitMapfix}
size="large"
>
Submit a Mapfix
</Button>
</Paper>
</Grid>
</Grid>
</>
)
)}
<Snackbar
open={copySuccess}
autoHideDuration={3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
</Alert>
</Snackbar>
</Container>
</Webpage>
);
<Snackbar
open={copySuccess}
autoHideDuration={3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

@@ -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<unknown>, page: number) => {
setCurrentPage(page);
@@ -262,13 +264,19 @@ export default function MapsPage() {
>
{getGameName(map.GameID)}
</Box>
<Image
loader={thumbnailLoader}
src={`/thumbnails/asset/${map.ID}`}
alt={map.DisplayName}
fill
style={{objectFit: 'cover'}}
/>
{thumbnails[map.ID] ? (
<Image
src={thumbnails[map.ID]}
alt={map.DisplayName}
fill
style={{ objectFit: 'cover' }}
loading="eager"
/>
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={32} />
</Box>
)}
</CardMedia>
<CardContent>
<Typography variant="h6" component="h2" noWrap>

View File

@@ -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]}
/>
);

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,6 @@
// Roblox user info type for batch endpoint
export interface RobloxUserInfo {
id: number;
name: string;
displayName: string;
}

View File

@@ -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)
Review

wtf is this comment

wtf is this comment
// 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<number, { info: RobloxUserInfo, expires: number }>();
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<number, RobloxUserInfo> = {};
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 });
}

View File

@@ -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<number, string> = {};
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<number, string> = {};
for (const uid of auditEventUserIds) {
if (avatarUrls[uid]) auditEventUserAvatarUrls[uid] = avatarUrls[uid];
}
const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
Review

showSnackbar and handleCloseSnackbar are moved for no reason, I got confused because I couldn't see the code relevant to where they were used in the diff. My error I guess not noticing it was moved right away

`showSnackbar` and `handleCloseSnackbar` are moved for no reason, I got confused because I couldn't see the code relevant to where they were used in the diff. My error I guess not noticing it was moved right away
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() {
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
{submission.AssetID ? (
<CardMedia
component="img"
image={`/thumbnails/asset/${submission.AssetID}`}
alt="Map Thumbnail"
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
/>
thumbnailUrls[submission.AssetID] ? (
<CardMedia
component="img"
image={thumbnailUrls[submission.AssetID]}
alt="Map Thumbnail"
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
/>
) : (
<Box
sx={{
width: '100%',
aspectRatio: '1/1',
bgcolor: 'grey.900',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<CircularProgress size={32} />
</Box>
)
) : (
<Box
sx={{
@@ -220,7 +262,7 @@ export default function SubmissionDetailsPage() {
alignItems: 'center',
justifyContent: 'center'
}}
>
>
<Typography variant="body2" color="text.secondary">No image available</Typography>
</Box>
)}
@@ -234,14 +276,14 @@ export default function SubmissionDetailsPage() {
roles={roles}
type="submission"/>
</Grid>
{/* Right Column - Submission Details and Comments */}
<Grid item xs={12} md={8}>
<ReviewItem
item={submission}
handleCopyValue={handleCopyId}
submitterAvatarUrl={avatarUrls[submitterId]}
submitterUsername={usernameMap[submitterId]}
/>
{/* Comments Section */}
<CommentsAndAuditSection
auditEvents={auditEvents}
@@ -250,6 +292,8 @@ export default function SubmissionDetailsPage() {
handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser}
userId={user}
commentUserAvatarUrls={commentUserAvatarUrls}
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/>
</Grid>
</Grid>

View File

@@ -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 (
<Webpage>
@@ -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]}
/>
))}
</Box>

View File

@@ -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<NextResponse> {
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}`,
})
}
}

View File

@@ -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<number, { url: string, expires: number }>();
const userImageCache = new Map<number, { url: string, expires: number }>();
// 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<number, string | null> = {};
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);
}

View File

@@ -1,20 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
export async function GET(
request: NextRequest,
context: { params: Promise<{ mapId: string }> }
): Promise<NextResponse> {
// 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)
}

View File

@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
context: { params: Promise<{ userId: number }> }
): Promise<NextResponse> {
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 }
);
}
}

View File

@@ -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';
}

View File

@@ -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;
};
}

32
web/src/lib/rateLimit.ts Normal file
View File

@@ -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.
Review

The entire rate limit thing seems extremely overbuilt and should probably be ripped out, or if it becomes a problem, implemented in the backend like it suggests

The entire rate limit thing seems extremely overbuilt and should probably be ripped out, or if it becomes a problem, implemented in the backend like it suggests
//
// 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<ip, { count: number, expires: number }>
const ipRateLimitMap = new Map<string, { count: number, expires: number }>();
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;
}