diff --git a/app/routes/to/$organization.$tournament.tsx b/app/routes/to/$organization.$tournament.tsx index ebeb2f5a1..09c36df4f 100644 --- a/app/routes/to/$organization.$tournament.tsx +++ b/app/routes/to/$organization.$tournament.tsx @@ -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(); - return
hello
; + return ( + //
+
+ + {/* */} +
} + className="tournament__links-container" + > + + Overview + + + Map Pool + + + Bracket + + + Teams ({data.teams.length}) + + + Streams (4) + +
+ +
+ ); +} + +export function InfoBanner() { + const data = useLoaderData(); + + return ( + <> +
+ } + > +
+
+
+
+ {shortMonthName(data.startTime)} +
+
+ {dayNumber(data.startTime)} +
+
+
+ {data.name} +
+
+
+ {data.organizer.twitter && ( + + + + )} + + + +
+
+
+
+
+
+ Starting time +
+
{weekdayAndStartTime(data.startTime)}
+
+
+
Format
+
Double Elimination
+
+
+
+ Organizer +
+
{data.organizer.name}
+
+
+
+
+ + ); +} + +// 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" }); } diff --git a/app/routes/to/$organization.$tournament/map-pool.tsx b/app/routes/to/$organization.$tournament/map-pool.tsx new file mode 100644 index 000000000..a964f6212 --- /dev/null +++ b/app/routes/to/$organization.$tournament/map-pool.tsx @@ -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 ( +
+
+ + {mapPool.length} maps + +
+ {stages.map((stage) => ( +
+ {stage} + {modesPerStage(mapPool)[stage] && ( +
+ {modesShort.map( + (mode) => + modesPerStage(mapPool)[stage]?.includes(mode as Mode) && ( + {mode} + ) + )} +
+ )} +
+ ))} +
+ ); +} + +export function modesPerStage( + mapPool: { + name: string; + mode: Mode; + }[] +) { + return mapPool.reduce((acc: Record, { name, mode }) => { + if (!acc[name]) { + acc[name] = []; + } + + acc[name].push(mode); + return acc; + }, {}); +} diff --git a/app/styles/map-pool.css b/app/styles/map-pool.css new file mode 100644 index 000000000..eff696252 --- /dev/null +++ b/app/styles/map-pool.css @@ -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)); + } +} diff --git a/app/styles/tournament.css b/app/styles/tournament.css new file mode 100644 index 000000000..46d6230be --- /dev/null +++ b/app/styles/tournament.css @@ -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; + } +} diff --git a/app/utils.ts b/app/utils.ts index 86f32d37a..0b7652925 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -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 = { + [P in keyof T]: T[P] extends Date ? string : Serialized; +}; + +// TODO: +// export type InferredSerializedAPI = Serialized>; diff --git a/services/tournament.ts b/services/tournament.ts index 54af4a21e..c97e8ded9 100644 --- a/services/tournament.ts +++ b/services/tournament.ts @@ -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 >; export async function findTournamentByNameForUrl({ diff --git a/tsconfig.json b/tsconfig.json index 705bb4a24..c93b82938 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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",