Taking care of some issues & QOL changes #209

Merged
itzaname merged 4 commits from issues-162-170 into staging 2025-06-28 07:45:00 +00:00
21 changed files with 208 additions and 33 deletions

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' },
];
@@ -58,16 +56,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,52 @@ 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;
statusId: SubmissionStatus | MapfixStatus;
creator: string | null | undefined;
submitterId: number;
}
export const ReviewItemHeader = ({ displayName, 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); }
@@ -25,7 +58,7 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
{displayName}
{displayName} by {creator}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isProcessing && (
@@ -60,9 +93,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

@@ -32,7 +32,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();
@@ -44,6 +45,8 @@ export default function MapDetails() {
const [roles, setRoles] = useState(RolesConstants.Empty);
const [downloading, setDownloading] = useState(false);
useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
useEffect(() => {
async function getMap() {
try {

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}`,