Team: UI initial

This commit is contained in:
Kalle 2022-12-27 22:01:15 +02:00
parent 1a8c2fc892
commit d51beef9d5
13 changed files with 464 additions and 0 deletions

View File

@ -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;
}

View 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,
},
],
},
};
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
import { z } from "zod";
export const teamParamsSchema = z.object({ customUrl: z.string() });

View 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
View 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);
}

View File

@ -182,6 +182,7 @@ export const namespaceJsonsToPreloadObj: Record<
user: true,
weapons: true,
tournament: true,
team: true,
};
const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj);

View File

@ -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%);

View File

@ -0,0 +1,7 @@
{
"roles.CAPTAIN": "Captain",
"roles.FRONTLINE": "Frontline",
"roles.SUPPORT": "Support",
"roles.BACKLINE": "Backline",
"roles.COACH": "Coach"
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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");
});
},
};

View File

@ -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;
};
}
}