Port tournaments with map pool

This commit is contained in:
Kalle (Sendou) 2021-11-26 22:01:48 +02:00
parent 7463c2a02c
commit dff33033b3
7 changed files with 457 additions and 9 deletions

View File

@ -1,13 +1,20 @@
// TODO: 404 page that shows other tournaments by the organization
import type { MetaFunction, LoaderFunction } from "remix";
import { useLoaderData, json, Link } from "remix";
import { MetaFunction, LoaderFunction, LinksFunction, NavLink } from "remix";
import { useLoaderData, Outlet } from "remix";
import invariant from "tiny-invariant";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitterIcon } from "~/components/icons/Twitter";
import { makeTitle } from "~/utils";
import {
findTournamentByNameForUrl,
FindTournamentByNameForUrlI,
} from "../../../services/tournament";
import tournamentStylesUrl from "../../styles/tournament.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tournamentStylesUrl }];
};
export const loader: LoaderFunction = ({ params }) => {
invariant(
@ -34,9 +41,124 @@ export const meta: MetaFunction = (props) => {
};
};
// https://remix.run/guides/routing#index-routes
export default function Index() {
export default function TournamentPage() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
return <div className="remix__page">hello</div>;
return (
// <div className={s.container}>
<div className="tournament__container">
<InfoBanner />
{/* <ActionSection /> */}
<div
style={{ "--tabs-count": 5 } as Record<string, number>}
className="tournament__links-container"
>
<NavLink className="tournament__nav-link" to="overview">
Overview
</NavLink>
<NavLink className="tournament__nav-link" to="map-pool">
Map Pool
</NavLink>
<NavLink className="tournament__nav-link" to="bracket">
Bracket
</NavLink>
<NavLink className="tournament__nav-link" to="teams">
Teams ({data.teams.length})
</NavLink>
<NavLink className="tournament__nav-link" to="streams">
Streams (4)
</NavLink>
</div>
<Outlet />
</div>
);
}
export function InfoBanner() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
return (
<>
<div
className="info-banner"
style={
{
"--background": data.bannerBackground,
// TODO: do this on backend
"--text": `hsl(${data.bannerTextHSLArgs})`,
// TODO: and this
"--text-transparent": `hsla(${data.bannerTextHSLArgs}, 0.3)`,
} as Record<string, string>
}
>
<div className="info-banner__top-row">
<div className="info-banner__top-row__date-name">
<div className="info-banner__top-row__month-date">
<div className="info-banner__top-row__month-date__month">
{shortMonthName(data.startTime)}
</div>
<div className="info-banner__top-row__month-date__date">
{dayNumber(data.startTime)}
</div>
</div>
<div className="info-banner__top-row__tournament-name">
{data.name}
</div>
</div>
<div className="info-banner__icon-buttons-container">
{data.organizer.twitter && (
<a
className="info-banner__icon-button"
href={data.organizer.twitter}
>
<TwitterIcon />
</a>
)}
<a
className="info-banner__icon-button"
href={data.organizer.discordInvite}
>
<DiscordIcon />
</a>
</div>
</div>
<div className="info-banner__bottom-row">
<div className="info-banner__bottom-row__infos">
<div className="info-banner__bottom-row__info-container">
<div className="info-banner__bottom-row__info-label">
Starting time
</div>
<div>{weekdayAndStartTime(data.startTime)}</div>
</div>
<div className="info-banner__bottom-row__info-container">
<div className="info-banner__bottom-row__info-label">Format</div>
<div>Double Elimination</div>
</div>
<div className="info-banner__bottom-row__info-container">
<div className="info-banner__bottom-row__info-label">
Organizer
</div>
<div>{data.organizer.name}</div>
</div>
</div>
</div>
</div>
</>
);
}
// TODO: https://github.com/remix-run/remix/issues/656
function weekdayAndStartTime(date: string) {
return new Date(date).toLocaleString("en-US", {
weekday: "long",
hour: "numeric",
});
}
function shortMonthName(date: string) {
return new Date(date).toLocaleString("en-US", { month: "short" });
}
function dayNumber(date: string) {
return new Date(date).toLocaleString("en-US", { day: "numeric" });
}

View File

@ -0,0 +1,68 @@
import mapPoolStylesUrl from "~/styles/map-pool.css";
import type { Mode } from ".prisma/client";
import classNames from "classnames";
import { modesShort, stages } from "~/utils";
import { LinksFunction, useMatches } from "remix";
import { FindTournamentByNameForUrlI } from "../../../../services/tournament";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: mapPoolStylesUrl }];
};
export default function MapPoolTab() {
const [, parentRoute] = useMatches();
const { mapPool } = parentRoute.data as FindTournamentByNameForUrlI;
return (
<div className="map-pool">
<div className="map-pool__info-square">
<span className="map-pool__info-square__text">
{mapPool.length} maps
</span>
</div>
{stages.map((stage) => (
<div key={stage} className="map-pool__stage-images-container">
<img
className={classNames("map-pool__stage-image", {
"map-pool__stage-image-disabled": !modesPerStage(mapPool)[stage],
})}
loading="lazy"
alt={stage}
src={`/img/stages/${stage.replaceAll(" ", "-").toLowerCase()}.webp`}
/>
{modesPerStage(mapPool)[stage] && (
<div className="map-pool__mode-images-container">
{modesShort.map(
(mode) =>
modesPerStage(mapPool)[stage]?.includes(mode as Mode) && (
<img
key={mode}
className="map-pool__mode-image"
src={`/img/modes/${mode}.webp`}
alt={mode}
/>
)
)}
</div>
)}
</div>
))}
</div>
);
}
export function modesPerStage(
mapPool: {
name: string;
mode: Mode;
}[]
) {
return mapPool.reduce((acc: Record<string, Mode[]>, { name, mode }) => {
if (!acc[name]) {
acc[name] = [];
}
acc[name].push(mode);
return acc;
}, {});
}

62
app/styles/map-pool.css Normal file
View File

@ -0,0 +1,62 @@
.map-pool__info-square {
display: grid;
background-color: var(--bg-lighter);
background-image: url("/svg/background-pattern.svg");
border-radius: var(--rounded);
font-size: var(--fonts-xl);
font-weight: var(--semi-bold);
place-items: center;
}
.map-pool {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
}
.map-pool__info-square__text {
font-weight: var(--bold);
}
.map-pool__stage-images-container {
position: relative;
}
.map-pool__stage-image {
width: 100%;
height: 100%;
border-radius: var(--rounded);
}
.map-pool__mode-image {
width: 1.5rem;
height: 1.5rem;
}
.map-pool__stage-image-disabled {
filter: grayscale(100%);
}
.map-pool__mode-images-container {
position: absolute;
display: flex;
padding: var(--s-1);
backdrop-filter: blur(5px) grayscale(25%);
border-end-end-radius: var(--rounded);
border-start-start-radius: var(--rounded);
gap: var(--s-2);
inset-block-start: 0;
inset-inline-start: 0;
}
@media screen and (min-width: 480px) {
.map-pool {
grid-template-columns: 1fr 1fr;
}
}
@media screen and (min-width: 640px) {
.map-pool {
grid-template-columns: repeat(4, minmax(1px, 200px));
}
}

160
app/styles/tournament.css Normal file
View File

@ -0,0 +1,160 @@
.tournament__container {
display: flex;
flex-direction: column;
align-items: center;
}
.tournament__links-container {
display: grid;
justify-content: center;
gap: var(--s-10);
grid-template-columns: repeat(2, 100px);
padding-block: var(--s-8);
place-items: center;
}
.tournament__nav-link {
all: unset;
border-radius: var(--rounded);
cursor: pointer;
font-size: var(--fonts-sm);
}
.tournament__nav-link::after {
display: block;
width: 1.25rem;
height: 3px;
border-bottom: 3px solid transparent;
content: "";
}
.tournament__nav-link:hover::after {
border-color: var(--theme-transparent);
}
.tournament__nav-link.active {
font-weight: bold;
}
.tournament__nav-link.active::after {
width: 1.25rem;
border-color: var(--theme);
}
.info-banner {
width: 100%;
padding: var(--s-6);
background: var(--background);
border-radius: var(--rounded);
color: var(--text);
}
.info-banner__top-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--s-4);
}
.info-banner__top-row__date-name {
display: flex;
align-items: center;
gap: var(--s-4);
}
.info-banner__top-row__month-date {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.25;
}
.info-banner__top-row__month-date__month {
font-size: var(--fonts-xs);
text-transform: uppercase;
}
.info-banner__top-row__month-date__date {
font-size: var(--fonts-lg);
font-weight: var(--bold);
}
.info-banner__top-row__tournament-name {
border-color: var(--text);
border-inline-start: 1px solid;
font-size: var(--fonts-xl);
font-weight: var(--extra-bold);
padding-inline-start: var(--s-4);
}
.info-banner__bottom-row {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--s-4);
}
.info-banner__bottom-row__infos {
display: flex;
flex-wrap: wrap;
gap: var(--s-4);
margin-block-start: var(--s-8);
}
.info-banner__icon-buttons-container {
display: flex;
gap: var(--s-4);
}
.info-banner__bottom-row__info-container {
font-size: var(--fonts-xs);
}
.info-banner__bottom-row__info-label {
font-weight: var(--extra-bold);
}
.info-banner__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border: 1px solid;
border-color: var(--text-transparent);
block-size: 2.25rem;
border-radius: 50%;
color: inherit;
inline-size: 2.25rem;
transition: background-color 0.3s;
}
.info-banner__icon-button:active {
transform: translateY(1px);
}
@media screen and (min-width: 480px) {
.tournament__links-container {
grid-template-columns: repeat(3, 100px);
}
.info-banner__bottom-row__infos {
gap: var(--s-8);
}
}
@media screen and (min-width: 640px) {
.tournament__links-container {
grid-template-columns: repeat(var(--tabs-count), 100px);
}
.info-banner__bottom-row {
flex-direction: row;
align-items: flex-end;
}
}
@media screen and (min-width: 768px) {
.info-banner {
width: 48rem;
}
}

View File

@ -1,3 +1,31 @@
export const stages = [
"The Reef",
"Musselforge Fitness",
"Starfish Mainstage",
"Humpback Pump Track",
"Inkblot Art Academy",
"Sturgeon Shipyard",
"Moray Towers",
"Port Mackerel",
"Manta Maria",
"Kelp Dome",
"Snapper Canal",
"Blackbelly Skatepark",
"MakoMart",
"Walleye Warehouse",
"Shellendorf Institute",
"Arowana Mall",
"Goby Arena",
"Piranha Pit",
"Camp Triggerfish",
"Wahoo World",
"New Albacore Hotel",
"Ancho-V Games",
"Skipper Pavilion",
];
export const modesShort = ["TW", "SZ", "TC", "RM", "CB"];
export const navItems = [
{
title: "builds",
@ -18,3 +46,10 @@ export const navItems = [
];
export const makeTitle = (endOfTitle: string) => `sendou.ink | ${endOfTitle}`;
export type Serialized<T> = {
[P in keyof T]: T[P] extends Date ? string : Serialized<T[P]>;
};
// TODO:
// export type InferredSerializedAPI<T> = Serialized<Prisma.PromiseReturnType<T>>;

View File

@ -1,9 +1,10 @@
import { Prisma } from ".prisma/client";
import { json } from "@remix-run/server-runtime";
import { json } from "remix";
import { Serialized } from "~/utils";
import prisma from "../prisma/client";
export type FindTournamentByNameForUrlI = Prisma.PromiseReturnType<
typeof findTournamentByNameForUrl
export type FindTournamentByNameForUrlI = Serialized<
Prisma.PromiseReturnType<typeof findTournamentByNameForUrl>
>;
export async function findTournamentByNameForUrl({

View File

@ -1,7 +1,7 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"lib": ["DOM", "DOM.Iterable", "ES2019", "es2021"],
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "node",