diff --git a/app/routes/u.$identifier/results.tsx b/app/routes/u.$identifier/results.tsx deleted file mode 100644 index 2a6a431e2..000000000 --- a/app/routes/u.$identifier/results.tsx +++ /dev/null @@ -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 ( -
-
- - - - - - - - - - - - - {data.results.map((result) => ( - - - - - - - - - ))} - -
{t("results.placing")}{t("results.team")}{t("results.tournament")}{t("results.participants")}{t("results.date")}{t("results.mates")}
- - {result.teamName} - - {result.eventName} - - {result.participantCount} - {databaseTimestampToDate(result.startTime).toLocaleDateString( - i18n.language, - { - day: "numeric", - month: "numeric", - year: "numeric", - } - )} - -
    - {result.mates.map((player) => ( -
  • - {typeof player === "string" ? ( - player - ) : ( - - {" "} - {discordFullName(player)} - - )} -
  • - ))} -
-
-
-
- ); -} diff --git a/app/routes/u.$identifier/results/components/UserResultsTable.tsx b/app/routes/u.$identifier/results/components/UserResultsTable.tsx new file mode 100644 index 000000000..b2a251d06 --- /dev/null +++ b/app/routes/u.$identifier/results/components/UserResultsTable.tsx @@ -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 ( + + + + {hasHighlightCheckboxes && + + + + + + + + + {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 ( + + {hasHighlightCheckboxes && ( + + )} + + + + + + + + ); + })} + +
} + {t("results.placing")}{t("results.team")}{t("results.tournament")}{t("results.participants")}{t("results.date")}{t("results.mates")}
+ + + + {result.teamName} + + {result.eventName} + + {result.participantCount} + {databaseTimestampToDate(result.startTime).toLocaleDateString( + i18n.language, + { + day: "numeric", + month: "numeric", + year: "numeric", + } + )} + +
    + {result.mates.map((player) => ( +
  • + {typeof player === "string" ? ( + player + ) : ( + + + {discordFullName(player)} + + )} +
  • + ))} +
+
+ ); +} diff --git a/app/routes/u.$identifier/results/highlights.tsx b/app/routes/u.$identifier/results/highlights.tsx new file mode 100644 index 000000000..70086ff7e --- /dev/null +++ b/app/routes/u.$identifier/results/highlights.tsx @@ -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 ( +
+
+

{t("user:results.highlights.choose")}

+
+
+ {t("user:results.highlights.explanation")} + +
+
+ + + +
+ ); +} diff --git a/app/routes/u.$identifier/results/index.tsx b/app/routes/u.$identifier/results/index.tsx new file mode 100644 index 000000000..954bd9f6b --- /dev/null +++ b/app/routes/u.$identifier/results/index.tsx @@ -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 ( +
+ {showHighlightsSection && ( +
+ {hasHighlights && ( + + )} + {isOwnResultsPage && ( + + {t("results.highlights.choose")} + + )} +
+ )} + {hasNonHighlights && ( +
+ +
+ )} +
+ ); +} diff --git a/app/styles/common.css b/app/styles/common.css index 2d1cbf3be..4c4a37b65 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -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); } diff --git a/app/styles/u.css b/app/styles/u.css index 673c38771..b3e504367 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -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; diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts index efdccb926..3c29f98a7 100644 --- a/app/utils/arrays.ts +++ b/app/utils/arrays.ts @@ -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; +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index c9ead88eb..ef4d3c07c 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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`; diff --git a/public/locales/de/user.json b/public/locales/de/user.json index e34fa88cf..e228b04e9 100644 --- a/public/locales/de/user.json +++ b/public/locales/de/user.json @@ -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", diff --git a/public/locales/en/user.json b/public/locales/en/user.json index 760f2a9b5..901b3b64a 100644 --- a/public/locales/en/user.json +++ b/public/locales/en/user.json @@ -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", diff --git a/translation-progress.md b/translation-progress.md index f51cb0f58..6dc638414 100644 --- a/translation-progress.md +++ b/translation-progress.md @@ -78,7 +78,7 @@ ### 🟡 user.json -**7/20** +**7/25**
Missing @@ -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**
Missing @@ -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**
Missing @@ -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**
Missing @@ -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**
Missing @@ -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**
Missing +- results.title - results.participants +- results.highlights +- results.nonHighlights +- results.highlights.choose +- results.highlights.explanation
@@ -988,7 +1018,7 @@ ### 🟡 user.json -**7/20** +**7/25**
Missing @@ -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**
Missing @@ -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