Rework map list page
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
2025-06-07 19:34:02 -04:00
parent 1176d2f46f
commit 08bf4412c8

View File

@@ -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 3:
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 3: // Fly Trials
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="3">Fly Trials</MenuItem>
</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>
);
}