mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 02:55:52 -05:00
Implement user result highlights UI
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
import { Link, useMatches } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { Section } from "~/components/Section";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { calendarEventPage, userPage } from "~/utils/urls";
|
||||
import type { UserPageLoaderData } from "../u.$identifier";
|
||||
|
||||
export default function UserResultsPage() {
|
||||
const { t, i18n } = useTranslation("user");
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const data = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
return (
|
||||
<main className="main layout__main">
|
||||
<Section className="u__results-section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("results.placing")}</th>
|
||||
<th>{t("results.team")}</th>
|
||||
<th>{t("results.tournament")}</th>
|
||||
<th>{t("results.participants")}</th>
|
||||
<th>{t("results.date")}</th>
|
||||
<th>{t("results.mates")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.results.map((result) => (
|
||||
<tr key={result.eventId}>
|
||||
<td className="pl-4">
|
||||
<Placement placement={result.placement} />
|
||||
</td>
|
||||
<td>{result.teamName}</td>
|
||||
<td>
|
||||
<Link to={calendarEventPage(result.eventId)}>
|
||||
{result.eventName}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{result.participantCount}</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}
|
||||
className="flex items-center"
|
||||
>
|
||||
{typeof player === "string" ? (
|
||||
player
|
||||
) : (
|
||||
<Link
|
||||
to={userPage(player)}
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
<Avatar user={player} size="xxs" />{" "}
|
||||
{discordFullName(player)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
111
app/routes/u.$identifier/results/components/UserResultsTable.tsx
Normal file
111
app/routes/u.$identifier/results/components/UserResultsTable.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { type UserPageLoaderData } from "~/routes/u.$identifier";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { calendarEventPage, userPage } from "~/utils/urls";
|
||||
|
||||
export type UserResultsTableProps = {
|
||||
results: UserPageLoaderData["results"];
|
||||
id: string;
|
||||
hasHighlightCheckboxes?: boolean;
|
||||
};
|
||||
|
||||
export const HIGHLIGHT_CHECKBOX_NAME = "highlightTeamIds";
|
||||
|
||||
export function UserResultsTable({
|
||||
results,
|
||||
id,
|
||||
hasHighlightCheckboxes,
|
||||
}: UserResultsTableProps) {
|
||||
const { t, i18n } = useTranslation("user");
|
||||
|
||||
const placementHeaderId = `${id}-th-placement`;
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{hasHighlightCheckboxes && <th />}
|
||||
<th id={placementHeaderId}>{t("results.placing")}</th>
|
||||
<th>{t("results.team")}</th>
|
||||
<th>{t("results.tournament")}</th>
|
||||
<th>{t("results.participants")}</th>
|
||||
<th>{t("results.date")}</th>
|
||||
<th>{t("results.mates")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
// We are trying to construct a reasonable label for the checkbox
|
||||
// which shouldn't contain the whole information of the table row as
|
||||
// that can be also accessed when needed.
|
||||
// e.g. "20xx Placing 2nd", "Big House 10 Placing 20th"
|
||||
const placementCellId = `${id}-${result.teamId}-placement`;
|
||||
const nameCellId = `${id}-${result.teamId}-name`;
|
||||
const checkboxLabelIds = `${nameCellId} ${placementHeaderId} ${placementCellId}`;
|
||||
|
||||
return (
|
||||
<tr key={result.teamId}>
|
||||
{hasHighlightCheckboxes && (
|
||||
<td>
|
||||
<input
|
||||
value={result.teamId}
|
||||
aria-labelledby={checkboxLabelIds}
|
||||
name={HIGHLIGHT_CHECKBOX_NAME}
|
||||
type="checkbox"
|
||||
defaultChecked={result.isHighlight}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="pl-4" id={placementCellId}>
|
||||
<Placement placement={result.placement} />
|
||||
</td>
|
||||
<td>{result.teamName}</td>
|
||||
<td id={nameCellId}>
|
||||
<Link to={calendarEventPage(result.eventId)}>
|
||||
{result.eventName}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{result.participantCount}</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}
|
||||
className="flex items-center"
|
||||
>
|
||||
{typeof player === "string" ? (
|
||||
player
|
||||
) : (
|
||||
<Link
|
||||
to={userPage(player)}
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
<Avatar user={player} size="xxs" />
|
||||
{discordFullName(player)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
79
app/routes/u.$identifier/results/highlights.tsx
Normal file
79
app/routes/u.$identifier/results/highlights.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type ActionFunction, redirect } from "@remix-run/node";
|
||||
import { Form, useMatches, useTransition } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
import { Main } from "~/components/Main";
|
||||
import { db } from "~/db";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import { type UserPageLoaderData } from "~/routes/u.$identifier";
|
||||
import { normalizeFormFieldArray } from "~/utils/arrays";
|
||||
import { parseRequestFormData } from "~/utils/remix";
|
||||
import { userResultsPage } from "~/utils/urls";
|
||||
import {
|
||||
HIGHLIGHT_CHECKBOX_NAME,
|
||||
UserResultsTable,
|
||||
} from "./components/UserResultsTable";
|
||||
|
||||
const editHighlightsActionSchema = z.object({
|
||||
[HIGHLIGHT_CHECKBOX_NAME]: z.optional(
|
||||
z.union([z.array(z.string()), z.string()])
|
||||
),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: editHighlightsActionSchema,
|
||||
});
|
||||
|
||||
const resultTeamIds = normalizeFormFieldArray(
|
||||
data[HIGHLIGHT_CHECKBOX_NAME]
|
||||
).map((id) => parseInt(id, 10));
|
||||
|
||||
db.users.updateResultHighlights({
|
||||
userId: user.id,
|
||||
resultTeamIds,
|
||||
});
|
||||
|
||||
return redirect(userResultsPage(user));
|
||||
};
|
||||
|
||||
export default function ResultHighlightsEditPage() {
|
||||
const { t } = useTranslation(["common", "user"]);
|
||||
const [, parentRoute] = useMatches();
|
||||
const transition = useTransition();
|
||||
|
||||
invariant(parentRoute);
|
||||
const userPageData = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<Form method="post" className="stack md items-start">
|
||||
<h2 className="text-start">{t("user:results.highlights.choose")}</h2>
|
||||
<div className="u__results-table-wrapper">
|
||||
<fieldset className="u__results-table-highlights">
|
||||
<legend>{t("user:results.highlights.explanation")}</legend>
|
||||
<UserResultsTable
|
||||
id="user-results-highlight-selection"
|
||||
results={userPageData.results}
|
||||
hasHighlightCheckboxes
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<Button
|
||||
loadingText={t("common:actions.saving")}
|
||||
type="submit"
|
||||
loading={transition.state === "submitting"}
|
||||
data-cy="submit-button"
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</Button>
|
||||
<FormErrors namespace="user" />
|
||||
</Form>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
68
app/routes/u.$identifier/results/index.tsx
Normal file
68
app/routes/u.$identifier/results/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useMatches } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Section } from "~/components/Section";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { userResultsEditHighlightsPage } from "~/utils/urls";
|
||||
import type { UserPageLoaderData } from "../../u.$identifier";
|
||||
import { UserResultsTable } from "./components/UserResultsTable";
|
||||
|
||||
export default function UserResultsPage() {
|
||||
const { t } = useTranslation("user");
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
|
||||
const userPageData = parentRoute.data as UserPageLoaderData;
|
||||
const hasResults = userPageData.results.length > 0;
|
||||
|
||||
const nonHighlights = userPageData.results.filter((r) => !r.isHighlight);
|
||||
const hasNonHighlights = nonHighlights.length > 0;
|
||||
|
||||
const highlights = userPageData.results.filter((r) => r.isHighlight);
|
||||
const hasHighlights = highlights.length > 0;
|
||||
|
||||
const user = useUser();
|
||||
const isOwnResultsPage = user?.id === userPageData.id;
|
||||
|
||||
const showHighlightsSection =
|
||||
hasHighlights || (isOwnResultsPage && hasResults);
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
{showHighlightsSection && (
|
||||
<Section
|
||||
title={t("results.highlights")}
|
||||
className="u__results-table-wrapper u__results-table-highlights stack md items-center"
|
||||
>
|
||||
{hasHighlights && (
|
||||
<UserResultsTable
|
||||
id="user-results-highlight-table"
|
||||
results={highlights}
|
||||
/>
|
||||
)}
|
||||
{isOwnResultsPage && (
|
||||
<LinkButton
|
||||
variant="outlined"
|
||||
tiny
|
||||
to={userResultsEditHighlightsPage(userPageData)}
|
||||
>
|
||||
{t("results.highlights.choose")}
|
||||
</LinkButton>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{hasNonHighlights && (
|
||||
<Section
|
||||
title={
|
||||
hasHighlights ? t("results.nonHighlights") : t("results.title")
|
||||
}
|
||||
className="u__results-table-wrapper"
|
||||
>
|
||||
<UserResultsTable id="user-results-table" results={nonHighlights} />
|
||||
</Section>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -292,6 +292,10 @@ table > tbody > tr > td {
|
||||
padding-inline: var(--s-1);
|
||||
}
|
||||
|
||||
td > input[type="checkbox"] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--theme-transparent);
|
||||
}
|
||||
|
||||
@@ -147,10 +147,21 @@
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.u__results-section {
|
||||
.u__results-table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.u__results-table-highlights {
|
||||
border: var(--s-2) solid var(--bg-lighter);
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.u__results-table-highlights > legend {
|
||||
margin-inline-start: var(--s-2);
|
||||
padding-inline: var(--s-1);
|
||||
}
|
||||
|
||||
.u__results-players {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -34,3 +34,9 @@ export function joinListToNaturalString(arg: string[]) {
|
||||
|
||||
return last ? `${commaJoined} and ${last}` : commaJoined;
|
||||
}
|
||||
|
||||
export function normalizeFormFieldArray(
|
||||
value: undefined | null | string | string[]
|
||||
): string[] {
|
||||
return value == null ? [] : typeof value === "string" ? [value] : value;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export const userBuildsPage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/builds`;
|
||||
export const userResultsPage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/results`;
|
||||
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
|
||||
`${userResultsPage(user)}/highlights`;
|
||||
export const userNewBuildPage = (user: UserLinkArgs) =>
|
||||
`${userBuildsPage(user)}/new`;
|
||||
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
"stick": "Stick",
|
||||
"sens": "Empfindlichkeit",
|
||||
|
||||
"results.title": "Ergebnisse",
|
||||
"results.placing": "Platzierung",
|
||||
"results.team": "Team",
|
||||
"results.tournament": "Turnier",
|
||||
"results.participants": "Teilnehmer",
|
||||
"results.date": "Datum",
|
||||
"results.mates": "Mitspieler",
|
||||
"results.highlights": "Highlights",
|
||||
"results.nonHighlights": "Weitere Ergebnisse",
|
||||
"results.highlights.choose": "Highlights wählen",
|
||||
"results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest",
|
||||
|
||||
"forms.errors.invalidCustomUrl.numbers": "Benutzerdefinierte URL kann nicht nur aus Zahlen bestehen",
|
||||
"forms.errors.invalidCustomUrl.strangeCharacter": "Benutzerdefinierte URL kann nicht aus speziellen Zeichen bestehen",
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
"stick": "Stick",
|
||||
"sens": "Sens",
|
||||
|
||||
"results.title": "Results",
|
||||
"results.placing": "Placing",
|
||||
"results.team": "Team",
|
||||
"results.tournament": "Tournament",
|
||||
"results.participants": "Participants",
|
||||
"results.date": "Date",
|
||||
"results.mates": "Mates",
|
||||
"results.highlights": "Highlights",
|
||||
"results.nonHighlights": "Other Results",
|
||||
"results.highlights.choose": "Choose Highlights",
|
||||
"results.highlights.explanation": "Select the results you want to highlight",
|
||||
|
||||
"forms.errors.invalidCustomUrl.numbers": "Custom URL can't only contain numbers",
|
||||
"forms.errors.invalidCustomUrl.strangeCharacter": "Custom URL can't contain special characters",
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -91,7 +91,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
@@ -179,7 +184,7 @@
|
||||
|
||||
### 🟢 user.json
|
||||
|
||||
**20/20**
|
||||
**25/25**
|
||||
|
||||
---
|
||||
|
||||
@@ -271,7 +276,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -284,7 +289,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
@@ -394,7 +404,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -407,7 +417,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
@@ -468,7 +483,7 @@
|
||||
|
||||
### 🔴 user.json
|
||||
|
||||
**0/20**
|
||||
**0/25**
|
||||
|
||||
---
|
||||
|
||||
@@ -650,7 +665,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -663,7 +678,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
@@ -774,7 +794,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -787,7 +807,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
@@ -876,12 +901,17 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**19/20**
|
||||
**19/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
|
||||
</details>
|
||||
|
||||
@@ -988,7 +1018,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -1001,7 +1031,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
@@ -1112,7 +1147,7 @@
|
||||
|
||||
### 🟡 user.json
|
||||
|
||||
**7/20**
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -1125,7 +1160,12 @@
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
|
||||
Reference in New Issue
Block a user