QoL Web Changes + Map Download Permission Fix #214

Merged
Quaternions merged 5 commits from staging into master 2025-06-30 10:20:03 +00:00
22 changed files with 314 additions and 59 deletions

View File

@@ -80,6 +80,20 @@ func (svc *Service) GetMap(ctx context.Context, params api.GetMapParams) (*api.M
//
// GET /maps/{MapID}/location
func (svc *Service) GetMapAssetLocation(ctx context.Context, params api.GetMapAssetLocationParams) (ok api.GetMapAssetLocationOK, err error) {
userInfo, success := ctx.Value("UserInfo").(UserInfoHandle)
if !success {
return ok, ErrUserInfo
}
has_role, err := userInfo.HasRoleMapDownload()
if err != nil {
return ok, err
}
if !has_role {
return ok, ErrPermissionDeniedNeedRoleMapDownload
}
// Ensure map exists in the db!
// This could otherwise be used to access any asset
_, err = svc.Maps.Get(ctx, &maps.IdMessage{

View File

@@ -7,10 +7,7 @@ const nextConfig: NextConfig = {
remotePatterns: [
{
protocol: "https",
hostname: "tr.rbxcdn.com",
pathname: "/**",
port: "",
search: "",
hostname: "**.rbxcdn.com",
},
],
},

View File

@@ -21,6 +21,7 @@ import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
interface HeaderButton {
name: string;
@@ -28,6 +29,7 @@ interface HeaderButton {
}
const navItems: HeaderButton[] = [
{ name: "Home", href: "/" },
{ name: "Submissions", href: "/submissions" },
{ name: "Mapfixes", href: "/mapfixes" },
{ name: "Maps", href: "/maps" },
@@ -54,6 +56,7 @@ export default function Header() {
const [valid, setValid] = useState<boolean>(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [quickLinksAnchor, setQuickLinksAnchor] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -67,6 +70,13 @@ export default function Header() {
setMobileOpen(!mobileOpen);
};
const handleQuickLinksOpen = (event: React.MouseEvent<HTMLElement>) => {
setQuickLinksAnchor(event.currentTarget);
};
const handleQuickLinksClose = () => {
setQuickLinksAnchor(null);
};
useEffect(() => {
async function getLoginInfo() {
try {
@@ -129,6 +139,15 @@ export default function Header() {
</Box>
);
const quickLinks = [
{ name: "Bhop", href: "https://www.roblox.com/games/5315046213" },
{ name: "Bhop Maptest", href: "https://www.roblox.com/games/517201717" },
{ name: "Surf", href: "https://www.roblox.com/games/5315066937" },
{ name: "Surf Maptest", href: "https://www.roblox.com/games/517206177" },
{ name: "Fly Trials", href: "https://www.roblox.com/games/12591611759" },
{ name: "Fly Trials Maptest", href: "https://www.roblox.com/games/12724901535" },
];
return (
<AppBar position="static">
<Toolbar>
@@ -146,10 +165,43 @@ export default function Header() {
{/* Desktop navigation */}
{!isMobile && (
<Box display="flex" flexGrow={1} gap={2}>
<Box display="flex" flexGrow={1} gap={2} alignItems="center">
{navItems.map((item) => (
<HeaderButton key={item.name} name={item.name} href={item.href} />
))}
<Box sx={{ flexGrow: 1 }} /> {/* Push quick links to the right */}
{/* Quick Links Dropdown */}
<Box>
<Button
color="inherit"
endIcon={<ArrowDropDownIcon />}
onClick={handleQuickLinksOpen}
sx={{ textTransform: 'none', fontSize: '0.95rem', px: 1 }}
>
QUICK LINKS
</Button>
<Menu
anchorEl={quickLinksAnchor}
open={Boolean(quickLinksAnchor)}
onClose={handleQuickLinksClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{quickLinks.map(link => (
<MenuItem
key={link.name}
onClick={handleQuickLinksClose}
sx={{ minWidth: 180 }}
component="a"
href={link.href}
target="_blank"
rel="noopener noreferrer"
>
{link.name}
</MenuItem>
))}
</Menu>
</Box>
</Box>
)}

View File

@@ -6,13 +6,15 @@ interface CopyableFieldProps {
value: string | number | null | undefined;
onCopy: (value: string) => void;
placeholderText?: string;
link?: string; // Optional link prop
}
export const CopyableField = ({
label,
value,
onCopy,
placeholderText = "Not assigned"
placeholderText = "Not assigned",
link
}: CopyableFieldProps) => {
const displayValue = value?.toString() || placeholderText;
@@ -20,7 +22,18 @@ export const CopyableField = ({
<>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{displayValue}</Typography>
{link ? (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'none', cursor: 'pointer' }}
>
<Typography variant="body1" sx={{ '&:hover': { textDecoration: 'underline' } }}>{displayValue}</Typography>
</a>
) : (
<Typography variant="body1">{displayValue}</Typography>
)}
{value && (
<Tooltip title="Copy ID">
<IconButton

View File

@@ -34,14 +34,12 @@ export function ReviewItem({
if (isSubmission) {
// Fields for Submission
fields = [
{ key: 'Submitter', label: 'Submitter ID' },
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'UploadedAssetID', label: 'Uploaded Asset ID' },
];
} else if (isMapfix) {
// Fields for Mapfix
fields = [
{ key: 'Submitter', label: 'Submitter ID' },
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'TargetAssetID', label: 'Target Asset ID' },
];
@@ -51,6 +49,7 @@ export function ReviewItem({
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 4 }}>
<ReviewItemHeader
displayName={item.DisplayName}
assetId={isMapfix ? item.TargetAssetID : undefined}
statusId={item.StatusID}
creator={item.Creator}
submitterId={item.Submitter}
@@ -58,16 +57,23 @@ export function ReviewItem({
{/* Item Details */}
<Grid container spacing={2} sx={{ mt: 2 }}>
{fields.map((field) => (
<Grid item xs={12} sm={6} key={field.key}>
<CopyableField
label={field.label}
value={(item as never)[field.key]}
onCopy={handleCopyValue}
placeholderText={field.placeholder}
/>
</Grid>
))}
{fields.map((field) => {
const fieldValue = (item as never)[field.key];
const displayValue = fieldValue === 0 || fieldValue == null ? 'N/A' : fieldValue;
const isAssetId = field.key.includes('AssetID') && fieldValue !== 0 && fieldValue != null;
return (
<Grid item xs={12} sm={6} key={field.key}>
<CopyableField
label={field.label}
value={String(displayValue)}
onCopy={handleCopyValue}
placeholderText={field.placeholder}
link={isAssetId ? `https://create.roblox.com/store/asset/${fieldValue}` : undefined}
/>
</Grid>
);
})}
</Grid>
{/* Description Section */}

View File

@@ -3,19 +3,53 @@ 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';
type StatusIdType = SubmissionStatus | MapfixStatus;
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;
statusId: StatusIdType;
assetId: number | null | undefined,
statusId: SubmissionStatus | MapfixStatus;
creator: string | null | undefined;
submitterId: number;
}
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
const pulse = keyframes`
0%, 100% { opacity: 0.2; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
@@ -24,9 +58,30 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }
return (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
{displayName}
</Typography>
{assetId != null ? (
<Link href={`/maps/${assetId}`} passHref legacyBehavior>
<Box sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="View related map">
<Typography
variant="h4"
component="h1"
gutterBottom
sx={{ color: 'inherit', textDecoration: 'none', mr: 1 }}
>
{displayName} by {creator}
</Typography>
<LaunchIcon sx={{ fontSize: '1.5rem', color: 'text.secondary' }} />
</Box>
</Link>
) : (
<Typography
variant="h4"
component="h1"
gutterBottom
sx={{ color: 'inherit', textDecoration: 'none', mr: 1 }}
>
{displayName} by {creator}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isProcessing && (
<Box sx={{
@@ -60,9 +115,7 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }
src={`/thumbnails/user/${submitterId}`}
sx={{ mr: 1, width: 24, height: 24 }}
/>
<Typography variant="body1">
by {creator || "Unknown Creator"}
</Typography>
<SubmitterName submitterId={submitterId} />
</Box>
</>
);

View File

@@ -6,6 +6,7 @@ import GameSelection from "./_game";
import SendIcon from '@mui/icons-material/Send';
import Webpage from "@/app/_components/webpage"
import React, { useState } from "react";
import {useTitle} from "@/app/hooks/useTitle";
import "./(styles)/page.scss"
@@ -20,6 +21,8 @@ interface IdResponse {
}
export default function SubmissionInfoPage() {
useTitle("Admin Submit");
const [game, setGame] = useState(1);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {

View File

@@ -0,0 +1,9 @@
'use client';
import { useEffect } from 'react';
export function useTitle(title: string) {
useEffect(() => {
document.title = `${title} | StrafesNET`;
}, [title]);
}

View File

@@ -1,4 +1,5 @@
'use client';
"use client";
import "./globals.scss";
import {theme} from "@/app/lib/theme";
import {ThemeProvider} from "@mui/material";

View File

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

View File

@@ -26,6 +26,7 @@ import {ErrorDisplay} from "@/app/_components/ErrorDisplay";
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";
interface SnackbarState {
open: boolean;
@@ -50,7 +51,6 @@ export default function MapfixDetailsPage() {
severity
});
};
const handleCloseSnackbar = () => {
setSnackbar({
...snackbar,
@@ -74,6 +74,8 @@ export default function MapfixDetailsPage() {
});
const mapfix = mapfixData as MapfixInfo;
useTitle(mapfix ? `${mapfix.DisplayName} Mapfix` : 'Loading Mapfix...');
// Handle review button actions
async function handleReviewAction(action: string, mapfixId: number) {
try {
@@ -179,6 +181,7 @@ export default function MapfixDetailsPage() {
/>
);
}
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>

View File

@@ -15,8 +15,11 @@ import {
} from "@mui/material";
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle";
export default function MapfixInfoPage() {
useTitle("Map Fixes");
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);

View File

@@ -19,6 +19,7 @@ import Webpage from "@/app/_components/webpage";
import { useParams } from "next/navigation";
import Link from "next/link";
import {MapInfo} from "@/app/ts/Map";
import {useTitle} from "@/app/hooks/useTitle";
interface MapfixPayload {
AssetID: number;
@@ -41,6 +42,8 @@ export default function MapfixInfoPage() {
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
useTitle("Submit Mapfix");
useEffect(() => {
const fetchMapDetails = async () => {
try {

View File

@@ -6,6 +6,8 @@ import { useParams, useRouter } from "next/navigation";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Snackbar, Alert } from "@mui/material";
import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix";
import LaunchIcon from '@mui/icons-material/Launch';
// MUI Components
import {
@@ -32,7 +34,8 @@ 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 { hasRole, RolesConstants } from "@/app/ts/Roles";
import {hasRole, RolesConstants} from "@/app/ts/Roles";
import {useTitle} from "@/app/hooks/useTitle";
export default function MapDetails() {
const { mapId } = useParams();
@@ -43,6 +46,9 @@ export default function MapDetails() {
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...');
useEffect(() => {
async function getMap() {
@@ -84,6 +90,33 @@ export default function MapDetails() {
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]);
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
@@ -136,28 +169,8 @@ export default function MapDetails() {
const location = await res.text();
// Method 1: Try direct download with proper cleanup
try {
const link = document.createElement('a');
link.href = location.trim(); // Remove any whitespace
link.download = `${map?.DisplayName}.rbxm`;
link.target = '_blank'; // Open in new tab as fallback
link.rel = 'noopener noreferrer'; // Security best practice
// Ensure the link is properly attached before clicking
document.body.appendChild(link);
link.click();
// Clean up after a short delay to ensure download starts
setTimeout(() => {
document.body.removeChild(link);
}, 100);
} catch (domError) {
console.warn('Direct download failed, trying fallback:', domError);
// Method 2: Fallback - open in new window
window.open(location.trim(), '_blank');
}
// open in new window
window.open(location.trim(), '_blank');
} catch (err) {
console.error('Download error:', err);
@@ -378,13 +391,46 @@ export default function MapDetails() {
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
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>

View File

@@ -26,6 +26,8 @@ import {
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';
interface Map {
ID: number;
@@ -36,6 +38,8 @@ interface Map {
}
export default function MapsPage() {
useTitle("Map Collection");
const router = useRouter();
const [maps, setMaps] = useState<Map[]>([]);
const [loading, setLoading] = useState(true);
@@ -259,6 +263,7 @@ export default function MapsPage() {
{getGameName(map.GameID)}
</Box>
<Image
loader={thumbnailLoader}
src={`/thumbnails/asset/${map.ID}`}
alt={map.DisplayName}
fill

View File

@@ -21,6 +21,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PendingIcon from '@mui/icons-material/Pending';
import Webpage from "@/app/_components/webpage";
import {useTitle} from "@/app/hooks/useTitle";
interface Operation {
OperationID: number;
@@ -41,7 +42,8 @@ export default function OperationStatusPage() {
const [expandStatusMessage, setExpandStatusMessage] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useTitle(`Operation ${operationId}`);
useEffect(() => {
if (!operationId) return;

View File

@@ -15,8 +15,11 @@ import {
import Link from "next/link";
import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
import {Carousel} from "@/app/_components/carousel";
import {useTitle} from "@/app/hooks/useTitle";
export default function Home() {
useTitle("Home");
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);

View File

@@ -0,0 +1,31 @@
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 });
}
}

View File

@@ -25,6 +25,7 @@ import {ErrorDisplay} from "@/app/_components/ErrorDisplay";
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";
interface SnackbarState {
open: boolean;
@@ -73,6 +74,8 @@ export default function SubmissionDetailsPage() {
});
const submission = submissionData as SubmissionInfo;
useTitle(submission ? `${submission.DisplayName} Submission` : 'Loading Submission...');
// Handle review button actions
async function handleReviewAction(action: string, submissionId: number) {
try {

View File

@@ -1,4 +1,4 @@
'use client'
"use client"
import { useState, useEffect } from "react";
import { SubmissionList } from "../ts/Submission";
@@ -15,8 +15,11 @@ import {
} from "@mui/material";
import Link from "next/link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import {useTitle} from "@/app/hooks/useTitle";
export default function SubmissionInfoPage() {
useTitle("Submissions");
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);

View File

@@ -18,6 +18,7 @@ import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import Webpage from "@/app/_components/webpage";
import GameSelection from "./_game";
import Link from "next/link";
import {useTitle} from "@/app/hooks/useTitle";
interface SubmissionPayload {
AssetID: number;
@@ -27,6 +28,8 @@ interface SubmissionPayload {
}
export default function SubmitPage() {
useTitle("Submit");
const [game, setGame] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

View File

@@ -17,7 +17,7 @@ export async function GET(
try {
const mediaResponse = await fetch(
`https://publish.roblox.com/v1/assets/${assetId}/media`
`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();
@@ -47,7 +47,6 @@ export async function GET(
// 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}`,