Subtle visual changes, user avatar batching, rate-limiting, submitter name instead of author, ai comments
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -12,13 +12,14 @@ 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}
|
||||
>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
|
||||
@@ -12,13 +12,14 @@ 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}
|
||||
>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import CommentsTabPanel from './CommentsTabPanel';
|
||||
import AuditEventsTabPanel from './AuditEventsTabPanel';
|
||||
import { AuditEvent } from "@/app/ts/AuditEvent";
|
||||
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||
|
||||
interface CommentsAndAuditSectionProps {
|
||||
auditEvents: AuditEvent[];
|
||||
@@ -32,6 +33,10 @@ export default function CommentsAndAuditSection({
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Collect all unique user IDs from auditEvents for avatar fetching
|
||||
const commentUserIds = Array.from(new Set(auditEvents.map(ev => ev.User)));
|
||||
const { avatars: commentUserAvatarUrls } = useBatchUserAvatars(commentUserIds);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
@@ -53,6 +58,7 @@ export default function CommentsAndAuditSection({
|
||||
setNewComment={setNewComment}
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
userId={userId}
|
||||
commentUserAvatarUrls={commentUserAvatarUrls}
|
||||
/>
|
||||
|
||||
<AuditEventsTabPanel
|
||||
|
||||
@@ -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,14 @@ 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, userId, userAvatarUrl }: CommentInputProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<Avatar
|
||||
src={`/thumbnails/user/${userId}`}
|
||||
src={userAvatarUrl}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
|
||||
@@ -6,7 +6,8 @@ import {StatusChip} from "@/app/_components/statusChip";
|
||||
interface MapCardProps {
|
||||
displayName: string;
|
||||
assetId: number;
|
||||
authorId: number;
|
||||
submitterId: number;
|
||||
submitterUsername: string;
|
||||
author: string;
|
||||
rating: number;
|
||||
id: number;
|
||||
@@ -15,6 +16,7 @@ interface MapCardProps {
|
||||
created: number;
|
||||
type: 'mapfix' | 'submission';
|
||||
thumbnailUrl?: string;
|
||||
authorAvatarUrl?: string;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 270;
|
||||
@@ -152,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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ReviewItemHeader } from "./ReviewItemHeader";
|
||||
import { CopyableField } from "@/app/_components/review/CopyableField";
|
||||
import { SubmissionInfo } from "@/app/ts/Submission";
|
||||
import { MapfixInfo } from "@/app/ts/Mapfix";
|
||||
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||
|
||||
// Define a field configuration for specific types
|
||||
interface FieldConfig {
|
||||
@@ -23,6 +24,7 @@ export function ReviewItem({
|
||||
handleCopyValue
|
||||
}: ReviewItemProps) {
|
||||
// Type guard to check if item is valid
|
||||
const { avatars: submitterAvatars } = useBatchUserAvatars(item ? [item.Submitter] : []);
|
||||
if (!item) return null;
|
||||
|
||||
// Determine the type of item
|
||||
@@ -52,6 +54,7 @@ export function ReviewItem({
|
||||
statusId={item.StatusID}
|
||||
creator={item.Creator}
|
||||
submitterId={item.Submitter}
|
||||
submitterAvatarUrl={submitterAvatars[item.Submitter]}
|
||||
/>
|
||||
|
||||
{/* Item Details */}
|
||||
|
||||
@@ -45,9 +45,10 @@ interface ReviewItemHeaderProps {
|
||||
statusId: SubmissionStatus | MapfixStatus;
|
||||
creator: string | null | undefined;
|
||||
submitterId: number;
|
||||
submitterAvatarUrl?: string;
|
||||
}
|
||||
|
||||
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
|
||||
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, submitterAvatarUrl }: ReviewItemHeaderProps) => {
|
||||
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 0.2; transform: scale(0.8); }
|
||||
@@ -90,8 +91,8 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }
|
||||
|
||||
<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} />
|
||||
</Box>
|
||||
|
||||
41
web/src/app/hooks/useBatchUserAvatars.ts
Normal file
41
web/src/app/hooks/useBatchUserAvatars.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Fetches avatar URLs for a batch of user IDs using the /thumbnails/user/batch 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);
|
||||
|
||||
const joinedIds = (userIds && userIds.filter(Boolean).join(",")) || "";
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (!userIds || userIds.length === 0) {
|
||||
setAvatars({});
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (!joinedIds) {
|
||||
setAvatars({});
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/thumbnails/user/batch?ids=${joinedIds}`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Failed to fetch user avatars: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(data => setAvatars(data))
|
||||
.catch(err => setError(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [joinedIds]);
|
||||
|
||||
return { avatars, loading, error };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
|
||||
export default function MapfixInfoPage() {
|
||||
useTitle("Map Fixes");
|
||||
@@ -24,6 +25,7 @@ export default function MapfixInfoPage() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitterUsernames, setSubmitterUsernames] = useState<Record<number, string>>({});
|
||||
const cardsPerPage = 24;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,6 +61,34 @@ export default function MapfixInfoPage() {
|
||||
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);
|
||||
|
||||
// Fetch submitter usernames
|
||||
useEffect(() => {
|
||||
if (!mapfixes) return;
|
||||
const uniqueIds = Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter)));
|
||||
const fetchUsernames = async () => {
|
||||
const results: Record<number, string> = {};
|
||||
await Promise.all(uniqueIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/proxy/users/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results[id] = data.name || String(id);
|
||||
} else {
|
||||
results[id] = String(id);
|
||||
}
|
||||
} catch {
|
||||
results[id] = String(id);
|
||||
}
|
||||
}));
|
||||
setSubmitterUsernames(results);
|
||||
};
|
||||
fetchUsernames();
|
||||
}, [mapfixes]);
|
||||
|
||||
if (isLoading || !mapfixes) {
|
||||
return (
|
||||
<Webpage>
|
||||
@@ -115,13 +145,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>
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
|
||||
export default function Home() {
|
||||
useTitle("Home");
|
||||
@@ -25,6 +26,8 @@ export default function Home() {
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
|
||||
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
|
||||
const [submissionUsernames, setSubmissionUsernames] = useState<Record<number, string>>({});
|
||||
const [mapfixUsernames, setMapfixUsernames] = useState<Record<number, string>>({});
|
||||
const itemsPerSection: number = 8; // Show more items for the carousel
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +82,60 @@ export default function Home() {
|
||||
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);
|
||||
|
||||
// Fetch submitter usernames for submissions
|
||||
useEffect(() => {
|
||||
if (!submissions) return;
|
||||
const uniqueIds = Array.from(new Set(submissions.Submissions.map(s => s.Submitter)));
|
||||
const fetchUsernames = async () => {
|
||||
const results: Record<number, string> = {};
|
||||
await Promise.all(uniqueIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/proxy/users/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results[id] = data.name || String(id);
|
||||
} else {
|
||||
results[id] = String(id);
|
||||
}
|
||||
} catch {
|
||||
results[id] = String(id);
|
||||
}
|
||||
}));
|
||||
setSubmissionUsernames(results);
|
||||
};
|
||||
fetchUsernames();
|
||||
}, [submissions]);
|
||||
|
||||
// Fetch submitter usernames for mapfixes
|
||||
useEffect(() => {
|
||||
if (!mapfixes) return;
|
||||
const uniqueIds = Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter)));
|
||||
const fetchUsernames = async () => {
|
||||
const results: Record<number, string> = {};
|
||||
await Promise.all(uniqueIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/proxy/users/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results[id] = data.name || String(id);
|
||||
} else {
|
||||
results[id] = String(id);
|
||||
}
|
||||
} catch {
|
||||
results[id] = String(id);
|
||||
}
|
||||
}));
|
||||
setMapfixUsernames(results);
|
||||
};
|
||||
fetchUsernames();
|
||||
}, [mapfixes]);
|
||||
|
||||
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
|
||||
|
||||
if (isLoading && (!mapfixes || !submissions)) {
|
||||
@@ -108,13 +165,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]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -125,13 +184,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,9 +1,23 @@
|
||||
// NOTE: This API endpoint proxies Roblox user info 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';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ userId: string }> }
|
||||
) {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 });
|
||||
}
|
||||
|
||||
const { userId } = await params;
|
||||
|
||||
if (!userId) {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
|
||||
export default function SubmissionInfoPage() {
|
||||
useTitle("Submissions");
|
||||
@@ -24,6 +25,7 @@ export default function SubmissionInfoPage() {
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitterUsernames, setSubmitterUsernames] = useState<Record<number, string>>({});
|
||||
const cardsPerPage = 24;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,6 +61,34 @@ export default function SubmissionInfoPage() {
|
||||
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);
|
||||
|
||||
// Fetch submitter usernames
|
||||
useEffect(() => {
|
||||
if (!submissions) return;
|
||||
const uniqueIds = Array.from(new Set(submissions.Submissions.map(s => s.Submitter)));
|
||||
const fetchUsernames = async () => {
|
||||
const results: Record<number, string> = {};
|
||||
await Promise.all(uniqueIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/proxy/users/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results[id] = data.name || String(id);
|
||||
} else {
|
||||
results[id] = String(id);
|
||||
}
|
||||
} catch {
|
||||
results[id] = String(id);
|
||||
}
|
||||
}));
|
||||
setSubmitterUsernames(results);
|
||||
};
|
||||
fetchUsernames();
|
||||
}, [submissions]);
|
||||
|
||||
if (isLoading || !submissions) {
|
||||
return (
|
||||
<Webpage>
|
||||
@@ -127,13 +157,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,4 +1,13 @@
|
||||
// NOTE: This API endpoint proxies Roblox asset 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';
|
||||
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
||||
const imageCache = new Map<number, { url: string, expires: number }>();
|
||||
@@ -9,6 +18,11 @@ interface RobloxThumbnailData {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const idsParam = url.searchParams.get('ids');
|
||||
if (!idsParam) {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
/**
|
||||
* @deprecated This endpoint is deprecated. Use /thumbnails/batch instead. This route will be removed in a future release.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
||||
const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000;
|
||||
const cache = new Map<number, { url: string; expires: number }>();
|
||||
|
||||
// Periodic cleanup to prevent memory leaks
|
||||
if (typeof globalThis.__userThumbCacheCleanup === 'undefined') {
|
||||
globalThis.__userThumbCacheCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [userId, entry] of cache.entries()) {
|
||||
if (entry.expires <= now) {
|
||||
cache.delete(userId);
|
||||
}
|
||||
}
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ userId: number }> }
|
||||
@@ -13,6 +33,12 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cached = cache.get(userId);
|
||||
if (cached && cached.expires > now) {
|
||||
return NextResponse.redirect(cached.url);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${userId}&size=420x420&format=Png&isCircular=false`
|
||||
@@ -32,9 +58,11 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cache.set(userId, { url: imageUrl, expires: now + CACHE_TTL });
|
||||
|
||||
// Redirect to the image URL instead of proxying
|
||||
return NextResponse.redirect(imageUrl);
|
||||
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch avatar headshot URL' },
|
||||
|
||||
74
web/src/app/thumbnails/user/batch/route.ts
Normal file
74
web/src/app/thumbnails/user/batch/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// NOTE: This API endpoint proxies Roblox 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';
|
||||
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
|
||||
const userImageCache = new Map<number, { url: string, expires: number }>();
|
||||
|
||||
// Periodic cleanup to prevent memory leaks
|
||||
if (typeof globalThis.__userBatchThumbCacheCleanup === 'undefined') {
|
||||
globalThis.__userBatchThumbCacheCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [userId, entry] of userImageCache.entries()) {
|
||||
if (entry.expires <= now) {
|
||||
userImageCache.delete(userId);
|
||||
}
|
||||
}
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
interface RobloxUserThumbnailData {
|
||||
targetId: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const idsParam = url.searchParams.get('ids');
|
||||
if (!idsParam) {
|
||||
return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 });
|
||||
}
|
||||
const ids = idsParam.split(',').map(Number).filter(Boolean);
|
||||
const now = Date.now();
|
||||
const result: Record<number, string | null> = {};
|
||||
const idsToFetch: number[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const cached = userImageCache.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);
|
||||
const response = await fetch(
|
||||
`https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=420x420&format=Png&isCircular=false`
|
||||
);
|
||||
if (!response.ok) continue;
|
||||
const data = await response.json();
|
||||
for (const id of batch) {
|
||||
const found = (data.data as RobloxUserThumbnailData[]).find(d => String(d.targetId) === String(id));
|
||||
const imageUrl = found?.imageUrl || null;
|
||||
if (imageUrl) userImageCache.set(id, { url: imageUrl, expires: now + CACHE_TTL });
|
||||
result[id] = imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
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.
|
||||
//
|
||||
// For production, use a distributed store (e.g., Redis) and import this from a shared location.
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const RATE_LIMIT_MAX = 20; // 20 requests per minute per IP
|
||||
|
||||
// Map<ip, { count: number, expires: number }>
|
||||
const ipRateLimitMap = new Map<string, { count: number, expires: number }>();
|
||||
|
||||
export function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.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;
|
||||
}
|
||||
|
||||
if (typeof globalThis.__ipRateLimitCleanup === 'undefined') {
|
||||
globalThis.__ipRateLimitCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of ipRateLimitMap.entries()) {
|
||||
if (entry.expires < now) ipRateLimitMap.delete(ip);
|
||||
}
|
||||
}, RATE_LIMIT_WINDOW_MS);
|
||||
}
|
||||
Reference in New Issue
Block a user