New navigation (#821)

* Menu skeleton

* Menu with nav icons

* Menu opens and closes

* More menu icons + links work

* Menu closes on navigation

* Blurred menu

* Remove other nav

* Rounded nav

* Fix menu alignment for Safari

* Close on click outside

* Disable body scroll when menu open

* SubNav for tournament

* Use grid

* Make images same size again

* Remove comment

* Different style mobile nav

* Readd InfoBanner elements

* Move menu css to layout.css

* Move admin command input top left

* Page title from loader

* Fix error when getting pageTitle

* Fix CI
This commit is contained in:
Kalle 2022-04-30 11:10:09 +03:00 committed by GitHub
parent b84917133b
commit 03da81a84c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 995 additions and 507 deletions

View File

@ -1,8 +1,8 @@
import { useCatch, useLocation } from "@remix-run/react";
import { DISCORD_URL } from "~/constants";
import { getLogInUrl } from "~/utils";
import { useUser } from "~/hooks/common";
import { Button } from "./Button";
import { discordUrl } from "~/utils/urls";
// TODO: some nice art
export function Catcher() {
@ -18,7 +18,7 @@ export function Catcher() {
{user ? (
<p>
If you need assistance you can ask for help on{" "}
<a className="four-zero-one__link" href={DISCORD_URL}>
<a className="four-zero-one__link" href={discordUrl()}>
our Discord
</a>
</p>

View File

@ -0,0 +1,373 @@
import clsx from "clsx";
import randomColor from "randomcolor";
import { useState } from "react";
interface HSL {
h: number;
s: number;
l: number;
}
class Color {
public r: number;
public g: number;
public b: number;
constructor(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
toString() {
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
this.b
)})`;
}
set(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
hueRotate(angle = 0) {
angle = (angle / 180) * Math.PI;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
this.multiply([
0.213 + cos * 0.787 - sin * 0.213,
0.715 - cos * 0.715 - sin * 0.715,
0.072 - cos * 0.072 + sin * 0.928,
0.213 - cos * 0.213 + sin * 0.143,
0.715 + cos * 0.285 + sin * 0.14,
0.072 - cos * 0.072 - sin * 0.283,
0.213 - cos * 0.213 - sin * 0.787,
0.715 - cos * 0.715 + sin * 0.715,
0.072 + cos * 0.928 + sin * 0.072,
]);
}
grayscale(value = 1) {
this.multiply([
0.2126 + 0.7874 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 + 0.2848 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 + 0.9278 * (1 - value),
]);
}
sepia(value = 1) {
this.multiply([
0.393 + 0.607 * (1 - value),
0.769 - 0.769 * (1 - value),
0.189 - 0.189 * (1 - value),
0.349 - 0.349 * (1 - value),
0.686 + 0.314 * (1 - value),
0.168 - 0.168 * (1 - value),
0.272 - 0.272 * (1 - value),
0.534 - 0.534 * (1 - value),
0.131 + 0.869 * (1 - value),
]);
}
saturate(value = 1) {
this.multiply([
0.213 + 0.787 * value,
0.715 - 0.715 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 + 0.285 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 - 0.715 * value,
0.072 + 0.928 * value,
]);
}
multiply(matrix: number[]) {
const newR = this.clamp(
this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]
);
const newG = this.clamp(
this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]
);
const newB = this.clamp(
this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]
);
this.r = newR;
this.g = newG;
this.b = newB;
}
brightness(value = 1) {
this.linear(value);
}
contrast(value = 1) {
this.linear(value, -(0.5 * value) + 0.5);
}
linear(slope = 1, intercept = 0) {
this.r = this.clamp(this.r * slope + intercept * 255);
this.g = this.clamp(this.g * slope + intercept * 255);
this.b = this.clamp(this.b * slope + intercept * 255);
}
invert(value = 1) {
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}
hsl(): HSL {
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
const r = this.r / 255;
const g = this.g / 255;
const b = this.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 100,
s: s * 100,
l: l * 100,
};
}
clamp(value: number): number {
if (value > 255) {
value = 255;
} else if (value < 0) {
value = 0;
}
return value;
}
}
interface Solution {
loss: number;
values: number[];
}
class Solver {
private target: Color;
private targetHSL: HSL;
private reusedColor: Color;
constructor(target: Color) {
this.target = target;
this.targetHSL = target.hsl();
this.reusedColor = new Color(0, 0, 0);
}
solve() {
const result = this.solveNarrow(this.solveWide());
return {
values: result.values,
loss: result.loss,
filter: this.css(result.values),
};
}
solveWide(): Solution {
const A = 5;
const c = 15;
const a = [60, 180, 18000, 600, 1.2, 1.2];
let best = { loss: Infinity, values: [] as number[] };
for (let i = 0; best.loss > 25 && i < 3; i++) {
const initial = [50, 20, 3750, 50, 100, 100];
const result = this.spsa(A, a, c, initial, 1000);
if (result.loss < best.loss) {
best = result;
}
}
return best;
}
solveNarrow(wide: Solution) {
const A = wide.loss;
const c = 2;
const A1 = A + 1;
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
return this.spsa(A, a, c, wide.values, 500);
}
spsa(
A: number,
a: number[],
c: number,
values: number[],
iters: number
): Solution {
const alpha = 1;
const gamma = 0.16666666666666666;
let best = [] as number[];
let bestLoss = Infinity;
const deltas = new Array(6);
const highArgs = new Array<number>(6);
const lowArgs = new Array<number>(6);
for (let k = 0; k < iters; k++) {
const ck = c / Math.pow(k + 1, gamma);
for (let i = 0; i < 6; i++) {
deltas[i] = Math.random() > 0.5 ? 1 : -1;
highArgs[i] = values[i] + ck * deltas[i];
lowArgs[i] = values[i] - ck * deltas[i];
}
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
for (let i = 0; i < 6; i++) {
const g = (lossDiff / (2 * ck)) * deltas[i];
const ak = a[i] / Math.pow(A + k + 1, alpha);
values[i] = fix(values[i] - ak * g, i);
}
const loss = this.loss(values);
if (loss < bestLoss) {
best = values.slice(0);
bestLoss = loss;
}
}
return { values: best, loss: bestLoss };
function fix(value: number, idx: number): number {
let max = 100;
if (idx === 2 /* saturate */) {
max = 7500;
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
max = 200;
}
if (idx === 3 /* hue-rotate */) {
if (value > max) {
value %= max;
} else if (value < 0) {
value = max + (value % max);
}
} else if (value < 0) {
value = 0;
} else if (value > max) {
value = max;
}
return value;
}
}
loss(filters: number[]) {
// Argument is array of percentages.
const color = this.reusedColor;
color.set(0, 0, 0);
color.invert(filters[0] / 100);
color.sepia(filters[1] / 100);
color.saturate(filters[2] / 100);
color.hueRotate(filters[3] * 3.6);
color.brightness(filters[4] / 100);
color.contrast(filters[5] / 100);
const colorHSL = color.hsl();
return (
Math.abs(color.r - this.target.r) +
Math.abs(color.g - this.target.g) +
Math.abs(color.b - this.target.b) +
Math.abs(colorHSL.h - this.targetHSL.h) +
Math.abs(colorHSL.s - this.targetHSL.s) +
Math.abs(colorHSL.l - this.targetHSL.l)
);
}
css(filters: number[]) {
function fmt(idx: number, multiplier = 1) {
return Math.round(filters[idx] * multiplier);
}
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
2
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
5
)}%)`;
}
}
type RGB = [number, number, number];
function hexToRgb(hex: string): RGB {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (_m, r: string, g: string, b: string) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
];
}
throw new Error("Error parsing hex: " + hex);
}
function getFilters(hex: string) {
let rgb = [255, 255, 255];
rgb = hexToRgb(hex);
const color = new Color(rgb[0], rgb[1], rgb[2]);
const solver = new Solver(color);
const result = solver.solve();
return result.filter;
}
export function DrawingSection({ type }: { type: "girl" | "boy" }) {
const [hexCode, setHexCode] = useState(randomColor());
const handleColorChange = () => setHexCode(randomColor());
return (
<div
className={clsx("menu__img-container", type)}
onClick={handleColorChange}
onMouseEnter={handleColorChange}
>
<img
className={clsx("menu__img", type)}
src={`/img/layout/new_${type}_dark.png`}
/>
<img
className={clsx("menu__img-bg", type)}
src={`/img/layout/new_${type}_bg.png`}
style={{ filter: getFilters(hexCode) }}
/>
</div>
);
}
export default DrawingSection;

View File

@ -0,0 +1,70 @@
import { Link } from "@remix-run/react";
import { navItemsGrouped } from "~/constants";
import { useOnClickOutside } from "~/hooks/common";
import { layoutIcon } from "~/utils";
import { discordUrl, gitHubUrl, patreonUrl, twitterUrl } from "~/utils/urls";
import { Button } from "../Button";
import { CrossIcon } from "../icons/Cross";
import { DiscordIcon } from "../icons/Discord";
import { GitHubIcon } from "../icons/Github";
import { PatreonIcon } from "../icons/Patreon";
import { TwitterIcon } from "../icons/Twitter";
import DrawingSection from "./DrawingSection";
import * as React from "react";
export function Menu({ close }: { close: () => void }) {
const ref = React.useRef(null);
useOnClickOutside(ref, close);
return (
<div className="menu">
<div className="menu__inner" ref={ref}>
<DrawingSection type="boy" />
<div className="menu__top-extras">
<div className="menu__logo-container">
<img height="20" width="20" src={layoutIcon("logo")} />
sendou.ink
</div>
<Button onClick={close} variant="minimal" aria-label="Close menu">
<CrossIcon className="menu__cross-icon" />
</Button>
</div>
<nav className="menu__nav">
{navItemsGrouped
.flatMap((group) => group.items)
.map((navItem) => (
<Link
key={navItem.name}
className="menu__nav__link"
to={navItem.disabled ? "/" : navItem.url ?? navItem.name}
data-cy={`nav-link-${navItem.name}`}
onClick={close}
>
<img
src={layoutIcon(navItem.name.replace(" ", ""))}
width="32"
height="32"
/>
{navItem.displayName ?? navItem.name}
</Link>
))}
</nav>
<div className="menu__icons-container">
<a href={gitHubUrl()}>
<GitHubIcon className="menu__icon" />
</a>
<a href={discordUrl()}>
<DiscordIcon className="menu__icon" />
</a>
<a href={twitterUrl()}>
<TwitterIcon className="menu__icon" />
</a>
<a href={patreonUrl()}>
<PatreonIcon className="menu__icon" />
</a>
</div>
<DrawingSection type="girl" />
</div>
</div>
);
}

View File

@ -1,10 +1,9 @@
import clsx from "clsx";
import { Fragment } from "react";
import { Link } from "@remix-run/react";
import { navItems } from "~/constants";
import { SearchInput } from "./SearchInput";
import { navItemsGrouped } from "~/constants";
export function MobileNav({
export function MobileMenu({
expanded,
closeMenu,
}: {
@ -14,10 +13,10 @@ export function MobileNav({
return (
<div className={clsx("layout__mobile-nav", { expanded })}>
<div className="layout__mobile-nav__top-action">
<SearchInput />
{/* <SearchInput /> */}
</div>
<div className="layout__mobile-nav__links">
{navItems.map((navGroup) => (
{navItemsGrouped.map((navGroup) => (
<Fragment key={navGroup.title}>
<div className="layout__mobile-nav__group-title">
{navGroup.title}

View File

@ -1,6 +1,5 @@
import * as React from "react";
import clsx from "clsx";
import { SearchIcon } from "../../components/icons/Search";
import * as React from "react";
export function SearchInput() {
// TODO: search input that searches
@ -69,7 +68,7 @@ export function DumbSearchInput({
<input
className={clsx("plain", "layout__search-input")}
type="text"
placeholder="Search"
placeholder="Admin command"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(event) => {
@ -78,7 +77,6 @@ export function DumbSearchInput({
}
}}
/>
<SearchIcon className="layout__search-input__icon" />
</div>
);
}

View File

@ -1,79 +1,76 @@
import { useMatches } from "@remix-run/react";
import * as React from "react";
import { useState } from "react";
import { PAGE_TITLE_KEY } from "~/constants";
import { useWindowSize } from "~/hooks/common";
import { HamburgerButton } from "./HamburgerButton";
import { MobileNav } from "./MobileNav";
import { Menu } from "./Menu";
import { MobileMenu } from "./MobileMenu";
import { SearchInput } from "./SearchInput";
import { UserItem } from "./UserItem";
import { Link } from "@remix-run/react";
import { navItems } from "~/constants";
import { layoutIcon } from "~/utils";
import clsx from "clsx";
interface LayoutProps {
children: React.ReactNode;
menuOpen: boolean;
setMenuOpen: (open: boolean) => void;
}
export const Layout = React.memo(function Layout({
children,
}: {
children: React.ReactNode;
}) {
const [menuExpanded, setMenuExpanded] = useState(false);
menuOpen,
setMenuOpen,
}: LayoutProps) {
const matches = useMatches();
// you can set this page title from any loader
// deeper routes take precedence
const pageTitle = matches
.map((match) => match.data)
.filter(Boolean)
.reduceRight((acc: Nullable<string>, routeData) => {
if (!acc && typeof routeData[PAGE_TITLE_KEY] === "string") {
return routeData[PAGE_TITLE_KEY] as string;
}
return acc;
}, null);
return (
<>
<header className="layout__header">
<div className="layout__header__logo-container">
<Link to="/">
<img className="layout__logo" src={layoutIcon("logo")} />
</Link>
</div>
<div className="layout__header__search-container">
<SearchInput />
</div>
<div className="layout__header__title-container">{pageTitle}</div>
<div className="layout__header__right-container">
<UserItem />
<HamburgerButton
expanded={menuExpanded}
onClick={() => setMenuExpanded((e) => !e)}
expanded={menuOpen}
onClick={() => setMenuOpen(!menuOpen)}
/>
</div>
</header>
<MobileNav
expanded={menuExpanded}
closeMenu={() => setMenuExpanded(false)}
/>
<nav className="layout__nav">
<div className="layout__nav__items">
{navItems.map((navGroup) => (
<div key={navGroup.title} className="layout__nav__items__column">
<div className="layout__nav__column__title">{navGroup.title}</div>
{navGroup.items.map((navItem) => (
<Link
key={navItem.name}
className={clsx("layout__nav__link", {
disabled: navItem.disabled,
})}
to={navItem.disabled ? "/" : navItem.url ?? navItem.name}
data-cy={`nav-link-${navItem.name}`}
>
<img
src={layoutIcon(navItem.name.replace(" ", ""))}
className="layout__nav__link__icon"
width="32"
height="32"
/>
{navItem.displayName ?? navItem.name}
</Link>
))}
</div>
))}
</div>
</nav>
<Link className="layout__beta__link" to="/beta">
<div className="layout__beta__banner">
{new Array(50).fill(null).map((_, i) => (
<span key={i}>BETA</span>
))}
</div>
</Link>
<ScreenWidthSensitiveMenu menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
<main className="layout__main">{children}</main>
</>
);
});
function ScreenWidthSensitiveMenu({
menuOpen,
setMenuOpen,
}: Pick<LayoutProps, "menuOpen" | "setMenuOpen">) {
const { width } = useWindowSize();
const closeMenu = () => setMenuOpen(false);
if (typeof width === "undefined") return null;
// render it on mobile even if menuOpen = false for the sliding animation
if (width < 900) {
return <MobileMenu expanded={menuOpen} closeMenu={closeMenu} />;
}
if (!menuOpen) return null;
return <Menu close={closeMenu} />;
}

24
app/components/SubNav.tsx Normal file
View File

@ -0,0 +1,24 @@
import { NavLink } from "@remix-run/react";
import { RemixNavLinkProps } from "@remix-run/react/components";
import clsx from "clsx";
import React from "react";
import { ArrowUpIcon } from "./icons/ArrowUp";
export function SubNav({ children }: { children: React.ReactNode }) {
return <nav className="sub-nav__container">{children}</nav>;
}
export function SubNavLink({
children,
className,
...props
}: RemixNavLinkProps & {
children: React.ReactNode;
}) {
return (
<NavLink className={clsx("sub-nav__link", className)} end {...props}>
<span className="sub-nav__link__text">{children}</span>
<ArrowUpIcon className="sub-nav__active-icon" />
</NavLink>
);
}

View File

@ -5,7 +5,7 @@ export function ArrowUpIcon({
style,
}: {
className?: string;
style: CSSProperties;
style?: CSSProperties;
}) {
return (
<svg

View File

@ -0,0 +1,15 @@
export function GitHubIcon({ className }: { className?: string }) {
return (
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
version="1.1"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M8 0.198c-4.418 0-8 3.582-8 8 0 3.535 2.292 6.533 5.471 7.591 0.4 0.074 0.547-0.174 0.547-0.385 0-0.191-0.008-0.821-0.011-1.489-2.226 0.484-2.695-0.944-2.695-0.944-0.364-0.925-0.888-1.171-0.888-1.171-0.726-0.497 0.055-0.486 0.055-0.486 0.803 0.056 1.226 0.824 1.226 0.824 0.714 1.223 1.872 0.869 2.328 0.665 0.072-0.517 0.279-0.87 0.508-1.070-1.777-0.202-3.645-0.888-3.645-3.954 0-0.873 0.313-1.587 0.824-2.147-0.083-0.202-0.357-1.015 0.077-2.117 0 0 0.672-0.215 2.201 0.82 0.638-0.177 1.322-0.266 2.002-0.269 0.68 0.003 1.365 0.092 2.004 0.269 1.527-1.035 2.198-0.82 2.198-0.82 0.435 1.102 0.162 1.916 0.079 2.117 0.513 0.56 0.823 1.274 0.823 2.147 0 3.073-1.872 3.749-3.653 3.947 0.287 0.248 0.543 0.735 0.543 1.481 0 1.070-0.009 1.932-0.009 2.195 0 0.213 0.144 0.462 0.55 0.384 3.177-1.059 5.466-4.057 5.466-7.59 0-4.418-3.582-8-8-8z"></path>
</svg>
);
}

View File

@ -0,0 +1,16 @@
export function PatreonIcon({ className }: { className?: string }) {
return (
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title></title>
<path d="M0 .48v23.04h4.22V.48zm15.385 0c-4.764 0-8.641 3.88-8.641 8.65 0 4.755 3.877 8.623 8.641 8.623 4.75 0 8.615-3.868 8.615-8.623C24 4.36 20.136.48 15.385.48z"></path>
</svg>
);
}

View File

@ -1,14 +1,12 @@
import { Link, useLoaderData, useLocation } from "@remix-run/react";
import { Link, useLocation, useMatches } from "@remix-run/react";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitterIcon } from "~/components/icons/Twitter";
import { resolveTournamentFormatString } from "~/core/tournament/bracket";
import { tournamentHasStarted } from "~/core/tournament/utils";
import { FindTournamentByNameForUrlI } from "~/services/tournament";
import { getLogInUrl } from "~/utils";
import { useUser } from "~/hooks/common";
export function InfoBanner() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
const [, parentRoute] = useMatches();
const data = parentRoute.data as FindTournamentByNameForUrlI;
const location = useLocation();
const urlToTournamentFrontPage = location.pathname
@ -78,7 +76,6 @@ export function InfoBanner() {
<div>{data.organizer.name}</div>
</div>
</div>
<InfoBannerActionButton />
</div>
</div>
</>
@ -104,57 +101,3 @@ function dayNumber(date: string) {
function dateYYYYMMDD(date: string) {
return new Date(date).toISOString().split("T")[0];
}
function InfoBannerActionButton() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
const user = useUser();
const location = useLocation();
const isAlreadyInATeamButNotCaptain = data.teams
.flatMap((team) => team.members)
.filter(({ captain }) => !captain)
.some(({ member }) => member.id === user?.id);
if (isAlreadyInATeamButNotCaptain) return null;
const alreadyRegistered = data.teams
.flatMap((team) => team.members)
.some(({ member }) => member.id === user?.id);
if (alreadyRegistered) {
return (
<Link
to="manage-team"
className="info-banner__action-button"
prefetch="intent"
>
Add players
</Link>
);
}
if (tournamentHasStarted(data.brackets)) {
return null;
}
if (!user) {
return (
<form action={getLogInUrl(location)} method="post">
<button
className="info-banner__action-button"
data-cy="log-in-to-join-button"
>
Log in to join
</button>
</form>
);
}
return (
<Link
to="register"
className="info-banner__action-button"
data-cy="register-button"
>
Register
</Link>
);
}

View File

@ -1,7 +1,5 @@
import { Ability } from "@prisma/client";
export const DISCORD_URL = "https://discord.gg/sendou";
export const ADMIN_UUID = "ee2d82dd-624f-4b07-9d8d-ddee1f8fb36f";
export const ADMIN_TEST_DISCORD_ID = "79237403620945920";
@ -10,6 +8,8 @@ export const NZAP_UUID = "6cd9d01d-b724-498a-b706-eb70edd8a773";
export const NZAP_TEST_DISCORD_ID = "455039198672453645";
export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339";
export const PAGE_TITLE_KEY = "pageTitle";
export const ROOM_PASS_LENGTH = 4;
export const LFG_GROUP_FULL_SIZE = 4;
export const TOURNAMENT_TEAM_ROSTER_MIN_SIZE = 4;
@ -39,7 +39,7 @@ export const checkInClosesDate = (startTime: string): Date => {
return new Date(new Date(startTime).getTime() - 1000 * 10);
};
export const navItems: {
export const navItemsGrouped: {
title: string;
items: {
name: string;
@ -51,7 +51,7 @@ export const navItems: {
{
title: "builds",
items: [
{ name: "browse", disabled: true },
{ name: "builds", disabled: true },
{ name: "gear", disabled: true },
{ name: "analyzer", disabled: true },
],

View File

@ -95,3 +95,33 @@ export function usePolling(pollingActive = true) {
return lastUpdated;
}
// https://usehooks.com/useWindowSize/
export function useWindowSize() {
// Initialize state with undefined width/height so server and client renders match
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
const [windowSize, setWindowSize] = React.useState<{
width?: number;
height?: number;
}>({
width: undefined,
height: undefined,
});
React.useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowSize;
}

View File

@ -1,4 +1,3 @@
import * as React from "react";
import type { LinksFunction, LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
@ -8,17 +7,18 @@ import {
Outlet,
Scripts,
useCatch,
useLoaderData,
} from "@remix-run/react";
import { LoggedInUser, LoggedInUserSchema } from "~/utils/schemas";
import { Layout } from "./components/Layout";
import { Catcher } from "./components/Catcher";
import resetStyles from "~/styles/reset.css";
import clsx from "clsx";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import globalStyles from "~/styles/global.css";
import layoutStyles from "~/styles/layout.css";
import { DISCORD_URL } from "./constants";
import { io, Socket } from "socket.io-client";
import resetStyles from "~/styles/reset.css";
import { LoggedInUser, LoggedInUserSchema } from "~/utils/schemas";
import { Catcher } from "./components/Catcher";
import { Layout } from "./components/Layout";
import { SocketProvider } from "./utils/socketContext";
import { discordUrl } from "./utils/urls";
export const links: LinksFunction = () => {
return [
@ -28,14 +28,14 @@ export const links: LinksFunction = () => {
];
};
export interface EnvironmentVariables {
FF_ENABLE_CHAT?: "true" | "admin" | string;
}
// export interface EnvironmentVariables {
// FF_ENABLE_CHAT?: "true" | "admin" | string;
// }
export interface RootLoaderData {
user?: LoggedInUser;
baseURL: string;
ENV: EnvironmentVariables;
// ENV: EnvironmentVariables;
}
export const loader: LoaderFunction = ({ context }) => {
@ -45,19 +45,20 @@ export const loader: LoaderFunction = ({ context }) => {
return json<RootLoaderData>({
user: data?.user,
baseURL,
ENV: {
FF_ENABLE_CHAT: process.env.FF_ENABLE_CHAT,
},
// ENV: {
// FF_ENABLE_CHAT: process.env.FF_ENABLE_CHAT,
// },
});
};
export const unstable_shouldReload = () => false;
export default function App() {
const [menuOpen, setMenuOpen] = React.useState(false);
const [socket, setSocket] = React.useState<Socket>();
const children = React.useMemo(() => <Outlet />, []);
const data = useLoaderData<RootLoaderData>();
// const data = useLoaderData<RootLoaderData>();
// TODO: for future optimization could only connect socket on sendouq/tournament pages
React.useEffect(() => {
@ -69,9 +70,11 @@ export default function App() {
}, []);
return (
<Document ENV={data.ENV}>
<Document /*ENV={data.ENV}*/ disableBodyScroll={menuOpen}>
<SocketProvider socket={socket}>
<Layout>{children}</Layout>
<Layout menuOpen={menuOpen} setMenuOpen={setMenuOpen}>
{children}
</Layout>
</SocketProvider>
</Document>
);
@ -80,11 +83,13 @@ export default function App() {
function Document({
children,
title,
ENV,
}: {
disableBodyScroll = false,
}: // ENV,
{
children: React.ReactNode;
title?: string;
ENV?: EnvironmentVariables;
disableBodyScroll?: boolean;
// ENV?: EnvironmentVariables;
}) {
return (
<html lang="en">
@ -95,9 +100,9 @@ function Document({
<Meta />
<Links />
</head>
<body>
<body className={clsx({ "no-scroll": disableBodyScroll })}>
{children}
<script
{/* <script
dangerouslySetInnerHTML={
ENV
? {
@ -105,7 +110,7 @@ function Document({
}
: undefined
}
/>
/> */}
<Scripts />
{process.env.NODE_ENV === "development" && <LiveReload />}
</body>
@ -114,11 +119,15 @@ function Document({
}
export function CatchBoundary() {
const [menuOpen, setMenuOpen] = React.useState(false);
const caught = useCatch();
return (
<Document title={`${caught.status} ${caught.statusText}`}>
<Layout>
<Document
title={`${caught.status} ${caught.statusText}`}
disableBodyScroll={menuOpen}
>
<Layout menuOpen={menuOpen} setMenuOpen={setMenuOpen}>
<Catcher />
</Layout>
</Document>
@ -126,18 +135,20 @@ export function CatchBoundary() {
}
export function ErrorBoundary({ error }: { error: Error }) {
const [menuOpen, setMenuOpen] = React.useState(false);
// TODO: do something not hacky with this
const [message, data] = error.message.split(",");
return (
<Document title="Error!">
<Layout>
<Document title="Error!" disableBodyScroll={menuOpen}>
<Layout menuOpen={menuOpen} setMenuOpen={setMenuOpen}>
<div>
<h1>Error happened: {message}</h1>
{data && data.length > 0 && data !== "null" && <p>Message: {data}</p>}
<hr />
<p className="mt-2 text-sm">
If you need help or want to report the error so that it can be fixed
please visit <a href={DISCORD_URL}>our Discord</a>
please visit <a href={discordUrl()}>our Discord</a>
</p>
</div>
</Layout>

View File

@ -1,32 +0,0 @@
import { DISCORD_URL } from "~/constants";
export default function BetaPage() {
return (
<div>
<h2>Beta of sendou.ink (Splatoon 3)</h2>
<p>
Hello there! I appreciate you taking time to visit this beta version of
sendou.ink&apos;s Splatoon 3 site. This being a beta there is a few
things you should consider:
</p>
<ul className="mt-2">
<li>
It&apos;s likely the database will be cleared (more than once) before
beta ends
</li>
<li>
Bugs are expected. Please give feedback on{" "}
<a href={DISCORD_URL}>our Discord</a>
</li>
<li>
Follow <a href="https://twitter.com/sendouink">Twitter</a> for
announcements about test tournaments and everything else related to
sendou.ink
</li>
</ul>
<p className="mt-4">
<a href="https://sendou.ink">Return to Splatoon 2 sendou.ink</a>
</p>
</div>
);
}

View File

@ -1,7 +1,4 @@
import { Mode } from "@prisma/client";
import clsx from "clsx";
import React from "react";
import {
ActionFunction,
json,
@ -10,7 +7,6 @@ import {
MetaFunction,
redirect,
} from "@remix-run/node";
import {
Form,
ShouldReloadFunction,
@ -19,7 +15,8 @@ import {
useParams,
useTransition,
} from "@remix-run/react";
import clsx from "clsx";
import React from "react";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
@ -31,7 +28,7 @@ import { DetailedPlayers } from "~/components/play/DetailedPlayers";
import { MapList } from "~/components/play/MapList";
import { MatchTeams } from "~/components/play/MatchTeams";
import { SubmitButton } from "~/components/SubmitButton";
import { DISCORD_URL, LFG_AMOUNT_OF_STAGES_TO_GENERATE } from "~/constants";
import { LFG_AMOUNT_OF_STAGES_TO_GENERATE } from "~/constants";
import { isAdmin } from "~/core/common/permissions";
import { requestMatchDetails } from "~/core/lanista";
import {
@ -56,6 +53,7 @@ import {
} from "~/utils";
import {
chatRoute,
discordUrl,
sendouQAddPlayersPage,
sendouQFrontPage,
} from "~/utils/urls";
@ -434,7 +432,7 @@ export default function LFGMatchPage() {
<div className="play-match__error">
The score you reported is different from what your opponent
reported. If you think the information below is wrong notify us on
the #helpdesk channel of our <a href={DISCORD_URL}>Discord</a>{" "}
the #helpdesk channel of our <a href={discordUrl()}>Discord</a>{" "}
channel
</div>
)}

View File

@ -6,29 +6,22 @@ import {
LoaderFunction,
MetaFunction,
} from "@remix-run/node";
import {
NavLink,
Outlet,
ShouldReloadFunction,
useLoaderData,
} from "@remix-run/react";
import { Outlet, ShouldReloadFunction, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { AdminIcon } from "~/components/icons/Admin";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { CheckinActions } from "~/components/tournament/CheckinActions";
import { InfoBanner } from "~/components/tournament/InfoBanner";
import { PAGE_TITLE_KEY } from "~/constants";
import { tournamentHasStarted } from "~/core/tournament/utils";
import { isTournamentAdmin } from "~/core/tournament/validators";
import { useUser } from "~/hooks/common";
import {
checkIn,
findTournamentByNameForUrl,
FindTournamentByNameForUrlI,
} from "~/services/tournament";
import { makeTitle, requireUser } from "~/utils";
import type { MyCSSProperties } from "~/utils";
import { useUser } from "~/hooks/common";
import tournamentStylesUrl from "../../styles/tournament.css";
import * as React from "react";
import { isTournamentAdmin } from "~/core/tournament/validators";
import { makeTitle, MyCSSProperties, requireUser } from "~/utils";
import { chatRoute } from "~/utils/urls";
import tournamentStylesUrl from "../../styles/tournament.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tournamentStylesUrl }];
@ -57,7 +50,7 @@ export const action: ActionFunction = async ({ request, context }) => {
return new Response(undefined, { status: 200 });
};
export const loader: LoaderFunction = ({ params }) => {
export const loader: LoaderFunction = async ({ params }) => {
invariant(
typeof params.organization === "string",
"Expected params.organization to be string"
@ -67,10 +60,15 @@ export const loader: LoaderFunction = ({ params }) => {
"Expected params.tournament to be string"
);
return findTournamentByNameForUrl({
const tournament = await findTournamentByNameForUrl({
organizationNameForUrl: params.organization,
tournamentNameForUrl: params.tournament,
});
return {
...tournament,
[PAGE_TITLE_KEY]: tournament.name,
};
};
export const meta: MetaFunction = (props) => {
@ -78,8 +76,6 @@ export const meta: MetaFunction = (props) => {
return {
title: makeTitle(data?.name),
// TODO: description, image?
//description: data.description ?? undefined,
};
};
@ -95,7 +91,7 @@ export default function TournamentPage() {
const user = useUser();
const navLinks = (() => {
const result: { code: string; text: string; icon?: React.ReactNode }[] = [
const result: { code: string; text: string }[] = [
{ code: "", text: "Overview" },
{ code: "map-pool", text: "Map Pool" },
{ code: "teams", text: `Teams (${data.teams.length})` },
@ -119,15 +115,12 @@ export default function TournamentPage() {
if (isTournamentAdmin({ userId: user?.id, organization: data.organizer })) {
result.push({
code: "manage",
// TODO: figure out a good name
text: "Controls",
icon: <AdminIcon />,
});
if (!tournamentHasStarted(data.brackets)) {
result.push({ code: "seeds", text: "Seeds", icon: <AdminIcon /> });
result.push({ code: "seeds", text: "Seeds" });
}
if (thereIsABracketToStart)
result.push({ code: "start", text: "Start", icon: <AdminIcon /> });
if (thereIsABracketToStart) result.push({ code: "start", text: "Start" });
}
return result;
@ -139,31 +132,16 @@ export default function TournamentPage() {
"--tournaments-text-transparent": data.CSSProperties.textTransparent,
};
const linksContainerStyle: MyCSSProperties = {
"--tabs-count": navLinks.length,
};
return (
<div className="tournament__container" style={tournamentContainerStyle}>
<InfoBanner />
<div className="tournament__container__spacer" />
<div className="tournament__links-overflower">
<div className="tournament__links-border">
<div
style={linksContainerStyle}
className="tournament__links-container"
>
{navLinks.map(({ code, text, icon }) => (
<TournamentNavLink
key={code}
code={code}
icon={icon}
text={text}
/>
))}
</div>
</div>
</div>
<SubNav>
{navLinks.map((link) => (
<SubNavLink key={link.code} to={link.code}>
{link.text}
</SubNavLink>
))}
<MyTeamLink />
</SubNav>
<div className="tournament__container__spacer" />
<CheckinActions />
<div className="tournament__outlet-spacer" />
@ -173,36 +151,57 @@ export default function TournamentPage() {
);
}
function TournamentNavLink({
code,
icon,
text,
}: {
code: string;
icon: React.ReactNode;
text: string;
}) {
const ref = React.useRef<HTMLAnchorElement>(null);
function MyTeamLink() {
const data = useLoaderData<FindTournamentByNameForUrlI>();
const user = useUser();
React.useEffect(() => {
if (!ref.current?.className.includes("active")) return;
ref.current?.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
}, []);
const isAlreadyInATeamButNotCaptain = data.teams
.flatMap((team) => team.members)
.filter(({ captain }) => !captain)
.some(({ member }) => member.id === user?.id);
if (isAlreadyInATeamButNotCaptain) return null;
const alreadyRegistered = data.teams
.flatMap((team) => team.members)
.some(({ member }) => member.id === user?.id);
if (alreadyRegistered) {
return (
<SubNavLink
to="manage-team"
className="info-banner__action-button"
prefetch="intent"
>
Add players
</SubNavLink>
);
}
if (tournamentHasStarted(data.brackets)) {
return null;
}
// TODO: prompt user to log in if not logged in
// if (!user) {
// return (
// <form action={getLogInUrl(location)} method="post">
// <button
// className="info-banner__action-button"
// data-cy="log-in-to-join-button"
// >
// Log in to join
// </button>
// </form>
// );
// }
if (!user) return null;
return (
<NavLink
className="tournament__nav-link"
to={code}
data-cy={`${code}-nav-link`}
prefetch="intent"
end
ref={ref}
<SubNavLink
to="register"
className="info-banner__action-button"
data-cy="register-button"
>
{icon} {text}
</NavLink>
Register
</SubNavLink>
);
}

View File

@ -1,9 +1,15 @@
import { useMatches } from "@remix-run/react";
import { InfoBanner } from "~/components/tournament/InfoBanner";
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
export default function DefaultTab() {
const [, parentRoute] = useMatches();
const { description } = parentRoute.data as FindTournamentByNameForUrlI;
return <article>{description}</article>;
return (
<div>
<InfoBanner />
<article className="mt-4">{description}</article>
</div>
);
}

View File

@ -5,6 +5,7 @@
--bg-darker: hsl(237.3deg 42.3% 26.6%);
--bg-lighter: hsl(237.3deg 42.3% 35.6%);
--bg-lighter-transparent: hsla(237.3deg 42.3% 35.6% / 50%);
--bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
--bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%);
--bg-modal-backdrop: hsla(237deg 98% 1% / 70%);
--border: hsl(237.3deg 42.3% 45.6%);
@ -345,7 +346,6 @@ hr {
top: 0;
right: 0;
width: 70px;
color: var(--text);
font-size: 80%;
line-height: 50px;
text-align: center;
@ -447,6 +447,40 @@ hr {
font-weight: var(--bold);
}
.sub-nav__container {
display: flex;
width: 100vw;
flex-wrap: wrap;
justify-content: center;
background-color: var(--bg-lighter);
background-image: url("/svg/background-pattern.svg");
overflow-x: auto;
}
.sub-nav__link {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
padding: var(--s-4);
background-color: var(--bg-lighter);
color: var(--text);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
white-space: nowrap;
}
.sub-nav__active-icon {
height: 1.2rem;
margin-block-end: -1rem;
margin-block-start: -3px;
visibility: hidden;
}
.sub-nav__link.active > .sub-nav__active-icon {
visibility: visible;
}
.popover-content {
z-index: 1;
max-width: 20rem;
@ -813,6 +847,10 @@ hr {
border-radius: var(--rounded);
}
.no-scroll {
overflow: hidden;
}
.z-10 {
z-index: 10;
}

View File

@ -10,125 +10,40 @@
grid-template-columns: 1fr 2fr;
}
.layout__header__logo-container {
display: grid;
width: var(--item-size);
min-width: var(--item-size);
height: var(--item-size);
min-height: var(--item-size);
padding: var(--s-1);
background-color: var(--bg-lighter);
background-image: url("/svg/background-pattern.svg");
border-radius: var(--rounded);
cursor: pointer;
justify-self: flex-start;
place-items: center;
}
.layout__logo {
max-width: 100%;
max-height: 100%;
}
.layout__header__search-container {
display: none;
}
.layout__header__title-container {
color: var(--text-lighter);
font-size: var(--fonts-sm);
font-weight: var(--bold);
text-align: center;
}
.layout__header__right-container {
display: flex;
gap: var(--s-4);
justify-self: flex-end;
}
.layout__beta__link {
text-decoration: none;
}
.layout__beta__banner {
display: flex;
width: 100%;
justify-content: center;
background-color: var(--theme);
color: var(--button-text-transparent);
font-size: var(--fonts-xs);
font-weight: var(--bold);
gap: var(--s-8);
overflow-x: hidden;
white-space: nowrap;
}
.layout__nav {
display: none;
justify-content: center;
background-color: var(--bg-lighter);
background-image: url("/svg/background-pattern.svg");
}
.layout__nav__items {
display: inline-flex;
justify-content: center;
padding: var(--s-4) var(--s-8);
background-color: var(--bg-lighter);
gap: var(--s-12);
grid-template-columns: repeat(4, 100px);
}
.layout__nav__items__column {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.layout__nav__link {
display: flex;
align-items: center;
color: var(--text);
font-size: var(--fonts-sm);
font-weight: var(--bold);
gap: var(--s-2);
text-decoration: none;
text-transform: capitalize;
transition: 0.2s transform;
}
.layout__nav__link.disabled {
filter: grayscale(100%);
}
.layout__nav__link:hover {
transform: translateX(2px);
}
.layout__nav__column__title {
color: var(--text-lighter);
font-size: var(--fonts-xxs);
font-weight: var(--bold);
text-transform: uppercase;
}
.layout__nav__link__icon {
width: 2rem;
height: 2rem;
}
.layout__main {
max-width: 48rem;
margin: 0 auto;
padding-block-end: var(--s-32);
padding-block-start: var(--s-8);
padding-inline: var(--s-2);
}
.layout__burger {
display: flex;
width: 3rem;
height: 3rem;
width: var(--item-size);
height: var(--item-size);
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.5rem;
padding: 0.25rem;
border: 2px solid;
border-color: var(--bg-lighter);
border-color: var(--theme-transparent);
background-color: transparent;
border-radius: var(--rounded);
color: inherit;
@ -194,7 +109,7 @@
.layout__mobile-nav__top-action {
display: grid;
padding-top: var(--s-24);
padding-top: var(--s-12);
padding-bottom: var(--s-8);
background-color: var(--bg);
gap: var(--s-4);
@ -282,11 +197,6 @@
letter-spacing: 0.5px;
}
.layout__search-input__icon {
height: 1.25rem;
color: var(--text);
}
.layout__log-in-button {
display: flex;
height: var(--item-size);
@ -329,23 +239,181 @@
.layout__header__search-container {
display: block;
}
.layout__nav {
display: flex;
}
.layout__burger {
display: none;
}
.layout__mobile-nav {
display: none;
max-width: 12rem;
}
}
@media screen and (min-width: 480px) {
.mobileNavLink {
margin: 0 var(--s-24);
.menu {
--small-icons-height: 1.25rem;
position: fixed;
z-index: 999;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: grid;
background: radial-gradient(
circle,
rgb(14 3 40 / 100%) 0%,
rgb(28 4 82) 100%
);
place-items: center;
}
@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
.menu {
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
background: transparent;
}
}
.menu__inner {
display: grid;
grid-template:
". top-icons ." 1.25rem
"img-boy nav-items img-girl" 14rem
". bottom-icons ." 1.25rem
/ 1.15fr 2fr 1.15fr;
place-items: center;
}
@media screen and (min-width: 1024px) {
.menu__inner {
grid-template-rows: 1.25rem 18rem 1.25rem;
}
}
.menu__top-extras {
display: flex;
width: 100%;
justify-content: space-between;
grid-area: top-icons;
}
.menu__logo-container {
display: flex;
height: var(--small-icons-height);
align-items: center;
color: var(--text);
font-size: var(--fonts-xs);
font-weight: var(--bold);
gap: var(--s-1);
}
.menu__cross-icon {
width: 1.25rem;
height: 100%;
fill: var(--text);
}
.menu__nav {
display: grid;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
padding: var(--s-12);
background-color: var(--bg-darker-very-transparent);
grid-area: nav-items;
grid-template-columns: repeat(3, 8rem);
row-gap: var(--s-2);
}
.menu__nav__link {
display: flex;
align-items: center;
color: var(--text);
font-size: var(--fonts-xs);
font-weight: var(--bold);
gap: var(--s-1);
text-decoration: none;
text-transform: capitalize;
transition: 0.2s transform;
}
.menu__icons-container {
display: flex;
width: 100%;
height: var(--small-icons-height);
justify-content: flex-end;
gap: var(--s-1);
grid-area: bottom-icons;
margin-block-start: 1px;
}
.menu__icons-container > a {
display: flex;
height: 100%;
justify-content: flex-end;
}
.menu__icon {
width: 1.25rem;
padding: 0.1rem;
fill: var(--text);
}
.menu__img-container {
position: relative;
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
justify-content: center;
background-color: hsla(237deg 98% 1% / 25%);
border-radius: 0 var(--rounded) var(--rounded) 0;
grid-area: img-girl;
justify-self: flex-start;
}
.menu__img-container.boy {
border-radius: var(--rounded) 0 0 var(--rounded);
grid-area: img-boy;
justify-self: flex-end;
}
@keyframes slide-in-from-top {
0% {
transform: translateY(-50%);
}
100% {
transform: translateY(0);
}
}
@keyframes slide-in-from-bottom {
0% {
transform: translateY(50%);
}
100% {
transform: translateY(0);
}
}
.menu__img {
overflow: hidden;
height: 100%;
animation: 0.75s ease-out 0s 1 slide-in-from-bottom;
object-fit: cover;
}
.menu__img-bg {
position: absolute;
z-index: -1;
top: 0;
height: 100%;
animation: 0.75s ease-out 0s 1 slide-in-from-bottom;
object-fit: cover;
}
.menu__img.boy {
animation: 0.75s ease-out 0s 1 slide-in-from-top;
}
.menu__img-bg.boy {
animation: 0.75s ease-out 0s 1 slide-in-from-top;
}

View File

@ -14,7 +14,7 @@
.tournament-match-modal__error-msg {
color: var(--theme-error);
font-size: var(--fonts-sm);
/* Prevent layout shift */
padding-block: 0.16rem;
}

View File

@ -59,47 +59,6 @@
place-items: center;
}
.tournament__nav-link {
all: unset;
display: flex;
width: 100%;
justify-content: center;
background-color: var(--bg-lighter);
color: var(--text);
cursor: pointer;
font-size: var(--fonts-sm);
gap: var(--s-1);
padding-block: var(--s-1-5);
}
.tournament__nav-link > svg {
width: 1rem;
}
.tournament__nav-link:first-of-type {
border-bottom-left-radius: var(--rounded);
border-top-left-radius: var(--rounded);
}
.tournament__nav-link:last-of-type {
border-bottom-right-radius: var(--rounded);
border-top-right-radius: var(--rounded);
}
.tournament__nav-link:hover {
font-weight: var(--bold);
}
.tournament__nav-link:focus-visible {
background-color: var(--bg-lighter-transparent);
}
.tournament__nav-link.active {
background-color: transparent;
color: var(--tournaments-text);
font-weight: var(--bold);
}
.info-banner {
width: 100%;
padding: var(--s-6);
@ -189,44 +148,10 @@
inline-size: 2.25rem;
}
.info-banner__icon-button:active {
transform: translateY(1px);
}
.info-banner__icon-button:focus-visible {
outline: 2px solid var(--tournaments-text-transparent);
}
.info-banner__action-button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid;
border-color: var(--tournaments-text-transparent);
border-radius: var(--rounded);
color: inherit;
cursor: pointer;
font-size: var(--fonts-sm);
font-weight: var(--bold);
padding-block: var(--s-2);
padding-inline: var(--s-4);
text-decoration: none;
}
.info-banner__action-button:active {
transform: translateY(1px);
}
.info-banner__action-button:focus-visible {
outline: 2px solid var(--tournaments-text-transparent);
}
.tournament__outlet-spacer {
padding-block-start: var(--s-8);
}
/* separate .css file..? */
.teams-tab {
display: flex;
flex-direction: column;
@ -307,8 +232,4 @@
border-inline-start: 2px solid var(--theme);
padding-inline-start: var(--s-6);
}
.tournament__nav-link:last-of-type {
padding-inline-end: 0;
}
}

View File

@ -4,8 +4,6 @@ import { json } from "@remix-run/node";
import { useLocation } from "@remix-run/react";
import type { Socket } from "socket.io-client";
import { z } from "zod";
import { ADMIN_UUID, NZAP_UUID } from "~/constants";
import { EnvironmentVariables } from "~/root";
import { LoggedInUserSchema } from "~/utils/schemas";
export function flipObject<
@ -156,21 +154,21 @@ export function falsyToNull(value: unknown): unknown {
return null;
}
export function isFeatureFlagOn({
flag,
userId,
}: {
flag: keyof EnvironmentVariables;
userId?: string;
}) {
if (typeof window === "undefined") return false;
// export function isFeatureFlagOn({
// flag,
// userId,
// }: {
// flag: keyof EnvironmentVariables;
// userId?: string;
// }) {
// if (typeof window === "undefined") return false;
if (window.ENV[flag] === "admin") {
return userId === ADMIN_UUID || userId === NZAP_UUID;
}
// if (window.ENV[flag] === "admin") {
// return userId === ADMIN_UUID || userId === NZAP_UUID;
// }
return window.ENV[flag] === "true";
}
// return window.ENV[flag] === "true";
// }
export type Serialized<T> = {
[P in keyof T]: T[P] extends Date
@ -217,7 +215,6 @@ export interface MyCSSProperties extends CSSProperties {
"--brackets-bottom-border-length"?: number;
"--brackets-column-matches"?: number;
"--height-override"?: string;
"--tabs-count"?: number;
}
/** Minimal information on user to show their name and avatar */

View File

@ -56,3 +56,12 @@ export function chatRoute(roomIds?: string[]) {
if (!roomIds || roomIds.length === 0) return "/chat";
return `/chat?${roomIds.map((id) => `id=${id}`).join("&")}`;
}
//
// Outside of sendou.ink
//
export const discordUrl = () => "https://discord.gg/sendou";
export const twitterUrl = () => "https://twitter.com/sendouink";
export const patreonUrl = () => "https://patreon.com/sendou";
export const gitHubUrl = () => "https://github.com/Sendouc/sendou.ink";

37
package-lock.json generated
View File

@ -7,7 +7,6 @@
"": {
"name": "sendou.ink",
"version": "3.0.0",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^5.0.3",
"@dnd-kit/sortable": "^6.0.1",
@ -16,6 +15,7 @@
"@popperjs/core": "^2.11.5",
"@prisma/client": "^3.10.0",
"@remix-run/express": "^1.4.0",
"@remix-run/node": "^1.4.0",
"@remix-run/react": "^1.4.0",
"clsx": "^1.1.1",
"compression": "^1.7.4",
@ -31,10 +31,10 @@
"openskill": "^2.1.0",
"passport": "^0.5.2",
"passport-discord": "^0.1.4",
"randomcolor": "^0.6.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-popper": "^2.2.5",
"remix": "^1.4.0",
"socket.io": "^4.4.1",
"socket.io-client": "^4.4.1",
"tiny-invariant": "^1.2.0",
@ -50,6 +50,7 @@
"@types/morgan": "^1.9.3",
"@types/passport": "^1.0.7",
"@types/passport-discord": "^0.1.5",
"@types/randomcolor": "^0.5.6",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"@types/uuid": "^8.3.4",
@ -3046,6 +3047,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/randomcolor": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/@types/randomcolor/-/randomcolor-0.5.6.tgz",
"integrity": "sha512-lKkW8DGUQpZldTrwa+HM5rY+7eTyaHOMTsnj9ewt7AAwXuMPwBmkLlfh8+SXdgOhBW9iNI4x4zRjQ/TQZkdycQ==",
"dev": true
},
"node_modules/@types/range-parser": {
"version": "1.2.4",
"dev": true,
@ -12907,6 +12914,11 @@
"url": "https://opencollective.com/ramda"
}
},
"node_modules/randomcolor": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
"integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A=="
},
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
@ -13368,11 +13380,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remix": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/remix/-/remix-1.4.0.tgz",
"integrity": "sha512-yCSh9L+voOFGaFKCEw/379F0YNQLxDU3mDveEdxHUsJkqVyJI4qbSY27H5+2aVtcEW6h0qEefCZcXZyBa5A6ww=="
},
"node_modules/repeat-element": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
@ -18676,6 +18683,12 @@
"version": "6.9.7",
"dev": true
},
"@types/randomcolor": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/@types/randomcolor/-/randomcolor-0.5.6.tgz",
"integrity": "sha512-lKkW8DGUQpZldTrwa+HM5rY+7eTyaHOMTsnj9ewt7AAwXuMPwBmkLlfh8+SXdgOhBW9iNI4x4zRjQ/TQZkdycQ==",
"dev": true
},
"@types/range-parser": {
"version": "1.2.4",
"dev": true
@ -25327,6 +25340,11 @@
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
"integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA=="
},
"randomcolor": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
"integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A=="
},
"range-parser": {
"version": "1.2.1"
},
@ -25672,11 +25690,6 @@
"unified": "^10.0.0"
}
},
"remix": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/remix/-/remix-1.4.0.tgz",
"integrity": "sha512-yCSh9L+voOFGaFKCEw/379F0YNQLxDU3mDveEdxHUsJkqVyJI4qbSY27H5+2aVtcEW6h0qEefCZcXZyBa5A6ww=="
},
"repeat-element": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",

View File

@ -52,6 +52,7 @@
"openskill": "^2.1.0",
"passport": "^0.5.2",
"passport-discord": "^0.1.4",
"randomcolor": "^0.6.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-popper": "^2.2.5",
@ -70,6 +71,7 @@
"@types/morgan": "^1.9.3",
"@types/passport": "^1.0.7",
"@types/passport-discord": "^0.1.5",
"@types/randomcolor": "^0.5.6",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"@types/uuid": "^8.3.4",

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

7
window.d.ts vendored
View File

@ -1,7 +0,0 @@
import { EnvironmentVariables } from "~/root";
declare global {
interface Window {
ENV: EnvironmentVariables;
}
}