13 Commits

Author SHA1 Message Date
ic3w0lf
1f7ba5bb9b Sorry to whoever uses 2 spaces instead of a tab
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-01 17:57:06 -06:00
ic3w0lf
3e0bc9804f Merge branch 'staging' into thumbnail-cache-batch
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-07-01 17:47:01 -06:00
ic3w0lf
dfe9107112 User information caching
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-07-01 17:31:50 -06:00
ic3w0lf
ba5e449569 User information caching 2025-07-01 17:31:35 -06:00
ic3w0lf
709bb708d3 build succeed + use media for thumbnail if available
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-01 17:17:07 -06:00
ic3w0lf
87c1d161fc Batch limits, AI suggested anti-spam.
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-01 16:45:04 -06:00
ic3w0lf
82284947ee more batching & shii
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-01 15:50:48 -06:00
ic3w0lf
8d4d6b7bfe forgot to click stage all: Batched user information requests, optimized requests (halved from 6 to 3 :money_mouth:), cleanup
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-30 17:40:42 -06:00
ic3w0lf
7421e6d989 Batched user information requests, optimized requests (halved from 6 to 3 :money_mouth:), cleanup 2025-06-30 17:40:13 -06:00
ic3w0lf
a1e0e5f720 Subtle visual changes, user avatar batching, rate-limiting, submitter name instead of author, ai comments
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-30 16:47:19 -06:00
ic3w0lf
c21afaa846 why were these untracked... severely vibe coded files btw
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-30 04:03:34 -06:00
ic3w0lf
f0abb9ffbf severe vibe coding going on here...
All checks were successful
continuous-integration/drone/push Build is passing
on another note, build succeeds :D (i love eslint-disable >:)) <- is this even safe? we'll find out if the server explodes
2025-06-30 03:24:04 -06:00
ic3w0lf
8ca7f99098 pushing ai changes before i lose them 2025-06-29 19:01:35 -06:00
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[][] {
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(",")]);
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";
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)
// 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') => {
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.
//
// 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;
}