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 { interface AuditEventItemProps {
event: AuditEvent; event: AuditEvent;
validatorUser: number; validatorUser: number;
userAvatarUrl?: string;
} }
export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) { export default function AuditEventItem({ event, validatorUser, userAvatarUrl }: AuditEventItemProps) {
return ( return (
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2 }}>
<Avatar <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 /> <PersonIcon />
</Avatar> </Avatar>

View File

@@ -10,12 +10,14 @@ interface AuditEventsTabPanelProps {
activeTab: number; activeTab: number;
auditEvents: AuditEvent[]; auditEvents: AuditEvent[];
validatorUser: number; validatorUser: number;
auditEventUserAvatarUrls?: Record<number, string>;
} }
export default function AuditEventsTabPanel({ export default function AuditEventsTabPanel({
activeTab, activeTab,
auditEvents, auditEvents,
validatorUser validatorUser,
auditEventUserAvatarUrls
}: AuditEventsTabPanelProps) { }: AuditEventsTabPanelProps) {
const filteredEvents = auditEvents.filter( const filteredEvents = auditEvents.filter(
event => event.EventType !== AuditEventType.Comment event => event.EventType !== AuditEventType.Comment
@@ -30,6 +32,7 @@ export default function AuditEventsTabPanel({
key={index} key={index}
event={event} event={event}
validatorUser={validatorUser} validatorUser={validatorUser}
userAvatarUrl={auditEventUserAvatarUrls?.[event.User]}
/> />
))} ))}
</Stack> </Stack>

View File

@@ -12,13 +12,15 @@ import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent";
interface CommentItemProps { interface CommentItemProps {
event: AuditEvent; event: AuditEvent;
validatorUser: number; validatorUser: number;
userAvatarUrl?: string;
} }
export default function CommentItem({ event, validatorUser }: CommentItemProps) { export default function CommentItem({ event, validatorUser, userAvatarUrl }: CommentItemProps) {
return ( return (
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2 }}>
<Avatar <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 /> <PersonIcon />
</Avatar> </Avatar>

View File

@@ -16,17 +16,20 @@ interface CommentsAndAuditSectionProps {
handleCommentSubmit: () => void; handleCommentSubmit: () => void;
validatorUser: number; validatorUser: number;
userId: number | null; userId: number | null;
commentUserAvatarUrls: Record<number, string>;
auditEventUserAvatarUrls?: Record<number, string>;
} }
export default function CommentsAndAuditSection({ export default function CommentsAndAuditSection({
auditEvents, auditEvents,
newComment, newComment,
setNewComment, setNewComment,
handleCommentSubmit, handleCommentSubmit,
validatorUser, validatorUser,
userId, userId,
}: CommentsAndAuditSectionProps) { commentUserAvatarUrls,
auditEventUserAvatarUrls
}: CommentsAndAuditSectionProps) {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue); setActiveTab(newValue);
@@ -53,12 +56,14 @@ export default function CommentsAndAuditSection({
setNewComment={setNewComment} setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit} handleCommentSubmit={handleCommentSubmit}
userId={userId} userId={userId}
commentUserAvatarUrls={commentUserAvatarUrls}
/> />
<AuditEventsTabPanel <AuditEventsTabPanel
activeTab={activeTab} activeTab={activeTab}
auditEvents={auditEvents} auditEvents={auditEvents}
validatorUser={validatorUser} validatorUser={validatorUser}
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/> />
</Paper> </Paper>
); );

View File

@@ -18,6 +18,8 @@ interface CommentsTabPanelProps {
setNewComment: (comment: string) => void; setNewComment: (comment: string) => void;
handleCommentSubmit: () => void; handleCommentSubmit: () => void;
userId: number | null; userId: number | null;
userAvatarUrl?: string;
commentUserAvatarUrls?: Record<number, string>;
} }
export default function CommentsTabPanel({ export default function CommentsTabPanel({
@@ -27,7 +29,9 @@ export default function CommentsTabPanel({
newComment, newComment,
setNewComment, setNewComment,
handleCommentSubmit, handleCommentSubmit,
userId userId,
userAvatarUrl,
commentUserAvatarUrls
}: CommentsTabPanelProps) { }: CommentsTabPanelProps) {
const commentEvents = auditEvents.filter( const commentEvents = auditEvents.filter(
event => event.EventType === AuditEventType.Comment event => event.EventType === AuditEventType.Comment
@@ -44,6 +48,7 @@ export default function CommentsTabPanel({
key={index} key={index}
event={event} event={event}
validatorUser={validatorUser} validatorUser={validatorUser}
userAvatarUrl={commentUserAvatarUrls?.[event.User]}
/> />
)) ))
) : ( ) : (
@@ -59,6 +64,7 @@ export default function CommentsTabPanel({
setNewComment={setNewComment} setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit} handleCommentSubmit={handleCommentSubmit}
userId={userId} userId={userId}
userAvatarUrl={userAvatarUrl}
/> />
)} )}
</> </>
@@ -72,13 +78,15 @@ interface CommentInputProps {
setNewComment: (comment: string) => void; setNewComment: (comment: string) => void;
handleCommentSubmit: () => void; handleCommentSubmit: () => void;
userId: number | null; userId: number | null;
userAvatarUrl?: string;
} }
function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) { function CommentInput({ newComment, setNewComment, handleCommentSubmit, userAvatarUrl }: CommentInputProps) {
return ( return (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}> <Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar <Avatar
src={`/thumbnails/user/${userId}`} src={userAvatarUrl}
sx={{ border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
/> />
<TextField <TextField
fullWidth fullWidth

View File

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

View File

@@ -16,13 +16,16 @@ type ReviewItemType = SubmissionInfo | MapfixInfo;
interface ReviewItemProps { interface ReviewItemProps {
item: ReviewItemType; item: ReviewItemType;
handleCopyValue: (value: string) => void; handleCopyValue: (value: string) => void;
submitterAvatarUrl?: string;
submitterUsername?: string;
} }
export function ReviewItem({ export function ReviewItem({
item, item,
handleCopyValue handleCopyValue,
}: ReviewItemProps) { submitterAvatarUrl,
// Type guard to check if item is valid submitterUsername
}: ReviewItemProps) {
if (!item) return null; if (!item) return null;
// Determine the type of item // Determine the type of item
@@ -53,6 +56,8 @@ export function ReviewItem({
statusId={item.StatusID} statusId={item.StatusID}
creator={item.Creator} creator={item.Creator}
submitterId={item.Submitter} submitterId={item.Submitter}
submitterAvatarUrl={submitterAvatarUrl}
submitterUsername={submitterUsername}
/> />
{/* Item Details */} {/* Item Details */}

View File

@@ -3,52 +3,20 @@ import { StatusChip } from "@/app/_components/statusChip";
import { SubmissionStatus } from "@/app/ts/Submission"; import { SubmissionStatus } from "@/app/ts/Submission";
import { MapfixStatus } from "@/app/ts/Mapfix"; import { MapfixStatus } from "@/app/ts/Mapfix";
import {Status, StatusMatches} from "@/app/ts/Status"; import {Status, StatusMatches} from "@/app/ts/Status";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import LaunchIcon from '@mui/icons-material/Launch'; 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 { interface ReviewItemHeaderProps {
displayName: string; displayName: string;
assetId: number | null | undefined, assetId: number | null | undefined,
statusId: SubmissionStatus | MapfixStatus; statusId: SubmissionStatus | MapfixStatus;
creator: string | null | undefined; creator: string | null | undefined;
submitterId: number; 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 isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
const pulse = keyframes` const pulse = keyframes`
0%, 100% { opacity: 0.2; transform: scale(0.8); } 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 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar <Avatar
src={`/thumbnails/user/${submitterId}`} src={submitterAvatarUrl}
sx={{ mr: 1, width: 24, height: 24 }} 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> </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 Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import {useState} from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
// MUI Components // MUI Components
@@ -17,6 +17,7 @@ import {
CardMedia, CardMedia,
Snackbar, Snackbar,
Alert, Alert,
CircularProgress,
} from "@mui/material"; } from "@mui/material";
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; 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 {useReviewData} from "@/app/hooks/useReviewData";
import {MapfixInfo} from "@/app/ts/Mapfix"; import {MapfixInfo} from "@/app/ts/Mapfix";
import {useTitle} from "@/app/hooks/useTitle"; 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 { interface SnackbarState {
open: boolean; open: boolean;
@@ -44,6 +48,7 @@ export default function MapfixDetailsPage() {
message: null, message: null,
severity: 'success' severity: 'success'
}); });
const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => {
setSnackbar({ setSnackbar({
open: true, open: true,
@@ -76,6 +81,43 @@ export default function MapfixDetailsPage() {
useTitle(mapfix ? `${mapfix.DisplayName} Mapfix` : 'Loading Mapfix...'); 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 // Handle review button actions
async function handleReviewAction(action: string, mapfixId: number) { async function handleReviewAction(action: string, mapfixId: number) {
try { try {
@@ -220,12 +262,18 @@ export default function MapfixDetailsPage() {
transition: 'opacity 0.5s ease-in-out' transition: 'opacity 0.5s ease-in-out'
}} }}
> >
<CardMedia {thumbnailUrls[mapfix.TargetAssetID] ? (
component="img" <CardMedia
image={`/thumbnails/asset/${mapfix.TargetAssetID}`} component="img"
alt="Before Map Thumbnail" image={thumbnailUrls[mapfix.TargetAssetID]}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} 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> </Box>
{/* After Image */} {/* After Image */}
@@ -241,12 +289,18 @@ export default function MapfixDetailsPage() {
transition: 'opacity 0.5s ease-in-out' transition: 'opacity 0.5s ease-in-out'
}} }}
> >
<CardMedia {thumbnailUrls[mapfix.AssetID] ? (
component="img" <CardMedia
image={`/thumbnails/asset/${mapfix.AssetID}`} component="img"
alt="After Map Thumbnail" image={thumbnailUrls[mapfix.AssetID]}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} 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>
<Box <Box
sx={{ sx={{
@@ -343,6 +397,8 @@ export default function MapfixDetailsPage() {
<ReviewItem <ReviewItem
item={mapfix} item={mapfix}
handleCopyValue={handleCopyId} handleCopyValue={handleCopyId}
submitterAvatarUrl={submitterAvatarUrl}
submitterUsername={submitterUsername}
/> />
{/* Comments Section */} {/* Comments Section */}
@@ -353,6 +409,8 @@ export default function MapfixDetailsPage() {
handleCommentSubmit={handleCommentSubmit} handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser} validatorUser={validatorUser}
userId={user} userId={user}
commentUserAvatarUrls={commentUserAvatarUrls}
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -16,6 +16,9 @@ import {
import Link from "next/link"; import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle"; 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() { export default function MapfixInfoPage() {
useTitle("Map Fixes"); useTitle("Map Fixes");
@@ -55,6 +58,14 @@ export default function MapfixInfoPage() {
return () => controller.abort(); return () => controller.abort();
}, [currentPage]); }, [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) { if (isLoading || !mapfixes) {
return ( return (
<Webpage> <Webpage>
@@ -111,12 +122,15 @@ export default function MapfixInfoPage() {
assetId={mapfix.AssetID} assetId={mapfix.AssetID}
displayName={mapfix.DisplayName} displayName={mapfix.DisplayName}
author={mapfix.Creator} author={mapfix.Creator}
authorId={mapfix.Submitter} submitterId={mapfix.Submitter}
submitterUsername={submitterUsernames[mapfix.Submitter] || String(mapfix.Submitter)}
rating={mapfix.StatusID} rating={mapfix.StatusID}
statusID={mapfix.StatusID} statusID={mapfix.StatusID}
gameID={mapfix.GameID} gameID={mapfix.GameID}
created={mapfix.CreatedAt} created={mapfix.CreatedAt}
type="mapfix" type="mapfix"
thumbnailUrl={thumbnailUrls[mapfix.AssetID]}
authorAvatarUrl={avatarUrls[mapfix.Submitter]}
/> />
))} ))}
</Box> </Box>

View File

@@ -6,25 +6,26 @@ import { useParams, useRouter } from "next/navigation";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Snackbar, Alert } from "@mui/material"; import { Snackbar, Alert } from "@mui/material";
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix"; import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix";
import LaunchIcon from '@mui/icons-material/Launch';
// MUI Components // MUI Components
import { import {
Typography, Typography,
Box, Box,
Button, Button,
Container, Container,
Breadcrumbs, Breadcrumbs,
Chip, Chip,
Grid, Grid,
Divider, Divider,
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
CardMedia, CardMedia,
Tooltip, Tooltip,
IconButton IconButton,
CircularProgress
} from "@mui/material"; } from "@mui/material";
import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; 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 ContentCopyIcon from "@mui/icons-material/ContentCopy";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import LaunchIcon from '@mui/icons-material/Launch';
import {hasRole, RolesConstants} from "@/app/ts/Roles"; import {hasRole, RolesConstants} from "@/app/ts/Roles";
import {useTitle} from "@/app/hooks/useTitle"; import {useTitle} from "@/app/hooks/useTitle";
export default function MapDetails() { export default function MapDetails() {
const { mapId } = useParams(); const { mapId } = useParams();
const router = useRouter(); const router = useRouter();
const [map, setMap] = useState<MapInfo | null>(null); const [map, setMap] = useState<MapInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
const [roles, setRoles] = useState(RolesConstants.Empty); const [roles, setRoles] = useState(RolesConstants.Empty);
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]); const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
useTitle(map ? `${map.DisplayName}` : 'Loading Map...'); useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
useEffect(() => { useEffect(() => {
async function getMap() { async function getMap() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`/api/maps/${mapId}`); const res = await fetch(`/api/maps/${mapId}`);
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch map: ${res.status}`); throw new Error(`Failed to fetch map: ${res.status}`);
} }
const data = await res.json(); const data = await res.json();
setMap(data); setMap(data);
} catch (error) { } catch (error) {
console.error("Error fetching map details:", error); console.error("Error fetching map details:", error);
setError(error instanceof Error ? error.message : "Failed to load map details"); setError(error instanceof Error ? error.message : "Failed to load map details");
} finally { } finally {
setLoading(false); 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);
} }
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(() => { return (
if (!map) return; <Webpage>
const targetAssetId = map.ID; <Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
async function fetchMapfixes() { {/* Breadcrumbs Navigation */}
try { <Breadcrumbs
const limit = 100; separator={<NavigateNextIcon fontSize="small" />}
let page = 1; aria-label="breadcrumb"
let allMapfixes: MapfixInfo[] = []; sx={{ mb: 3 }}
let total = 0; >
do { <Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
const res = await fetch(`/api/mapfixes?Page=${page}&Limit=${limit}&TargetAssetID=${targetAssetId}`); <Typography color="text.primary">Home</Typography>
if (!res.ok) break; </Link>
const data = await res.json(); <Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
if (page === 1) total = data.Total; <Typography color="text.primary">Maps</Typography>
allMapfixes = allMapfixes.concat(data.Mapfixes); </Link>
page++; <Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
} while (allMapfixes.length < total); </Breadcrumbs>
// Filter out rejected, uploading, uploaded (StatusID > 7) {loading ? (
const active = allMapfixes.filter((fix: MapfixInfo) => fix.StatusID <= MapfixStatus.Validated); <Box>
setMapfixes(active); <Box sx={{ mb: 4 }}>
} catch { <Skeleton variant="text" width="60%" height={60} />
setMapfixes([]); <Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
} <Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
} <Skeleton variant="text" width={120} />
fetchMapfixes(); <Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
}, [map]); </Box>
</Box>
const formatDate = (timestamp: number) => { <Grid container spacing={3}>
return new Date(timestamp * 1000).toLocaleDateString('en-US', { <Grid item xs={12} md={8}>
year: 'numeric', <Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
month: 'long', </Grid>
day: 'numeric'
});
};
const getGameInfo = (gameId: number) => { <Grid item xs={12} md={4}>
switch (gameId) { <Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
case 1: <Skeleton variant="text" width="90%" />
return { <Skeleton variant="text" width="70%" />
name: "Bhop", <Skeleton variant="text" width="80%" />
color: "#2196f3" // blue <Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
}; </Grid>
case 2: </Grid>
return { </Box>
name: "Surf", ) : (
color: "#4caf50" // green map && (
}; <>
case 5: {/* Map Header */}
return { <Box sx={{ mb: 4 }}>
name: "Fly Trials", <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
color: "#ff9800" // orange <Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
}; {map.DisplayName}
default: </Typography>
return {
name: "Unknown",
color: "#9e9e9e" // gray
};
}
};
const handleSubmitMapfix = () => { {map.GameID && (
router.push(`/maps/${mapId}/fix`); <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) => { <Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
navigator.clipboard.writeText(idToCopy); <Box sx={{ display: 'flex', alignItems: 'center' }}>
setCopySuccess(true); <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 () => { <Box sx={{ display: 'flex', alignItems: 'center' }}>
setDownloading(true); <FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
try { <Box sx={{ display: 'flex', alignItems: 'center' }}>
// Fetch the download URL <Typography variant="body1">
const res = await fetch(`/api/maps/${mapId}/location`); <strong>ID:</strong> {mapId}
if (!res.ok) throw new Error('Failed to fetch download location'); </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 {/* Map Details Section */}
window.open(location.trim(), '_blank'); <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) { <Stack spacing={2}>
console.error('Download error:', err); <Box>
// Optional: Show user-friendly error message <Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
alert('Download failed. Please try again.'); <Typography variant="body1">{map.DisplayName}</Typography>
} finally { </Box>
setDownloading(false);
}
};
const handleCloseSnackbar = () => { <Box>
setCopySuccess(false); <Typography variant="subtitle2" color="text.secondary">Creator</Typography>
}; <Typography variant="body1">{map.Creator}</Typography>
</Box>
if (error) { <Box>
return ( <Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Webpage> <Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
<Container maxWidth="lg" sx={{ py: 6 }}> </Box>
<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>
);
}
return ( <Box>
<Webpage> <Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}> <Typography variant="body1">{formatDate(map.Date)}</Typography>
{/* Breadcrumbs Navigation */} </Box>
<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>
<Grid container spacing={3}> <Box>
<Grid item xs={12} md={8}> <Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} /> <Box sx={{ display: 'flex', alignItems: 'center' }}>
</Grid> <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}> {/* Active Mapfix in Map Details */}
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} /> {mapfixes.length > 0 && (() => {
<Skeleton variant="text" width="90%" /> const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated);
<Skeleton variant="text" width="70%" /> const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b));
<Skeleton variant="text" width="80%" /> const showFix = active || latest;
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} /> return (
</Grid> <Box>
</Grid> <Typography variant="subtitle2" color="text.secondary">
</Box> Active Mapfix
) : ( </Typography>
map && ( <Box sx={{ display: 'flex', alignItems: 'center' }}>
<> <Typography
{/* Map Header */} variant="body2"
<Box sx={{ mb: 4 }}> component={Link}
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}> href={`/mapfixes/${showFix.ID}`}
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}> sx={{
{map.DisplayName} textDecoration: 'underline',
</Typography> 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 && ( <Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Chip <Button
label={getGameInfo(map.GameID).name} fullWidth
sx={{ variant="contained"
bgcolor: getGameInfo(map.GameID).color, color="primary"
color: '#fff', startIcon={<BugReportIcon />}
fontWeight: 'bold', onClick={handleSubmitMapfix}
fontSize: '0.9rem', size="large"
height: 32 >
}} Submit a Mapfix
/> </Button>
)} </Paper>
</Box> </Grid>
</Grid>
</>
)
)}
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}> <Snackbar
<Box sx={{ display: 'flex', alignItems: 'center' }}> open={copySuccess}
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} /> autoHideDuration={3000}
<Typography variant="body1"> onClose={handleCloseSnackbar}
<strong>Created by:</strong> {map.Creator} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
</Typography> >
</Box> <Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
<Box sx={{ display: 'flex', alignItems: 'center' }}> </Alert>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} /> </Snackbar>
<Typography variant="body1"> </Container>
{formatDate(map.Date)} </Webpage>
</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>
);
} }

View File

@@ -27,7 +27,7 @@ import {Search as SearchIcon} from "@mui/icons-material";
import Link from "next/link"; import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle"; import {useTitle} from "@/app/hooks/useTitle";
import {thumbnailLoader} from '@/app/lib/thumbnailLoader'; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
interface Map { interface Map {
ID: number; ID: number;
@@ -77,11 +77,6 @@ export default function MapsPage() {
fetchMaps(); fetchMaps();
}, []); }, []);
const handleGameFilterChange = (event: SelectChangeEvent) => {
setGameFilter(event.target.value);
setCurrentPage(1);
};
// Filter maps based on search query and game filter // Filter maps based on search query and game filter
const filteredMaps = maps.filter(map => { const filteredMaps = maps.filter(map => {
const matchesSearch = const matchesSearch =
@@ -101,6 +96,13 @@ export default function MapsPage() {
(currentPage - 1) * mapsPerPage, (currentPage - 1) * mapsPerPage,
currentPage * 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) => { const handlePageChange = (_event: React.ChangeEvent<unknown>, page: number) => {
setCurrentPage(page); setCurrentPage(page);
@@ -262,13 +264,19 @@ export default function MapsPage() {
> >
{getGameName(map.GameID)} {getGameName(map.GameID)}
</Box> </Box>
<Image {thumbnails[map.ID] ? (
loader={thumbnailLoader} <Image
src={`/thumbnails/asset/${map.ID}`} src={thumbnails[map.ID]}
alt={map.DisplayName} alt={map.DisplayName}
fill fill
style={{objectFit: 'cover'}} 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> </CardMedia>
<CardContent> <CardContent>
<Typography variant="h6" component="h2" noWrap> <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 {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
import {Carousel} from "@/app/_components/carousel"; import {Carousel} from "@/app/_components/carousel";
import {useTitle} from "@/app/hooks/useTitle"; 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() { export default function Home() {
useTitle("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; const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
if (isLoading && (!mapfixes || !submissions)) { if (isLoading && (!mapfixes || !submissions)) {
@@ -102,12 +118,15 @@ export default function Home() {
assetId={mapfix.AssetID} assetId={mapfix.AssetID}
displayName={mapfix.DisplayName} displayName={mapfix.DisplayName}
author={mapfix.Creator} author={mapfix.Creator}
authorId={mapfix.Submitter} submitterId={mapfix.Submitter}
submitterUsername={mapfixUsernames[mapfix.Submitter] || String(mapfix.Submitter)}
rating={mapfix.StatusID} rating={mapfix.StatusID}
statusID={mapfix.StatusID} statusID={mapfix.StatusID}
gameID={mapfix.GameID} gameID={mapfix.GameID}
created={mapfix.CreatedAt} created={mapfix.CreatedAt}
type="mapfix" type="mapfix"
thumbnailUrl={mapfixThumbnails[mapfix.AssetID]}
authorAvatarUrl={mapfixAvatars[mapfix.Submitter]}
/> />
); );
@@ -118,12 +137,15 @@ export default function Home() {
assetId={submission.AssetID} assetId={submission.AssetID}
displayName={submission.DisplayName} displayName={submission.DisplayName}
author={submission.Creator} author={submission.Creator}
authorId={submission.Submitter} submitterId={submission.Submitter}
submitterUsername={submissionUsernames[submission.Submitter] || String(submission.Submitter)}
rating={submission.StatusID} rating={submission.StatusID}
statusID={submission.StatusID} statusID={submission.StatusID}
gameID={submission.GameID} gameID={submission.GameID}
created={submission.CreatedAt} created={submission.CreatedAt}
type="submission" 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"; "use client";
import Webpage from "@/app/_components/webpage"; import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import {useState} from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
// MUI Components // MUI Components
@@ -14,6 +14,7 @@ import {
Skeleton, Skeleton,
Grid, Grid,
CardMedia, CardMedia,
CircularProgress,
Snackbar, Snackbar,
Alert, Alert,
} from "@mui/material"; } from "@mui/material";
@@ -26,6 +27,9 @@ import ReviewButtons from "@/app/_components/review/ReviewButtons";
import {useReviewData} from "@/app/hooks/useReviewData"; import {useReviewData} from "@/app/hooks/useReviewData";
import {SubmissionInfo} from "@/app/ts/Submission"; import {SubmissionInfo} from "@/app/ts/Submission";
import {useTitle} from "@/app/hooks/useTitle"; 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 { interface SnackbarState {
open: boolean; open: boolean;
@@ -42,22 +46,6 @@ export default function SubmissionDetailsPage() {
message: null, message: null,
severity: 'success' 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 validatorUser = 9223372036854776000;
const { const {
@@ -76,6 +64,45 @@ export default function SubmissionDetailsPage() {
useTitle(submission ? `${submission.DisplayName} Submission` : 'Loading Submission...'); 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 // Handle review button actions
async function handleReviewAction(action: string, submissionId: number) { async function handleReviewAction(action: string, submissionId: number) {
try { try {
@@ -204,12 +231,27 @@ export default function SubmissionDetailsPage() {
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}> <Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
{submission.AssetID ? ( {submission.AssetID ? (
<CardMedia thumbnailUrls[submission.AssetID] ? (
component="img" <CardMedia
image={`/thumbnails/asset/${submission.AssetID}`} component="img"
alt="Map Thumbnail" image={thumbnailUrls[submission.AssetID]}
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }} 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 <Box
sx={{ sx={{
@@ -220,7 +262,7 @@ export default function SubmissionDetailsPage() {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
<Typography variant="body2" color="text.secondary">No image available</Typography> <Typography variant="body2" color="text.secondary">No image available</Typography>
</Box> </Box>
)} )}
@@ -234,14 +276,14 @@ export default function SubmissionDetailsPage() {
roles={roles} roles={roles}
type="submission"/> type="submission"/>
</Grid> </Grid>
{/* Right Column - Submission Details and Comments */} {/* Right Column - Submission Details and Comments */}
<Grid item xs={12} md={8}> <Grid item xs={12} md={8}>
<ReviewItem <ReviewItem
item={submission} item={submission}
handleCopyValue={handleCopyId} handleCopyValue={handleCopyId}
submitterAvatarUrl={avatarUrls[submitterId]}
submitterUsername={usernameMap[submitterId]}
/> />
{/* Comments Section */} {/* Comments Section */}
<CommentsAndAuditSection <CommentsAndAuditSection
auditEvents={auditEvents} auditEvents={auditEvents}
@@ -250,6 +292,8 @@ export default function SubmissionDetailsPage() {
handleCommentSubmit={handleCommentSubmit} handleCommentSubmit={handleCommentSubmit}
validatorUser={validatorUser} validatorUser={validatorUser}
userId={user} userId={user}
commentUserAvatarUrls={commentUserAvatarUrls}
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -16,6 +16,9 @@ import {
import Link from "next/link"; import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle"; 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() { export default function SubmissionInfoPage() {
useTitle("Submissions"); useTitle("Submissions");
@@ -55,6 +58,14 @@ export default function SubmissionInfoPage() {
return () => controller.abort(); return () => controller.abort();
}, [currentPage]); }, [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) { if (isLoading || !submissions) {
return ( return (
<Webpage> <Webpage>
@@ -123,12 +134,15 @@ export default function SubmissionInfoPage() {
assetId={submission.AssetID} assetId={submission.AssetID}
displayName={submission.DisplayName} displayName={submission.DisplayName}
author={submission.Creator} author={submission.Creator}
authorId={submission.Submitter} submitterId={submission.Submitter}
submitterUsername={submitterUsernames[submission.Submitter] || String(submission.Submitter)}
rating={submission.StatusID} rating={submission.StatusID}
statusID={submission.StatusID} statusID={submission.StatusID}
gameID={submission.GameID} gameID={submission.GameID}
created={submission.CreatedAt} created={submission.CreatedAt}
type="submission" type="submission"
thumbnailUrl={thumbnailUrls[submission.AssetID]}
authorAvatarUrl={submitterAvatars[submission.Submitter]}
/> />
))} ))}
</Box> </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;
}