Fix thumbnails, reduce network activity, rate limiting, caching, script review page #222
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
61
web/src/app/hooks/useBatchThumbnails.ts
Normal file
61
web/src/app/hooks/useBatchThumbnails.ts
Normal 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(",")]);
|
||||
|
Quaternions
commented
is this joining an array of ids into a string and then parsing it out again later? is this joining an array of ids into a string and then parsing it out again later?
|
||||
|
||||
return { thumbnails, loading, error };
|
||||
}
|
||||
61
web/src/app/hooks/useBatchUserAvatars.ts
Normal file
61
web/src/app/hooks/useBatchUserAvatars.ts
Normal 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 };
|
||||
}
|
||||
63
web/src/app/hooks/useBatchUsernames.ts
Normal file
63
web/src/app/hooks/useBatchUsernames.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,26 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
Quaternions
commented
This entire file is extremely difficult to review because of the formatting changes. the green and red code essentially doubles the entire file and is interspersed randomly, making it impossible to spot actual changes This entire file is extremely difficult to review because of the formatting changes. the green and red code essentially doubles the entire file and is interspersed randomly, making it impossible to spot actual changes
|
||||
import Link from "next/link";
|
||||
import { Snackbar, Alert } from "@mui/material";
|
||||
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
||||
import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix";
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
|
||||
// MUI Components
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Breadcrumbs,
|
||||
Chip,
|
||||
Grid,
|
||||
Divider,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
CardMedia,
|
||||
Tooltip,
|
||||
IconButton
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Breadcrumbs,
|
||||
Chip,
|
||||
Grid,
|
||||
Divider,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
CardMedia,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
CircularProgress
|
||||
} from "@mui/material";
|
||||
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
|
||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||
@@ -34,435 +35,446 @@ import BugReportIcon from "@mui/icons-material/BugReport";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import {hasRole, RolesConstants} from "@/app/ts/Roles";
|
||||
import {useTitle} from "@/app/hooks/useTitle";
|
||||
|
||||
export default function MapDetails() {
|
||||
const { mapId } = useParams();
|
||||
const router = useRouter();
|
||||
const [map, setMap] = useState<MapInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [roles, setRoles] = useState(RolesConstants.Empty);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
|
||||
const { mapId } = useParams();
|
||||
const router = useRouter();
|
||||
const [map, setMap] = useState<MapInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [roles, setRoles] = useState(RolesConstants.Empty);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
|
||||
|
||||
useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
|
||||
useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
|
||||
|
||||
useEffect(() => {
|
||||
async function getMap() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/maps/${mapId}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch map: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setMap(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching map details:", error);
|
||||
setError(error instanceof Error ? error.message : "Failed to load map details");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
getMap();
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getRoles() {
|
||||
try {
|
||||
const rolesResponse = await fetch("/api/session/roles");
|
||||
if (rolesResponse.ok) {
|
||||
const rolesData = await rolesResponse.json();
|
||||
setRoles(rolesData.Roles);
|
||||
} else {
|
||||
console.warn(`Failed to fetch roles: ${rolesResponse.status}`);
|
||||
setRoles(RolesConstants.Empty);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error fetching roles data:", error);
|
||||
setRoles(RolesConstants.Empty);
|
||||
useEffect(() => {
|
||||
async function getMap() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/maps/${mapId}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch map: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setMap(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching map details:", error);
|
||||
setError(error instanceof Error ? error.message : "Failed to load map details");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
getMap();
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const targetAssetId = map.ID;
|
||||
async function fetchMapfixes() {
|
||||
try {
|
||||
const limit = 100;
|
||||
let page = 1;
|
||||
let allMapfixes: MapfixInfo[] = [];
|
||||
let total = 0;
|
||||
do {
|
||||
const res = await fetch(`/api/mapfixes?Page=${page}&Limit=${limit}&TargetAssetID=${targetAssetId}`);
|
||||
if (!res.ok) break;
|
||||
const data = await res.json();
|
||||
if (page === 1) total = data.Total;
|
||||
allMapfixes = allMapfixes.concat(data.Mapfixes);
|
||||
page++;
|
||||
} while (allMapfixes.length < total);
|
||||
// Filter out rejected, uploading, uploaded (StatusID > 7)
|
||||
const active = allMapfixes.filter((fix: MapfixInfo) => fix.StatusID <= MapfixStatus.Validated);
|
||||
setMapfixes(active);
|
||||
} catch {
|
||||
setMapfixes([]);
|
||||
}
|
||||
}
|
||||
fetchMapfixes();
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getRoles() {
|
||||
try {
|
||||
const rolesResponse = await fetch("/api/session/roles");
|
||||
if (rolesResponse.ok) {
|
||||
const rolesData = await rolesResponse.json();
|
||||
setRoles(rolesData.Roles);
|
||||
} else {
|
||||
console.warn(`Failed to fetch roles: ${rolesResponse.status}`);
|
||||
setRoles(RolesConstants.Empty);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error fetching roles data:", error);
|
||||
setRoles(RolesConstants.Empty);
|
||||
}
|
||||
}
|
||||
getRoles()
|
||||
}, [mapId]);
|
||||
|
||||
// Use useBatchThumbnails for the map thumbnail
|
||||
const assetIds = map?.ID ? [map.ID] : [];
|
||||
const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getGameInfo = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1:
|
||||
return {
|
||||
name: "Bhop",
|
||||
color: "#2196f3" // blue
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
name: "Surf",
|
||||
color: "#4caf50" // green
|
||||
};
|
||||
case 5:
|
||||
return {
|
||||
name: "Fly Trials",
|
||||
color: "#ff9800" // orange
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: "Unknown",
|
||||
color: "#9e9e9e" // gray
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMapfix = () => {
|
||||
router.push(`/maps/${mapId}/fix`);
|
||||
};
|
||||
|
||||
const handleCopyId = (idToCopy: string) => {
|
||||
navigator.clipboard.writeText(idToCopy);
|
||||
setCopySuccess(true);
|
||||
};
|
||||
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
// Fetch the download URL
|
||||
const res = await fetch(`/api/maps/${mapId}/location`);
|
||||
if (!res.ok) throw new Error('Failed to fetch download location');
|
||||
|
||||
const location = await res.text();
|
||||
|
||||
// open in new window
|
||||
window.open(location.trim(), '_blank');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Download error:', err);
|
||||
// Optional: Show user-friendly error message
|
||||
alert('Download failed. Please try again.');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setCopySuccess(false);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'error.light',
|
||||
color: 'error.contrastText'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
|
||||
<Typography variant="body1">{error}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => router.push('/maps')}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
Return to Maps
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
getRoles()
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const targetAssetId = map.ID;
|
||||
async function fetchMapfixes() {
|
||||
try {
|
||||
const limit = 100;
|
||||
let page = 1;
|
||||
let allMapfixes: MapfixInfo[] = [];
|
||||
let total = 0;
|
||||
do {
|
||||
const res = await fetch(`/api/mapfixes?Page=${page}&Limit=${limit}&TargetAssetID=${targetAssetId}`);
|
||||
if (!res.ok) break;
|
||||
const data = await res.json();
|
||||
if (page === 1) total = data.Total;
|
||||
allMapfixes = allMapfixes.concat(data.Mapfixes);
|
||||
page++;
|
||||
} while (allMapfixes.length < total);
|
||||
// Filter out rejected, uploading, uploaded (StatusID > 7)
|
||||
const active = allMapfixes.filter((fix: MapfixInfo) => fix.StatusID <= MapfixStatus.Validated);
|
||||
setMapfixes(active);
|
||||
} catch {
|
||||
setMapfixes([]);
|
||||
}
|
||||
}
|
||||
fetchMapfixes();
|
||||
}, [map]);
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
|
||||
{/* Breadcrumbs Navigation */}
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography color="text.primary">Home</Typography>
|
||||
</Link>
|
||||
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography color="text.primary">Maps</Typography>
|
||||
</Link>
|
||||
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
|
||||
</Breadcrumbs>
|
||||
{loading ? (
|
||||
<Box>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Skeleton variant="text" width="60%" height={60} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||
<Skeleton variant="text" width={120} />
|
||||
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
|
||||
</Grid>
|
||||
|
||||
const getGameInfo = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1:
|
||||
return {
|
||||
name: "Bhop",
|
||||
color: "#2196f3" // blue
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
name: "Surf",
|
||||
color: "#4caf50" // green
|
||||
};
|
||||
case 5:
|
||||
return {
|
||||
name: "Fly Trials",
|
||||
color: "#ff9800" // orange
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: "Unknown",
|
||||
color: "#9e9e9e" // gray
|
||||
};
|
||||
}
|
||||
};
|
||||
<Grid item xs={12} md={4}>
|
||||
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
|
||||
<Skeleton variant="text" width="90%" />
|
||||
<Skeleton variant="text" width="70%" />
|
||||
<Skeleton variant="text" width="80%" />
|
||||
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
) : (
|
||||
map && (
|
||||
<>
|
||||
{/* Map Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
|
||||
{map.DisplayName}
|
||||
</Typography>
|
||||
|
||||
const handleSubmitMapfix = () => {
|
||||
router.push(`/maps/${mapId}/fix`);
|
||||
};
|
||||
{map.GameID && (
|
||||
<Chip
|
||||
label={getGameInfo(map.GameID).name}
|
||||
sx={{
|
||||
bgcolor: getGameInfo(map.GameID).color,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.9rem',
|
||||
height: 32
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
const handleCopyId = (idToCopy: string) => {
|
||||
navigator.clipboard.writeText(idToCopy);
|
||||
setCopySuccess(true);
|
||||
};
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="body1">
|
||||
<strong>Created by:</strong> {map.Creator}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="body1">
|
||||
{formatDate(map.Date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
// Fetch the download URL
|
||||
const res = await fetch(`/api/maps/${mapId}/location`);
|
||||
if (!res.ok) throw new Error('Failed to fetch download location');
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1">
|
||||
<strong>ID:</strong> {mapId}
|
||||
</Typography>
|
||||
<Tooltip title="Copy ID to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopyId(mapId as string)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{!loading && hasRole(roles,RolesConstants.MapDownload) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<InsertDriveFileIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="body1">
|
||||
Download
|
||||
</Typography>
|
||||
<Tooltip title="File extension must be changed to .rbxm manually">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleDownload}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={downloading}
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
const location = await res.text();
|
||||
<Grid container spacing={3}>
|
||||
{/* Map Preview Section */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{thumbnailUrls[map.ID] ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={thumbnailUrls[map.ID]}
|
||||
alt={`Preview of map: ${map?.DisplayName}`}
|
||||
sx={{
|
||||
height: 400,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
// open in new window
|
||||
window.open(location.trim(), '_blank');
|
||||
{/* Map Details Section */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Map Details</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
} catch (err) {
|
||||
console.error('Download error:', err);
|
||||
// Optional: Show user-friendly error message
|
||||
alert('Download failed. Please try again.');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
|
||||
<Typography variant="body1">{map.DisplayName}</Typography>
|
||||
</Box>
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setCopySuccess(false);
|
||||
};
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
|
||||
<Typography variant="body1">{map.Creator}</Typography>
|
||||
</Box>
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'error.light',
|
||||
color: 'error.contrastText'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
|
||||
<Typography variant="body1">{error}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => router.push('/maps')}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
Return to Maps
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
|
||||
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
|
||||
</Box>
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
|
||||
{/* Breadcrumbs Navigation */}
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography color="text.primary">Home</Typography>
|
||||
</Link>
|
||||
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography color="text.primary">Maps</Typography>
|
||||
</Link>
|
||||
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
|
||||
</Breadcrumbs>
|
||||
{loading ? (
|
||||
<Box>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Skeleton variant="text" width="60%" height={60} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||
<Skeleton variant="text" width={120} />
|
||||
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
|
||||
<Typography variant="body1">{formatDate(map.Date)}</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
|
||||
</Grid>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1">{mapId}</Typography>
|
||||
<Tooltip title="Copy ID to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopyId(mapId as string)}
|
||||
sx={{ ml: 1, p: 0 }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
|
||||
<Skeleton variant="text" width="90%" />
|
||||
<Skeleton variant="text" width="70%" />
|
||||
<Skeleton variant="text" width="80%" />
|
||||
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
) : (
|
||||
map && (
|
||||
<>
|
||||
{/* Map Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
|
||||
{map.DisplayName}
|
||||
</Typography>
|
||||
{/* Active Mapfix in Map Details */}
|
||||
{mapfixes.length > 0 && (() => {
|
||||
const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated);
|
||||
const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b));
|
||||
const showFix = active || latest;
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Active Mapfix
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component={Link}
|
||||
href={`/mapfixes/${showFix.ID}`}
|
||||
sx={{
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
color: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mt: 0.5
|
||||
}}
|
||||
>
|
||||
{showFix.Description}
|
||||
<LaunchIcon sx={{ fontSize: '1rem', ml: 0.5 }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{map.GameID && (
|
||||
<Chip
|
||||
label={getGameInfo(map.GameID).name}
|
||||
sx={{
|
||||
bgcolor: getGameInfo(map.GameID).color,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.9rem',
|
||||
height: 32
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<BugReportIcon />}
|
||||
onClick={handleSubmitMapfix}
|
||||
size="large"
|
||||
>
|
||||
Submit a Mapfix
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="body1">
|
||||
<strong>Created by:</strong> {map.Creator}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="body1">
|
||||
{formatDate(map.Date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1">
|
||||
<strong>ID:</strong> {mapId}
|
||||
</Typography>
|
||||
<Tooltip title="Copy ID to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopyId(mapId as string)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{!loading && hasRole(roles,RolesConstants.MapDownload) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<InsertDriveFileIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="body1">
|
||||
Download
|
||||
</Typography>
|
||||
<Tooltip title="File extension must be changed to .rbxm manually">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleDownload}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={downloading}
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Map Preview Section */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`/thumbnails/asset/${map.ID}`}
|
||||
alt={`Preview of map: ${map.DisplayName}`}
|
||||
sx={{
|
||||
height: 400,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Map Details Section */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Map Details</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
|
||||
<Typography variant="body1">{map.DisplayName}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
|
||||
<Typography variant="body1">{map.Creator}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
|
||||
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
|
||||
<Typography variant="body1">{formatDate(map.Date)}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1">{mapId}</Typography>
|
||||
<Tooltip title="Copy ID to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopyId(mapId as string)}
|
||||
sx={{ ml: 1, p: 0 }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Active Mapfix in Map Details */}
|
||||
{mapfixes.length > 0 && (() => {
|
||||
const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated);
|
||||
const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b));
|
||||
const showFix = active || latest;
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Active Mapfix
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component={Link}
|
||||
href={`/mapfixes/${showFix.ID}`}
|
||||
sx={{
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
color: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mt: 0.5
|
||||
}}
|
||||
>
|
||||
{showFix.Description}
|
||||
<LaunchIcon sx={{ fontSize: '1rem', ml: 0.5 }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<BugReportIcon />}
|
||||
onClick={handleSubmitMapfix}
|
||||
size="large"
|
||||
>
|
||||
Submit a Mapfix
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={copySuccess}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
|
||||
Map ID copied to clipboard!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
<Snackbar
|
||||
open={copySuccess}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
|
||||
Map ID copied to clipboard!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
6
web/src/app/proxy/users/batch/RobloxUserInfo.ts
Normal file
6
web/src/app/proxy/users/batch/RobloxUserInfo.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Roblox user info type for batch endpoint
|
||||
export interface RobloxUserInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
99
web/src/app/proxy/users/batch/route.ts
Normal file
99
web/src/app/proxy/users/batch/route.ts
Normal 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)
|
||||
|
Quaternions
commented
wtf is this comment wtf is this comment
|
||||
// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota.
|
||||
//
|
||||
// If you are behind a CDN/proxy, ensure you trust the IP headers.
|
||||
// Consider using Redis or another distributed store for rate limiting in production.
|
||||
|
||||
import { checkRateLimit } from '@/lib/rateLimit';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClientIp } from '@/lib/getClientIp';
|
||||
import { createGlobalRateLimiter } from '@/lib/globalRateLimit';
|
||||
import type { RobloxUserInfo } from './RobloxUserInfo';
|
||||
|
||||
const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); // 500 per 5 min
|
||||
|
||||
const VALIDATOR_USER_ID = 9223372036854776000;
|
||||
|
||||
const USER_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const userInfoCache = new Map<number, { info: RobloxUserInfo, expires: number }>();
|
||||
let lastUserCacheCleanup = 0;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const idsParam = url.searchParams.get('ids');
|
||||
const ip = getClientIp(request);
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 });
|
||||
}
|
||||
if (!checkGlobalRateLimit()) {
|
||||
return NextResponse.json({ error: 'Server busy. Please try again later.' }, { status: 429 });
|
||||
}
|
||||
if (!idsParam) {
|
||||
return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 });
|
||||
}
|
||||
let userIds = idsParam
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter(id => Number.isInteger(id) && id > 0 && id !== VALIDATOR_USER_ID);
|
||||
// De-duplicate
|
||||
userIds = Array.from(new Set(userIds));
|
||||
if (userIds.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid user IDs provided' }, { status: 400 });
|
||||
}
|
||||
if (userIds.length > 50) {
|
||||
return NextResponse.json({ error: 'Too many user IDs in batch (max 50)' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// Cleanup expired cache entries
|
||||
if (now - lastUserCacheCleanup > USER_CACHE_TTL) {
|
||||
for (const [id, entry] of userInfoCache.entries()) {
|
||||
if (entry.expires <= now) userInfoCache.delete(id);
|
||||
}
|
||||
lastUserCacheCleanup = now;
|
||||
}
|
||||
|
||||
const result: RobloxUserInfo[] = [];
|
||||
const idsToFetch: number[] = [];
|
||||
const cachedMap: Record<number, RobloxUserInfo> = {};
|
||||
for (const id of userIds) {
|
||||
const cached = userInfoCache.get(id);
|
||||
if (cached && cached.expires > now) {
|
||||
cachedMap[id] = cached.info;
|
||||
result.push(cached.info);
|
||||
} else {
|
||||
idsToFetch.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToFetch.length > 0) {
|
||||
try {
|
||||
const apiResponse = await fetch('https://users.roblox.com/v1/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userIds: idsToFetch }),
|
||||
});
|
||||
if (!apiResponse.ok) {
|
||||
const errorData = await apiResponse.text();
|
||||
return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status });
|
||||
}
|
||||
const data = await apiResponse.json();
|
||||
for (const user of data.data || []) {
|
||||
userInfoCache.set(user.id, { info: user, expires: now + USER_CACHE_TTL });
|
||||
result.push(user);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure result order matches input order
|
||||
const ordered = userIds.map(id => {
|
||||
return userInfoCache.get(id)?.info || cachedMap[id] || null;
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
|
||||
return NextResponse.json({ data: ordered }, { headers });
|
||||
}
|
||||
@@ -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') => {
|
||||
|
Quaternions
commented
`showSnackbar` and `handleCloseSnackbar` are moved for no reason, I got confused because I couldn't see the code relevant to where they were used in the diff. My error I guess not noticing it was moved right away
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message,
|
||||
severity
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbar({
|
||||
...snackbar,
|
||||
open: false
|
||||
});
|
||||
};
|
||||
|
||||
// Handle review button actions
|
||||
async function handleReviewAction(action: string, submissionId: number) {
|
||||
try {
|
||||
@@ -204,12 +231,27 @@ export default function SubmissionDetailsPage() {
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
|
||||
{submission.AssetID ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`/thumbnails/asset/${submission.AssetID}`}
|
||||
alt="Map Thumbnail"
|
||||
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
/>
|
||||
thumbnailUrls[submission.AssetID] ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={thumbnailUrls[submission.AssetID]}
|
||||
alt="Map Thumbnail"
|
||||
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
bgcolor: 'grey.900',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
)
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -220,7 +262,7 @@ export default function SubmissionDetailsPage() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">No image available</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -234,14 +276,14 @@ export default function SubmissionDetailsPage() {
|
||||
roles={roles}
|
||||
type="submission"/>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column - Submission Details and Comments */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<ReviewItem
|
||||
item={submission}
|
||||
handleCopyValue={handleCopyId}
|
||||
submitterAvatarUrl={avatarUrls[submitterId]}
|
||||
submitterUsername={usernameMap[submitterId]}
|
||||
/>
|
||||
|
||||
{/* Comments Section */}
|
||||
<CommentsAndAuditSection
|
||||
auditEvents={auditEvents}
|
||||
@@ -250,6 +292,8 @@ export default function SubmissionDetailsPage() {
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
validatorUser={validatorUser}
|
||||
userId={user}
|
||||
commentUserAvatarUrls={commentUserAvatarUrls}
|
||||
auditEventUserAvatarUrls={auditEventUserAvatarUrls}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
133
web/src/app/thumbnails/batch/route.ts
Normal file
133
web/src/app/thumbnails/batch/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
16
web/src/lib/getClientIp.ts
Normal file
16
web/src/lib/getClientIp.ts
Normal 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';
|
||||
}
|
||||
18
web/src/lib/globalRateLimit.ts
Normal file
18
web/src/lib/globalRateLimit.ts
Normal 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
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.
|
||||
|
Quaternions
commented
The entire rate limit thing seems extremely overbuilt and should probably be ripped out, or if it becomes a problem, implemented in the backend like it suggests The entire rate limit thing seems extremely overbuilt and should probably be ripped out, or if it becomes a problem, implemented in the backend like it suggests
|
||||
//
|
||||
// For production, use a distributed store (e.g., Redis) and import this from a shared location.
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
const RATE_LIMIT_MAX = 30;
|
||||
|
||||
// Map<ip, { count: number, expires: number }>
|
||||
const ipRateLimitMap = new Map<string, { count: number, expires: number }>();
|
||||
|
||||
let lastIpRateLimitCleanup = 0;
|
||||
|
||||
export function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
// Cleanup expired entries if needed
|
||||
if (now - lastIpRateLimitCleanup > RATE_LIMIT_WINDOW_MS) {
|
||||
for (const [ip, entry] of ipRateLimitMap.entries()) {
|
||||
if (entry.expires < now) ipRateLimitMap.delete(ip);
|
||||
}
|
||||
lastIpRateLimitCleanup = now;
|
||||
}
|
||||
const entry = ipRateLimitMap.get(ip);
|
||||
if (!entry || entry.expires < now) {
|
||||
ipRateLimitMap.set(ip, { count: 1, expires: now + RATE_LIMIT_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user
This is cursed, surely it can be done in chunks without allocating the chunks ahead of time