Rework submission/mapfix/maps list views #173
151
web/src/app/_components/carousel.tsx
Normal file
151
web/src/app/_components/carousel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import {Box, IconButton, Typography} from "@mui/material";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
import {SubmissionInfo} from "@/app/ts/Submission";
|
||||
import {MapfixInfo} from "@/app/ts/Mapfix";
|
||||
|
||||
// Type for the items in the carousel
|
||||
type CarouselItem = SubmissionInfo | MapfixInfo;
|
||||
|
||||
// Props for the Carousel component
|
||||
interface CarouselProps<T extends CarouselItem> {
|
||||
title: string;
|
||||
items: T[] | undefined;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
viewAllLink: string;
|
||||
}
|
||||
|
||||
export function Carousel<T extends CarouselItem>({ title, items, renderItem, viewAllLink }: CarouselProps<T>) {
|
||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState<number>(0);
|
||||
const [maxScroll, setMaxScroll] = useState<number>(0);
|
||||
|
||||
const SCROLL_AMOUNT = 300;
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselRef.current) {
|
||||
const scrollWidth = carouselRef.current.scrollWidth;
|
||||
const clientWidth = carouselRef.current.clientWidth;
|
||||
setMaxScroll(scrollWidth - clientWidth);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right'): void => {
|
||||
if (carouselRef.current) {
|
||||
const scrollAmount = direction === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT;
|
||||
|
||||
carouselRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (carouselRef.current) {
|
||||
setScrollPosition(carouselRef.current.scrollLeft);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (carouselRef.current) {
|
||||
setScrollPosition(carouselRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
const ref = carouselRef.current;
|
||||
if (ref) {
|
||||
ref.addEventListener('scroll', handleScroll);
|
||||
return () => ref.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box mb={6}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4" component="h2" fontWeight="bold">
|
||||
{title}
|
||||
</Typography>
|
||||
<Link href={viewAllLink} style={{textDecoration: 'none'}}>
|
||||
<Typography component="span" color="primary">
|
||||
View All →
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box position="relative">
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
visibility: scrollPosition <= 5 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => scroll('left')}
|
||||
>
|
||||
<ArrowBackIosNewIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
ref={carouselRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
gap: '16px', // Fixed 16px gap - using string with px unit to ensure it's absolute
|
||||
padding: '8px 4px',
|
||||
}}
|
||||
>
|
||||
{items?.map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: {
|
||||
xs: '260px', // Fixed width at different breakpoints
|
||||
sm: '280px',
|
||||
md: '300px'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
visibility: scrollPosition >= maxScroll - 5 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => scroll('right')}
|
||||
>
|
||||
<ArrowForwardIosIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,62 +3,132 @@
|
||||
import Link from "next/link"
|
||||
import Image from "next/image";
|
||||
|
||||
import "./styles/header.scss"
|
||||
import { UserInfo } from "@/app/ts/User";
|
||||
import { useState, useEffect } from "react";
|
||||
import {UserInfo} from "@/app/ts/User";
|
||||
import {useState, useEffect} from "react";
|
||||
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
interface HeaderButton {
|
||||
name: string,
|
||||
href: string
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
function HeaderButton(header: HeaderButton) {
|
||||
return (
|
||||
<Link href={header.href}>
|
||||
<button>{header.name}</button>
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<Button color="inherit" component={Link} href={header.href}>
|
||||
{header.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const handleLoginClick = () => {
|
||||
window.location.href = "/auth/oauth2/login?redirect=" + window.location.href;
|
||||
};
|
||||
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 [valid, setValid] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getLoginInfo() {
|
||||
const [validateData, userData] = await Promise.all([
|
||||
fetch("/api/session/validate").then(validateResponse => validateResponse.json()),
|
||||
fetch("/api/session/user").then(userResponse => userResponse.json())
|
||||
]);
|
||||
setValid(validateData)
|
||||
setUser(userData)
|
||||
}
|
||||
getLoginInfo()
|
||||
}, [])
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header-bar">
|
||||
<nav className="left">
|
||||
<HeaderButton name="Submissions" href="/submissions"/>
|
||||
<HeaderButton name="Mapfixes" href="/mapfixes"/>
|
||||
<HeaderButton name="Maps" href="/maps"/>
|
||||
</nav>
|
||||
<nav className="right">
|
||||
<HeaderButton name="Submit" href="/submit"/>
|
||||
{valid && user ? (
|
||||
<div className="author">
|
||||
<Link href="/auth">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={user.AvatarURL} alt={user.Username}/>
|
||||
<button>{user.Username}</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleLoginClick}>Login</button>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(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();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Box display="flex" flexGrow={1} gap={2}>
|
||||
<HeaderButton name="Submissions" href="/submissions"/>
|
||||
<HeaderButton name="Mapfixes" href="/mapfixes"/>
|
||||
<HeaderButton name="Maps" href="/maps"/>
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
{valid && user && (
|
||||
<Button variant="outlined" color="success" component={Link} href="/submit">
|
||||
Submit Map
|
||||
</Button>
|
||||
)}
|
||||
{valid && user ? (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Button
|
||||
onClick={handleMenuOpen}
|
||||
color="inherit"
|
||||
size="small"
|
||||
style={{textTransform: "none"}}
|
||||
>
|
||||
<Image
|
||||
className="avatar"
|
||||
width={28}
|
||||
height={28}
|
||||
priority={true}
|
||||
src={user.AvatarURL}
|
||||
alt={user.Username}
|
||||
style={{marginRight: 8}}
|
||||
/>
|
||||
<Typography variant="body1">{user.Username}</Typography>
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} href="/auth">
|
||||
Manage
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
) : (
|
||||
<Button color="inherit" onClick={handleLoginClick}>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,268 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Rating } from "@mui/material";
|
||||
import React, {JSX} from "react";
|
||||
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Grid, Typography} from "@mui/material";
|
||||
import {Cancel, CheckCircle, Explore, Pending, Person2} from "@mui/icons-material";
|
||||
|
||||
interface SubmissionCardProps {
|
||||
interface MapCardProps {
|
||||
displayName: string;
|
||||
assetId: number;
|
||||
authorId: number;
|
||||
author: string;
|
||||
rating: number;
|
||||
id: number;
|
||||
statusID: number;
|
||||
gameID: number;
|
||||
created: number;
|
||||
type: 'mapfix' | 'submission';
|
||||
}
|
||||
|
||||
export function SubmissionCard(props: SubmissionCardProps) {
|
||||
return (
|
||||
<Link href={`/submissions/${props.id}`}>
|
||||
<div className="submissionCard">
|
||||
<div className="content">
|
||||
<div className="map-image">
|
||||
{/* TODO: Grab image of model */}
|
||||
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="header">
|
||||
<span className="displayName">{props.displayName}</span>
|
||||
<div className="rating">
|
||||
<Rating value={props.rating} readOnly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="author">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
|
||||
<span>{props.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const CARD_WIDTH = 270;
|
||||
|
||||
export function MapfixCard(props: SubmissionCardProps) {
|
||||
export function MapCard(props: MapCardProps) {
|
||||
const StatusChip = ({status}: { status: number }) => {
|
||||
let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
|
||||
let icon: JSX.Element = <Pending fontSize="small"/>;
|
||||
let label: string = 'Unknown';
|
||||
|
||||
switch (status) {
|
||||
case 0:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Under Construction';
|
||||
break;
|
||||
case 1:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Changes Requested';
|
||||
break;
|
||||
case 2:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Submitting';
|
||||
break;
|
||||
case 3:
|
||||
color = 'warning';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Under Review';
|
||||
|
Quaternions marked this conversation as resolved
|
||||
break;
|
||||
case 4:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Accepted Unvalidated';
|
||||
break;
|
||||
case 5:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Validating';
|
||||
break;
|
||||
case 6:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Validated';
|
||||
break;
|
||||
case 7:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Uploading';
|
||||
break;
|
||||
case 8:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Uploaded';
|
||||
break;
|
||||
case 9:
|
||||
color = 'error';
|
||||
icon = <Cancel fontSize="small"/>;
|
||||
label = 'Rejected';
|
||||
break;
|
||||
case 10:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Released';
|
||||
break;
|
||||
default:
|
||||
color = 'default';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Unknown';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
color={color}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Link href={`/mapfixes/${props.id}`}>
|
||||
<div className="MapfixCard">
|
||||
<div className="content">
|
||||
<div className="map-image">
|
||||
{/* TODO: Grab image of model */}
|
||||
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="header">
|
||||
<span className="displayName">{props.displayName}</span>
|
||||
<div className="rating">
|
||||
<Rating value={props.rating} readOnly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="author">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
|
||||
<span>{props.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
|
||||
<Box sx={{
|
||||
width: CARD_WIDTH,
|
||||
mx: 'auto', // Center the card in its grid cell
|
||||
}}>
|
||||
<Card sx={{
|
||||
width: CARD_WIDTH,
|
||||
height: 340, // Fixed height for all cards
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
href={`/${props.type === 'submission' ? 'submissions' : 'mapfixes'}/${props.id}`}>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`/thumbnails/asset/${props.assetId}`}
|
||||
alt={props.displayName}
|
||||
sx={{
|
||||
height: 160, // Fixed height for all images
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<StatusChip status={props.statusID}/>
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
width: '100%',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{
|
||||
mb: 1,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
lineHeight: '1.3',
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{props.displayName}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
}}>
|
||||
<Explore sx={{
|
||||
mr: 0.75,
|
||||
mt: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.gameID === 1 ? 'Bhop' : props.gameID === 2 ? 'Surf' : props.gameID === 5 ? 'Fly Trials' : props.gameID === 4 ? 'Deathrun' : 'Unknown'}
|
||||
|
itzaname marked this conversation as resolved
Outdated
Quaternions
commented
FlyTrials is GameID 5 FlyTrials is GameID 5
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
}}>
|
||||
<Person2 sx={{
|
||||
mr: 0.75,
|
||||
mt: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.author}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
src={`/thumbnails/user/${props.authorId}`}
|
||||
alt={props.author}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
ml: 1,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/*In the future author should be the username of the submitter not the info from the map*/}
|
||||
{props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
import "./globals.scss";
|
||||
import {theme} from "@/app/lib/theme";
|
||||
import {ThemeProvider} from "@mui/material";
|
||||
|
||||
export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<ThemeProvider theme={theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
91
web/src/app/lib/theme.tsx
Normal file
91
web/src/app/lib/theme.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {createTheme} from "@mui/material";
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#90caf9',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f48fb1',
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
subtitle1: {
|
||||
fontWeight: 500,
|
||||
fontSize: '0.95rem',
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardMedia: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 16,
|
||||
'&:last-child': {
|
||||
paddingBottom: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDivider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,113 +2,130 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MapfixList } from "../ts/Mapfix";
|
||||
import { MapfixCard } from "../_components/mapCard";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
// TODO: MAKE MAPFIX & SUBMISSIONS USE THE SAME COMPONENTS :angry: (currently too lazy)
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
import {Box, Breadcrumbs, CircularProgress, Container, Pagination, Typography} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MapfixInfoPage() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList|null>(null)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMapfixes() {
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchMapFixes() {
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
setMapfixes(await res.json())
|
||||
setMapfixes(await res.json());
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fetchMapfixes()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
fetchMapFixes();
|
||||
|
||||
if (!mapfixes) {
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !mapfixes) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Loading...
|
||||
</main>
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
|
||||
const currentCards = mapfixes.Mapfixes.slice(
|
||||
(currentPage - 1) * cardsPerPage,
|
||||
currentPage * cardsPerPage
|
||||
);
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (mapfixes.Total == 0) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Mapfixes list is empty.
|
||||
</main>
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<div className="pagination-dots">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(index+1)}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<button onClick={prevPage} disabled={currentPage === 1}><</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button onClick={nextPage} disabled={currentPage === totalPages}>></button>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{currentCards.map((mapfix) => (
|
||||
<MapfixCard
|
||||
key={mapfix.ID}
|
||||
id={mapfix.ID}
|
||||
assetId={mapfix.AssetID}
|
||||
displayName={mapfix.DisplayName}
|
||||
author={mapfix.Creator}
|
||||
authorId={mapfix.Submitter}
|
||||
rating={mapfix.StatusID}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading mapfixes...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
const currentCards = mapfixes.Mapfixes;
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Mapfixes</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Map Fixes
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Explore all submitted fixes for maps from the community.
|
||||
</Typography>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentCards.map((submission) => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="mapfix"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={(_, page) => setCurrentPage(page)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import {useState, useEffect} from "react";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent, Breadcrumbs
|
||||
} from "@mui/material";
|
||||
import {Search as SearchIcon} from "@mui/icons-material";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Map {
|
||||
ID: number;
|
||||
DisplayName: string;
|
||||
Creator: string;
|
||||
GameID: number;
|
||||
Date: number;
|
||||
ID: number;
|
||||
DisplayName: string;
|
||||
Creator: string;
|
||||
GameID: number;
|
||||
Date: number;
|
||||
}
|
||||
|
||||
// TODO: should rewrite this entire page, just wanted to get a simple page working. This was written by chatgippity
|
||||
|
||||
export default function MapsPage() {
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
const router = useRouter();
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [gameFilter, setGameFilter] = useState<string>("0"); // 0 means "All Maps"
|
||||
const mapsPerPage = 12;
|
||||
const requestPageSize = 100;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
const res = await fetch("/api/maps?Page=1&Limit=100");
|
||||
const data: Map[] = await res.json();
|
||||
setMaps(data);
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
// Just send it and load all maps hoping for the best
|
||||
try {
|
||||
setLoading(true);
|
||||
let allMaps: Map[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
fetchMaps();
|
||||
}, []);
|
||||
while (hasMore) {
|
||||
const res = await fetch(`/api/maps?Page=${page}&Limit=${requestPageSize}`);
|
||||
const data: Map[] = await res.json();
|
||||
allMaps = [...allMaps, ...data];
|
||||
hasMore = data.length === requestPageSize;
|
||||
page++;
|
||||
}
|
||||
|
||||
const customLoader = ({ src }: { src: string }) => {
|
||||
return src;
|
||||
};
|
||||
setMaps(allMaps);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch maps:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<div className="maps-container">
|
||||
{maps.map((map) => (
|
||||
<div key={map.ID} className="map-card">
|
||||
<a href={`/maps/${map.ID}`} className="block">
|
||||
<Image
|
||||
loader={customLoader}
|
||||
src={`/thumbnails/maps/${map.ID}`}
|
||||
alt={map.DisplayName}
|
||||
width={500}
|
||||
height={300}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="map-info">
|
||||
<h2>{map.DisplayName}</h2>
|
||||
<p>By {map.Creator}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Webpage>
|
||||
);
|
||||
fetchMaps();
|
||||
}, []);
|
||||
|
||||
const handleGameFilterChange = (event: SelectChangeEvent) => {
|
||||
setGameFilter(event.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Filter maps based on search query and game filter
|
||||
const filteredMaps = maps.filter(map => {
|
||||
const matchesSearch =
|
||||
map.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
map.Creator.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesGameFilter =
|
||||
gameFilter === "0" || // "All Maps"
|
||||
map.GameID === parseInt(gameFilter);
|
||||
|
||||
return matchesSearch && matchesGameFilter;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredMaps.length / mapsPerPage);
|
||||
const currentMaps = filteredMaps.slice(
|
||||
(currentPage - 1) * mapsPerPage,
|
||||
currentPage * mapsPerPage
|
||||
);
|
||||
|
||||
const handlePageChange = (_event: React.ChangeEvent<unknown>, page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
};
|
||||
|
||||
const handleMapClick = (mapId: number) => {
|
||||
router.push(`/maps/${mapId}`);
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getGameName = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1:
|
||||
return "Bhop";
|
||||
case 2:
|
||||
return "Surf";
|
||||
case 5:
|
||||
|
itzaname marked this conversation as resolved
Outdated
Quaternions
commented
FlyTrials = 5 FlyTrials = 5
|
||||
return "Fly Trials";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getGameLabelStyles = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1: // Bhop
|
||||
return {
|
||||
bgcolor: "info.main",
|
||||
color: "white",
|
||||
};
|
||||
case 2: // Surf
|
||||
return {
|
||||
bgcolor: "success.main",
|
||||
color: "white",
|
||||
};
|
||||
case 5: // Fly Trials
|
||||
|
itzaname marked this conversation as resolved
Outdated
Quaternions
commented
FlyTrials = 5 FlyTrials = 5
|
||||
return {
|
||||
bgcolor: "warning.main",
|
||||
color: "white",
|
||||
};
|
||||
default: // Unknown
|
||||
return {
|
||||
bgcolor: "grey.500",
|
||||
color: "white",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{py: 6}}>
|
||||
<Box mb={6}>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Maps</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Map Collection
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Browse all community-created maps or find your favorites
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search maps by name or creator..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon/>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{mb: 4}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" my={8}>
|
||||
<CircularProgress/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography>
|
||||
Showing {filteredMaps.length} {filteredMaps.length === 1 ? 'map' : 'maps'}
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{minWidth: 200}}>
|
||||
<InputLabel id="game-filter-label">Filter by Game</InputLabel>
|
||||
<Select
|
||||
labelId="game-filter-label"
|
||||
id="game-filter"
|
||||
value={gameFilter}
|
||||
label="Filter by Game"
|
||||
onChange={handleGameFilterChange}
|
||||
>
|
||||
<MenuItem value="0">All Maps</MenuItem>
|
||||
<MenuItem value="1">Bhop</MenuItem>
|
||||
<MenuItem value="2">Surf</MenuItem>
|
||||
<MenuItem value="5">Fly Trials</MenuItem>
|
||||
|
itzaname marked this conversation as resolved
Outdated
Quaternions
commented
FlyTrials = 5 FlyTrials = 5
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{currentMaps.map((map) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={map.ID}>
|
||||
<Card
|
||||
elevation={1}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 4,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={() => handleMapClick(map.ID)}>
|
||||
<CardMedia
|
||||
component="div"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: 180,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={10}
|
||||
right={10}
|
||||
px={1}
|
||||
py={0.5}
|
||||
borderRadius={1}
|
||||
fontSize="0.75rem"
|
||||
fontWeight="bold"
|
||||
{...getGameLabelStyles(map.GameID)}
|
||||
>
|
||||
{getGameName(map.GameID)}
|
||||
</Box>
|
||||
<Image
|
||||
src={`/thumbnails/asset/${map.ID}`}
|
||||
alt={map.DisplayName}
|
||||
fill
|
||||
style={{objectFit: 'cover'}}
|
||||
/>
|
||||
</CardMedia>
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="h2" noWrap>
|
||||
{map.DisplayName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
By {map.Creator}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Added {formatDate(map.Date)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {MapfixInfo, MapfixList} from "./ts/Mapfix";
|
||||
import { MapCard } from "./_components/mapCard";
|
||||
import Webpage from "./_components/webpage";
|
||||
import { ListSortConstants } from "./ts/Sort";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
|
||||
import {Carousel} from "@/app/_components/carousel";
|
||||
|
||||
export default function Home() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
|
||||
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
|
||||
const itemsPerSection: number = 8; // Show more items for the carousel
|
||||
|
||||
useEffect(() => {
|
||||
const mapfixController = new AbortController();
|
||||
const submissionsController = new AbortController();
|
||||
|
||||
async function fetchMapFixes(): Promise<void> {
|
||||
setIsLoadingMapfixes(true);
|
||||
try {
|
||||
const res = await fetch(`/api/mapfixes?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: mapfixController.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: MapfixList = await res.json();
|
||||
setMapfixes(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch mapfixes:", error);
|
||||
} finally {
|
||||
setIsLoadingMapfixes(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSubmissions(): Promise<void> {
|
||||
setIsLoadingSubmissions(true);
|
||||
try {
|
||||
const res = await fetch(`/api/submissions?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: submissionsController.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: SubmissionList = await res.json();
|
||||
setSubmissions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch submissions:", error);
|
||||
} finally {
|
||||
setIsLoadingSubmissions(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchMapFixes();
|
||||
fetchSubmissions();
|
||||
|
||||
return () => {
|
||||
mapfixController.abort();
|
||||
submissionsController.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
|
||||
|
||||
if (isLoading && (!mapfixes || !submissions)) {
|
||||
return <Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading content...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const renderMapfixCard = (mapfix: MapfixInfo): React.ReactNode => (
|
||||
<MapCard
|
||||
key={mapfix.ID}
|
||||
id={mapfix.ID}
|
||||
assetId={mapfix.AssetID}
|
||||
displayName={mapfix.DisplayName}
|
||||
author={mapfix.Creator}
|
||||
authorId={mapfix.Submitter}
|
||||
rating={mapfix.StatusID}
|
||||
statusID={mapfix.StatusID}
|
||||
gameID={mapfix.GameID}
|
||||
created={mapfix.CreatedAt}
|
||||
type="mapfix"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSubmissionCard = (submission: SubmissionInfo): React.ReactNode => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="submission"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Webpage></Webpage>
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={5}>
|
||||
Welcome to the Maps Service!
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 6,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
Contribute to the community
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Help improve maps by submitting fixes or creating new maps submissions for the community.
|
||||
</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<Link href="/submit" style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="button"
|
||||
sx={{
|
||||
backgroundColor: 'white',
|
||||
color: '#2196F3',
|
||||
border: 'none',
|
||||
borderRadius: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit Map
|
||||
</Box>
|
||||
</Link>
|
||||
<Link href="/maps" style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="button"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid white',
|
||||
borderRadius: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create Map Fix
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Submissions Carousel */}
|
||||
{submissions && (
|
||||
<Carousel<SubmissionInfo>
|
||||
title="Recent Submissions"
|
||||
items={submissions.Submissions}
|
||||
renderItem={renderSubmissionCard}
|
||||
viewAllLink="/submissions"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Map Fixes Carousel */}
|
||||
{mapfixes && (
|
||||
<Carousel<MapfixInfo>
|
||||
title="Recent Map Fixes"
|
||||
items={mapfixes.Mapfixes}
|
||||
renderItem={renderMapfixCard}
|
||||
viewAllLink="/mapfixes"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { SubmissionList } from "../ts/Submission";
|
||||
import { SubmissionCard } from "../_components/mapCard";
|
||||
import {useState, useEffect} from "react";
|
||||
import {SubmissionList} from "../ts/Submission";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
import {ListSortConstants} from "../ts/Sort";
|
||||
import {Breadcrumbs, Pagination, Typography, CircularProgress, Box, Container} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SubmissionInfoPage() {
|
||||
const [submissions, setSubmissions] = useState<SubmissionList|null>(null)
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchSubmissions() {
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
setSubmissions(await res.json())
|
||||
setSubmissions(await res.json());
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fetchSubmissions()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
fetchSubmissions();
|
||||
|
||||
if (!submissions) {
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !submissions) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Loading...
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading submissions...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
|
||||
const currentCards = submissions.Submissions;
|
||||
|
||||
const currentCards = submissions.Submissions.slice(
|
||||
(currentPage - 1) * cardsPerPage,
|
||||
currentPage * cardsPerPage
|
||||
);
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (submissions.Total == 0) {
|
||||
if (submissions.Total === 0) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Submissions list is empty.
|
||||
</main>
|
||||
</Webpage>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div className="pagination-dots">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(index+1)}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<button onClick={prevPage} disabled={currentPage === 1}><</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button onClick={nextPage} disabled={currentPage === totalPages}>></button>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{currentCards.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Submissions</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Submissions
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Explore all submitted maps from the community.
|
||||
</Typography>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentCards.map((submission) => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="submission"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={(_, page) => setCurrentPage(page)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface MapfixInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly Date: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
@@ -17,7 +17,7 @@ interface SubmissionInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly Date: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
Reference in New Issue
Block a user
This says "Under Review" when the status is "Submitted". Is that on purpose?
Took some liberty with the naming. "Under Review" more clearly communicates what is happening versus "Submitted". Would expect to make this change throughout.