diff --git a/pkg/service/maps.go b/pkg/service/maps.go index 40ea3de..9350424 100644 --- a/pkg/service/maps.go +++ b/pkg/service/maps.go @@ -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{ diff --git a/web/next.config.ts b/web/next.config.ts index d68a004..7c674d8 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -7,10 +7,7 @@ const nextConfig: NextConfig = { remotePatterns: [ { protocol: "https", - hostname: "tr.rbxcdn.com", - pathname: "/**", - port: "", - search: "", + hostname: "**.rbxcdn.com", }, ], }, diff --git a/web/src/app/_components/header.tsx b/web/src/app/_components/header.tsx index 0eac6b4..801e954 100644 --- a/web/src/app/_components/header.tsx +++ b/web/src/app/_components/header.tsx @@ -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(false); const [user, setUser] = useState(null); const [anchorEl, setAnchorEl] = useState(null); + const [quickLinksAnchor, setQuickLinksAnchor] = useState(null); const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -67,6 +70,13 @@ export default function Header() { setMobileOpen(!mobileOpen); }; + const handleQuickLinksOpen = (event: React.MouseEvent) => { + setQuickLinksAnchor(event.currentTarget); + }; + const handleQuickLinksClose = () => { + setQuickLinksAnchor(null); + }; + useEffect(() => { async function getLoginInfo() { try { @@ -129,6 +139,15 @@ export default function Header() { ); + 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 ( @@ -146,10 +165,43 @@ export default function Header() { {/* Desktop navigation */} {!isMobile && ( - + {navItems.map((item) => ( ))} + {/* Push quick links to the right */} + {/* Quick Links Dropdown */} + + + + {quickLinks.map(link => ( + + {link.name} + + ))} + + )} diff --git a/web/src/app/_components/review/CopyableField.tsx b/web/src/app/_components/review/CopyableField.tsx index ae0ebe5..e683701 100644 --- a/web/src/app/_components/review/CopyableField.tsx +++ b/web/src/app/_components/review/CopyableField.tsx @@ -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 = ({ <> {label} - {displayValue} + {link ? ( + + {displayValue} + + ) : ( + {displayValue} + )} {value && ( - {fields.map((field) => ( - - - - ))} + {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 ( + + + + ); + })} {/* Description Section */} diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx index f04cf6c..3fd975d 100644 --- a/web/src/app/_components/review/ReviewItemHeader.tsx +++ b/web/src/app/_components/review/ReviewItemHeader.tsx @@ -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(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 Loading...; + return + + + {name || submitterId} + + + + +} 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 ( <> - - {displayName} - + {assetId != null ? ( + + + + {displayName} by {creator} + + + + + ) : ( + + {displayName} by {creator} + + )} {isProcessing && ( - - by {creator || "Unknown Creator"} - + ); diff --git a/web/src/app/admin-submit/page.tsx b/web/src/app/admin-submit/page.tsx index b04a0b9..d76b11c 100644 --- a/web/src/app/admin-submit/page.tsx +++ b/web/src/app/admin-submit/page.tsx @@ -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) => { diff --git a/web/src/app/hooks/useTitle.ts b/web/src/app/hooks/useTitle.ts new file mode 100644 index 0000000..ea5c4a6 --- /dev/null +++ b/web/src/app/hooks/useTitle.ts @@ -0,0 +1,9 @@ +'use client'; + +import { useEffect } from 'react'; + +export function useTitle(title: string) { + useEffect(() => { + document.title = `${title} | StrafesNET`; + }, [title]); +} \ No newline at end of file diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 3fb5ba9..8d4e312 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,4 +1,5 @@ -'use client'; +"use client"; + import "./globals.scss"; import {theme} from "@/app/lib/theme"; import {ThemeProvider} from "@mui/material"; diff --git a/web/src/app/lib/thumbnailLoader.ts b/web/src/app/lib/thumbnailLoader.ts new file mode 100644 index 0000000..6e45a87 --- /dev/null +++ b/web/src/app/lib/thumbnailLoader.ts @@ -0,0 +1,3 @@ +export const thumbnailLoader = ({ src, width, quality }: { src: string, width: number, quality?: number }) => { + return `${src}?w=${width}&q=${quality || 75}`; +}; \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx index 548fa76..bc05191 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -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 ( diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index dccdc63..b45446f 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -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(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); diff --git a/web/src/app/maps/[mapId]/fix/page.tsx b/web/src/app/maps/[mapId]/fix/page.tsx index 84c5d47..67156e7 100644 --- a/web/src/app/maps/[mapId]/fix/page.tsx +++ b/web/src/app/maps/[mapId]/fix/page.tsx @@ -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(null); + useTitle("Submit Mapfix"); + useEffect(() => { const fetchMapDetails = async () => { try { diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index c1d9b75..1989152 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -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([]); + + 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() { handleCopyId(mapId as string)} - sx={{ ml: 1 }} + sx={{ ml: 1, p: 0 }} > + + {/* 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 ( + + + Active Mapfix + + + + {showFix.Description} + + + + + ); + })()} diff --git a/web/src/app/maps/page.tsx b/web/src/app/maps/page.tsx index 737e9c9..1a63514 100644 --- a/web/src/app/maps/page.tsx +++ b/web/src/app/maps/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -259,6 +263,7 @@ export default function MapsPage() { {getGameName(map.GameID)} {map.DisplayName}(null); - + + useTitle(`Operation ${operationId}`); useEffect(() => { if (!operationId) return; diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 0ac6930..0ad41a6 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -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(null); const [submissions, setSubmissions] = useState(null); const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false); diff --git a/web/src/app/proxy/users/[userId]/route.ts b/web/src/app/proxy/users/[userId]/route.ts new file mode 100644 index 0000000..b745db2 --- /dev/null +++ b/web/src/app/proxy/users/[userId]/route.ts @@ -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 }); + } +} diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index b967c44..5654f7f 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -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 { diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index 4c226f9..877cb43 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -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(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); diff --git a/web/src/app/submit/page.tsx b/web/src/app/submit/page.tsx index 51c2963..5ac4b44 100644 --- a/web/src/app/submit/page.tsx +++ b/web/src/app/submit/page.tsx @@ -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(null); diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx index 338de9b..0c76487 100644 --- a/web/src/app/thumbnails/asset/[assetId]/route.tsx +++ b/web/src/app/thumbnails/asset/[assetId]/route.tsx @@ -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}`,