mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-01 01:10:44 -05:00
User profile results page
This commit is contained in:
parent
f38fafd493
commit
2a7d32b77f
4
TODO.md
4
TODO.md
|
|
@ -16,9 +16,9 @@ Calendar
|
|||
- [x] E2E test adding and editing event
|
||||
- [x] Add winners page
|
||||
- [ ] E2E test add winners page
|
||||
- [ ] Winners on the event info page
|
||||
- [x] Winners on the event info page
|
||||
- [ ] There should be a banner on the list page if you have past tournaments to report
|
||||
- [ ] On the user page tab showing past results
|
||||
- [x] On the user page tab showing past results
|
||||
- [ ] Translations
|
||||
|
||||
## Other
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ export function Section({
|
|||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>{title}</h2>
|
||||
{title && <h2>{title}</h2>}
|
||||
<div className={className}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -285,6 +285,69 @@ export function findResultsByEventId(eventId: CalendarEvent["id"]) {
|
|||
return result;
|
||||
}
|
||||
|
||||
const findResultsByUserIdStm = sql.prepare(`
|
||||
select
|
||||
"CalendarEvent"."id" as "eventId",
|
||||
"CalendarEvent"."name" as "eventName",
|
||||
"CalendarEventResultTeam"."name" as "teamName",
|
||||
"CalendarEventResultTeam"."placement",
|
||||
"CalendarEvent"."participantCount",
|
||||
(select max("startTime")
|
||||
from "CalendarEventDate"
|
||||
where "eventId" = "CalendarEvent"."id")
|
||||
as "startTime"
|
||||
from "CalendarEventResultPlayer"
|
||||
join "CalendarEventResultTeam"
|
||||
on "CalendarEventResultTeam"."id" = "CalendarEventResultPlayer"."teamId"
|
||||
join "CalendarEvent"
|
||||
on "CalendarEvent"."id" = "CalendarEventResultTeam"."eventId"
|
||||
where "CalendarEventResultPlayer"."userId" = $userId
|
||||
order by "startTime" desc
|
||||
`);
|
||||
|
||||
const findMatesByResultTeamIdStm = sql.prepare(`
|
||||
select
|
||||
"CalendarEventResultPlayer"."name",
|
||||
"User"."id",
|
||||
"User"."discordName" as "discordName",
|
||||
"User"."discordDiscriminator" as "discordDiscriminator",
|
||||
"User"."discordId" as "discordId",
|
||||
"User"."discordAvatar" as "discordAvatar"
|
||||
from "CalendarEventResultPlayer"
|
||||
join "User"
|
||||
on "User"."id" = "CalendarEventResultPlayer"."userId"
|
||||
where "teamId" = $teamId
|
||||
and "userId" != $userId
|
||||
`);
|
||||
|
||||
export function findResultsByUserId(userId: User["id"]) {
|
||||
return (
|
||||
findResultsByUserIdStm.all({ userId }) as Array<{
|
||||
eventId: CalendarEvent["id"];
|
||||
eventName: CalendarEvent["name"];
|
||||
teamName: CalendarEventResultTeam["name"];
|
||||
placement: CalendarEventResultTeam["placement"];
|
||||
participantCount: CalendarEvent["participantCount"];
|
||||
startTime: CalendarEventDate["startTime"];
|
||||
}>
|
||||
).map((row) => ({
|
||||
...row,
|
||||
mates: (
|
||||
findMatesByResultTeamIdStm.all({
|
||||
teamId: row.eventId,
|
||||
userId,
|
||||
}) as Array<{
|
||||
name: CalendarEventResultPlayer["name"];
|
||||
id: User["id"];
|
||||
discordName: User["discordName"];
|
||||
discordDiscriminator: User["discordDiscriminator"];
|
||||
discordId: User["discordId"];
|
||||
discordAvatar: User["discordAvatar"];
|
||||
}>
|
||||
).map(({ name, ...mate }) => name ?? mate),
|
||||
}));
|
||||
}
|
||||
|
||||
const findAllBetweenTwoTimestampsStm = sql.prepare(`
|
||||
select
|
||||
"CalendarEvent"."name",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
import styles from "~/styles/calendar-event.css";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { notFoundIfFalsy } from "~/utils/remix";
|
||||
import { discordFullName, makeTitle } from "~/utils/strings";
|
||||
import { discordFullName, makeTitle, placementString } from "~/utils/strings";
|
||||
import {
|
||||
calendarEditPage,
|
||||
calendarReportWinnersPage,
|
||||
|
|
@ -171,10 +171,10 @@ function Results() {
|
|||
<div className="event__results-participant-count">
|
||||
{data.event.participantCount} teams participated
|
||||
</div>
|
||||
<table className="event__results-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Placement</th>
|
||||
<th>Placing</th>
|
||||
<th>Name</th>
|
||||
<th>Members</th>
|
||||
</tr>
|
||||
|
|
@ -182,7 +182,9 @@ function Results() {
|
|||
<tbody>
|
||||
{data.results.map((result, i) => (
|
||||
<tr key={i}>
|
||||
<td className="text-center">{result.placement}</td>
|
||||
<td className="text-center">
|
||||
{placementString(result.placement)}
|
||||
</td>
|
||||
<td>{result.teamName}</td>
|
||||
<td>
|
||||
<ul className="event__results-players">
|
||||
|
|
@ -193,7 +195,7 @@ function Results() {
|
|||
) : (
|
||||
<Link
|
||||
to={userPage(player.discordId)}
|
||||
className="stack horizontal xs justify-center"
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
<Avatar
|
||||
discordAvatar={player.discordAvatar}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Outlet, useLoaderData } from "@remix-run/react";
|
||||
import type { UseDataFunctionReturn } from "@remix-run/react/dist/components";
|
||||
import { countries } from "countries-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { db } from "~/db";
|
||||
import type { CountsByUserId } from "~/db/models/badges.server";
|
||||
import type { User } from "~/db/types";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import { translatedCountry } from "~/utils/i18n.server";
|
||||
import { notFoundIfFalsy } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { discordFullName, makeTitle } from "~/utils/strings";
|
||||
import styles from "~/styles/u.css";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = ({ data }: { data: UserPageLoaderData }) => {
|
||||
return {
|
||||
|
|
@ -28,23 +31,9 @@ export const handle = {
|
|||
|
||||
export const userParamsSchema = z.object({ identifier: z.string() });
|
||||
|
||||
export type UserPageLoaderData = Pick<
|
||||
User,
|
||||
| "id"
|
||||
| "discordName"
|
||||
| "discordAvatar"
|
||||
| "discordDiscriminator"
|
||||
| "discordId"
|
||||
| "youtubeId"
|
||||
| "twitch"
|
||||
| "twitter"
|
||||
| "bio"
|
||||
> & {
|
||||
country?: { emoji: string; code: string; name: string };
|
||||
badges: CountsByUserId;
|
||||
};
|
||||
export type UserPageLoaderData = UseDataFunctionReturn<typeof loader>;
|
||||
|
||||
export const loader: LoaderFunction = async ({ request, params }) => {
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const locale = await i18next.getLocale(request);
|
||||
const { identifier } = userParamsSchema.parse(params);
|
||||
const user = notFoundIfFalsy(db.users.findByIdentifier(identifier));
|
||||
|
|
@ -53,7 +42,7 @@ export const loader: LoaderFunction = async ({ request, params }) => {
|
|||
? countries[user.country as keyof typeof countries]
|
||||
: undefined;
|
||||
|
||||
return json<UserPageLoaderData>({
|
||||
return json({
|
||||
id: user.id,
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordDiscriminator: user.discordDiscriminator,
|
||||
|
|
@ -76,11 +65,12 @@ export const loader: LoaderFunction = async ({ request, params }) => {
|
|||
}
|
||||
: undefined,
|
||||
badges: db.badges.countsByUserId(user.id),
|
||||
results: db.calendarEvents.findResultsByUserId(user.id),
|
||||
});
|
||||
};
|
||||
|
||||
export default function UserPageLayout() {
|
||||
const data = useLoaderData<UserPageLoaderData>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -92,11 +82,16 @@ export default function UserPageLayout() {
|
|||
<SubNavLink to="" data-cy="profile-page-link">
|
||||
{t("header.profile")}
|
||||
</SubNavLink>
|
||||
{isOwnPage ? (
|
||||
{isOwnPage && (
|
||||
<SubNavLink to="edit" data-cy="edit-page-link">
|
||||
{t("actions.edit")}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
)}
|
||||
{data.results.length > 0 && (
|
||||
<SubNavLink to="results" data-cy="results-page-link">
|
||||
{t("results")}
|
||||
</SubNavLink>
|
||||
)}
|
||||
</SubNav>
|
||||
<Main>
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -1,23 +1,17 @@
|
|||
import type { LinksFunction } from "@remix-run/node";
|
||||
import { useMatches } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import styles from "~/styles/u.css";
|
||||
import type { UserPageLoaderData } from "../u.$identifier";
|
||||
import * as React from "react";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import clsx from "clsx";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { TwitchIcon } from "~/components/icons/Twitch";
|
||||
import { TwitterIcon } from "~/components/icons/Twitter";
|
||||
import { YouTubeIcon } from "~/components/icons/YouTube";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { badgeExplanationText } from "../badges/$id";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
import type { UserPageLoaderData } from "../u.$identifier";
|
||||
|
||||
export const handle = {
|
||||
i18n: "badges",
|
||||
|
|
|
|||
76
app/routes/u.$identifier/results.tsx
Normal file
76
app/routes/u.$identifier/results.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Link, useMatches } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Section } from "~/components/Section";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { discordFullName, placementString } from "~/utils/strings";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import type { UserPageLoaderData } from "../u.$identifier";
|
||||
|
||||
export default function UserResultsPage() {
|
||||
const { i18n } = useTranslation();
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const data = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
return (
|
||||
<Section className="u__results-section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Placing</th>
|
||||
<th>Team</th>
|
||||
<th>Tournament</th>
|
||||
<th>Date</th>
|
||||
<th>Members</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.results.map((result) => (
|
||||
<tr key={result.eventId}>
|
||||
<td className="text-center">
|
||||
{placementString(result.placement)}
|
||||
</td>
|
||||
<td>{result.teamName}</td>
|
||||
<td>{result.eventName}</td>
|
||||
<td>
|
||||
{databaseTimestampToDate(result.startTime).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<ul className="u__results-players">
|
||||
{result.mates.map((player) => (
|
||||
<li key={typeof player === "string" ? player : player.id}>
|
||||
{typeof player === "string" ? (
|
||||
player
|
||||
) : (
|
||||
<Link
|
||||
to={userPage(player.discordId)}
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
<Avatar
|
||||
discordAvatar={player.discordAvatar}
|
||||
discordId={player.discordId}
|
||||
size="xxs"
|
||||
/>{" "}
|
||||
{discordFullName(player)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,33 +25,10 @@
|
|||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.event__results-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 var(--s-1-5);
|
||||
font-size: var(--fonts-sm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.event__results-table > thead {
|
||||
font-size: var(--fonts-xxs);
|
||||
}
|
||||
|
||||
.event__results-table > thead > tr {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.event__results-table > tbody > tr {
|
||||
padding-block: var(--s-2);
|
||||
}
|
||||
|
||||
.event__results-table > tbody > tr:nth-child(2n) {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.event__results-players {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
gap: var(--s-3);
|
||||
list-style: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,6 +263,30 @@ select:focus {
|
|||
outline: 2px solid var(--theme);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 var(--s-1-5);
|
||||
font-size: var(--fonts-xs);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table > thead {
|
||||
font-size: var(--fonts-xxs);
|
||||
}
|
||||
|
||||
table > tbody > tr:nth-child(2n) {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
table > thead > tr > th {
|
||||
padding-inline: var(--s-1);
|
||||
}
|
||||
|
||||
table > tbody > tr > td {
|
||||
padding-inline: var(--s-1);
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--theme-transparent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,3 +129,15 @@
|
|||
font-size: var(--fonts-xxxs);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.u__results-section {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.u__results-players {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
gap: var(--s-3);
|
||||
list-style: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,11 @@ export function discordFullName(
|
|||
export function makeTitle(title: string | string[]) {
|
||||
return `${Array.isArray(title) ? title.join(" | ") : title} | sendou.ink`;
|
||||
}
|
||||
|
||||
export function placementString(placement: number) {
|
||||
if (placement === 1) return "🥇";
|
||||
if (placement === 2) return "🥈";
|
||||
if (placement === 3) return "🥉";
|
||||
|
||||
return `${placement}th`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,5 +18,7 @@
|
|||
|
||||
"actions.save": "Save",
|
||||
"actions.saving": "Saving...",
|
||||
"actions.edit": "Edit"
|
||||
"actions.edit": "Edit",
|
||||
|
||||
"results": "Results"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user