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 {
|
interface AuditEventItemProps {
|
||||||
event: AuditEvent;
|
event: AuditEvent;
|
||||||
validatorUser: number;
|
validatorUser: number;
|
||||||
|
userAvatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) {
|
export default function AuditEventItem({ event, validatorUser, userAvatarUrl }: AuditEventItemProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
|
src={event.User === validatorUser ? undefined : userAvatarUrl}
|
||||||
>
|
>
|
||||||
<PersonIcon />
|
<PersonIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent";
|
|||||||
interface CommentItemProps {
|
interface CommentItemProps {
|
||||||
event: AuditEvent;
|
event: AuditEvent;
|
||||||
validatorUser: number;
|
validatorUser: number;
|
||||||
|
userAvatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CommentItem({ event, validatorUser }: CommentItemProps) {
|
export default function CommentItem({ event, validatorUser, userAvatarUrl }: CommentItemProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
|
src={event.User === validatorUser ? undefined : userAvatarUrl}
|
||||||
>
|
>
|
||||||
<PersonIcon />
|
<PersonIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import CommentsTabPanel from './CommentsTabPanel';
|
import CommentsTabPanel from './CommentsTabPanel';
|
||||||
import AuditEventsTabPanel from './AuditEventsTabPanel';
|
import AuditEventsTabPanel from './AuditEventsTabPanel';
|
||||||
import { AuditEvent } from "@/app/ts/AuditEvent";
|
import { AuditEvent } from "@/app/ts/AuditEvent";
|
||||||
|
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||||
|
|
||||||
interface CommentsAndAuditSectionProps {
|
interface CommentsAndAuditSectionProps {
|
||||||
auditEvents: AuditEvent[];
|
auditEvents: AuditEvent[];
|
||||||
@@ -32,6 +33,10 @@ export default function CommentsAndAuditSection({
|
|||||||
setActiveTab(newValue);
|
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 (
|
return (
|
||||||
<Paper sx={{ p: 3, mt: 3 }}>
|
<Paper sx={{ p: 3, mt: 3 }}>
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
@@ -53,6 +58,7 @@ export default function CommentsAndAuditSection({
|
|||||||
setNewComment={setNewComment}
|
setNewComment={setNewComment}
|
||||||
handleCommentSubmit={handleCommentSubmit}
|
handleCommentSubmit={handleCommentSubmit}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
commentUserAvatarUrls={commentUserAvatarUrls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuditEventsTabPanel
|
<AuditEventsTabPanel
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface CommentsTabPanelProps {
|
|||||||
setNewComment: (comment: string) => void;
|
setNewComment: (comment: string) => void;
|
||||||
handleCommentSubmit: () => void;
|
handleCommentSubmit: () => void;
|
||||||
userId: number | null;
|
userId: number | null;
|
||||||
|
userAvatarUrl?: string;
|
||||||
|
commentUserAvatarUrls?: Record<number, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CommentsTabPanel({
|
export default function CommentsTabPanel({
|
||||||
@@ -27,7 +29,9 @@ export default function CommentsTabPanel({
|
|||||||
newComment,
|
newComment,
|
||||||
setNewComment,
|
setNewComment,
|
||||||
handleCommentSubmit,
|
handleCommentSubmit,
|
||||||
userId
|
userId,
|
||||||
|
userAvatarUrl,
|
||||||
|
commentUserAvatarUrls
|
||||||
}: CommentsTabPanelProps) {
|
}: CommentsTabPanelProps) {
|
||||||
const commentEvents = auditEvents.filter(
|
const commentEvents = auditEvents.filter(
|
||||||
event => event.EventType === AuditEventType.Comment
|
event => event.EventType === AuditEventType.Comment
|
||||||
@@ -44,6 +48,7 @@ export default function CommentsTabPanel({
|
|||||||
key={index}
|
key={index}
|
||||||
event={event}
|
event={event}
|
||||||
validatorUser={validatorUser}
|
validatorUser={validatorUser}
|
||||||
|
userAvatarUrl={commentUserAvatarUrls?.[event.User]}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -59,6 +64,7 @@ export default function CommentsTabPanel({
|
|||||||
setNewComment={setNewComment}
|
setNewComment={setNewComment}
|
||||||
handleCommentSubmit={handleCommentSubmit}
|
handleCommentSubmit={handleCommentSubmit}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
userAvatarUrl={userAvatarUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -72,13 +78,14 @@ interface CommentInputProps {
|
|||||||
setNewComment: (comment: string) => void;
|
setNewComment: (comment: string) => void;
|
||||||
handleCommentSubmit: () => void;
|
handleCommentSubmit: () => void;
|
||||||
userId: number | null;
|
userId: number | null;
|
||||||
|
userAvatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) {
|
function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId, userAvatarUrl }: CommentInputProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={`/thumbnails/user/${userId}`}
|
src={userAvatarUrl}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {StatusChip} from "@/app/_components/statusChip";
|
|||||||
interface MapCardProps {
|
interface MapCardProps {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
assetId: number;
|
assetId: number;
|
||||||
authorId: number;
|
submitterId: number;
|
||||||
|
submitterUsername: string;
|
||||||
author: string;
|
author: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,6 +16,7 @@ interface MapCardProps {
|
|||||||
created: number;
|
created: number;
|
||||||
type: 'mapfix' | 'submission';
|
type: 'mapfix' | 'submission';
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
authorAvatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARD_WIDTH = 270;
|
const CARD_WIDTH = 270;
|
||||||
@@ -152,37 +154,35 @@ export function MapCard(props: MapCardProps) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Divider sx={{ my: 1.5 }} />
|
||||||
<Divider sx={{ my: 1.5 }} />
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Avatar
|
||||||
<Avatar
|
src={props.authorAvatarUrl}
|
||||||
src={`/thumbnails/user/${props.authorId}`}
|
alt={props.submitterUsername}
|
||||||
alt={props.author}
|
sx={{
|
||||||
sx={{
|
width: 24,
|
||||||
width: 24,
|
height: 24,
|
||||||
height: 24,
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
bgcolor: 'grey.900'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{
|
sx={{
|
||||||
ml: 1,
|
ml: 1,
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/*In the future author should be the username of the submitter not the info from the map*/}
|
{props.submitterUsername} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
|
||||||
{props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
|
year: 'numeric',
|
||||||
year: 'numeric',
|
month: 'long',
|
||||||
month: 'long',
|
day: 'numeric'
|
||||||
day: 'numeric'
|
})}
|
||||||
})}
|
</Typography>
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ReviewItemHeader } from "./ReviewItemHeader";
|
|||||||
import { CopyableField } from "@/app/_components/review/CopyableField";
|
import { CopyableField } from "@/app/_components/review/CopyableField";
|
||||||
import { SubmissionInfo } from "@/app/ts/Submission";
|
import { SubmissionInfo } from "@/app/ts/Submission";
|
||||||
import { MapfixInfo } from "@/app/ts/Mapfix";
|
import { MapfixInfo } from "@/app/ts/Mapfix";
|
||||||
|
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||||
|
|
||||||
// Define a field configuration for specific types
|
// Define a field configuration for specific types
|
||||||
interface FieldConfig {
|
interface FieldConfig {
|
||||||
@@ -23,6 +24,7 @@ export function ReviewItem({
|
|||||||
handleCopyValue
|
handleCopyValue
|
||||||
}: ReviewItemProps) {
|
}: ReviewItemProps) {
|
||||||
// Type guard to check if item is valid
|
// Type guard to check if item is valid
|
||||||
|
const { avatars: submitterAvatars } = useBatchUserAvatars(item ? [item.Submitter] : []);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
// Determine the type of item
|
// Determine the type of item
|
||||||
@@ -52,6 +54,7 @@ export function ReviewItem({
|
|||||||
statusId={item.StatusID}
|
statusId={item.StatusID}
|
||||||
creator={item.Creator}
|
creator={item.Creator}
|
||||||
submitterId={item.Submitter}
|
submitterId={item.Submitter}
|
||||||
|
submitterAvatarUrl={submitterAvatars[item.Submitter]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Item Details */}
|
{/* Item Details */}
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ interface ReviewItemHeaderProps {
|
|||||||
statusId: SubmissionStatus | MapfixStatus;
|
statusId: SubmissionStatus | MapfixStatus;
|
||||||
creator: string | null | undefined;
|
creator: string | null | undefined;
|
||||||
submitterId: number;
|
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 isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
|
||||||
const pulse = keyframes`
|
const pulse = keyframes`
|
||||||
0%, 100% { opacity: 0.2; transform: scale(0.8); }
|
0%, 100% { opacity: 0.2; transform: scale(0.8); }
|
||||||
@@ -90,8 +91,8 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }
|
|||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={`/thumbnails/user/${submitterId}`}
|
src={submitterAvatarUrl}
|
||||||
sx={{ mr: 1, width: 24, height: 24 }}
|
sx={{ mr: 1, width: 24, height: 24, border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
|
||||||
/>
|
/>
|
||||||
<SubmitterName submitterId={submitterId} />
|
<SubmitterName submitterId={submitterId} />
|
||||||
</Box>
|
</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 NavigateNextIcon from "@mui/icons-material/NavigateNext";
|
||||||
import {useTitle} from "@/app/hooks/useTitle";
|
import {useTitle} from "@/app/hooks/useTitle";
|
||||||
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
||||||
|
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||||
|
|
||||||
export default function MapfixInfoPage() {
|
export default function MapfixInfoPage() {
|
||||||
useTitle("Map Fixes");
|
useTitle("Map Fixes");
|
||||||
@@ -24,6 +25,7 @@ export default function MapfixInfoPage() {
|
|||||||
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
|
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [submitterUsernames, setSubmitterUsernames] = useState<Record<number, string>>({});
|
||||||
const cardsPerPage = 24;
|
const cardsPerPage = 24;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,6 +61,34 @@ export default function MapfixInfoPage() {
|
|||||||
const assetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? [];
|
const assetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? [];
|
||||||
const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
|
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) {
|
if (isLoading || !mapfixes) {
|
||||||
return (
|
return (
|
||||||
<Webpage>
|
<Webpage>
|
||||||
@@ -115,13 +145,15 @@ export default function MapfixInfoPage() {
|
|||||||
assetId={mapfix.AssetID}
|
assetId={mapfix.AssetID}
|
||||||
displayName={mapfix.DisplayName}
|
displayName={mapfix.DisplayName}
|
||||||
author={mapfix.Creator}
|
author={mapfix.Creator}
|
||||||
authorId={mapfix.Submitter}
|
submitterId={mapfix.Submitter}
|
||||||
|
submitterUsername={submitterUsernames[mapfix.Submitter] || String(mapfix.Submitter)}
|
||||||
rating={mapfix.StatusID}
|
rating={mapfix.StatusID}
|
||||||
statusID={mapfix.StatusID}
|
statusID={mapfix.StatusID}
|
||||||
gameID={mapfix.GameID}
|
gameID={mapfix.GameID}
|
||||||
created={mapfix.CreatedAt}
|
created={mapfix.CreatedAt}
|
||||||
type="mapfix"
|
type="mapfix"
|
||||||
thumbnailUrl={thumbnailUrls[mapfix.AssetID]}
|
thumbnailUrl={thumbnailUrls[mapfix.AssetID]}
|
||||||
|
authorAvatarUrl={avatarUrls[mapfix.Submitter]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
|
|||||||
import {Carousel} from "@/app/_components/carousel";
|
import {Carousel} from "@/app/_components/carousel";
|
||||||
import {useTitle} from "@/app/hooks/useTitle";
|
import {useTitle} from "@/app/hooks/useTitle";
|
||||||
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
||||||
|
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
useTitle("Home");
|
useTitle("Home");
|
||||||
@@ -25,6 +26,8 @@ export default function Home() {
|
|||||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||||
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
|
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
|
||||||
const [isLoadingSubmissions, setIsLoadingSubmissions] = 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
|
const itemsPerSection: number = 8; // Show more items for the carousel
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,6 +82,60 @@ export default function Home() {
|
|||||||
const { thumbnails: submissionThumbnails } = useBatchThumbnails(submissionAssetIds);
|
const { thumbnails: submissionThumbnails } = useBatchThumbnails(submissionAssetIds);
|
||||||
const { thumbnails: mapfixThumbnails } = useBatchThumbnails(mapfixAssetIds);
|
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;
|
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
|
||||||
|
|
||||||
if (isLoading && (!mapfixes || !submissions)) {
|
if (isLoading && (!mapfixes || !submissions)) {
|
||||||
@@ -108,13 +165,15 @@ export default function Home() {
|
|||||||
assetId={mapfix.AssetID}
|
assetId={mapfix.AssetID}
|
||||||
displayName={mapfix.DisplayName}
|
displayName={mapfix.DisplayName}
|
||||||
author={mapfix.Creator}
|
author={mapfix.Creator}
|
||||||
authorId={mapfix.Submitter}
|
submitterId={mapfix.Submitter}
|
||||||
|
submitterUsername={mapfixUsernames[mapfix.Submitter] || String(mapfix.Submitter)}
|
||||||
rating={mapfix.StatusID}
|
rating={mapfix.StatusID}
|
||||||
statusID={mapfix.StatusID}
|
statusID={mapfix.StatusID}
|
||||||
gameID={mapfix.GameID}
|
gameID={mapfix.GameID}
|
||||||
created={mapfix.CreatedAt}
|
created={mapfix.CreatedAt}
|
||||||
type="mapfix"
|
type="mapfix"
|
||||||
thumbnailUrl={mapfixThumbnails[mapfix.AssetID]}
|
thumbnailUrl={mapfixThumbnails[mapfix.AssetID]}
|
||||||
|
authorAvatarUrl={mapfixAvatars[mapfix.Submitter]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -125,13 +184,15 @@ export default function Home() {
|
|||||||
assetId={submission.AssetID}
|
assetId={submission.AssetID}
|
||||||
displayName={submission.DisplayName}
|
displayName={submission.DisplayName}
|
||||||
author={submission.Creator}
|
author={submission.Creator}
|
||||||
authorId={submission.Submitter}
|
submitterId={submission.Submitter}
|
||||||
|
submitterUsername={submissionUsernames[submission.Submitter] || String(submission.Submitter)}
|
||||||
rating={submission.StatusID}
|
rating={submission.StatusID}
|
||||||
statusID={submission.StatusID}
|
statusID={submission.StatusID}
|
||||||
gameID={submission.GameID}
|
gameID={submission.GameID}
|
||||||
created={submission.CreatedAt}
|
created={submission.CreatedAt}
|
||||||
type="submission"
|
type="submission"
|
||||||
thumbnailUrl={submissionThumbnails[submission.AssetID]}
|
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';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ userId: string }> }
|
{ 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;
|
const { userId } = await params;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import Link from "next/link";
|
|||||||
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
|
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
|
||||||
import {useTitle} from "@/app/hooks/useTitle";
|
import {useTitle} from "@/app/hooks/useTitle";
|
||||||
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
|
||||||
|
import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars";
|
||||||
|
|
||||||
export default function SubmissionInfoPage() {
|
export default function SubmissionInfoPage() {
|
||||||
useTitle("Submissions");
|
useTitle("Submissions");
|
||||||
@@ -24,6 +25,7 @@ export default function SubmissionInfoPage() {
|
|||||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [submitterUsernames, setSubmitterUsernames] = useState<Record<number, string>>({});
|
||||||
const cardsPerPage = 24;
|
const cardsPerPage = 24;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,6 +61,34 @@ export default function SubmissionInfoPage() {
|
|||||||
const assetIds = submissions?.Submissions.map(s => s.AssetID) ?? [];
|
const assetIds = submissions?.Submissions.map(s => s.AssetID) ?? [];
|
||||||
const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
|
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) {
|
if (isLoading || !submissions) {
|
||||||
return (
|
return (
|
||||||
<Webpage>
|
<Webpage>
|
||||||
@@ -127,13 +157,15 @@ export default function SubmissionInfoPage() {
|
|||||||
assetId={submission.AssetID}
|
assetId={submission.AssetID}
|
||||||
displayName={submission.DisplayName}
|
displayName={submission.DisplayName}
|
||||||
author={submission.Creator}
|
author={submission.Creator}
|
||||||
authorId={submission.Submitter}
|
submitterId={submission.Submitter}
|
||||||
|
submitterUsername={submitterUsernames[submission.Submitter] || String(submission.Submitter)}
|
||||||
rating={submission.StatusID}
|
rating={submission.StatusID}
|
||||||
statusID={submission.StatusID}
|
statusID={submission.StatusID}
|
||||||
gameID={submission.GameID}
|
gameID={submission.GameID}
|
||||||
created={submission.CreatedAt}
|
created={submission.CreatedAt}
|
||||||
type="submission"
|
type="submission"
|
||||||
thumbnailUrl={thumbnailUrls[submission.AssetID]}
|
thumbnailUrl={thumbnailUrls[submission.AssetID]}
|
||||||
|
authorAvatarUrl={submitterAvatars[submission.Submitter]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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 { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { checkRateLimit } from '@/lib/rateLimit';
|
||||||
|
|
||||||
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
||||||
const imageCache = new Map<number, { url: string, expires: number }>();
|
const imageCache = new Map<number, { url: string, expires: number }>();
|
||||||
@@ -9,6 +18,11 @@ interface RobloxThumbnailData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 url = new URL(request.url);
|
||||||
const idsParam = url.searchParams.get('ids');
|
const idsParam = url.searchParams.get('ids');
|
||||||
if (!idsParam) {
|
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';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ userId: number }> }
|
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 {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${userId}&size=420x420&format=Png&isCircular=false`
|
`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
|
// Redirect to the image URL instead of proxying
|
||||||
return NextResponse.redirect(imageUrl);
|
return NextResponse.redirect(imageUrl);
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch avatar headshot URL' },
|
{ 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