Added titles, some buttons, made submitter display usernames instead of userids, quick links
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing

Closes #162
Closes #170
This commit is contained in:
ic3w0lf
2025-06-27 22:28:33 -06:00
parent abb3cf3076
commit f84f95fcc2
18 changed files with 190 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

@@ -1,8 +1,56 @@
import { Paper, Grid, Typography } from "@mui/material";
import { Paper, Grid, Typography, Box } from "@mui/material";
import { ReviewItemHeader } from "./ReviewItemHeader";
import { CopyableField } from "@/app/_components/review/CopyableField";
import { SubmissionInfo } from "@/app/ts/Submission";
import { MapfixInfo } from "@/app/ts/Mapfix";
import { useState, useEffect } from "react";
import Link from "next/link";
import LaunchIcon from '@mui/icons-material/Launch'; // Import the icon
// New component to fetch and display submitter info
function SubmitterInfo({ submitterId }: { submitterId: string | number }) {
const [name, setName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!submitterId) return;
const fetchUserName = async () => {
try {
setLoading(true);
// Use the internal proxy API route
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 (error) {
console.error("Error fetching user name:", error);
setName(String(submitterId)); // Fallback to ID on error
} 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>
);
}
// Define a field configuration for specific types
interface FieldConfig {
@@ -34,14 +82,14 @@ export function ReviewItem({
if (isSubmission) {
// Fields for Submission
fields = [
{ key: 'Submitter', label: 'Submitter ID' },
{ key: 'Submitter', label: 'Submitter' },
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'UploadedAssetID', label: 'Uploaded Asset ID' },
];
} else if (isMapfix) {
// Fields for Mapfix
fields = [
{ key: 'Submitter', label: 'Submitter ID' },
{ key: 'Submitter', label: 'Submitter' },
{ key: 'AssetID', label: 'Asset ID' },
{ key: 'TargetAssetID', label: 'Target Asset ID' },
];
@@ -58,16 +106,33 @@ 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];
if (field.key === 'Submitter') {
return (
<Grid item xs={12} sm={6} key={field.key}>
<Typography variant="subtitle2" color="text.secondary">{field.label}</Typography>
<SubmitterInfo submitterId={fieldValue} />
</Grid>
);
}
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

@@ -1,4 +1,4 @@
import {Typography, Box, Avatar, keyframes} from "@mui/material";
import {Typography, Box, keyframes} from "@mui/material";
import { StatusChip } from "@/app/_components/statusChip";
import { SubmissionStatus } from "@/app/ts/Submission";
import { MapfixStatus } from "@/app/ts/Mapfix";
@@ -13,7 +13,7 @@ interface ReviewItemHeaderProps {
submitterId: number;
}
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
export const ReviewItemHeader = ({ displayName, statusId, creator }: ReviewItemHeaderProps) => {
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
const pulse = keyframes`
@@ -56,10 +56,6 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar
src={`/thumbnails/user/${submitterId}`}
sx={{ mr: 1, width: 24, height: 24 }}
/>
<Typography variant="body1">
by {creator || "Unknown Creator"}
</Typography>

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

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

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

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