model/user thumbnails

This commit is contained in:
ic3w0lf 2025-04-02 21:20:30 -06:00
parent e9f79241f1
commit 8a28d6cfcf
16 changed files with 184 additions and 167 deletions
web
next.config.ts
src/app
_components
mapfixes
submissions
thumbnails
asset/[assetId]
user/[userId]

@ -6,11 +6,11 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'api.ic3.space',
pathname: '/strafe/map-images/**',
port: '',
search: '',
protocol: "https",
hostname: "tr.rbxcdn.com",
pathname: "/**",
port: "",
search: "",
},
],
},

@ -6,8 +6,9 @@ import { Rating } from "@mui/material";
interface SubmissionCardProps {
displayName: string;
assetId: number;
rating: number;
authorId: number;
author: string;
rating: number;
id: number;
}
@ -18,7 +19,7 @@ export default function SubmissionCard(props: SubmissionCardProps) {
<div className="content">
<div className="map-image">
{/* TODO: Grab image of model */}
<Image height={200} width={200} priority={true} src="https://api.ic3.space/strafe/map-images/11222350808" style={{ width: `100%` }} alt={props.displayName} />
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
</div>
<div className="details">
<div className="header">
@ -29,7 +30,7 @@ export default function SubmissionCard(props: SubmissionCardProps) {
</div>
<div className="footer">
<div className="author">
<Image className="avatar" width={28} height={28} priority={true} src="https://api.ic3.space/strafe/map-images/11222350808" alt={props.author}/>
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
<span>{props.author}</span>
</div>
</div>

@ -1,4 +1,4 @@
@use "../../../globals.scss";
@use "../../globals.scss";
.submissionCard {
display: flex;
@ -9,7 +9,7 @@
flex-direction: column;
justify-content: space-between;
height: 100%;
min-width: 180px;
min-width: 230px;
max-width: 340px;
}
@ -43,8 +43,6 @@
overflow: hidden;
> img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

@ -1,4 +1,4 @@
@forward "./page/card.scss";
@forward "../../_components/styles/mapCard.scss";
@use "../../globals.scss";

@ -2,9 +2,11 @@
import React, { useState, useEffect } from "react";
import { MapfixInfo } from "../ts/Mapfix";
import MapfixCard from "./_card";
import MapfixCard from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
// TODO: MAKE MAPFIX & SUBMISSIONS USE THE SAME COMPONENTS :angry: (currently too lazy)
import "./(styles)/page.scss";
export default function MapfixInfoPage() {
@ -100,6 +102,7 @@ export default function MapfixInfoPage() {
assetId={mapfix.AssetID}
displayName={mapfix.DisplayName}
author={mapfix.Creator}
authorId={mapfix.Submitter}
rating={mapfix.StatusID}
/>
))}

@ -1,4 +1,4 @@
@forward "./page/card.scss";
@forward "../../_components/styles/mapCard.scss";
@use "../../globals.scss";
@ -27,7 +27,7 @@ a {
@media (max-width: 768px) {
.grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
}

@ -1,87 +0,0 @@
@use "../../../globals.scss";
.submissionCard {
display: flex;
background-color: #2020207c;
border: 1px solid #97979783;
border-radius: 10px;
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
height: 100%;
min-width: 180px;
max-width: 340px;
}
.content {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: space-between;
}
.details {
padding: 2px 4px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 3px 0px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
margin: 3px 0px;
}
.map-image {
border-radius: 10px 10px 0 0;
overflow: hidden;
> img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.displayName {
font-size: 1rem;
font-weight: bold;
overflow: hidden;
max-width: 70%;
text-overflow: ellipsis;
white-space: nowrap;
}
.rating {
flex-shrink: 0;
}
.author {
display: flex;
align-items: center;
gap: 0.5rem;
flex-grow: 1;
min-width: 0;
}
.author span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rating {
margin-left: auto;
flex-shrink: 0;
}
.avatar {
border-radius: 50%;
object-fit: cover;
}

@ -5,8 +5,12 @@
display: flex;
justify-content: center;
align-items: center;
width: 350px;
height: 350px;
width: 100%;
height: auto;
margin-left: auto;
margin-right: auto;
border-radius: 12px;
overflow: hidden;
> p {
text-align: center;

@ -40,8 +40,7 @@
gap: 25px;
img {
width: 100%;
height: 350px;
height: 100%;
object-fit: contain
}
}

@ -1,14 +0,0 @@
import { SubmissionInfo } from "@/app/ts/Submission"
interface AssetID {
id: SubmissionInfo["AssetID"]
}
function MapImage() {
return <p>Fetching map image...</p>
}
export {
type AssetID,
MapImage
}

@ -0,0 +1,28 @@
import Image from "next/image";
import { SubmissionInfo } from "@/app/ts/Submission";
interface AssetID {
id: SubmissionInfo["AssetID"];
}
function MapImage({ id }: AssetID) {
if (!id) {
return <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export { type AssetID, MapImage };

@ -2,7 +2,7 @@
import { SubmissionInfo, SubmissionStatusToString } from "@/app/ts/Submission";
import type { CreatorAndReviewStatus } from "./_comments";
import { MapImage } from "./_map";
import { MapImage } from "./_mapImage";
import { useParams } from "next/navigation";
import ReviewButtons from "./_reviewButtons";
import { Rating } from "@mui/material";
@ -15,7 +15,8 @@ import { useState, useEffect } from "react";
import "./(styles)/page.scss";
interface ReviewId {
submissionId: string
submissionId: string;
assetId: number;
}
function Ratings() {
@ -43,7 +44,7 @@ function RatingArea(submission: ReviewId) {
return (
<aside className="review-area">
<section className="map-image-area">
<MapImage/>
<MapImage id={submission.assetId}/>
</section>
<Ratings/>
<ReviewButtons submissionId={submission.submissionId}/>
@ -96,7 +97,7 @@ export default function SubmissionInfoPage() {
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea submissionId={dynamicId.submissionId}/>
<RatingArea assetId={submission.AssetID} submissionId={dynamicId.submissionId}/>
<TitleAndComments name={submission.DisplayName} creator={submission.Creator} review={submission.StatusID} status_message={submission.StatusMessage} asset_id={submission.AssetID} comments={[]}/>
</section>
</main>

@ -1,41 +0,0 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Rating } from "@mui/material";
interface SubmissionCardProps {
displayName: string;
assetId: number;
rating: number;
author: string;
id: number;
}
export default function SubmissionCard(props: SubmissionCardProps) {
return (
<Link href={`/submissions/${props.id}`}>
<div className="submissionCard">
<div className="content">
<div className="map-image">
{/* TODO: Grab image of model */}
<Image height={200} width={200} priority={true} src="https://api.ic3.space/strafe/map-images/11222350808" style={{ width: `100%` }} alt={props.displayName} />
</div>
<div className="details">
<div className="header">
<span className="displayName">{props.displayName}</span>
<div className="rating">
<Rating value={props.rating} readOnly size="small" />
</div>
</div>
<div className="footer">
<div className="author">
<Image className="avatar" width={28} height={28} priority={true} src="https://api.ic3.space/strafe/map-images/11222350808" alt={props.author}/>
<span>{props.author}</span>
</div>
</div>
</div>
</div>
</div>
</Link>
);
}

@ -2,7 +2,7 @@
import React, { useState, useEffect } from "react";
import { SubmissionInfo } from "../ts/Submission";
import SubmissionCard from "./_card";
import SubmissionCard from "../_components/mapCard";
import Webpage from "@/app/_components/webpage";
import "./(styles)/page.scss";
@ -100,6 +100,7 @@ export default function SubmissionInfoPage() {
assetId={submission.AssetID}
displayName={submission.DisplayName}
author={submission.Creator}
authorId={submission.Submitter}
rating={submission.StatusID}
/>
))}

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
context: { params: Promise<{ assetId: number }> }
): Promise<NextResponse> {
const { assetId } = await context.params;
if (!assetId) {
return NextResponse.json(
{ error: 'Missing asset ID' },
{ status: 400 }
);
}
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 = 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');
}
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 }
);
}
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error('Failed to fetch the image');
}
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Content-Length': buffer.length.toString(),
},
});
} catch {
return NextResponse.json(
{ error: 'Failed to fetch or process thumbnail' },
{ status: 500 }
);
}
}

@ -0,0 +1,55 @@
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 }
);
}
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error('Failed to fetch the image');
}
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Content-Length': buffer.length.toString(),
},
});
} catch {
return NextResponse.json(
{ error: 'Failed to fetch or process avatar headshot' },
{ status: 500 }
);
}
}