Switch to Solid.js

This commit is contained in:
Kalle (Sendou) 2021-11-13 17:04:36 +02:00
parent 29d53774a1
commit 88b690b6ad
132 changed files with 1455 additions and 67092 deletions

View File

@ -1 +0,0 @@
.next

View File

@ -1 +0,0 @@
{}

View File

@ -1,7 +0,0 @@
{
"Styled component (Stitches)": {
"prefix": "st",
"body": ["const S_$1 = styled(\"$2\", {", " $0", "});"],
"scope": "typescriptreact"
}
}

13
App.tsx Normal file
View File

@ -0,0 +1,13 @@
import { Router } from "solid-app-router";
import type { Component } from "solid-js";
import Layout from "./scenes/layout";
const App: Component = () => {
return (
<Router>
<Layout>routes here</Layout>
</Router>
);
};
export default App;

View File

@ -1,57 +0,0 @@
# sendou.ink (Splatoon 3)
Next version of sendou.ink
⚠️ This branch is still experimental and likely to have very big changes quickly
## Getting started
> If these instructions are wrong please make an issue and let's fix them
Prerequisites: [Node.js](https://nodejs.org/en/) & PostgreSQL running locally ([guide](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database))
You might be able to skip steps 2-4 and use a few pages but most pages need a server with database connected.
1. Install dependencies with running `npm install` command in the root folder
2. Make a copy of the .env.sample file in the /server folder and name it .env
- See below for documentation about what the values mean and which are optional
3. In the /server folder seed the database with the `npm run seed` command
4. In the /server folder run the development server with the `npm run dev` command
5. Make a copy of the .env.sample file in the /frontend folder and name it .env.local
- See below for documentation about what the values mean and which are optional
6. In the /frontend folder run the app with the `npm run dev` command
## .env
### /frontend
| Name | Description | Required |
| ----------------------- | ------------------------- | -------- |
| NEXT_PUBLIC_BACKEND_URL | Where backend is located. | Yes |
## Folder structure
### /api
TypeScript types that acts as a pact between frontend and backend.
### /frontend
This folder contains frontend specific code.
```
frontend/
├── __mocks__/ mocks for tests
├── assets/ images and other assets to be imported directly to code
├── components/ react components
├── hooks/ react hooks including data fetching logic
└── utils/ frontend specific utility methods, constants etc.
```
### /shared
Utilities, constants etc. that are shared between frontend and backend.

View File

@ -1 +0,0 @@
export type Mode = "TW" | "SZ" | "TC" | "RM" | "CB";

View File

@ -1 +0,0 @@
export * from "./tournaments.api";

View File

@ -1,4 +0,0 @@
{
"name": "@sendou-ink/api",
"version": "1.0.0"
}

View File

@ -1,20 +0,0 @@
import type { Mode } from "./common";
export interface GetTournamentByOrganizationAndName {
name: string;
description: string | null;
startTime: Date;
checkInTime: Date;
bannerBackground: string;
bannerTextHSLArgs: string;
organizer: {
name: string;
discordInvite: string;
twitter: string | null;
nameForUrl: string;
};
mapPool: {
name: string;
mode: Mode;
}[];
}

View File

@ -0,0 +1,18 @@
export function SearchIcon(p: { class?: string }) {
return (
<svg
class={p.class}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
);
}

View File

@ -1 +0,0 @@
NEXT_PUBLIC_BACKEND_URL=http://localhost:3001

34
frontend/.gitignore vendored
View File

@ -1,34 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel

View File

@ -1 +0,0 @@
module.exports = "test-file-stub";

View File

@ -1 +0,0 @@
module.exports = {};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,17 +0,0 @@
overwrite: true
schema: "http://localhost:4000/graphql"
documents: "**/*.graphql"
config:
scalars:
Datetime: Date
generates:
generated/introspection.json:
plugins:
- "introspection"
config:
minify: true
generated/graphql.ts:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-urql"

View File

@ -1,26 +0,0 @@
import { styled } from "stitches.config";
export function Avatar({ src }: { src: string }) {
return (
<S_Container>
<S_Avatar src={src} />
</S_Container>
);
}
const S_Container = styled("div", {
position: "relative",
userSelect: "none",
overflow: "hidden",
width: "var(--item-size)",
minWidth: "var(--item-size)",
height: "var(--item-size)",
borderRadius: "$rounded",
});
const S_Avatar = styled("img", {
objectFit: "cover",
width: "100%",
height: "100%",
display: "block",
});

View File

@ -1,61 +0,0 @@
import { HiSearch } from "react-icons/hi";
import { styled } from "stitches.config";
export function SearchInput() {
return (
<S_Container>
<S_Input type="text" placeholder="Search" />
<HiSearch />
</S_Container>
);
}
const S_Container = styled("div", {
display: "flex",
height: "1rem",
alignItems: "center",
justifyContent: "center",
paddingX: "$4",
paddingY: "$5",
backgroundColor: "$bgLighter",
borderRadius: "$rounded",
"&:focus-within": {
outline: "2px solid $theme",
},
"&> svg": {
height: "1.25rem",
color: "$text",
},
});
const S_Input = styled("input", {
width: "12rem",
height: "2rem",
border: "none",
backgroundColor: "$bgLighter",
fontSize: "$sm",
outline: "none",
color: "$text",
flexGrow: 1,
"&::placeholder": {
color: "$textLighter",
letterSpacing: "0.5px",
fontWeight: "$semiBold",
},
});
// .bigSearchInput {
// width: 12rem;
// border: none;
// background-color: var(--input-bg);
// font-size: var(--font-xs);
// outline: none;
// }
// .bigSearchInput::placeholder {
// color: var(--input-placeholder-color);
// letter-spacing: 0.5px;
// }

View File

@ -1,46 +0,0 @@
import { styled } from "stitches.config";
export function Select({
values,
onChange,
selected,
}: {
values: { id: string; name: string }[];
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
selected: string;
}) {
return (
<S_Select value={selected} onChange={onChange}>
{values.map((value) => {
return (
<option key={value.id} value={value.id}>
{value.name}
</option>
);
})}
</S_Select>
);
}
const S_Select = styled("select", {
all: "unset",
width: "100%",
padding: "$2 $4",
backgroundColor: "$bgLighter",
backgroundImage: `url('data:image/svg+xml;utf8,<svg width="1rem" color="white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>')`,
backgroundPositionX: "95%",
backgroundPositionY: "50%",
backgroundRepeat: "no-repeat",
borderRadius: "$rounded",
cursor: "pointer",
fontWeight: "$body",
fontSize: "$sm",
"&::selection": {
fontWeight: "$bold",
},
"&:focus": {
outline: "2px solid $theme",
},
});

View File

@ -1,86 +0,0 @@
import { Tab as HeadlessTab } from "@headlessui/react";
import { ComponentProps } from "react";
import { css } from "stitches.config";
export function Tab(props: any) {
return (
<HeadlessTab
{...props}
className={({ selected }) => (selected ? tab({ type: "active" }) : tab())}
/>
);
}
function Group(props: ComponentProps<typeof HeadlessTab["Group"]>) {
return <HeadlessTab.Group {...props} />;
}
function List({
tabsCount,
...props
}: ComponentProps<typeof HeadlessTab["List"]> & { tabsCount: number }) {
return (
<HeadlessTab.List
{...props}
className={container()}
style={{ "--tabs-count": tabsCount }}
/>
);
}
function Panels(props: ComponentProps<typeof HeadlessTab["Panels"]>) {
return <HeadlessTab.Panels {...props} />;
}
function Panel(props: ComponentProps<typeof HeadlessTab["Panel"]>) {
return <HeadlessTab.Panel {...props} />;
}
Tab.Group = Group;
Tab.List = List;
Tab.Panels = Panels;
Tab.Panel = Panel;
const container = css({
display: "grid",
justifyContent: "center",
placeItems: "center",
gap: "$10",
gridTemplateColumns: "repeat(var(--tabs-count), 100px)",
});
const tab = css({
all: "unset",
cursor: "pointer",
borderRadius: "$rounded",
"&::after": {
display: "block",
width: "1.25rem",
height: "3px",
borderBottom: "3px solid",
content: '""',
borderColor: "transparent",
},
"&:hover::after": {
borderColor: "$themeTransparent",
},
"&:focus-visible": {
outline: "2px solid $themeTransparent",
outlineOffset: "7px",
},
variants: {
type: {
active: {
fontWeight: "$bold",
"&::after": {
borderColor: "$theme !important",
},
},
},
},
});

View File

@ -1,121 +0,0 @@
import { styled } from "stitches.config";
export function HamburgerButton({
isExpanded,
onClick,
}: {
onClick: () => void;
isExpanded: boolean;
}) {
return (
<S_TransformingBurger
aria-label="Toggle menu visibility"
aria-expanded={isExpanded ? "true" : "false"}
onClick={onClick}
>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<S_TopLine
x="6"
y="9"
width="20"
height="2"
rx="1"
fill="currentColor"
type={isExpanded ? "expanded" : undefined}
/>
<S_MiddleLine
x="6"
y="15"
width="20"
height="2"
rx="1"
fill="currentColor"
type={isExpanded ? "expanded" : undefined}
/>
<S_BottomLine
x="6"
y="21"
width="20"
height="2"
rx="1"
fill="currentColor"
type={isExpanded ? "expanded" : undefined}
/>
</svg>
</S_TransformingBurger>
);
}
const S_TransformingBurger = styled("button", {
display: "flex",
width: "var(--item-size)",
height: "var(--item-size)",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "$1",
border: "3px solid",
borderColor: "$bgLighter",
backgroundColor: "transparent",
borderRadius: "$rounded",
color: "inherit",
cursor: "pointer",
gap: "2px",
"@sm": {
display: "none",
},
});
const S_TopLine = styled("rect", {
transform: "none",
transformOrigin: "16px 10px",
transitionDuration: "150ms",
transitionProperty: "transform",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
variants: {
type: {
expanded: {
transform: "translateY(7px) rotate(45deg)",
},
},
},
});
const S_MiddleLine = styled("rect", {
opacity: 1,
transitionDuration: "150ms",
transitionProperty: "opacity",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
variants: {
type: {
expanded: {
opacity: 0,
},
},
},
});
const S_BottomLine = styled("rect", {
transform: "none",
transformOrigin: "16px 22px",
transitionDuration: "150ms",
transitionProperty: "transform",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
variants: {
type: {
expanded: {
transform: "translateY(-5px) rotate(-45deg)",
},
},
},
});

View File

@ -1,154 +0,0 @@
import { ReactNode, useState } from "react";
import NextImage from "next/image";
import NextLink from "next/link";
import logo from "assets/img/logo.png";
import { Avatar } from "components/common/Avatar";
import { styled } from "stitches.config";
import { SearchInput } from "components/common/SearchInput";
import { HamburgerButton } from "./HamburgerButton";
import { navItems } from "utils/constants";
import { MobileNav } from "./MobileNav";
export function Layout({ children }: { children: ReactNode }) {
const [menuExpanded, setMenuExpanded] = useState(false);
return (
<>
<S_Header>
<NextLink href="/">
<S_LogoContainer>
<NextImage src={logo} />
</S_LogoContainer>
</NextLink>
<S_SearchContainer>
<SearchInput />
</S_SearchContainer>
<S_RightContainer>
<HamburgerButton
isExpanded={menuExpanded}
onClick={() => setMenuExpanded((expanded) => !expanded)}
/>
<Avatar src="https://cdn.discordapp.com/avatars/79237403620945920/fcfd65a3bea598905abb9ca25296816b.png?size=80" />
</S_RightContainer>
</S_Header>
<MobileNav isExpanded={menuExpanded} />
<S_Nav>
<S_NavItems>
{navItems.map((navItem) => (
<S_NavItemColumn key={navItem.title}>
<S_NavGroupTitle>{navItem.title}</S_NavGroupTitle>
{navItem.items.map((item) => (
<S_NavLink key={item} href="/">
<NextImage
src={`/img/nav-icons/${item.replace(" ", "")}.png`}
width={32}
height={32}
/>
{item}
</S_NavLink>
))}
</S_NavItemColumn>
))}
</S_NavItems>
</S_Nav>
<S_Main>{children}</S_Main>
</>
);
}
const S_Header = styled("header", {
position: "relative",
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
alignItems: "center",
padding: "$4",
"--item-size": "3rem",
zIndex: 10,
backgroundColor: "$bg",
"@sm": {
gridTemplateColumns: "repeat(3, 1fr)",
"--item-size": "38px",
},
});
const S_LogoContainer = styled("div", {
backgroundColor: "$bgLighter",
backgroundImage: `url(/svg/background-pattern.svg)`,
display: "grid",
placeItems: "center",
padding: "$1",
borderRadius: "$rounded",
justifySelf: "flex-start",
cursor: "pointer",
width: "var(--item-size)",
minWidth: "var(--item-size)",
height: "var(--item-size)",
});
const S_SearchContainer = styled("div", {
display: "none",
"@sm": {
display: "block",
},
});
const S_RightContainer = styled("div", {
display: "flex",
gap: "$4",
justifySelf: "flex-end",
});
const S_Nav = styled("nav", {
display: "none",
justifyContent: "center",
backgroundColor: "$bgLighter",
backgroundImage: `url(/svg/background-pattern.svg)`,
"@sm": {
display: "flex",
},
});
const S_NavItems = styled("div", {
backgroundColor: "$bgLighter",
display: "inline-flex",
justifyContent: "center",
gap: "$12",
gridTemplateColumns: "repeat(4, 100px)",
paddingY: "$4",
paddingX: "$8",
});
const S_NavItemColumn = styled("div", {
display: "flex",
flexDirection: "column",
gap: "$2",
});
const S_NavLink = styled("a", {
display: "flex",
alignItems: "center",
gap: "$2",
color: "$text",
textDecoration: "none",
fontSize: "$sm",
fontWeight: "$bold",
textTransform: "capitalize",
transition: "0.2s transform",
"&:hover": {
transform: "translateX(2px)",
},
});
const S_NavGroupTitle = styled("div", {
textTransform: "uppercase",
fontWeight: "$bold",
color: "$textLighter",
fontSize: "$xxs",
});
const S_Main = styled("main", {
paddingTop: "$8",
});

View File

@ -1,134 +0,0 @@
import { SearchInput } from "components/common/SearchInput";
import { styled } from "stitches.config";
import { navItems } from "utils/constants";
import NextLink from "next/link";
import { Fragment } from "react";
import NextImage from "next/image";
export function MobileNav({ isExpanded }: { isExpanded: boolean }) {
return (
<S_Container
aria-hidden={isExpanded ? "false" : "true"}
type={isExpanded ? "expanded" : undefined}
>
<S_TopAction>
<SearchInput />
</S_TopAction>
<S_LinksContainer>
{navItems.map((navItem) => {
return (
<Fragment key={navItem.title}>
<S_GroupTitle>{navItem.title}</S_GroupTitle>
{navItem.items.map((item, i, arr) => {
return (
<NextLink key={item} href="/">
<S_NavLink
type={
i === 0
? "first"
: i + 1 === arr.length
? "last"
: undefined
}
>
<NextImage
src={`/img/nav-icons/${item.replace(" ", "")}.png`}
width={38}
height={38}
/>
<div>{item}</div>
</S_NavLink>
</NextLink>
);
})}
</Fragment>
);
})}
</S_LinksContainer>
</S_Container>
);
}
const S_Container = styled("nav", {
position: "absolute",
bottom: "100vh",
width: "100%",
height: "100%",
transition: "bottom 0.5s",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
backgroundImage: `url(/svg/background-pattern.svg)`,
backgroundColor: "$bgLighter",
zIndex: 5,
overflowY: "auto",
paddingBottom: "$6",
// TODO: same for Safari
overscrollBehavior: "contain",
"@sm": {
display: "none",
},
variants: {
type: {
expanded: {
bottom: "0",
},
},
},
});
const S_TopAction = styled("div", {
display: "grid",
gap: "$4",
placeItems: "center",
backgroundColor: "$bg",
paddingTop: "$24",
paddingBottom: "$8",
});
const S_LinksContainer = styled("div", {
display: "grid",
justifyContent: "center",
gridAutoColumns: "1fr",
gridAutoRows: "4rem",
});
const S_GroupTitle = styled("div", {
textTransform: "uppercase",
alignSelf: "flex-end",
justifySelf: "center",
paddingX: "$3",
paddingY: "$2",
marginBottom: "$1",
backgroundColor: "$bg",
fontWeight: "$bold",
borderRadius: "$rounded",
});
const S_NavLink = styled("div", {
display: "flex",
alignItems: "center",
gap: "$2",
textTransform: "capitalize",
borderTop: "3px solid $bgLighter",
fontWeight: "$bold",
backgroundColor: "$bg",
paddingX: "$2",
fontSize: "$sm",
marginX: "$12",
"@xs": {
marginX: "$24",
},
variants: {
type: {
first: {
borderRadius: "$rounded $rounded 0 0",
},
last: {
borderRadius: "0 0 $rounded $rounded",
},
},
},
});

View File

@ -1,227 +0,0 @@
import { styled } from "stitches.config";
const rounds = [
{
title: "Round 1",
bestOf: 3,
status: "DONE",
},
{
title: "Round 2",
bestOf: 5,
status: "INPROGRESS",
},
{
title: "Semifinals",
bestOf: 5,
status: "UPCOMING",
},
{
title: "Finals",
bestOf: 7,
status: "UPCOMING",
},
] as const;
export function BracketTab() {
return (
<S_Container style={{ "--columns": rounds.length, "--matches": 4 } as any}>
<S_Bracket>
{rounds.map((round, i) => (
<RoundInfo round={round} isLast={i === rounds.length - 1} />
))}
<S_Column style={{ "--column-matches": 4 } as any}>
<S_MatchesContainer>
<Match />
<Match />
<Match />
<Match />
</S_MatchesContainer>
<S_LinesContainer>
<div />
<div />
</S_LinesContainer>
</S_Column>
<S_Column style={{ "--column-matches": 2 } as any}>
<S_MatchesContainer>
<Match />
<Match />
</S_MatchesContainer>
<S_LinesContainer>
<div />
</S_LinesContainer>
</S_Column>
<S_Column
style={{ "--column-matches": 0, "--bottom-border-length": 0 } as any}
>
<S_MatchesContainer>
<Match />
</S_MatchesContainer>
<S_LinesContainer>
<div />
</S_LinesContainer>
</S_Column>
<S_MatchesContainer>
<Match />
</S_MatchesContainer>
</S_Bracket>
</S_Container>
);
}
const S_Container = styled("div", {
margin: "0 auto",
width: "100%",
display: "flex",
justifyContent: "center",
});
const S_Bracket = styled("div", {
display: "grid",
gridTemplateColumns: "repeat(var(--columns), 250px)",
columnGap: "2px",
"--match-height": "100px",
// todo: fix overflow
overflowX: "hidden",
});
const S_Column = styled("div", {
display: "flex",
});
const S_MatchesContainer = styled("div", {
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
height: "calc(var(--match-height) * var(--matches))",
});
const S_LinesContainer = styled("div", {
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
paddingLeft: "7px",
"&> div": {
width: "calc(var(--match-height) / 4)",
height:
"calc(2px + var(--match-height) * (var(--matches) / var(--column-matches)))",
borderTop: "2px solid",
borderBottom: "var(--bottom-border-length, 2px) solid",
borderInlineEnd: "2px solid",
borderColor: "$theme",
borderRadius: "0 var(--rounded) var(--rounded) 0",
},
});
function RoundInfo(props: {
round: {
title: string;
bestOf: number;
status: "DONE" | "INPROGRESS" | "UPCOMING";
};
isLast?: boolean;
}) {
return (
<S_RoundInfo
status={props.round.status === "INPROGRESS" ? "active" : undefined}
order={props.isLast ? "last" : undefined}
>
<S_RoundTitle>{props.round.title}</S_RoundTitle>
{props.round.status !== "DONE" && (
<S_BestOf>Bo{props.round.bestOf}</S_BestOf>
)}
</S_RoundInfo>
);
}
const S_RoundInfo = styled("div", {
backgroundColor: "$bgLighter",
padding: "1rem 1rem",
"&:first-of-type": {
borderRadius: "$rounded 0 0 $rounded",
},
variants: {
status: {
active: {
backgroundColor: "$theme",
},
},
order: {
last: {
borderRadius: "0 $rounded $rounded 0",
},
},
},
/* TODO: transition: background-color 0.5s; */
});
const S_RoundTitle = styled("div", {
fontWeight: 500,
fontSize: "$sm",
});
const S_BestOf = styled("div", {
fontSize: "$xs",
});
export function Match() {
return (
<S_Match>
<S_RoundNumber>1</S_RoundNumber>
<S_Team team="one">
Team Olive <S_Score>1</S_Score>
</S_Team>
<S_Team team="two">
Chimera <S_Score>1</S_Score>
</S_Team>
</S_Match>
);
}
const S_Match = styled("div", {
margin: "$4 0",
display: "grid",
gridTemplateColumns: "10px 1fr",
rowGap: "2px",
columnGap: "5px",
placeItems: "center",
gridTemplateAreas: `
"round-number team-one"
"round-number team-two"
`,
});
const S_RoundNumber = styled("div", {
gridArea: "round-number",
fontSize: "$xs",
});
const S_Team = styled("div", {
placeSelf: "flex-start",
backgroundColor: "$bgLighter",
width: "200px",
fontSize: "$sm",
padding: "$1 $3",
display: "flex",
justifyContent: "space-between",
variants: {
team: {
one: {
gridArea: "team-one",
borderRadius: "$rounded $rounded 0 0",
},
two: {
gridArea: "team-two",
borderRadius: "0 0 $rounded $rounded",
},
},
},
});
const S_Score = styled("span", {
fontWeight: "bold",
});

View File

@ -1,184 +0,0 @@
import { FaDiscord, FaTwitter } from "react-icons/fa";
import { styled } from "stitches.config";
import { useTournamentData } from "../../hooks/data/useTournamentData";
export function InfoBanner() {
const { data } = useTournamentData();
const tournament = data?.tournamentByIdentifier;
if (!tournament) return null;
return (
<S_Container
style={
{
"--background": tournament.bannerBackground,
"--text": tournament.textColor,
} as any
}
>
<S_TopRow>
<S_DateName>
<S_MonthDate>
<S_Month>{shortMonthName(tournament.startTime)}</S_Month>
<S_Date>{dayNumber(tournament.startTime)}</S_Date>
</S_MonthDate>
<S_TournamentName>{tournament.name}</S_TournamentName>
</S_DateName>
<S_IconButtons>
{tournament.organizationByOrganizationIdentifier.twitterUrl && (
<S_IconButton
href={tournament.organizationByOrganizationIdentifier.twitterUrl}
>
<FaTwitter />
</S_IconButton>
)}
{tournament.organizationByOrganizationIdentifier.discordInviteUrl && (
<S_IconButton
href={
tournament.organizationByOrganizationIdentifier.discordInviteUrl
}
>
<FaDiscord />
</S_IconButton>
)}
</S_IconButtons>
</S_TopRow>
<S_BottomRow>
<S_Infos>
<S_InfoContainer>
<S_InfoLabel>Starting time</S_InfoLabel>
<div>{weekdayAndStartTime(tournament.startTime)}</div>
</S_InfoContainer>
<S_InfoContainer>
<S_InfoLabel>Format</S_InfoLabel>
<div>Double Elimination</div>
</S_InfoContainer>
<S_InfoContainer>
<S_InfoLabel>Organizer</S_InfoLabel>
<div>{tournament.organizationByOrganizationIdentifier.name}</div>
</S_InfoContainer>
</S_Infos>
</S_BottomRow>
</S_Container>
);
}
function weekdayAndStartTime(date: Date) {
return date.toLocaleString("en-US", {
weekday: "long",
hour: "numeric",
});
}
function shortMonthName(date: Date) {
return date.toLocaleString("en-US", { month: "short" });
}
function dayNumber(date: Date) {
return date.toLocaleString("en-US", { day: "numeric" });
}
const S_Container = styled("div", {
width: "100%",
padding: "$6",
background: "var(--background)",
color: "var(--text)",
"@md": {
borderRadius: "$rounded",
width: "48rem",
},
});
const S_TopRow = styled("div", {
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
gap: "$4",
});
const S_DateName = styled("div", {
display: "flex",
alignItems: "center",
gap: "$4",
});
const S_MonthDate = styled("div", {
display: "flex",
flexDirection: "column",
alignItems: "center",
lineHeight: 1.25,
});
const S_Month = styled("div", {
fontSize: "$xs",
textTransform: "uppercase",
});
const S_Date = styled("div", {
fontSize: "$lg",
fontWeight: "$bold",
});
const S_TournamentName = styled("div", {
paddingLeft: "$4",
borderColor: "var(--text)",
borderLeft: "1px solid",
fontSize: "$xl",
fontWeight: "$extraBold",
});
const S_BottomRow = styled("div", {
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
gap: "$4",
"@sm": {
flexDirection: "row",
alignItems: "flex-end",
},
});
const S_Infos = styled("div", {
display: "flex",
flexWrap: "wrap",
marginTop: "$8",
gap: "$4",
"@xs": {
gap: "$8",
},
});
const S_IconButtons = styled("div", {
display: "flex",
gap: "$4",
});
const S_InfoContainer = styled("div", {
fontSize: "$xs",
});
const S_InfoLabel = styled("label", {
fontWeight: "$extraBold",
});
const S_IconButton = styled("a", {
display: "inline-flex",
width: "2.25rem",
height: "2.25rem",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem",
border: "1px solid",
borderColor: "var(--text-transparent)",
borderRadius: "50%",
color: "inherit",
transition: "background-color 0.3s",
"&:active": {
transform: "translateY(1px)",
},
});

View File

@ -1,31 +0,0 @@
import { Mode } from "@sendou-ink/api/common";
import { modesPerStage } from "./MapPoolTab";
describe("modesPerStage()", () => {
test("lists are of right length", () => {
const mapPool = [
{ name: "a", mode: "1" as Mode },
{ name: "b", mode: "2" as Mode },
{ name: "c", mode: "3" as Mode },
{ name: "c", mode: "1" as Mode },
];
const stages = modesPerStage(mapPool);
expect(stages.a.length).toBe(1);
expect(stages.b.length).toBe(1);
expect(stages.c.length).toBe(2);
});
test("lists have right content", () => {
const mapPool = [
{ name: "a", mode: "1" as Mode },
{ name: "b", mode: "2" as Mode },
{ name: "c", mode: "3" as Mode },
{ name: "c", mode: "1" as Mode },
];
const stages = modesPerStage(mapPool);
expect(stages.c).toContain("3");
expect(stages.c).toContain("1");
});
});

View File

@ -1,126 +0,0 @@
import { styled } from "stitches.config";
import NextImage from "next/image";
import { useTournamentData } from "hooks/data/useTournamentData";
import { stages, modesShort } from "@sendou-ink/shared/constants";
import type { Mode } from "@sendou-ink/api/common";
import { ModeEnum } from "generated/graphql";
export function MapPoolTab() {
const { data } = useTournamentData();
const mapPool =
data?.tournamentByIdentifier?.mapPoolsByTournamentIdentifier.nodes.map(
(node) => node.mapModeByMapModeId
);
// TODO: handle loading
// TODO: handle error in parent
if (!mapPool) return null;
return (
<S_Container>
<S_InfoSquare>
<S_EmphasizedText>{mapPool.length} maps</S_EmphasizedText>
</S_InfoSquare>
{stages.map((stage) => (
<S_StageImageContainer key={stage}>
<S_StageImage
alt={stage}
src={`/img/stages/${stage.replaceAll(" ", "-").toLowerCase()}.png`}
filter={modesPerStage(mapPool)[stage] ? undefined : "bw"}
width={256}
height={144}
/>
{modesPerStage(mapPool)[stage] && (
<S_ModeImagesContainer>
{modesShort.map((mode) => {
if (!modesPerStage(mapPool)[stage]?.includes(mode as Mode)) {
return null;
}
return (
<NextImage
key={mode}
src={`/img/modes/${mode}.png`}
alt={mode}
width={28}
height={28}
/>
);
})}
</S_ModeImagesContainer>
)}
</S_StageImageContainer>
))}
</S_Container>
);
}
export function modesPerStage(
mapPool: {
stage: string;
gameMode: ModeEnum;
}[]
) {
return mapPool.reduce((acc: Record<string, Mode[]>, { stage, gameMode }) => {
if (!acc[stage]) {
acc[stage] = [];
}
acc[stage].push(gameMode);
return acc;
}, {});
}
const S_InfoSquare = styled("div", {
display: "grid",
placeItems: "center",
fontWeight: "$semiBold",
fontSize: "$xl",
backgroundImage: `url(/svg/background-pattern.svg)`,
backgroundColor: "$bgLighter",
borderRadius: "$rounded",
});
const S_Container = styled("div", {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "1rem",
"@xs": {
gridTemplateColumns: "repeat(2, 1fr)",
},
"@sm": {
gridTemplateColumns: "repeat(4, minmax(1px, 200px))",
},
});
const S_EmphasizedText = styled("span", {
fontWeight: "$bold",
});
const S_StageImageContainer = styled("div", {
position: "relative",
});
const S_StageImage = styled(NextImage, {
borderRadius: "$rounded",
variants: {
filter: {
bw: {
filter: "grayscale(100%)",
},
},
},
});
const S_ModeImagesContainer = styled("div", {
display: "flex",
position: "absolute",
backdropFilter: "blur(5px) grayscale(25%)",
top: 0,
left: 0,
borderRadius: "$rounded 0 $rounded 0",
padding: "$1",
gap: "$2",
});

View File

@ -1,11 +0,0 @@
import { useTournamentData } from "hooks/data/useTournamentData";
export function OverviewTab() {
const { data } = useTournamentData();
// TODO: handle loading
// TODO: handle error in parent
if (!data?.tournamentByIdentifier) return null;
return <div>{data.tournamentByIdentifier.description}</div>;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,36 +0,0 @@
query TournamentByIdentifier($identifier: String!) {
tournamentByIdentifier(identifier: $identifier) {
name
startTime
checkInTime
bannerBackground
description
textColor
organizationByOrganizationIdentifier {
name
discordInviteUrl
twitterUrl
}
tournamentTeamsByTournamentIdentifier {
nodes {
name
tournamentTeamRostersByTournamentTeamId {
nodes {
captain,
accountByMemberId {
discordFullUsername
}
}
}
}
}
mapPoolsByTournamentIdentifier {
nodes {
mapModeByMapModeId {
stage
gameMode
}
}
}
}
}

View File

@ -1,13 +0,0 @@
import { useTournamentByIdentifierQuery } from "generated/graphql";
import { useRouter } from "next/dist/client/router";
export function useTournamentData() {
const router = useRouter();
const { tournament } = router.query;
const [result] = useTournamentByIdentifierQuery({
variables: { identifier: typeof tournament === "string" ? tournament : "" },
pause: typeof tournament !== "string",
});
return result;
}

View File

@ -1,34 +0,0 @@
// jest.config.js
module.exports = {
collectCoverageFrom: [
"**/*.{js,jsx,ts,tsx}",
"!**/*.d.ts",
"!**/node_modules/**",
],
moduleNameMapper: {
/* Handle CSS imports (with CSS modules)
https://jestjs.io/docs/webpack#mocking-css-modules */
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy",
// Handle CSS imports (without CSS modules)
"^.+\\.(css|sass|scss)$": "<rootDir>/__mocks__/styleMock.js",
/* Handle image imports
https://jestjs.io/docs/webpack#handling-static-assets */
"^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$":
"<rootDir>/__mocks__/fileMock.js",
},
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/.next/"],
testEnvironment: "jsdom",
transform: {
/* Use babel-jest to transpile tests with the next/babel preset
https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object */
"^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }],
},
transformIgnorePatterns: [
"/node_modules/",
"^.+\\.module\\.(css|sass|scss)$",
],
moduleDirectories: ["node_modules", "<rootDir>"],
};

View File

@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,5 +0,0 @@
module.exports = {
images: {
formats: ["image/avif", "image/webp"],
},
};

28797
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +0,0 @@
{
"name": "@sendou.ink/frontend",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest",
"codegen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@headlessui/react": "^1.4.1",
"@stitches/react": "^1.2.5",
"graphql": "^15.7.2",
"next": "^12.0.1",
"normalize.css": "^8.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"urql": "^2.0.5",
"urql-custom-scalars-exchange": "^0.1.5"
},
"devDependencies": {
"@graphql-codegen/cli": "2.2.2",
"@graphql-codegen/introspection": "^2.1.0",
"@graphql-codegen/typescript": "2.3.1",
"@graphql-codegen/typescript-operations": "^2.2.0",
"@graphql-codegen/typescript-urql": "^3.4.0",
"@types/jest": "^27.0.2",
"@types/react": "^17.0.33",
"jest": "^27.3.1"
}
}

View File

@ -1,31 +0,0 @@
:root {
--colors-bg: hsl(237.3, 42.3%, 30.6%);
--colors-bg-lighter: hsl(237.3, 42.3%, 35.6%);
--colors-text: rgba(255, 255, 255, 0.95);
--colors-text-lighter: rgba(255, 255, 255, 0.55);
--colors-theme: rgba(103, 65, 217, 255);
--colors-theme-transparent: rgba(103, 65, 217, 0.4);
--radii-rounded: 16px;
--fonts-xl: 1.5rem;
--fonts-lg: 1.2rem;
--fonts-md: 1rem;
--fonts-sm: 0.9rem;
--fonts-xs: 0.8rem;
--text-transparent: hsla(231, 9%, 16%, 0.2);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
background-color: var(--colors-bg);
color: var(--colors-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: antialiased;
}

View File

@ -1,63 +0,0 @@
import "normalize.css";
import "./_app.css";
import { AppProps } from "next/app";
import Head from "next/head";
import { Layout } from "components/layout/Layout";
import { globalCss } from "stitches.config";
import { Provider, createClient, fetchExchange } from "urql";
import customScalarsExchange from "urql-custom-scalars-exchange";
import schema from "generated/introspection.json";
const globalStyles = globalCss({
"*": { boxSizing: "border-box" },
"*::before": { boxSizing: "border-box" },
"*::after": { boxSizing: "border-box" },
body: {
backgroundColor: "$bg",
color: "$text",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
lineHeight: 1.55,
"-webkit-font-smoothing": "antialiased",
"-moz-osx-font-smoothing": "antialiased",
},
});
const client = createClient({
url: "http://localhost:4000/graphql",
exchanges: [
customScalarsExchange({
schema: schema as any,
scalars: {
Datetime(value) {
return new Date(value);
},
},
}),
fetchExchange,
],
});
export default function App(props: AppProps) {
const { Component, pageProps } = props;
globalStyles();
return (
<>
<Head>
<title>Page title</title>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
</Head>
<Provider value={client}>
<Layout>
<Component {...pageProps} />
</Layout>
</Provider>
</>
);
}

View File

@ -1,21 +0,0 @@
import NextDocument, { Html, Head, Main, NextScript } from "next/document";
import { getCssText } from "stitches.config";
export default class Document extends NextDocument {
render() {
return (
<Html lang="en">
<Head>
<style
id="stitches"
dangerouslySetInnerHTML={{ __html: getCssText() }}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}

View File

@ -1,21 +0,0 @@
import Head from "next/head";
export default function Home() {
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
<h1 className="text-6xl font-bold">
Welcome to{" "}
<a className="text-blue-600" href="https://nextjs.org">
Next.js!
</a>
</h1>
</main>
</div>
);
}

View File

@ -1,79 +0,0 @@
import { InfoBanner } from "components/tournament/InfoBanner";
import { Tab } from "components/common/Tab";
import { styled } from "stitches.config";
import { MapPoolTab } from "components/tournament/MapPoolTab";
import { OverviewTab } from "components/tournament/OverviewTab";
import { Select } from "components/common/Select";
import { useState } from "react";
import { BracketTab } from "components/tournament/BracketTab";
const tabs = [
{ name: "Overview", id: "info", component: <OverviewTab /> },
{ name: "Map Pool", id: "map-pool", component: <MapPoolTab /> },
{ name: "Bracket", id: "bracket", component: <BracketTab /> },
{ name: "Teams (23)", id: "teams", component: null },
{ name: "Streams (4)", id: "streams", component: null },
];
export default function TournamentPage() {
const [activeTab, setActiveTab] = useState(tabs[0].id);
return (
<S_Container>
<InfoBanner />
<S_TabsContainer>
<Tab.Group>
<Tab.List tabsCount={tabs.length}>
{tabs.map((tab) => (
<Tab key={tab.id}>{tab.name}</Tab>
))}
</Tab.List>
<Tab.Panels>
{tabs.map((tab) => (
<Tab.Panel key={tab.id}>{tab.component}</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</S_TabsContainer>
<S_MobileContent>
<S_SelectContainer>
<Select
values={tabs}
onChange={(e) => setActiveTab(e.target.value)}
selected={activeTab}
/>
</S_SelectContainer>
{tabs.find((tab) => tab.id === activeTab)!.component}
</S_MobileContent>
</S_Container>
);
}
const S_Container = styled("div", {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "$8",
});
const S_TabsContainer = styled("div", {
display: "none",
"@sm": {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "$8",
},
});
const S_SelectContainer = styled("div", {
width: "12rem",
margin: "0 auto",
marginBottom: "$8",
});
const S_MobileContent = styled("div", {
"@sm": {
display: "none",
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,85 +0,0 @@
import { createStitches } from "@stitches/react";
export const {
styled,
css,
globalCss,
keyframes,
getCssText,
theme,
createTheme,
config,
} = createStitches({
theme: {
colors: {
bg: "hsl(237.3, 42.3%, 30.6%)",
bgLighter: "hsl(237.3, 42.3%, 35.6%)",
text: "rgba(255, 255, 255, 0.95)",
textLighter: "rgba(255, 255, 255, 0.55)",
theme: "rgba(103, 65, 217, 255)",
themeTransparent: "rgba(103, 65, 217, 0.4)",
},
radii: {
rounded: "16px",
},
fontSizes: {
xl: "1.5rem",
lg: "1.2rem",
md: "1rem",
sm: "0.9rem",
xs: "0.8rem",
xxs: "0.7rem",
},
fontWeights: {
extraBold: 700,
bold: 600,
semiBold: 500,
body: 400,
},
space: {
// scale loaned from Tailwind CSS
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"7": "1.75rem",
"8": "2rem",
"9": "2.25rem",
"10": "2.5rem",
"11": "2.75rem",
"12": "3rem",
"14": "3.5rem",
"16": "4rem",
"20": "5rem",
"24": "6rem",
"28": "7rem",
"32": "8rem",
"36": "9rem",
"40": "10rem",
"44": "11rem",
"48": "12rem",
"52": "13rem",
"56": "14rem",
"60": "15rem",
"64": "16rem",
"72": "18rem",
"80": "20rem",
"96": "24rem",
},
},
media: {
xs: "(min-width: 480px)",
sm: "(min-width: 640px)",
md: "(min-width: 768px)",
lg: "(min-width: 1024px)",
xl: "(min-width: 1280px)",
},
utils: {
marginX: (value: string) => ({ marginLeft: value, marginRight: value }),
marginY: (value: string) => ({ marginTop: value, marginBottom: value }),
paddingX: (value: string) => ({ paddingLeft: value, paddingRight: value }),
paddingY: (value: string) => ({ paddingTop: value, paddingBottom: value }),
},
});

View File

@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "."
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@ -1,4 +0,0 @@
// https://stackoverflow.com/a/52910586
export type Serialized<T> = {
[P in keyof T]: T[P] extends Date ? string : Serialized<T[P]>;
};

68
index.css Normal file
View File

@ -0,0 +1,68 @@
:root {
--bg: hsl(237.3, 42.3%, 30.6%);
--bg-lighter: hsl(237.3, 42.3%, 35.6%);
--text: rgba(255, 255, 255, 0.95);
--text-lighter: rgba(255, 255, 255, 0.55);
--theme: rgba(103, 65, 217);
--theme-transparent: rgba(103, 65, 217, 0.4);
--rounded: 16px;
--fonts-xl: 1.5rem;
--fonts-lg: 1.2rem;
--fonts-md: 1rem;
--fonts-sm: 0.9rem;
--fonts-xs: 0.8rem;
--fonts-xxs: 0.7rem;
--extra-bold: 700;
--bold: 600;
--semi-bold: 500;
--body: 400;
--s-1: 0.25rem;
--s-2: 0.5rem;
--s-3: 0.75rem;
--s-4: 1rem;
--s-5: 1.25rem;
--s-6: 1.5rem;
--s-7: 1.75rem;
--s-8: 2rem;
--s-9: 2.25rem;
--s-10: 2.5rem;
--s-11: 2.75rem;
--s-12: 3rem;
--s-14: 3.5rem;
--s-16: 4rem;
--s-20: 5rem;
--s-24: 6rem;
--s-28: 7rem;
--s-32: 8rem;
--s-40: 10rem;
--s-48: 12rem;
--s-56: 14rem;
--s-64: 16rem;
--s-72: 18rem;
--s-80: 20rem;
--s-96: 2rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: antialiased;
}
*:focus:not(:focus-visible) {
outline: none !important;
}

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>sendou.ink</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/index.tsx" type="module"></script>
</body>
</html>

7
index.tsx Normal file
View File

@ -0,0 +1,7 @@
import { render } from "solid-js/web";
import "normalize.css";
import "./index.css";
import App from "./App";
render(() => <App />, document.getElementById("root") as HTMLElement);

21799
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,22 @@
{
"name": "sendou.ink",
"version": "1.0.0",
"private": true,
"workspaces": [
"frontend",
"api",
"shared"
],
"name": "vite-template-solid",
"version": "0.0.0",
"description": "",
"scripts": {
"format:check": "npx prettier --check ."
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"prettier": "2.4.1"
"typescript": "^4.4.3",
"vite": "^2.5.7",
"vite-plugin-solid": "^2.0.3"
},
"dependencies": {
"normalize.css": "^8.0.1",
"solid-app-router": "^0.1.11",
"solid-js": "^1.1.3"
}
}

View File

@ -1,136 +0,0 @@
require("dotenv").config();
const { Pool } = require("pg");
const TEST_DATABASE_URL = process.env.DATABASE_URL;
if (!TEST_DATABASE_URL) {
throw new Error("Cannot run tests without a TEST_DATABASE_URL");
}
const pools = {};
// Make sure we release those pgPools so that our tests exit!
afterAll(() => {
const keys = Object.keys(pools);
return Promise.all(
keys.map(async (key) => {
try {
const pool = pools[key];
delete pools[key];
pool.end();
} catch (e) {
console.error("Failed to release connection!");
console.error(e);
}
})
);
});
const poolFromUrl = (url) => {
if (!pools[url]) {
pools[url] = new Pool({ connectionString: url });
}
return pools[url];
};
const withDbFromUrl = async (url, fn) => {
/** @type {import ("pg").Pool} */
const pool = poolFromUrl(url);
const client = await pool.connect();
await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE;");
try {
await fn(client);
} catch (e) {
// Error logging can be helpful:
if (typeof e.code === "string" && e.code.match(/^[0-9A-Z]{5}$/)) {
console.error([e.message, e.code, e.detail, e.hint, e.where].join("\n"));
}
throw e;
} finally {
await client.query("ROLLBACK;");
await client.query("RESET ALL;"); // Shouldn't be necessary, but just in case...
client.release();
}
};
const withRootDb = (fn) => withDbFromUrl(TEST_DATABASE_URL, fn);
// const becomeRoot = (client) => client.query("reset role");
/******************************************************************************
** **
** BELOW HERE, YOU'LL WANT TO CUSTOMISE FOR YOUR OWN DATABASE SCHEMA **
** **
******************************************************************************/
// export const becomeUser = async (
// client: PoolClient,
// userOrUserId: User | string | null
// ) => {
// await becomeRoot(client);
// const session = userOrUserId
// ? await createSession(
// client,
// typeof userOrUserId === "object" ? userOrUserId.id : userOrUserId
// )
// : null;
// await client.query(
// `select set_config('role', $1::text, true),
// set_config('jwt.claims.session_id', $2::text, true)`,
// [process.env.DATABASE_VISITOR, session ? session.uuid : ""]
// );
// };
// // Enables multiple calls to `createUsers` within the same test to still have
// // deterministic results without conflicts.
// let userCreationCounter = 0;
// beforeEach(() => {
// userCreationCounter = 0;
// });
// export const createUsers = async function createUsers(
// client: PoolClient,
// count: number = 1,
// verified: boolean = true
// ) {
// const users = [];
// if (userCreationCounter > 25) {
// throw new Error("Too many users created!");
// }
// for (let i = 0; i < count; i++) {
// const userLetter = "abcdefghijklmnopqrstuvwxyz"[userCreationCounter];
// userCreationCounter++;
// const password = userLetter.repeat(12);
// const email = `${userLetter}${i || ""}@b.c`;
// const user: User = (
// await client.query(
// `SELECT * FROM app_private.really_create_user(
// username := $1,
// email := $2,
// email_is_verified := $3,
// name := $4,
// avatar_url := $5,
// password := $6
// )`,
// [
// `testuser_${userLetter}`,
// email,
// verified,
// `User ${userLetter}`,
// null,
// password,
// ]
// )
// ).rows[0];
// expect(user.id).not.toBeNull();
// user._email = email;
// user._password = password;
// users.push(user);
// }
// return users;
// };
module.exports = {
withRootDb,
};

View File

@ -1,64 +0,0 @@
const { withRootDb } = require("./helpers");
describe("checks identifier", () => {
test("rejects invalid characters", () =>
withRootDb(async (pgClient) => {
try {
await pgClient.query(
`
insert into sendou_ink.organization (identifier, owner_id, name, discord_invite_code)
values ('in-the-zone-å', 1, 'Sendou', '');
`
);
throw new Error("query should not pass");
} catch (e) {
expect(e.message).toContain("violates check constraint");
}
}));
test("rejects too short", () =>
withRootDb(async (pgClient) => {
try {
await pgClient.query(
`
insert into sendou_ink.organization (identifier, owner_id, name, discord_invite_code)
values ('i', 1, 'Sendou', '');
`
);
throw new Error("query should not pass");
} catch (e) {
expect(e.message).toContain("violates check constraint");
}
}));
test("accepts normal valid", () =>
withRootDb(async (pgClient) => {
const {
rows: [organization],
} = await pgClient.query(
`
insert into sendou_ink.organization (identifier, owner_id, name, discord_invite_code)
values ('in-the-zone', 1, 'Sendou', '')
returning *;
`
);
expect(organization.identifier).toBe("in-the-zone");
}));
test("accepts long", () =>
withRootDb(async (pgClient) => {
const identifier = new Array(50).fill("a").join("");
const {
rows: [organization],
} = await pgClient.query(
`
insert into sendou_ink.organization (identifier, owner_id, name, discord_invite_code)
values ('${identifier}', 1, 'Sendou', '')
returning *;
`
);
expect(organization.identifier).toBe(identifier);
}));
});

View File

@ -1,67 +0,0 @@
const { withRootDb } = require("./helpers");
describe("checks hsl args", () => {
test("rejects invalid hsl arg", () =>
withRootDb(async (pgClient) => {
try {
await pgClient.query(
`
insert into sendou_ink.tournament (banner_text_hsl_args, identifier, name, description, start_time, banner_background, organization_identifier)
values ('31 9% 16%;', 'in-the-zone-xi', 'In The Zone X', 'In The Zone eXtremeeeee', '2022-06-22 20:00:00', 'linear-gradient(to bottom, #9796f0, #fbc7d4)', 'sendous');
`
);
throw new Error("query should not pass");
} catch (e) {
expect(e.message).toContain("violates check constraint");
}
}));
test("accepts normal valid", () =>
withRootDb(async (pgClient) => {
const {
rows: [tournament],
} = await pgClient.query(
`
insert into sendou_ink.tournament (banner_text_hsl_args, identifier, name, description, start_time, banner_background, organization_identifier)
values ('31 9% 16%', 'in-the-zone-xi', 'In The Zone X', 'In The Zone eXtremeeeee', '2022-06-22 20:00:00', 'linear-gradient(to bottom, #9796f0, #fbc7d4)', 'sendous')
returning *;
`
);
expect(tournament.banner_text_hsl_args).toBe("31 9% 16%");
}));
});
describe("checks dates", () => {
test("rejects start time in the past", () =>
withRootDb(async (pgClient) => {
try {
await pgClient.query(
`
insert into sendou_ink.tournament (start_time, check_in_time, banner_text_hsl_args, identifier, name, description, banner_background, organization_identifier)
values ('2020-06-22 20:00:00', '2020-06-22 19:00:00', '31 9% 16%', 'in-the-zone-xi', 'In The Zone X', 'In The Zone eXtremeeeee', 'linear-gradient(to bottom, #9796f0, #fbc7d4)', 'sendous')
returning *;
`
);
throw new Error("query should not pass");
} catch (e) {
expect(e.message).toContain("violates check constraint");
}
}));
test("rejects check-in time after start time", () =>
withRootDb(async (pgClient) => {
try {
await pgClient.query(
`
insert into sendou_ink.tournament (start_time, check_in_time, banner_text_hsl_args, identifier, name, description, banner_background, organization_identifier)
values ('2022-06-22 20:00:00', '2022-06-22 21:00:00', '31 9% 16%', 'in-the-zone-xi', 'In The Zone X', 'In The Zone eXtremeeeee', 'linear-gradient(to bottom, #9796f0, #fbc7d4)', 'sendous')
returning *;
`
);
throw new Error("query should not pass");
} catch (e) {
expect(e.message).toContain("violates check constraint");
}
}));
});

View File

@ -1,159 +0,0 @@
import { config } from "dotenv";
config();
import Fastify, { FastifyReply, FastifyRequest } from "fastify";
import cors from "fastify-cors";
import {
postgraphile,
PostGraphileResponseFastify3,
PostGraphileResponse,
PostGraphileOptions,
} from "postgraphile";
const PORT = 4000;
function NonNullRelationsPlugin(builder) {
builder.hook("GraphQLObjectType:fields:field", (field, build, context) => {
if (!context.scope.isPgForwardRelationField) {
return field;
}
return {
...field,
type: new build.graphql.GraphQLNonNull(field.type),
};
});
}
// PostGraphile options; see https://www.graphile.org/postgraphile/usage-library/#api-postgraphilepgconfig-schemaname-options
const options: PostGraphileOptions = {
// pluginHook,
appendPlugins: [NonNullRelationsPlugin],
// pgSettings(req) {
// // Adding this to ensure that all servers pass through the request in a
// // good enough way that we can extract headers.
// // CREATE FUNCTION current_user_id() RETURNS text AS $$ SELECT current_setting('graphile.test.x-user-id', TRUE); $$ LANGUAGE sql STABLE;
// return {
// 'graphile.test.x-user-id':
// req.headers['x-user-id'] ||
// // `normalizedConnectionParams` comes from websocket connections, where
// // the headers often cannot be customized by the client.
// (req as any).normalizedConnectionParams?.['x-user-id'],
// };
// },
watchPg: true,
graphiql: true,
enhanceGraphiql: true,
subscriptions: true,
dynamicJson: true,
setofFunctionsContainNulls: false,
ignoreRBAC: false,
showErrorStack: "json",
extendedErrors: ["hint", "detail", "errcode"],
allowExplain: true,
legacyRelations: "omit",
// exportGqlSchemaPath: `${__dirname}/schema.graphql`,
sortExport: true,
};
const middleware = postgraphile(process.env.DATABASE_URL, "sendou_ink", {
...options,
// pgSettings(req) {
// // Adding this to ensure that all servers pass through the request in a
// // good enough way that we can extract headers.
// // CREATE FUNCTION current_user_id() RETURNS text AS $$ SELECT current_setting('graphile.test.x-user-id', TRUE); $$ LANGUAGE sql STABLE;
// return {
// 'graphile.test.x-user-id':
// // In GraphiQL, open console and enter `document.cookie = "userId=3"` to become user 3.
// (req._fastifyRequest as FastifyRequest)?.cookies.userId ||
// // `normalizedConnectionParams` comes from websocket connections, where
// // the headers often cannot be customized by the client.
// (req as any).normalizedConnectionParams?.['x-user-id'],
// };
// },
});
/******************************************************************************/
// These middlewares aren't needed; we just add them to make sure that
// PostGraphile still works correctly with them in place.
// import fastifyCompression from 'fastify-compress';
// fastify.register(fastifyCompression, { threshold: 0, inflateIfDeflated: false });
// import fastifyCookie from 'fastify-cookie';
// fastify.register(fastifyCookie, { secret: 'USE_A_SECURE_SECRET!' });
/******************************************************************************/
const fastify = Fastify({ logger: true });
fastify.register(cors);
/**
* Converts a PostGraphile route handler into a Fastify request handler.
*/
const convertHandler =
(handler: (res: PostGraphileResponse) => Promise<void>) =>
(request: FastifyRequest, reply: FastifyReply) =>
handler(new PostGraphileResponseFastify3(request, reply));
// IMPORTANT: do **NOT** change these routes here; if you want to change the
// routes, do so in PostGraphile options. If you change the routes here only
// then GraphiQL won't know where to find the GraphQL endpoint and the GraphQL
// endpoint won't know where to indicate the EventStream for watch mode is.
// (There may be other problems too.)
// OPTIONS requests, for CORS/etc
fastify.options(
middleware.graphqlRoute,
convertHandler(middleware.graphqlRouteHandler)
);
// This is the main middleware
fastify.post(
middleware.graphqlRoute,
convertHandler(middleware.graphqlRouteHandler)
);
// GraphiQL, if you need it
if (middleware.options.graphiql) {
if (middleware.graphiqlRouteHandler) {
fastify.head(
middleware.graphiqlRoute,
convertHandler(middleware.graphiqlRouteHandler)
);
fastify.get(
middleware.graphiqlRoute,
convertHandler(middleware.graphiqlRouteHandler)
);
}
// Remove this if you don't want the PostGraphile logo as your favicon!
if (middleware.faviconRouteHandler) {
fastify.get("/favicon.ico", convertHandler(middleware.faviconRouteHandler));
}
}
// If you need watch mode, this is the route served by the
// X-GraphQL-Event-Stream header; see:
// https://github.com/graphql/graphql-over-http/issues/48
if (middleware.options.watchPg) {
if (middleware.eventStreamRouteHandler) {
fastify.options(
middleware.eventStreamRoute,
convertHandler(middleware.eventStreamRouteHandler)
);
fastify.get(
middleware.eventStreamRoute,
convertHandler(middleware.eventStreamRouteHandler)
);
}
}
fastify.listen(PORT, (err, address) => {
if (err) {
fastify.log.error(String(err));
process.exit(1);
}
fastify.log.info(
`PostGraphiQL available at ${address}${middleware.graphiqlRoute} 🚀`
);
});

View File

@ -1,155 +0,0 @@
-- todo dev only
drop schema if exists sendou_ink cascade;
drop schema if exists sendou_ink_private cascade;
create extension if not exists "uuid-ossp";
create schema sendou_ink;
create schema sendou_ink_private;
create function sendou_ink_private.set_updated_at() returns trigger as $$
begin
new.updated_at := current_timestamp;
return new;
end;
$$ language plpgsql;
-- ACCOUNT
-- no checks because this data is not provided by user but with the log in event
create table sendou_ink.account (
id serial primary key,
discord_id text not null,
discord_username text not null,
discord_discriminator text not null,
discord_avatar text,
twitch text,
twitter text,
youtube_id text,
youtube_name text,
created_at timestamp default now(),
updated_at timestamp default now()
);
create function sendou_ink.account_discord_full_username(account sendou_ink.account) returns text as $$
select account.discord_username || '#' || account.discord_discriminator
$$ language sql stable;
create trigger account_updated_at before update
on sendou_ink.account
for each row
execute procedure sendou_ink_private.set_updated_at();
-- ORGANIZATION
create table sendou_ink.organization (
identifier text primary key
check (identifier ~ '^[a-z0-9-]{2,50}$'),
name text not null
check (char_length(name) < 51),
discord_invite_code text not null
check (char_length(discord_invite_code) < 51),
twitter text
check (twitter ~ '^[a-zA-Z0-9_]{4,15}$'),
created_at timestamp default now(),
updated_at timestamp default now(),
owner_id integer not null references sendou_ink.account(id)
);
create function sendou_ink.organization_twitter_url(organization sendou_ink.organization) returns text as $$
select 'https://twitter.com/' || organization.twitter
$$ language sql stable;
create function sendou_ink.organization_discord_invite_url(organization sendou_ink.organization) returns text as $$
select 'https://discord.com/invite/' || organization.discord_invite_code
$$ language sql stable;
create index organization_owner_id on sendou_ink.organization (owner_id);
create trigger organization_updated_at before update
on sendou_ink.organization
for each row
execute procedure sendou_ink_private.set_updated_at();
-- TOURNAMENT
create table sendou_ink.tournament (
identifier text primary key
check (identifier ~ '^[a-z0-9-]{2,50}$'),
name text not null
check (char_length(name) < 51),
description text not null
check (char_length(description) < 5000),
start_time timestamp not null
check (start_time > now()),
check_in_time timestamp,
-- TODO check
banner_background text not null,
banner_text_hsl_args text not null
check (banner_text_hsl_args ~ '^[0-9]{1,3} [0-9]{1,3}% [0-9]{1,3}%$'),
created_at timestamp default now(),
updated_at timestamp default now(),
organization_identifier text not null references sendou_ink.organization(identifier),
CHECK (check_in_time < start_time)
);
create function sendou_ink.tournament_text_color(tournament sendou_ink.tournament) returns text as $$
select 'hsl(' || tournament.banner_text_hsl_args || ')'
$$ language sql stable;
create index tournament_organization_identifier on sendou_ink.tournament(organization_identifier);
create trigger tournament_updated_at before update
on sendou_ink.tournament
for each row
execute procedure sendou_ink_private.set_updated_at();
create table sendou_ink.mode_enum (
name text primary key
);
comment on table sendou_ink.mode_enum is E'@enum';
insert into sendou_ink.mode_enum (name) values
('TW'),
('SZ'),
('TC'),
('RM'),
('CB');
create table sendou_ink.map_mode (
id serial primary key,
stage text not null,
game_mode text not null references sendou_ink.mode_enum(name),
unique (stage, game_mode)
);
create table sendou_ink.map_pool (
tournament_identifier text not null references sendou_ink.tournament(identifier),
map_mode_id integer not null references sendou_ink.map_mode(id),
unique (tournament_identifier, map_mode_id)
);
create index map_pool_tournament_identifier on sendou_ink.map_pool(tournament_identifier);
create index map_pool_map_mode_id on sendou_ink.map_pool(map_mode_id);
create table sendou_ink.tournament_team (
id serial primary key,
name text not null
check (char_length(name) < 51),
checked_in boolean not null default false,
tournament_identifier text not null references sendou_ink.tournament(identifier),
-- todo restrict access
invite_code uuid not null unique default uuid_generate_v1mc(),
unique (name, tournament_identifier)
);
create index tournament_team_tournament_identifier on sendou_ink.tournament_team(tournament_identifier);
create table sendou_ink.tournament_team_roster (
member_id integer not null references sendou_ink.account(id),
tournament_team_id integer not null references sendou_ink.tournament_team(id),
captain boolean not null default false,
unique (member_id, tournament_team_id)
);
create index tournament_team_roster_member_id on sendou_ink.tournament_team_roster(member_id);
create index tournament_team_roster_tournament_team_id on sendou_ink.tournament_team_roster(tournament_team_id);

View File

@ -1,18 +0,0 @@
import { migrate } from "postgres-migrations";
async function main() {
const dbConfig = {
// TODO: env vars
database: "sendou_ink_postgraphile",
user: "sendou",
password: "password",
host: "localhost",
port: 5432,
ensureDatabaseExists: true,
defaultDatabase: "postgres",
};
await migrate(dbConfig, "migrations");
}
main().then(() => console.log("Migrations done"));

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
{
"name": "@sendou.ink/postgraphile",
"version": "1.0.0",
"dependencies": {
"fastify": "^3.22.1",
"fastify-cors": "^6.0.2",
"postgraphile": "^4.12.5",
"postgres-migrations": "^5.3.0"
},
"devDependencies": {
"@types/faker": "^5.5.9",
"dotenv": "^10.0.0",
"faker": "^5.5.3",
"jest": "^27.3.1",
"pg": "^8.7.1",
"ts-node-dev": "^1.1.8"
},
"scripts": {
"dev": "ts-node-dev index.ts",
"delete-migrations": "node scripts/delete-migrations.js",
"migrate": "node migrations/index.mjs",
"seed": "node scripts/seed.js",
"migrate:reset": "npm run delete-migrations && npm run migrate && npm run seed",
"test": "jest"
},
"jest": {
"testPathIgnorePatterns": [
"__tests__/helpers.js"
]
}
}

View File

@ -1,15 +0,0 @@
require("dotenv").config();
const { Client } = require("pg");
async function main() {
const client = new Client({ connectionString: process.env.DATABASE_URL });
try {
await client.connect();
await client.query("drop table migrations;");
} finally {
client.end();
}
}
main();

View File

@ -1,161 +0,0 @@
require("dotenv").config();
const { Client } = require("pg");
const faker = require("faker");
const stages = [
"The Reef",
"Musselforge Fitness",
"Starfish Mainstage",
"Humpback Pump Track",
"Inkblot Art Academy",
"Sturgeon Shipyard",
"Moray Towers",
"Port Mackerel",
"Manta Maria",
"Kelp Dome",
"Snapper Canal",
"Blackbelly Skatepark",
"MakoMart",
"Walleye Warehouse",
"Shellendorf Institute",
"Arowana Mall",
"Goby Arena",
"Piranha Pit",
"Camp Triggerfish",
"Wahoo World",
"New Albacore Hotel",
"Ancho-V Games",
"Skipper Pavilion",
];
const modesShort = ["TW", "SZ", "TC", "RM", "CB"];
async function main() {
const client = new Client({ connectionString: process.env.DATABASE_URL });
try {
await client.connect();
await client.query(`
insert into sendou_ink.account (discord_username, discord_discriminator, discord_avatar, twitch, twitter, youtube_id, youtube_name, discord_id)
values ('Sendou', '0043', 'fcfd65a3bea598905abb9ca25296816b', 'Sendou', 'Sendouc', 'UCWbJLXByvsfQvTcR4HLPs5Q', 'Sendou', '79237403620945920');
`);
await client.query(`
insert into sendou_ink.organization (identifier, name, discord_invite_code, twitter, owner_id)
values ('sendous', 'Sendou´s tournaments', 'sendou', 'sendouc', 1);
`);
await client.query(`
insert into sendou_ink.tournament (identifier, name, description, start_time, banner_background, banner_text_hsl_args, organization_identifier)
values ('in-the-zone-x', 'In The Zone X', 'In The Zone eXtremeeeee', '2022-06-22 20:00:00', 'linear-gradient(to bottom, #9796f0, #fbc7d4)', '31 9% 16%', 'sendous');
`);
for (const stage of stages) {
for (const mode of modesShort) {
await client.query(
`
insert into sendou_ink.map_mode (stage, game_mode)
values ($1, $2);
`,
[stage, mode]
);
}
}
await mapPool(client);
await fakeUsers(client);
await fakeTournamentTeams(client);
} finally {
client.end();
}
}
async function mapPool(client) {
const ids = Array.from(
new Set(new Array(24).fill(null).map(() => getRandomInt(115)))
);
for (const id of ids) {
await client.query(
`
insert into sendou_ink.map_pool (tournament_identifier, map_mode_id)
values ('in-the-zone-x', $1);
`,
[id]
);
}
}
async function fakeUsers(client) {
for (let index = 0; index < 200; index++) {
const name = faker.name.firstName();
const discordDiscriminator = Array(4)
.fill(null)
.map(() => faker.datatype.number(9))
.join("");
const discordId = Array(17)
.fill(null)
.map(() => faker.datatype.number(9))
.join("");
await client.query(
`
insert into sendou_ink.account (discord_username, discord_discriminator, discord_id)
values ($1, $2, $3);
`,
[name, discordDiscriminator, discordId]
);
}
}
async function fakeTournamentTeams(client) {
const randomIds = faker.helpers.shuffle(
Array(201)
.fill(null)
.map((_, i) => i + 1)
);
for (let index = 0; index < 24; index++) {
const name = faker.address.cityName();
const captainId = randomIds.pop();
const {
rows: [tournamentTeam],
} = await client.query(
`
insert into sendou_ink.tournament_team (name, tournament_identifier)
values ($1, 'in-the-zone-x')
returning *;
`,
[name]
);
await client.query(
`
insert into sendou_ink.tournament_team_roster (member_id, tournament_team_id, captain)
values ($1, $2, true);
`,
[captainId, tournamentTeam.id]
);
for (let index = 0; index < faker.datatype.number(6); index++) {
const memberId = randomIds.pop();
await client.query(
`
insert into sendou_ink.tournament_team_roster (member_id, tournament_team_id)
values ($1, $2);
`,
[memberId, tournamentTeam.id]
);
}
}
}
function getRandomInt(maxInclusive) {
let result = -1;
while (result < 24) {
result = Math.floor(Math.random() * maxInclusive) + 1;
}
return result;
}
main();

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
public/img/layout/gear.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/img/layout/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/img/layout/maps.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/img/layout/u.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 626 KiB

View File

Before

Width:  |  Height:  |  Size: 634 KiB

After

Width:  |  Height:  |  Size: 634 KiB

View File

Before

Width:  |  Height:  |  Size: 738 KiB

After

Width:  |  Height:  |  Size: 738 KiB

Some files were not shown because too many files have changed in this diff Show More