User results page search
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2026-04-18 18:14:02 +03:00
parent c0395cc1bc
commit a4eda293e7
22 changed files with 126 additions and 15 deletions

View File

@ -544,13 +544,27 @@ const baseTournamentResultsQuery = (userId: number) =>
.innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId")
.where("TournamentResult.userId", "=", userId);
const escapeLikePattern = (value: string) =>
value.replace(/[\\%_]/g, (char) => `\\${char}`);
const tournamentNameLikeExpr = (tournamentName: string) => {
const pattern = `%${escapeLikePattern(tournamentName)}%`;
return sql<boolean>`${sql.ref("CalendarEvent.name")} like ${pattern} escape '\\'`;
};
export function findResultsByUserId(
userId: number,
{
showHighlightsOnly = false,
limit,
offset,
}: { showHighlightsOnly?: boolean; limit?: number; offset?: number } = {},
tournamentName,
}: {
showHighlightsOnly?: boolean;
limit?: number;
offset?: number;
tournamentName?: string;
} = {},
) {
let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select(
({ eb, fn }) => [
@ -634,6 +648,15 @@ export function findResultsByUserId(
);
}
if (tournamentName) {
calendarEventResultsQuery = calendarEventResultsQuery.where(
tournamentNameLikeExpr(tournamentName),
);
tournamentResultsQuery = tournamentResultsQuery.where(
tournamentNameLikeExpr(tournamentName),
);
}
let query = calendarEventResultsQuery
.unionAll(tournamentResultsQuery)
.orderBy("startTime", "desc")
@ -652,7 +675,10 @@ export function findResultsByUserId(
export async function countResultsByUserId(
userId: number,
{ showHighlightsOnly = false }: { showHighlightsOnly?: boolean } = {},
{
showHighlightsOnly = false,
tournamentName,
}: { showHighlightsOnly?: boolean; tournamentName?: string } = {},
) {
let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select(
({ fn }) => [fn.countAll<number>().as("count")],
@ -675,6 +701,15 @@ export async function countResultsByUserId(
);
}
if (tournamentName) {
calendarEventResultsQuery = calendarEventResultsQuery.where(
tournamentNameLikeExpr(tournamentName),
);
tournamentResultsQuery = tournamentResultsQuery.where(
tournamentNameLikeExpr(tournamentName),
);
}
const [calendarEventResults, tournamentResults] = await Promise.all([
calendarEventResultsQuery.executeTakeFirst(),
tournamentResultsQuery.executeTakeFirst(),

View File

@ -1,9 +1,13 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy, parseSafeSearchParams } from "~/utils/remix.server";
import { RESULTS_PER_PAGE } from "../user-page-constants";
import {
HIGHLIGHTS_RESULTS_MAX,
RESULTS_PER_PAGE,
} from "../user-page-constants";
import { userResultsPageSearchParamsSchema } from "../user-page-schemas";
export type UserResultsLoaderData = SerializeFrom<typeof loader>;
@ -34,15 +38,23 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
}
const page = parsedSearchParams.success ? parsedSearchParams.data.page : 1;
const tournamentName =
!isChoosingHighlights && getUser() && parsedSearchParams.success
? parsedSearchParams.data.tournament
: undefined;
const [results, totalCount] = await Promise.all([
UserRepository.findResultsByUserId(userId, {
showHighlightsOnly,
tournamentName,
...(isChoosingHighlights
? {}
? { limit: HIGHLIGHTS_RESULTS_MAX }
: { limit: RESULTS_PER_PAGE, offset: (page - 1) * RESULTS_PER_PAGE }),
}),
UserRepository.countResultsByUserId(userId, { showHighlightsOnly }),
UserRepository.countResultsByUserId(userId, {
showHighlightsOnly,
tournamentName,
}),
]);
const maxPage = Math.ceil(totalCount / RESULTS_PER_PAGE);
@ -72,6 +84,6 @@ function redirectIfPageOutOfBounds({
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.searchParams);
searchParams.set("page", String(maxPage));
searchParams.set("page", String(Math.max(maxPage, 1)));
throw redirect(`${url.pathname}?${searchParams.toString()}`);
}

View File

@ -1,6 +1,10 @@
import { Search } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLoaderData, useMatches, useSearchParams } from "react-router";
import { useDebounce } from "react-use";
import { LinkButton } from "~/components/elements/Button";
import { Input } from "~/components/Input";
import { Pagination } from "~/components/Pagination";
import { useUser } from "~/features/auth/core/user";
import { UserResultsTable } from "~/features/user-page/components/UserResultsTable";
@ -10,6 +14,7 @@ import { SendouButton } from "../../../components/elements/Button";
import { SubPageHeader } from "../components/SubPageHeader";
import { loader } from "../loaders/u.$identifier.results.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "../user-page.module.css";
export { loader };
@ -25,6 +30,34 @@ export default function UserResultsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const showAll = searchParams.get("all") === "true";
const urlTournamentQuery = searchParams.get("tournament") ?? "";
const [tournamentQuery, setTournamentQuery] =
React.useState(urlTournamentQuery);
const [prevUrlTournamentQuery, setPrevUrlTournamentQuery] =
React.useState(urlTournamentQuery);
if (urlTournamentQuery !== prevUrlTournamentQuery) {
setPrevUrlTournamentQuery(urlTournamentQuery);
setTournamentQuery(urlTournamentQuery);
}
useDebounce(
() => {
if (urlTournamentQuery === tournamentQuery) return;
setSearchParams((params) => {
if (tournamentQuery) {
params.set("tournament", tournamentQuery);
} else {
params.delete("tournament");
}
params.delete("page");
return params;
});
},
300,
[tournamentQuery],
);
const setPage = (page: number) => {
setSearchParams((params) => {
params.set("page", String(page));
@ -44,15 +77,23 @@ export default function UserResultsPage() {
? t("results.title")
: t("results.highlights")}
</h2>
{user?.id === layoutData.user.id ? (
<LinkButton
to={userResultsEditHighlightsPage(user)}
className="ml-auto"
size="small"
>
{t("results.highlights.choose")}
</LinkButton>
) : null}
<div className="stack horizontal sm items-center">
{user ? (
<Input
className={styles.resultsFilterInput}
value={tournamentQuery}
onChange={(e) => setTournamentQuery(e.target.value)}
placeholder={t("results.filter.placeholder")}
aria-label={t("results.filter.placeholder")}
icon={<Search />}
/>
) : null}
{user?.id === layoutData.user.id ? (
<LinkButton to={userResultsEditHighlightsPage(user)} size="small">
{t("results.highlights.choose")}
</LinkButton>
) : null}
</div>
</div>
<UserResultsTable id="user-results-table" results={data.results.value} />
{data.results.pages > 1 ? (

View File

@ -24,6 +24,7 @@ export const IN_GAME_NAME_REGEXP = /^.{1,10}#[0-9a-z]{4,5}$/u;
export const MATCHES_PER_SEASONS_PAGE = 8;
export const RESULTS_PER_PAGE = 25;
export const HIGHLIGHTS_RESULTS_MAX = 500;
export const DEFAULT_BUILD_SORT = ["WEAPON_POOL", "UPDATED_AT"] as const;
/**

View File

@ -199,6 +199,7 @@ export const adminTabActionSchema = z.union([
export const userResultsPageSearchParamsSchema = z.object({
all: z.stringbool().catch(false),
page: z.coerce.number().min(1).max(1_000).catch(1),
tournament: z.string().trim().min(1).max(100).optional().catch(undefined),
});
const widgetSettingsSchemas = allWidgetsFlat().map((widget) => {

View File

@ -126,6 +126,11 @@
overflow-x: auto;
}
.resultsFilterInput {
width: 12rem;
max-width: 100%;
}
.resultsTableHighlights {
border: var(--s-2) solid var(--color-bg-high);
padding-inline: 0 !important;

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Vælg de resultater, som du vil fremhæve",
"results.button.showHighlights": "Vis højdepunkter",
"results.button.showAll": "Vis alt",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Maks antal våben nået",
"search.info": "",
"search.noResults": "Søgningen {{query}} fandt ingen brugere",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Maximale Zahl an Waffen erreicht",
"search.info": "",
"search.noResults": "Keine Nutzer gefunden, die '{{query}}' entsprechen",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Select the results you want to highlight",
"results.button.showHighlights": "Show highlights",
"results.button.showAll": "Show all",
"results.filter.placeholder": "Filter by tournament",
"forms.errors.maxWeapons": "Max weapon count reached",
"search.info": "Search for users by Discord or Splatoon 3 name",
"search.noResults": "No users found matching '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Elige los resultados que quieres resaltar",
"results.button.showHighlights": "Mostrar resaltados",
"results.button.showAll": "Mostrar todos",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Máxima cantidad de armas",
"search.info": "Busca usuarios por su nombre de Discord o de Splatoon 3",
"search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Elige los resultados que quieres resaltar",
"results.button.showHighlights": "Mostrar resaltos",
"results.button.showAll": "Mostrar todos",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Máxima cantidad de armas",
"search.info": "",
"search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Nombre d'armes maximum atteint",
"search.info": "",
"search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant",
"results.button.showHighlights": "Montrer les highlights",
"results.button.showAll": "Tout montrer",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Nombre d'armes maximum atteint",
"search.info": "Recherchez avec le pseudo Discord ou Splatoon 3 du compte",
"search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "בחרו את התוצאות שאתם רוצים להדגיש",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "הגעה לכמות מקסימלית של מספר נשקים",
"search.info": "",
"search.noResults": "לא נמצאו משתמשים התואמים '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Scegli il risultato che vuoi mettere come highlight",
"results.button.showHighlights": "Mostra highlight",
"results.button.showAll": "Mostra tutti",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Massimo numero di armi raggiunto",
"search.info": "Cerca utenti tramite nome Discord o Splatoon 3",
"search.noResults": "Nessun utente trovato per '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "戦績として選択したい結果を選ぶ",
"results.button.showHighlights": "ハイライトを表示",
"results.button.showAll": "全て表示",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "最大ブキ数を超えました",
"search.info": "",
"search.noResults": "該当ユーザーが見つかりません '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "",
"search.info": "",
"search.noResults": "",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "",
"search.info": "",
"search.noResults": "",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Wybierz wyniki, które chcesz wyróżnić",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Maksymalna ilość broni osiągnięta",
"search.info": "",
"search.noResults": "Nie znaleziono użytkownika o nazwie '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Escolha os resultados que você quer destacar",
"results.button.showHighlights": "",
"results.button.showAll": "",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Número máximo de armas no perfil atingido.",
"search.info": "",
"search.noResults": "Nenhum usuário encontrado com o termo '{{query}}'",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "Выберите ваш избранный результат",
"results.button.showHighlights": "Показать избранные",
"results.button.showAll": "Показать все",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "Достигнут максимум",
"search.info": "Поиск пользователей по имени Discord или Splatoon 3",
"search.noResults": "По запросу '{{query}}' пользователь не найден",

View File

@ -159,6 +159,7 @@
"results.highlights.explanation": "选择您想强调的高光成绩",
"results.button.showHighlights": "显示高光成绩",
"results.button.showAll": "显示全部成绩",
"results.filter.placeholder": "",
"forms.errors.maxWeapons": "已达到武器数量上限",
"search.info": "",
"search.noResults": "没有符合 '{{query}}' 的用户",