Rework submission/mapfix list views
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing

This commit is contained in:
2025-06-07 16:03:09 -04:00
parent 170e7c64b6
commit dcbb2df319
6 changed files with 496 additions and 177 deletions

View File

@@ -1,71 +1,269 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Rating } from "@mui/material";
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Typography} from "@mui/material";
import {Grid} from "@mui/system";
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 = 'info';
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 = 'info';
icon = <CheckCircle fontSize="small"/>;
label = 'Submitted';
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 === 3 ? 'Fly Trials' : props.gameID === 4 ? 'Deathrun' : 'Unknown'}
</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>
)
}

View File

@@ -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
View 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',
},
},
},
},
});

View File

@@ -2,69 +2,65 @@
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, 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
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(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>
}
const currentCards = mapfixes.Mapfixes;
return (
// TODO: Add filter settings & searchbar & page selector
<Webpage>
<main
style={{
@@ -79,35 +75,47 @@ export default function MapfixInfoPage() {
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}>&lt;</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button onClick={nextPage} disabled={currentPage === totalPages}>&gt;</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}
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginLeft: '1rem', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
</Link>
<Typography color="textPrimary">Mapfixes</Typography>
</Breadcrumbs>
<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>
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
variant="outlined"
shape="rounded"
/>
</div>
</main>
</Webpage>
)

View File

@@ -1,68 +1,72 @@
'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} 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={{
@@ -77,25 +81,24 @@ export default function SubmissionInfoPage() {
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}>&lt;</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button onClick={nextPage} disabled={currentPage === totalPages}>&gt;</button>
</div>
<div className="grid">
<Breadcrumbs separator="" aria-label="breadcrumb"
style={{alignSelf: 'flex-start', marginLeft: '1rem', marginBottom: '1rem'}}>
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
<Typography component="span">Home</Typography>
</Link>
<Typography color="textPrimary">Submissions</Typography>
</Breadcrumbs>
<div
className="grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
width: '100%',
}}
>
{currentCards.map((submission) => (
<SubmissionCard
<MapCard
key={submission.ID}
id={submission.ID}
assetId={submission.AssetID}
@@ -103,9 +106,21 @@ export default function SubmissionInfoPage() {
author={submission.Creator}
authorId={submission.Submitter}
rating={submission.StatusID}
statusID={submission.StatusID}
gameID={submission.GameID}
type="submission"
/>
))}
</div>
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
variant="outlined"
shape="rounded"
/>
</div>
</main>
</Webpage>
)

View File

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