mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
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:
parent
b84917133b
commit
03da81a84c
|
|
@ -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>
|
||||
|
|
|
|||
373
app/components/Layout/DrawingSection.tsx
Normal file
373
app/components/Layout/DrawingSection.tsx
Normal 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;
|
||||
70
app/components/Layout/Menu.tsx
Normal file
70
app/components/Layout/Menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
app/components/SubNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ export function ArrowUpIcon({
|
|||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style: CSSProperties;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
15
app/components/icons/GitHub.tsx
Normal file
15
app/components/icons/GitHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/components/icons/Patreon.tsx
Normal file
16
app/components/icons/Patreon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
69
app/root.tsx
69
app/root.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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's Splatoon 3 site. This being a beta there is a few
|
||||
things you should consider:
|
||||
</p>
|
||||
<ul className="mt-2">
|
||||
<li>
|
||||
It'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
37
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
public/img/layout/new_boy_bg.png
Normal file
BIN
public/img/layout/new_boy_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/img/layout/new_boy_dark.png
Normal file
BIN
public/img/layout/new_boy_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
BIN
public/img/layout/new_girl_bg.png
Normal file
BIN
public/img/layout/new_girl_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
public/img/layout/new_girl_dark.png
Normal file
BIN
public/img/layout/new_girl_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 635 KiB |
7
window.d.ts
vendored
7
window.d.ts
vendored
|
|
@ -1,7 +0,0 @@
|
|||
import { EnvironmentVariables } from "~/root";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ENV: EnvironmentVariables;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user