mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Team: UI initial
This commit is contained in:
parent
1a8c2fc892
commit
d51beef9d5
|
|
@ -233,3 +233,40 @@ export interface TournamentMatchGameResult {
|
|||
reporterId: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** TODO: incomplete team tables */
|
||||
|
||||
export interface UserSubmittedImage {
|
||||
id: number;
|
||||
validatedAt: number | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
customUrl: string;
|
||||
inviteCode: string;
|
||||
bio: string | null;
|
||||
lutiDiv: string | null;
|
||||
avatarImgId: number | null;
|
||||
bannerImgId: number | null;
|
||||
createdAt: number;
|
||||
deletedAt: number | null;
|
||||
}
|
||||
|
||||
export type MemberRole =
|
||||
| "CAPTAIN"
|
||||
| "FRONTLINE"
|
||||
| "SUPPORT"
|
||||
| "BACKLINE"
|
||||
| "COACH";
|
||||
|
||||
export interface TeamMember {
|
||||
teamId: number;
|
||||
userId: number;
|
||||
role: MemberRole | null;
|
||||
isCaptain: number;
|
||||
createdAt: number;
|
||||
leftAt: number | null;
|
||||
}
|
||||
|
|
|
|||
57
app/features/team/queries/findByIdentifier.server.ts
Normal file
57
app/features/team/queries/findByIdentifier.server.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { DetailedTeam } from "../team-types";
|
||||
|
||||
export function findByIdentifier(customUrl: string): DetailedTeam | null {
|
||||
return {
|
||||
countries: ["FR"],
|
||||
name: "Alliance Rogue",
|
||||
lutiDiv: "X",
|
||||
teamXp: "2981.2",
|
||||
twitter: "AllianceRogueFR",
|
||||
avatarSrc:
|
||||
"https://pbs.twimg.com/profile_images/1567529215523786754/RYRI0cNc_400x400.jpg",
|
||||
bannerSrc: "https://abload.de/img/fjkfa-uxkamgdbxr3iqn.jpeg",
|
||||
members: [
|
||||
{
|
||||
discordName: "Kiver",
|
||||
discordAvatar: "2ce2f7e4fe6cd2aeceec940ecebcb22c",
|
||||
discordId: "92909500100513792",
|
||||
role: "FRONTLINE",
|
||||
weapons: [40, 20, 210, 1000],
|
||||
},
|
||||
{
|
||||
discordName: "Scar",
|
||||
discordAvatar: "fcf2508d5bc21046108a19d26cafce31",
|
||||
discordId: "129931199383601153",
|
||||
role: "FRONTLINE",
|
||||
weapons: [20, 3020],
|
||||
},
|
||||
{
|
||||
discordName: "Grey",
|
||||
discordAvatar: "a_ce66d041d5bde589099ebc68ecf74da0",
|
||||
discordId: "99931397451419648",
|
||||
role: "SUPPORT",
|
||||
weapons: [20, 10, 1030],
|
||||
},
|
||||
{
|
||||
discordName: "Jay",
|
||||
discordAvatar: "fbafa57c8b571378806c743dcad6a067",
|
||||
discordId: "273503438124613632",
|
||||
role: "BACKLINE",
|
||||
weapons: [2040],
|
||||
},
|
||||
],
|
||||
results: {
|
||||
count: 23,
|
||||
placements: [
|
||||
{
|
||||
count: 10,
|
||||
placement: 1,
|
||||
},
|
||||
{
|
||||
count: 5,
|
||||
placement: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
184
app/features/team/routes/t.$customUrl.tsx
Normal file
184
app/features/team/routes/t.$customUrl.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import type {
|
||||
LinksFunction,
|
||||
LoaderArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { notFoundIfFalsy } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { navIconUrl, userPage } from "~/utils/urls";
|
||||
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import styles from "../team.css";
|
||||
import type { DetailedTeamMember } from "../team-types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
export const meta: MetaFunction = ({
|
||||
data,
|
||||
}: {
|
||||
data: SerializeFrom<typeof loader>;
|
||||
}) => {
|
||||
if (!data) return {};
|
||||
|
||||
return {
|
||||
title: makeTitle(data.team.name),
|
||||
description: data.team.bio,
|
||||
};
|
||||
};
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
// breadcrumb: () => ({
|
||||
// imgPath: navIconUrl("object-damage-calculator"),
|
||||
// href: OBJECT_DAMAGE_CALCULATOR_URL,
|
||||
// type: "IMAGE",
|
||||
// }),
|
||||
};
|
||||
|
||||
export const loader = ({ params }: LoaderArgs) => {
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
|
||||
return { team };
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<div className="stack sm">
|
||||
<TeamBanner />
|
||||
<InfoBadges />
|
||||
</div>
|
||||
<ResultsBanner />
|
||||
<div className="stack lg">
|
||||
{team.members.map((member) => (
|
||||
<MemberRow key={member.discordId} member={member} />
|
||||
))}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamBanner() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="team__banner"
|
||||
style={
|
||||
{
|
||||
// xxx: some fallback banner image
|
||||
"--team-banner-img": team.bannerSrc ? `url("${team.bannerSrc}")` : "",
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{team.avatarSrc ? (
|
||||
<div className="team__banner__avatar">
|
||||
<div>
|
||||
<img src={team.avatarSrc} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="team__banner__flags">
|
||||
{/* xxx: map to real flags */}
|
||||
<img
|
||||
src="https://twemoji.maxcdn.com/v/latest/svg/1f1eb-1f1ee.svg"
|
||||
alt="Flag of Finland"
|
||||
width={48}
|
||||
/>
|
||||
</div>
|
||||
<div className="team__banner__name">{team.name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBadges() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="team__badges">
|
||||
{team.teamXp ? (
|
||||
<div>
|
||||
<Image
|
||||
path={navIconUrl("xsearch")}
|
||||
width={26}
|
||||
alt="Team XP"
|
||||
title="Team XP"
|
||||
/>
|
||||
{team.teamXp}
|
||||
</div>
|
||||
) : null}
|
||||
{team.lutiDiv ? <div>LUTI Div {team.lutiDiv}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsBanner() {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
if (!team.results) return null;
|
||||
|
||||
return (
|
||||
<Link className="team__results" to="results">
|
||||
<div>View {team.results.count} results</div>
|
||||
<ul className="team__results__placements">
|
||||
{team.results.placements.map(({ placement, count }) => {
|
||||
return (
|
||||
<li key={placement}>
|
||||
<Placement placement={placement} />×{count}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({ member }: { member: DetailedTeamMember }) {
|
||||
const { t } = useTranslation(["team"]);
|
||||
|
||||
return (
|
||||
<div className="team__member">
|
||||
{member.role ? (
|
||||
<span className="team__member__role">
|
||||
{t(`team:roles.${member.role}`)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="team__member__section">
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="team__member__avatar-name-container"
|
||||
>
|
||||
<div className="team__member__avatar">
|
||||
<Avatar user={member} size="md" />
|
||||
</div>
|
||||
{member.discordName}
|
||||
</Link>
|
||||
<div className="stack horizontal md">
|
||||
{member.weapons.map((weapon) => (
|
||||
<WeaponImage
|
||||
key={weapon}
|
||||
variant="badge"
|
||||
weaponSplId={weapon}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
app/features/team/team-schemas.server.ts
Normal file
3
app/features/team/team-schemas.server.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const teamParamsSchema = z.object({ customUrl: z.string() });
|
||||
33
app/features/team/team-types.ts
Normal file
33
app/features/team/team-types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { MemberRole } from "~/db/types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
export interface DetailedTeam {
|
||||
name: string;
|
||||
bio?: string;
|
||||
twitter?: string;
|
||||
lutiDiv?: string;
|
||||
avatarSrc?: string;
|
||||
bannerSrc?: string;
|
||||
teamXp?: string;
|
||||
countries: string[];
|
||||
members: DetailedTeamMember[];
|
||||
results?: TeamResultPeek;
|
||||
}
|
||||
|
||||
export interface DetailedTeamMember {
|
||||
discordName: string;
|
||||
discordId: string;
|
||||
discordAvatar: string | null;
|
||||
weapons: MainWeaponId[];
|
||||
role?: MemberRole;
|
||||
}
|
||||
|
||||
export interface TeamResultPeek {
|
||||
count: number;
|
||||
placements: Array<TeamResultPeekPlacement>;
|
||||
}
|
||||
|
||||
export interface TeamResultPeekPlacement {
|
||||
placement: number;
|
||||
count: number;
|
||||
}
|
||||
136
app/features/team/team.css
Normal file
136
app/features/team/team.css
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
.team__banner {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
),
|
||||
var(--team-banner-img);
|
||||
background-size: cover;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
border-radius: var(--rounded);
|
||||
display: grid;
|
||||
grid-template-areas: "flags ." "avatar name";
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.team__banner__flags {
|
||||
grid-area: flags;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.team__banner__name {
|
||||
grid-area: name;
|
||||
align-self: flex-end;
|
||||
justify-self: flex-end;
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
font-weight: var(--bold);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.team__banner__avatar {
|
||||
grid-area: avatar;
|
||||
align-self: flex-end;
|
||||
margin-bottom: -90px;
|
||||
margin-left: var(--s-2);
|
||||
}
|
||||
|
||||
.team__banner__avatar > div {
|
||||
padding: var(--s-2);
|
||||
background-color: var(--bg);
|
||||
border-radius: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.team__banner__avatar img {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.team__badges {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.team__badges > div {
|
||||
background-color: var(--theme-semi-transparent);
|
||||
border-radius: var(--rounded);
|
||||
font-size: var(--fonts-xs);
|
||||
padding-inline: var(--s-2);
|
||||
font-weight: var(--bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.team__results {
|
||||
background-color: var(--bg-lightest);
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-1) var(--s-4);
|
||||
font-weight: var(--bold);
|
||||
font-size: var(--fonts-sm);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: min(100%, 48rem);
|
||||
color: var(--text);
|
||||
margin-block-start: var(--s-10);
|
||||
}
|
||||
|
||||
.team__results__placements {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.team__results__placements > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.team__member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.team__member__section {
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
font-size: var(--fonts-xl);
|
||||
font-weight: var(--bold);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
.team__member__avatar-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
color: var(--text);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.team__member__avatar {
|
||||
background-color: var(--bg);
|
||||
padding: var(--s-2);
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.team__member__role {
|
||||
margin-left: auto;
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--text-lighter);
|
||||
margin-inline-end: var(--s-2-5);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
|
@ -182,6 +182,7 @@ export const namespaceJsonsToPreloadObj: Record<
|
|||
user: true,
|
||||
weapons: true,
|
||||
tournament: true,
|
||||
team: true,
|
||||
};
|
||||
const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ html {
|
|||
--theme-transparent: #f3a0c386;
|
||||
--theme-very-transparent: #f3a0c341;
|
||||
--theme-vibrant: #f73e8b;
|
||||
--theme-semi-transparent: #ff99c477;
|
||||
--theme-secondary: rgb(63 58 255);
|
||||
--backdrop-filter: blur(10px) brightness(95%);
|
||||
--rounded: 16px;
|
||||
|
|
@ -51,6 +52,7 @@ html {
|
|||
--bold: 600;
|
||||
--semi-bold: 500;
|
||||
--body: 400;
|
||||
--s-0-5: 0.125rem;
|
||||
--s-1: 0.25rem;
|
||||
--s-1-5: 0.375rem;
|
||||
--s-2: 0.5rem;
|
||||
|
|
@ -118,6 +120,7 @@ html.dark {
|
|||
--theme: #ffc6de;
|
||||
--theme-very-transparent: #ffc6de36;
|
||||
--theme-vibrant: #f391ba;
|
||||
--theme-semi-transparent: #ff99c477;
|
||||
--theme-transparent: #ffc6de52;
|
||||
--theme-secondary: rgb(239 229 83);
|
||||
--backdrop-filter: blur(10px) brightness(75%);
|
||||
|
|
|
|||
7
public/locales/en/team.json
Normal file
7
public/locales/en/team.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"roles.CAPTAIN": "Captain",
|
||||
"roles.FRONTLINE": "Frontline",
|
||||
"roles.SUPPORT": "Support",
|
||||
"roles.BACKLINE": "Backline",
|
||||
"roles.COACH": "Coach"
|
||||
}
|
||||
BIN
public/static-assets/img/layout/xsearch.avif
Normal file
BIN
public/static-assets/img/layout/xsearch.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/xsearch.png
Normal file
BIN
public/static-assets/img/layout/xsearch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -21,6 +21,7 @@ module.exports = {
|
|||
route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx");
|
||||
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
|
||||
});
|
||||
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
2
types/react-i18next.d.ts
vendored
2
types/react-i18next.d.ts
vendored
|
|
@ -12,6 +12,7 @@ import type builds from "../public/locales/en/builds.json";
|
|||
import type analyzer from "../public/locales/en/analyzer.json";
|
||||
import type gameMisc from "../public/locales/en/game-misc.json";
|
||||
import type tournament from "../public/locales/en/tournament.json";
|
||||
import type team from "../public/locales/en/team.json";
|
||||
|
||||
declare module "react-i18next" {
|
||||
interface CustomTypeOptions {
|
||||
|
|
@ -29,6 +30,7 @@ declare module "react-i18next" {
|
|||
analyzer: typeof analyzer;
|
||||
"game-misc": typeof gameMisc;
|
||||
tournament: typeof tournament;
|
||||
team: typeof team;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user