7 Commits

Author SHA1 Message Date
ic3w0lf
8d39139004 diff
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-09 06:58:15 -07:00
ic3w0lf
2658c39d0c Build Succeed - working script review page 2025-07-09 06:58:15 -07:00
ic3w0lf
ce9c5baf27 Build Succeed. 2025-07-09 06:58:15 -07:00
ic3w0lf
f29a8b0e6d Script review page, server-side session fetching & no more manually fetching session information 2025-07-09 06:58:15 -07:00
ic3w0lf
eff47608f5 Sorry to whoever uses 2 spaces instead of a tab 2025-07-09 06:58:15 -07:00
362446c5ed random shit changed in the merge commit 2025-07-09 06:58:15 -07:00
3930212d5d Clickable titles and show active mapfix (#211)
Closes #144

Co-authored-by: ic3w0lf <bob@ic3.space>
Reviewed-on: #211
Reviewed-by: itzaname <itzaname@noreply@itzana.me>
Co-authored-by: ic3w0lf22 <ic3w0lf22@noreply@itzana.me>
Co-committed-by: ic3w0lf22 <ic3w0lf22@noreply@itzana.me>
2025-07-09 06:45:34 -07:00
11 changed files with 954 additions and 420 deletions

View File

@@ -11,13 +11,15 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"date-fns": "^4.1.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.82.0"
"sass": "^1.82.0",
"swr": "^2.3.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

View File

@@ -0,0 +1,15 @@
"use client";
import { ThemeProvider } from "@mui/material";
import { SessionProvider } from "@/app/_components/SessionContext";
import { theme } from "@/app/lib/theme";
export default function AppProviders({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</SessionProvider>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import React, { createContext, useContext, ReactNode } from "react";
import useSWR from "swr";
import { RolesConstants } from "@/app/ts/Roles";
interface UserInfo {
UserID: number;
Username: string;
AvatarURL: string;
}
interface SessionContextType {
roles: number;
user: UserInfo | null;
loading: boolean;
}
const SessionContext = createContext<SessionContextType>({ roles: RolesConstants.Empty, user: null, loading: true });
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) return null;
try {
const data = await res.json();
if (data && typeof data === 'object' && (data.code || data.error)) return null;
return data;
} catch {
return null;
}
};
export const SessionProvider = ({ children }: { children: ReactNode }) => {
const { data: rolesData, isLoading: rolesLoading } = useSWR("/api/session/roles", fetcher, { refreshInterval: 60000 });
const { data: userData, isLoading: userLoading } = useSWR("/api/session/user", fetcher, { refreshInterval: 60000 });
const loading = rolesLoading || userLoading;
const roles = rolesData?.Roles ?? RolesConstants.Empty;
const user = userData ?? null;
return (
<SessionContext.Provider value={{ roles, user, loading }}>
{children}
</SessionContext.Provider>
);
};
export const useSession = () => useContext(SessionContext);

View File

@@ -2,8 +2,8 @@
import Link from "next/link"
import Image from "next/image";
import { UserInfo } from "@/app/ts/User";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useSession } from "@/app/_components/SessionContext";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
@@ -22,6 +22,7 @@ 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';
import { RolesConstants, hasRole } from "@/app/ts/Roles";
interface HeaderButton {
name: string;
@@ -44,17 +45,18 @@ function HeaderButton(header: HeaderButton) {
}
export default function Header() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const { user, roles } = useSession();
const valid = !!user;
const handleLoginClick = () => {
window.location.href =
"/auth/oauth2/login?redirect=" + window.location.href;
};
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);
@@ -77,32 +79,6 @@ export default function Header() {
setQuickLinksAnchor(null);
};
useEffect(() => {
async function getLoginInfo() {
try {
const response = await fetch("/api/session/user");
if (!response.ok) {
setValid(false);
setUser(null);
return;
}
const userData = await response.json();
const isLoggedIn = userData && 'UserID' in userData;
setValid(isLoggedIn);
setUser(isLoggedIn ? userData : null);
} catch (error) {
console.error("Error fetching user data:", error);
setValid(false);
setUser(null);
}
}
getLoginInfo();
}, []);
// Mobile navigation drawer content
const drawer = (
<Box onClick={handleDrawerToggle} sx={{ textAlign: 'center' }}>
@@ -148,6 +124,8 @@ export default function Header() {
{ name: "Fly Trials Maptest", href: "https://www.roblox.com/games/12724901535" },
];
const showScriptReview = hasRole(roles, RolesConstants.ScriptWrite);
return (
<AppBar position="static">
<Toolbar>
@@ -169,6 +147,9 @@ export default function Header() {
{navItems.map((item) => (
<HeaderButton key={item.name} name={item.name} href={item.href} />
))}
{showScriptReview && (
<HeaderButton name="Script Review" href="/script-review" />
)}
<Box sx={{ flexGrow: 1 }} /> {/* Push quick links to the right */}
{/* Quick Links Dropdown */}
<Box>

View File

@@ -52,6 +52,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}

View File

@@ -8,6 +8,7 @@ import LaunchIcon from '@mui/icons-material/Launch';
interface ReviewItemHeaderProps {
displayName: string;
assetId: number | null | undefined,
statusId: SubmissionStatus | MapfixStatus;
creator: string | null | undefined;
submitterId: number;
@@ -15,7 +16,7 @@ interface ReviewItemHeaderProps {
submitterUsername?: string;
}
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, submitterAvatarUrl, submitterUsername }: ReviewItemHeaderProps) => {
export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, submitterId, submitterAvatarUrl, submitterUsername }: ReviewItemHeaderProps) => {
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
const pulse = keyframes`
0%, 100% { opacity: 0.2; transform: scale(0.8); }
@@ -25,9 +26,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>
{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>
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isProcessing && (
<Box sx={{
@@ -72,4 +94,4 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId,
</Box>
</>
);
};
};

View File

@@ -3,8 +3,10 @@
import Header from "./header";
export default function Webpage({children}: Readonly<{children?: React.ReactNode}>) {
return <>
<Header/>
{children}
</>
return (
<>
<Header />
{children}
</>
);
}

View File

@@ -1,16 +1,25 @@
"use client";
import "./globals.scss";
import {theme} from "@/app/lib/theme";
import {ThemeProvider} from "@mui/material";
import { SWRConfig } from "swr";
import { getSessionUser, getSessionRoles } from "@/app/lib/session";
import AppProviders from "@/app/_components/AppProviders";
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const user = await getSessionUser();
const roles = await getSessionRoles();
export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
return (
<html lang="en">
<body>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
<SWRConfig value={{
fallback: {
"/api/session/user": user,
"/api/session/roles": { Roles: roles }
}
}}>
<AppProviders>
{children}
</AppProviders>
</SWRConfig>
</body>
</html>
);

View File

@@ -0,0 +1,26 @@
import { cookies } from "next/headers";
const BASE_URL = process.env.API_HOST;
export async function getSessionUser() {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const res = await fetch(`${BASE_URL}/session/user`, {
headers: { Cookie: cookieHeader },
cache: 'no-store',
});
if (!res.ok) return null;
return await res.json();
}
export async function getSessionRoles() {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const res = await fetch(`${BASE_URL}/session/roles`, {
headers: { Cookie: cookieHeader },
cache: 'no-store',
});
if (!res.ok) return 0;
const data = await res.json();
return data.Roles ?? 0;
}

View File

@@ -7,24 +7,26 @@ import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Snackbar, Alert } from "@mui/material";
import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails";
import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix";
import { useSession } from "@/app/_components/SessionContext";
// MUI Components
import {
Typography,
Box,
Button,
Container,
Breadcrumbs,
Chip,
Grid,
Divider,
Paper,
Skeleton,
Stack,
CardMedia,
Tooltip,
IconButton,
CircularProgress
Typography,
Box,
Button,
Container,
Breadcrumbs,
Chip,
Grid,
Divider,
Paper,
Skeleton,
Stack,
CardMedia,
Tooltip,
IconButton,
CircularProgress
} from "@mui/material";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
@@ -34,384 +36,426 @@ 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 LaunchIcon from '@mui/icons-material/Launch';
import {hasRole, RolesConstants} from "@/app/ts/Roles";
import {useTitle} from "@/app/hooks/useTitle";
export default function MapDetails() {
const { mapId } = useParams();
const router = useRouter();
const [map, setMap] = useState<MapInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [roles, setRoles] = useState(RolesConstants.Empty);
const [downloading, setDownloading] = useState(false);
const { mapId } = useParams();
const router = useRouter();
const [map, setMap] = useState<MapInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [downloading, setDownloading] = useState(false);
const [mapfixes, setMapfixes] = useState<MapfixInfo[]>([]);
const { roles, loading: sessionLoading } = useSession();
useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
useTitle(map ? `${map.DisplayName}` : 'Loading Map...');
useEffect(() => {
async function getMap() {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/maps/${mapId}`);
if (!res.ok) {
throw new Error(`Failed to fetch map: ${res.status}`);
}
const data = await res.json();
setMap(data);
} catch (error) {
console.error("Error fetching map details:", error);
setError(error instanceof Error ? error.message : "Failed to load map details");
} finally {
setLoading(false);
}
}
getMap();
}, [mapId]);
useEffect(() => {
async function getRoles() {
try {
const rolesResponse = await fetch("/api/session/roles");
if (rolesResponse.ok) {
const rolesData = await rolesResponse.json();
setRoles(rolesData.Roles);
} else {
console.warn(`Failed to fetch roles: ${rolesResponse.status}`);
setRoles(RolesConstants.Empty);
}
} catch (error) {
console.warn("Error fetching roles data:", error);
setRoles(RolesConstants.Empty);
useEffect(() => {
async function getMap() {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/maps/${mapId}`);
if (!res.ok) {
throw new Error(`Failed to fetch map: ${res.status}`);
}
const data = await res.json();
setMap(data);
} catch (error) {
console.error("Error fetching map details:", error);
setError(error instanceof Error ? error.message : "Failed to load map details");
} finally {
setLoading(false);
}
}
getMap();
}, [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 assetIds = map?.ID ? [map.ID] : [];
const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const getGameInfo = (gameId: number) => {
switch (gameId) {
case 1:
return {
name: "Bhop",
color: "#2196f3" // blue
};
case 2:
return {
name: "Surf",
color: "#4caf50" // green
};
case 5:
return {
name: "Fly Trials",
color: "#ff9800" // orange
};
default:
return {
name: "Unknown",
color: "#9e9e9e" // gray
};
}
};
const handleSubmitMapfix = () => {
router.push(`/maps/${mapId}/fix`);
};
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
setCopySuccess(true);
};
const handleDownload = async () => {
setDownloading(true);
try {
// Fetch the download URL
const res = await fetch(`/api/maps/${mapId}/location`);
if (!res.ok) throw new Error('Failed to fetch download location');
const location = await res.text();
// open in new window
window.open(location.trim(), '_blank');
} catch (err) {
console.error('Download error:', err);
// Optional: Show user-friendly error message
alert('Download failed. Please try again.');
} finally {
setDownloading(false);
}
};
const handleCloseSnackbar = () => {
setCopySuccess(false);
};
if (error) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: 'error.light',
color: 'error.contrastText'
}}
>
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
<Typography variant="body1">{error}</Typography>
<Button
variant="contained"
onClick={() => router.push('/maps')}
sx={{ mt: 3 }}
>
Return to Maps
</Button>
</Paper>
</Container>
</Webpage>
);
}
getRoles()
}, [mapId]);
// Use useBatchThumbnails for the map thumbnail
const assetIds = map?.ID ? [map.ID] : [];
const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds);
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
</Breadcrumbs>
{loading ? (
<Box>
<Box sx={{ mb: 4 }}>
<Skeleton variant="text" width="60%" height={60} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
</Box>
</Box>
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
</Grid>
const getGameInfo = (gameId: number) => {
switch (gameId) {
case 1:
return {
name: "Bhop",
color: "#2196f3" // blue
};
case 2:
return {
name: "Surf",
color: "#4caf50" // green
};
case 5:
return {
name: "Fly Trials",
color: "#ff9800" // orange
};
default:
return {
name: "Unknown",
color: "#9e9e9e" // gray
};
}
};
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
</Grid>
</Grid>
</Box>
) : (
map && (
<>
{/* Map Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
{map.DisplayName}
</Typography>
const handleSubmitMapfix = () => {
router.push(`/maps/${mapId}/fix`);
};
{map.GameID && (
<Chip
label={getGameInfo(map.GameID).name}
sx={{
bgcolor: getGameInfo(map.GameID).color,
color: '#fff',
fontWeight: 'bold',
fontSize: '0.9rem',
height: 32
}}
/>
)}
</Box>
const handleCopyId = (idToCopy: string) => {
navigator.clipboard.writeText(idToCopy);
setCopySuccess(true);
};
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
<strong>Created by:</strong> {map.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
{formatDate(map.Date)}
</Typography>
</Box>
const handleDownload = async () => {
setDownloading(true);
try {
// Fetch the download URL
const res = await fetch(`/api/maps/${mapId}/location`);
if (!res.ok) throw new Error('Failed to fetch download location');
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">
<strong>ID:</strong> {mapId}
</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{!loading && !sessionLoading && hasRole(roles,RolesConstants.MapDownload) && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<InsertDriveFileIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
Download
</Typography>
<Tooltip title="File extension must be changed to .rbxm manually">
<IconButton
size="small"
onClick={handleDownload}
sx={{ ml: 1 }}
disabled={downloading}
>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Box>
const location = await res.text();
<Grid container spacing={3}>
{/* Map Preview Section */}
<Grid item xs={12} md={8}>
<Paper
elevation={3}
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
}}
>
{thumbnailUrls[map.ID] ? (
<CardMedia
component="img"
image={thumbnailUrls[map.ID]}
alt={`Preview of map: ${map?.DisplayName}`}
sx={{
height: 400,
objectFit: 'cover',
}}
/>
) : (
<Box sx={{ width: '100%', height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={40} />
</Box>
)}
</Paper>
</Grid>
// open in new window
window.open(location.trim(), '_blank');
{/* Map Details Section */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>Map Details</Typography>
<Divider sx={{ mb: 2 }} />
} catch (err) {
console.error('Download error:', err);
// Optional: Show user-friendly error message
alert('Download failed. Please try again.');
} finally {
setDownloading(false);
}
};
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
<Typography variant="body1">{map.DisplayName}</Typography>
</Box>
const handleCloseSnackbar = () => {
setCopySuccess(false);
};
<Box>
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
<Typography variant="body1">{map.Creator}</Typography>
</Box>
if (error) {
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: 6 }}>
<Paper
elevation={3}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: 'error.light',
color: 'error.contrastText'
}}
>
<Typography variant="h5" gutterBottom>Error Loading Map</Typography>
<Typography variant="body1">{error}</Typography>
<Button
variant="contained"
onClick={() => router.push('/maps')}
sx={{ mt: 3 }}
>
Return to Maps
</Button>
</Paper>
</Container>
</Webpage>
);
}
<Box>
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
</Box>
return (
<Webpage>
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
{/* Breadcrumbs Navigation */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Home</Typography>
</Link>
<Link href="/maps" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography color="text.primary">Maps</Typography>
</Link>
<Typography color="text.secondary">{loading ? "Loading..." : map?.DisplayName || "Map Details"}</Typography>
</Breadcrumbs>
{loading ? (
<Box>
<Box sx={{ mb: 4 }}>
<Skeleton variant="text" width="60%" height={60} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Skeleton variant="text" width={120} />
<Skeleton variant="rounded" width={80} height={30} sx={{ ml: 2 }} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Typography variant="body1">{formatDate(map.Date)}</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 2 }} />
</Grid>
<Box>
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{mapId}</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1, p: 0 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Grid item xs={12} md={4}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2, mb: 3 }} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2, mt: 3 }} />
</Grid>
</Grid>
</Box>
) : (
map && (
<>
{/* Map Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h3" component="h1" sx={{ fontWeight: 'bold' }}>
{map.DisplayName}
</Typography>
{/* 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>
{map.GameID && (
<Chip
label={getGameInfo(map.GameID).name}
sx={{
bgcolor: getGameInfo(map.GameID).color,
color: '#fff',
fontWeight: 'bold',
fontSize: '0.9rem',
height: 32
}}
/>
)}
</Box>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Button
fullWidth
variant="contained"
color="primary"
startIcon={<BugReportIcon />}
onClick={handleSubmitMapfix}
size="large"
>
Submit a Mapfix
</Button>
</Paper>
</Grid>
</Grid>
</>
)
)}
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
<strong>Created by:</strong> {map.Creator}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarTodayIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
{formatDate(map.Date)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FlagIcon sx={{ mr: 1, color: 'primary.main' }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">
<strong>ID:</strong> {mapId}
</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{!loading && hasRole(roles,RolesConstants.MapDownload) && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<InsertDriveFileIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body1">
Download
</Typography>
<Tooltip title="File extension must be changed to .rbxm manually">
<IconButton
size="small"
onClick={handleDownload}
sx={{ ml: 1 }}
disabled={downloading}
>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Box>
<Grid container spacing={3}>
{/* Map Preview Section */}
<Grid item xs={12} md={8}>
<Paper
elevation={3}
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative'
}}
>
{thumbnailUrls[map.ID] ? (
<CardMedia
component="img"
image={thumbnailUrls[map.ID]}
alt={`Preview of map: ${map?.DisplayName}`}
sx={{
height: 400,
objectFit: 'cover',
}}
/>
) : (
<Box sx={{ width: '100%', height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'grey.900' }}>
<CircularProgress size={40} />
</Box>
)}
</Paper>
</Grid>
{/* Map Details Section */}
<Grid item xs={12} md={4}>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>Map Details</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Display Name</Typography>
<Typography variant="body1">{map.DisplayName}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Creator</Typography>
<Typography variant="body1">{map.Creator}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Game Type</Typography>
<Typography variant="body1">{getGameInfo(map.GameID).name}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Release Date</Typography>
<Typography variant="body1">{formatDate(map.Date)}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Map ID</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">{mapId}</Typography>
<Tooltip title="Copy ID to clipboard">
<IconButton
size="small"
onClick={() => handleCopyId(mapId as string)}
sx={{ ml: 1 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Stack>
</Paper>
<Paper elevation={3} sx={{ p: 3, borderRadius: 2 }}>
<Button
fullWidth
variant="contained"
color="primary"
startIcon={<BugReportIcon />}
onClick={handleSubmitMapfix}
size="large"
>
Submit a Mapfix
</Button>
</Paper>
</Grid>
</Grid>
</>
)
)}
<Snackbar
open={copySuccess}
autoHideDuration={3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
</Alert>
</Snackbar>
</Container>
</Webpage>
);
<Snackbar
open={copySuccess}
autoHideDuration={3000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
Map ID copied to clipboard!
</Alert>
</Snackbar>
</Container>
</Webpage>
);
}

View File

@@ -0,0 +1,384 @@
"use client";
import Webpage from "@/app/_components/webpage";
import React, { useEffect, useState } from "react";
import {useTitle} from "@/app/hooks/useTitle";
import {
Box,
Button,
Container,
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
Typography,
Paper,
CircularProgress,
Alert
} from "@mui/material";
import dynamic from "next/dynamic";
import { DiffEditor } from "@monaco-editor/react";
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import Skeleton from '@mui/material/Skeleton';
const POLICY_OPTIONS = [
{ value: 0, label: "None (Unreviewed)" },
{ value: 1, label: "Allowed" },
{ value: 2, label: "Blocked" },
{ value: 3, label: "Delete" },
{ value: 4, label: "Replace" },
];
interface ScriptPolicy {
ID: number;
FromScriptHash: string;
ToScriptID: number;
Policy: number;
}
interface ScriptInfo {
ID: number;
Name: string;
Hash: string;
Source: string;
ResourceType: number;
ResourceID: number;
}
interface ScriptPolicyUpdateBody {
ID: number | null;
Policy?: number;
ToScriptID?: number;
}
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
export default function ScriptReviewPage() {
useTitle("Script Review");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [policy, setPolicy] = useState(0);
const [scriptPolicyId, setScriptPolicyId] = useState<number | null>(null);
const [scriptSource, setScriptSource] = useState("");
const [originalSource, setOriginalSource] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [scriptPolicies, setScriptPolicies] = useState<ScriptPolicy[]>([]);
const [scriptInfos, setScriptInfos] = useState<ScriptInfo[]>([]);
const [selectedScript, setSelectedScript] = useState<ScriptInfo | null>(null);
const [page, setPage] = useState(1);
const [editorTab, setEditorTab] = useState<'edit' | 'diff'>('edit');
// Extracted fetch logic for reuse
const fetchPoliciesAndScripts = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/script-policy?Page=${page}&Limit=10&Policy=0`);
if (!res.ok) throw new Error(`Failed to fetch script policies: ${res.status}`);
const policies: ScriptPolicy[] = await res.json();
setScriptPolicies(policies);
const scriptFetches = policies.map(async (policy) => {
const scriptId = policy.ToScriptID || 0;
const id = scriptId || 0;
if (id) {
const res = await fetch(`/api/scripts/${id}`);
if (res.ok) {
return await res.json();
}
}
return null;
});
const infos = (await Promise.all(scriptFetches)).filter(Boolean) as ScriptInfo[];
setScriptInfos(infos);
if (infos.length > 0) {
setSelectedScript(infos[0]);
setScriptSource(infos[0].Source || "");
setOriginalSource(infos[0].Source || "");
const firstPolicy = policies[0];
setScriptPolicyId(firstPolicy.ID);
setPolicy(firstPolicy.Policy);
} else {
setSelectedScript(null);
setScriptSource("");
setOriginalSource("");
setScriptPolicyId(null);
setPolicy(0);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load script policies");
}
setLoading(false);
}, [page]);
useEffect(() => {
fetchPoliciesAndScripts();
}, [page, fetchPoliciesAndScripts]);
useEffect(() => {
setSelectedScript(null);
setScriptSource("");
setOriginalSource("");
setScriptPolicyId(null);
setPolicy(0);
}, [page]);
const handleScriptSelect = (script: ScriptInfo, idx: number) => {
setError(null);
setSelectedScript(script);
setScriptSource(script.Source || "");
setOriginalSource(script.Source || "");
const policy = scriptPolicies[idx];
if (policy) {
setScriptPolicyId(policy.ID);
setPolicy(policy.Policy);
}
};
const handlePolicyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPolicy(Number(event.target.value));
};
const handleSourceChange = (newSource: string) => {
setScriptSource(newSource);
if (newSource !== originalSource) {
setPolicy(4);
} else if (policy === 4) {
setPolicy(0);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
setSuccess(false);
try {
let toScriptId: number | undefined = undefined;
if (policy === 4 && scriptSource !== originalSource && selectedScript) {
// Upload new script (deduplication handled on backend)
const uploadRes = await fetch("/api/scripts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Name: selectedScript.Name,
Source: scriptSource,
ResourceType: selectedScript.ResourceType
}),
});
if (!uploadRes.ok) throw new Error("Failed to upload replacement script");
const uploadData = await uploadRes.json();
toScriptId = uploadData.ScriptID;
}
// Update script policy
const updateBody: ScriptPolicyUpdateBody = { ID: scriptPolicyId };
if (policy !== undefined) updateBody.Policy = policy;
if (policy === 4 && toScriptId) updateBody.ToScriptID = toScriptId;
const updateRes = await fetch(`/api/script-policy/${scriptPolicyId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updateBody),
});
if (!updateRes.ok) throw new Error("Failed to update script policy");
setSuccess(true);
// Refresh the list after successful review
await fetchPoliciesAndScripts();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit review");
} finally {
setSubmitting(false);
}
};
const isSourceChanged = scriptSource !== originalSource;
const canSubmit = (policy !== 4 && !isSourceChanged) || (policy === 4 && isSourceChanged);
return (
<Webpage>
<Container maxWidth={false} sx={{ py: 4, width: '85vw', maxWidth: '100vw', minWidth: 320, mx: 'auto' }}>
<Paper sx={{ p: 3, display: "flex", gap: 3, width: '100%', minWidth: 320, mx: 'auto', boxSizing: 'border-box' }}>
<Box sx={{ minWidth: 250, maxWidth: 350 }}>
<Typography variant="h6" gutterBottom>Unreviewed Scripts</Typography>
{loading ? (
<Box component="ul" sx={{ listStyle: "none", p: 0, m: 0 }}>
{Array.from({ length: 10 }).map((_, i) => (
<li key={i}>
<Skeleton variant="rectangular" height={30} sx={{ mb: 1, borderRadius: 1 }} />
</li>
))}
</Box>
) : scriptInfos.length === 0 ? (
<Typography>No unreviewed scripts found.</Typography>
) : (
<Box component="ul" sx={{ listStyle: "none", p: 0, m: 0 }}>
{scriptInfos.map((script, idx) => {
const isSelected = selectedScript?.ID === script.ID;
const name = script.Name || String(script.ID);
const parts = name.split(".");
let crumbs: React.ReactNode[];
const crumbTextColor = isSelected ? '#fff' : 'text.primary';
if (parts.length <= 2) {
crumbs = parts.map((part, i) => (
<Typography key={i} color={crumbTextColor} sx={{ fontWeight: isSelected ? 600 : 400, fontSize: 13, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{part}</Typography>
));
} else {
crumbs = [
<Typography key={0} color={crumbTextColor} sx={{ fontSize: 13, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{parts[0]}</Typography>,
<Typography key="ellipsis" color={isSelected ? '#fff' : 'text.secondary'} sx={{ fontSize: 13 }}>...</Typography>,
<Typography key={parts.length-1} color={crumbTextColor} sx={{ fontSize: 13, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{parts[parts.length-1]}</Typography>
];
}
return (
<li key={script.ID}>
<Button
variant={isSelected ? "contained" : "outlined"}
fullWidth
sx={{
mb: 1,
textAlign: "left",
justifyContent: "flex-start",
whiteSpace: 'normal',
wordBreak: 'break-all',
px: 1,
py: 0.5,
color: isSelected ? '#fff' : undefined,
backgroundColor: isSelected ? '#1976d2' : undefined,
border: isSelected ? '1.5px solid #42a5f5' : undefined,
'&:hover': isSelected ? { backgroundColor: '#1565c0' } : undefined,
}}
onClick={() => handleScriptSelect(script, idx)}
>
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" sx={{ color: isSelected ? '#fff' : 'inherit' }} />} aria-label="breadcrumb" sx={{ p: 0, m: 0 }}>
{crumbs}
</Breadcrumbs>
</Button>
</li>
);
})}
</Box>
)}
{/* Pagination controls */}
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', mt: 2 }}>
<Button
variant="outlined"
disabled={page === 1 || loading}
onClick={() => setPage(page - 1)}
sx={{ mr: 1 }}
>
&#8592;
</Button>
<Typography sx={{ mx: 2 }}>{`Page ${page}`}</Typography>
<Button
variant="outlined"
disabled={scriptPolicies.length < 10 || loading}
onClick={() => setPage(page + 1)}
>
&#8594;
</Button>
</Box>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Typography variant="h6" gutterBottom>Script Review</Typography>
{/* Show full script name above Policy */}
{selectedScript && (
<Typography variant="subtitle1" sx={{ fontWeight: 500, mb: 1, wordBreak: 'break-all' }}>
{selectedScript.Name || selectedScript.ID}
</Typography>
)}
{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: 200 }}>
<CircularProgress />
</Box>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
<form onSubmit={handleSubmit}>
<FormControl component="fieldset" sx={{ mb: 0 }}>
<FormLabel component="legend">Policy</FormLabel>
<RadioGroup row value={policy} onChange={handlePolicyChange}>
{POLICY_OPTIONS.map(opt => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
disabled={isSourceChanged && opt.value !== 4}
/>
))}
</RadioGroup>
</FormControl>
<Box sx={{ mb: 1 }} />
<Box sx={{ mb: 1 }}>
{policy === 4 && scriptSource !== originalSource ? (
<Tabs
value={editorTab}
onChange={(_, v) => setEditorTab(v)}
indicatorColor="primary"
textColor="primary"
sx={{ mb: 1 }}
>
<Tab label="Editor" value="edit" />
<Tab label="Diff" value="diff" />
</Tabs>
) : null}
{(policy !== 4 || scriptSource === originalSource || editorTab === 'edit') && (
<MonacoEditor
height="60vh"
defaultLanguage="lua"
value={scriptSource}
onChange={v => handleSourceChange(v ?? "")}
options={{
fontFamily: "monospace",
fontSize: 14,
minimap: { enabled: false },
wordWrap: "off",
lineNumbers: "on",
readOnly: false,
scrollbar: { vertical: 'visible', horizontal: 'auto' },
automaticLayout: true
}}
theme="vs-dark"
/>
)}
{policy === 4 && scriptSource !== originalSource && editorTab === 'diff' && (
<Box sx={{ height: '60vh', minHeight: 150 }}>
<DiffEditor
height="100%"
original={originalSource}
modified={scriptSource}
language="lua"
theme="vs-dark"
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
automaticLayout: true,
}}
/>
</Box>
)}
</Box>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!canSubmit || submitting}
fullWidth
sx={{ mt: 2 }}
>
{submitting ? <CircularProgress size={24} /> : "Submit Review"}
</Button>
{success && <Alert severity="success" sx={{ mt: 2 }}>Review submitted successfully!</Alert>}
</form>
)}
</Box>
</Paper>
</Container>
</Webpage>
);
}