17 Commits

Author SHA1 Message Date
ddadbc3e00 diff 2025-07-02 05:18:21 -06:00
184795a513 Build Succeed - working script review page 2025-07-02 05:00:11 -06:00
7cf0bc3187 Build Succeed. 2025-07-02 04:37:14 -06:00
5995737dc3 Script review page, server-side session fetching & no more manually fetching session information 2025-07-02 04:32:55 -06:00
1f7ba5bb9b Sorry to whoever uses 2 spaces instead of a tab 2025-07-01 17:57:06 -06:00
3e0bc9804f Merge branch 'staging' into thumbnail-cache-batch 2025-07-01 17:47:01 -06:00
dfe9107112 User information caching 2025-07-01 17:31:50 -06:00
ba5e449569 User information caching 2025-07-01 17:31:35 -06:00
709bb708d3 build succeed + use media for thumbnail if available 2025-07-01 17:17:07 -06:00
87c1d161fc Batch limits, AI suggested anti-spam. 2025-07-01 16:45:04 -06:00
82284947ee more batching & shii 2025-07-01 15:50:48 -06:00
8d4d6b7bfe forgot to click stage all: Batched user information requests, optimized requests (halved from 6 to 3 :money_mouth:), cleanup 2025-06-30 17:40:42 -06:00
7421e6d989 Batched user information requests, optimized requests (halved from 6 to 3 :money_mouth:), cleanup 2025-06-30 17:40:13 -06:00
a1e0e5f720 Subtle visual changes, user avatar batching, rate-limiting, submitter name instead of author, ai comments 2025-06-30 16:47:19 -06:00
c21afaa846 why were these untracked... severely vibe coded files btw 2025-06-30 04:03:34 -06:00
f0abb9ffbf severe vibe coding going on here...
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
8ca7f99098 pushing ai changes before i lose them 2025-06-29 19:01:35 -06:00
37 changed files with 1740 additions and 777 deletions

@ -11,13 +11,15 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"date-fns": "^4.1.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.82.0"
"sass": "^1.82.0",
"swr": "^2.3.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

@ -0,0 +1,15 @@
"use client";
import { ThemeProvider } from "@mui/material";
import { SessionProvider } from "@/app/_components/SessionContext";
import { theme } from "@/app/lib/theme";
export default function AppProviders({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</SessionProvider>
);
}

@ -0,0 +1,48 @@
"use client";
import React, { createContext, useContext, ReactNode } from "react";
import useSWR from "swr";
import { RolesConstants } from "@/app/ts/Roles";
interface UserInfo {
UserID: number;
Username: string;
AvatarURL: string;
}
interface SessionContextType {
roles: number;
user: UserInfo | null;
loading: boolean;
}
const SessionContext = createContext<SessionContextType>({ roles: RolesConstants.Empty, user: null, loading: true });
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) return null;
try {
const data = await res.json();
if (data && typeof data === 'object' && (data.code || data.error)) return null;
return data;
} catch {
return null;
}
};
export const SessionProvider = ({ children }: { children: ReactNode }) => {
const { data: rolesData, isLoading: rolesLoading } = useSWR("/api/session/roles", fetcher, { refreshInterval: 60000 });
const { data: userData, isLoading: userLoading } = useSWR("/api/session/user", fetcher, { refreshInterval: 60000 });
const loading = rolesLoading || userLoading;
const roles = rolesData?.Roles ?? RolesConstants.Empty;
const user = userData ?? null;
return (
<SessionContext.Provider value={{ roles, user, loading }}>
{children}
</SessionContext.Provider>
);
};
export const useSession = () => useContext(SessionContext);

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

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

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

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

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

@ -2,8 +2,8 @@
import Link from "next/link"
import Image from "next/image";
import { UserInfo } from "@/app/ts/User";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useSession } from "@/app/_components/SessionContext";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
@ -22,6 +22,7 @@ import ListItemText from "@mui/material/ListItemText";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import { RolesConstants, hasRole } from "@/app/ts/Roles";
interface HeaderButton {
name: string;
@ -44,17 +45,18 @@ function HeaderButton(header: HeaderButton) {
}
export default function Header() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const { user, roles } = useSession();
const valid = !!user;
const handleLoginClick = () => {
window.location.href =
"/auth/oauth2/login?redirect=" + window.location.href;
};
const [valid, setValid] = useState<boolean>(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [quickLinksAnchor, setQuickLinksAnchor] = useState<null | HTMLElement>(null);
@ -77,32 +79,6 @@ export default function Header() {
setQuickLinksAnchor(null);
};
useEffect(() => {
async function getLoginInfo() {
try {
const response = await fetch("/api/session/user");
if (!response.ok) {
setValid(false);
setUser(null);
return;
}
const userData = await response.json();
const isLoggedIn = userData && 'UserID' in userData;
setValid(isLoggedIn);
setUser(isLoggedIn ? userData : null);
} catch (error) {
console.error("Error fetching user data:", error);
setValid(false);
setUser(null);
}
}
getLoginInfo();
}, []);
// Mobile navigation drawer content
const drawer = (
<Box onClick={handleDrawerToggle} sx={{ textAlign: 'center' }}>
@ -148,6 +124,8 @@ export default function Header() {
{ name: "Fly Trials Maptest", href: "https://www.roblox.com/games/12724901535" },
];
const showScriptReview = hasRole(roles, RolesConstants.ScriptWrite);
return (
<AppBar position="static">
<Toolbar>
@ -169,6 +147,9 @@ export default function Header() {
{navItems.map((item) => (
<HeaderButton key={item.name} name={item.name} href={item.href} />
))}
{showScriptReview && (
<HeaderButton name="Script Review" href="/script-review" />
)}
<Box sx={{ flexGrow: 1 }} /> {/* Push quick links to the right */}
{/* Quick Links Dropdown */}
<Box>

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

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

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

@ -3,8 +3,10 @@
import Header from "./header";
export default function Webpage({children}: Readonly<{children?: React.ReactNode}>) {
return <>
<Header/>
{children}
</>
return (
<>
<Header />
{children}
</>
);
}

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

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

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

@ -1,16 +1,25 @@
"use client";
import "./globals.scss";
import {theme} from "@/app/lib/theme";
import {ThemeProvider} from "@mui/material";
import { SWRConfig } from "swr";
import { getSessionUser, getSessionRoles } from "@/app/lib/session";
import AppProviders from "@/app/_components/AppProviders";
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const user = await getSessionUser();
const roles = await getSessionRoles();
export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
return (
<html lang="en">
<body>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
<SWRConfig value={{
fallback: {
"/api/session/user": user,
"/api/session/roles": { Roles: roles }
}
}}>
<AppProviders>
{children}
</AppProviders>
</SWRConfig>
</body>
</html>
);

@ -0,0 +1,26 @@
import { cookies } from "next/headers";
const BASE_URL = process.env.API_HOST;
export async function getSessionUser() {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const res = await fetch(`${BASE_URL}/session/user`, {
headers: { Cookie: cookieHeader },
cache: 'no-store',
});
if (!res.ok) return null;
return await res.json();
}
export async function getSessionRoles() {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const res = await fetch(`${BASE_URL}/session/roles`, {
headers: { Cookie: cookieHeader },
cache: 'no-store',
});
if (!res.ok) return 0;
const data = await res.json();
return data.Roles ?? 0;
}

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

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

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

@ -6,25 +6,27 @@ 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';
import { useSession } from "@/app/_components/SessionContext";
// 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 +36,426 @@ 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 [downloading, setDownloading] = useState(false);
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
const { roles, loading: sessionLoading } = useSession();
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]);
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 && !sessionLoading && 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>
);
}

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

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

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

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

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

@ -0,0 +1,384 @@
"use client";
import Webpage from "@/app/_components/webpage";
import React, { useEffect, useState } from "react";
import {useTitle} from "@/app/hooks/useTitle";
import {
Box,
Button,
Container,
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
Typography,
Paper,
CircularProgress,
Alert
} from "@mui/material";
import dynamic from "next/dynamic";
import { DiffEditor } from "@monaco-editor/react";
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import Skeleton from '@mui/material/Skeleton';
const POLICY_OPTIONS = [
{ value: 0, label: "None (Unreviewed)" },
{ value: 1, label: "Allowed" },
{ value: 2, label: "Blocked" },
{ value: 3, label: "Delete" },
{ value: 4, label: "Replace" },
];
interface ScriptPolicy {
ID: number;
FromScriptHash: string;
ToScriptID: number;
Policy: number;
}
interface ScriptInfo {
ID: number;
Name: string;
Hash: string;
Source: string;
ResourceType: number;
ResourceID: number;
}
interface ScriptPolicyUpdateBody {
ID: number | null;
Policy?: number;
ToScriptID?: number;
}
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
export default function ScriptReviewPage() {
useTitle("Script Review");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [policy, setPolicy] = useState(0);
const [scriptPolicyId, setScriptPolicyId] = useState<number | null>(null);
const [scriptSource, setScriptSource] = useState("");
const [originalSource, setOriginalSource] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [scriptPolicies, setScriptPolicies] = useState<ScriptPolicy[]>([]);
const [scriptInfos, setScriptInfos] = useState<ScriptInfo[]>([]);
const [selectedScript, setSelectedScript] = useState<ScriptInfo | null>(null);
const [page, setPage] = useState(1);
const [editorTab, setEditorTab] = useState<'edit' | 'diff'>('edit');
// Extracted fetch logic for reuse
const fetchPoliciesAndScripts = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/script-policy?Page=${page}&Limit=10&Policy=0`);
if (!res.ok) throw new Error(`Failed to fetch script policies: ${res.status}`);
const policies: ScriptPolicy[] = await res.json();
setScriptPolicies(policies);
const scriptFetches = policies.map(async (policy) => {
const scriptId = policy.ToScriptID || 0;
const id = scriptId || 0;
if (id) {
const res = await fetch(`/api/scripts/${id}`);
if (res.ok) {
return await res.json();
}
}
return null;
});
const infos = (await Promise.all(scriptFetches)).filter(Boolean) as ScriptInfo[];
setScriptInfos(infos);
if (infos.length > 0) {
setSelectedScript(infos[0]);
setScriptSource(infos[0].Source || "");
setOriginalSource(infos[0].Source || "");
const firstPolicy = policies[0];
setScriptPolicyId(firstPolicy.ID);
setPolicy(firstPolicy.Policy);
} else {
setSelectedScript(null);
setScriptSource("");
setOriginalSource("");
setScriptPolicyId(null);
setPolicy(0);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load script policies");
}
setLoading(false);
}, [page]);
useEffect(() => {
fetchPoliciesAndScripts();
}, [page, fetchPoliciesAndScripts]);
useEffect(() => {
setSelectedScript(null);
setScriptSource("");
setOriginalSource("");
setScriptPolicyId(null);
setPolicy(0);
}, [page]);
const handleScriptSelect = (script: ScriptInfo, idx: number) => {
setError(null);
setSelectedScript(script);
setScriptSource(script.Source || "");
setOriginalSource(script.Source || "");
const policy = scriptPolicies[idx];
if (policy) {
setScriptPolicyId(policy.ID);
setPolicy(policy.Policy);
}
};
const handlePolicyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPolicy(Number(event.target.value));
};
const handleSourceChange = (newSource: string) => {
setScriptSource(newSource);
if (newSource !== originalSource) {
setPolicy(4);
} else if (policy === 4) {
setPolicy(0);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
setSuccess(false);
try {
let toScriptId: number | undefined = undefined;
if (policy === 4 && scriptSource !== originalSource && selectedScript) {
// Upload new script (deduplication handled on backend)
const uploadRes = await fetch("/api/scripts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Name: selectedScript.Name,
Source: scriptSource,
ResourceType: selectedScript.ResourceType
}),
});
if (!uploadRes.ok) throw new Error("Failed to upload replacement script");
const uploadData = await uploadRes.json();
toScriptId = uploadData.ScriptID;
}
// Update script policy
const updateBody: ScriptPolicyUpdateBody = { ID: scriptPolicyId };
if (policy !== undefined) updateBody.Policy = policy;
if (policy === 4 && toScriptId) updateBody.ToScriptID = toScriptId;
const updateRes = await fetch(`/api/script-policy/${scriptPolicyId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updateBody),
});
if (!updateRes.ok) throw new Error("Failed to update script policy");
setSuccess(true);
// Refresh the list after successful review
await fetchPoliciesAndScripts();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit review");
} finally {
setSubmitting(false);
}
};
const isSourceChanged = scriptSource !== originalSource;
const canSubmit = (policy !== 4 && !isSourceChanged) || (policy === 4 && isSourceChanged);
return (
<Webpage>
<Container maxWidth={false} sx={{ py: 4, width: '85vw', maxWidth: '100vw', minWidth: 320, mx: 'auto' }}>
<Paper sx={{ p: 3, display: "flex", gap: 3, width: '100%', minWidth: 320, mx: 'auto', boxSizing: 'border-box' }}>
<Box sx={{ minWidth: 250, maxWidth: 350 }}>
<Typography variant="h6" gutterBottom>Unreviewed Scripts</Typography>
{loading ? (
<Box component="ul" sx={{ listStyle: "none", p: 0, m: 0 }}>
{Array.from({ length: 10 }).map((_, i) => (
<li key={i}>
<Skeleton variant="rectangular" height={30} sx={{ mb: 1, borderRadius: 1 }} />
</li>
))}
</Box>
) : scriptInfos.length === 0 ? (
<Typography>No unreviewed scripts found.</Typography>
) : (
<Box component="ul" sx={{ listStyle: "none", p: 0, m: 0 }}>
{scriptInfos.map((script, idx) => {
const isSelected = selectedScript?.ID === script.ID;
const name = script.Name || String(script.ID);
const parts = name.split(".");
let crumbs: React.ReactNode[];
const crumbTextColor = isSelected ? '#fff' : 'text.primary';
if (parts.length <= 2) {
crumbs = parts.map((part, i) => (
<Typography key={i} color={crumbTextColor} sx={{ fontWeight: isSelected ? 600 : 400, fontSize: 13, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{part}</Typography>
));
} else {
crumbs = [
<Typography key={0} color={crumbTextColor} sx={{ fontSize: 13, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{parts[0]}</Typography>,
<Typography key="ellipsis" color={isSelected ? '#fff' : 'text.secondary'} sx={{ fontSize: 13 }}>...</Typography>,
<Typography key={parts.length-1} color={crumbTextColor} sx={{ fontSize: 13, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{parts[parts.length-1]}</Typography>
];
}
return (
<li key={script.ID}>
<Button
variant={isSelected ? "contained" : "outlined"}
fullWidth
sx={{
mb: 1,
textAlign: "left",
justifyContent: "flex-start",
whiteSpace: 'normal',
wordBreak: 'break-all',
px: 1,
py: 0.5,
color: isSelected ? '#fff' : undefined,
backgroundColor: isSelected ? '#1976d2' : undefined,
border: isSelected ? '1.5px solid #42a5f5' : undefined,
'&:hover': isSelected ? { backgroundColor: '#1565c0' } : undefined,
}}
onClick={() => handleScriptSelect(script, idx)}
>
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" sx={{ color: isSelected ? '#fff' : 'inherit' }} />} aria-label="breadcrumb" sx={{ p: 0, m: 0 }}>
{crumbs}
</Breadcrumbs>
</Button>
</li>
);
})}
</Box>
)}
{/* Pagination controls */}
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', mt: 2 }}>
<Button
variant="outlined"
disabled={page === 1 || loading}
onClick={() => setPage(page - 1)}
sx={{ mr: 1 }}
>
&#8592;
</Button>
<Typography sx={{ mx: 2 }}>{`Page ${page}`}</Typography>
<Button
variant="outlined"
disabled={scriptPolicies.length < 10 || loading}
onClick={() => setPage(page + 1)}
>
&#8594;
</Button>
</Box>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Typography variant="h6" gutterBottom>Script Review</Typography>
{/* Show full script name above Policy */}
{selectedScript && (
<Typography variant="subtitle1" sx={{ fontWeight: 500, mb: 1, wordBreak: 'break-all' }}>
{selectedScript.Name || selectedScript.ID}
</Typography>
)}
{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: 200 }}>
<CircularProgress />
</Box>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
<form onSubmit={handleSubmit}>
<FormControl component="fieldset" sx={{ mb: 0 }}>
<FormLabel component="legend">Policy</FormLabel>
<RadioGroup row value={policy} onChange={handlePolicyChange}>
{POLICY_OPTIONS.map(opt => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
disabled={isSourceChanged && opt.value !== 4}
/>
))}
</RadioGroup>
</FormControl>
<Box sx={{ mb: 1 }} />
<Box sx={{ mb: 1 }}>
{policy === 4 && scriptSource !== originalSource ? (
<Tabs
value={editorTab}
onChange={(_, v) => setEditorTab(v)}
indicatorColor="primary"
textColor="primary"
sx={{ mb: 1 }}
>
<Tab label="Editor" value="edit" />
<Tab label="Diff" value="diff" />
</Tabs>
) : null}
{(policy !== 4 || scriptSource === originalSource || editorTab === 'edit') && (
<MonacoEditor
height="60vh"
defaultLanguage="lua"
value={scriptSource}
onChange={v => handleSourceChange(v ?? "")}
options={{
fontFamily: "monospace",
fontSize: 14,
minimap: { enabled: false },
wordWrap: "off",
lineNumbers: "on",
readOnly: false,
scrollbar: { vertical: 'visible', horizontal: 'auto' },
automaticLayout: true
}}
theme="vs-dark"
/>
)}
{policy === 4 && scriptSource !== originalSource && editorTab === 'diff' && (
<Box sx={{ height: '60vh', minHeight: 150 }}>
<DiffEditor
height="100%"
original={originalSource}
modified={scriptSource}
language="lua"
theme="vs-dark"
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
automaticLayout: true,
}}
/>
</Box>
)}
</Box>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!canSubmit || submitting}
fullWidth
sx={{ mt: 2 }}
>
{submitting ? <CircularProgress size={24} /> : "Submit Review"}
</Button>
{success && <Alert severity="success" sx={{ mt: 2 }}>Review submitted successfully!</Alert>}
</form>
)}
</Box>
</Paper>
</Container>
</Webpage>
);
}

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

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

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

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

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

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

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

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

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