diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 9675050e0..60b8284ab 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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`${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().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(), diff --git a/app/features/user-page/loaders/u.$identifier.results.server.ts b/app/features/user-page/loaders/u.$identifier.results.server.ts index 7d15bf425..5be3ea471 100644 --- a/app/features/user-page/loaders/u.$identifier.results.server.ts +++ b/app/features/user-page/loaders/u.$identifier.results.server.ts @@ -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; @@ -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()}`); } diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index e6be55a7e..fe0baafcf 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -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")} - {user?.id === layoutData.user.id ? ( - - {t("results.highlights.choose")} - - ) : null} +
+ {user ? ( + setTournamentQuery(e.target.value)} + placeholder={t("results.filter.placeholder")} + aria-label={t("results.filter.placeholder")} + icon={} + /> + ) : null} + {user?.id === layoutData.user.id ? ( + + {t("results.highlights.choose")} + + ) : null} +
{data.results.pages > 1 ? ( diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index bd87c25ed..92d16b3bf 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -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; /** diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index af9e516a7..642edf4a7 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -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) => { diff --git a/app/features/user-page/user-page.module.css b/app/features/user-page/user-page.module.css index a615f2bbb..88a6b57c8 100644 --- a/app/features/user-page/user-page.module.css +++ b/app/features/user-page/user-page.module.css @@ -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; diff --git a/locales/da/user.json b/locales/da/user.json index 3f2e14e76..3fe8398b3 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -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", diff --git a/locales/de/user.json b/locales/de/user.json index 2c3873d9e..4c877750d 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -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", diff --git a/locales/en/user.json b/locales/en/user.json index 28315d99f..008fe7ad3 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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}}'", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index c6b00e59d..4644f05df 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -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}}'", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 061c084cc..6fc05551c 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -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}}'", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 21247c963..2e59aec77 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -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é", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 92621b46c..5c70f11ac 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -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é", diff --git a/locales/he/user.json b/locales/he/user.json index b4f9ec5ef..cfe95db7a 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -159,6 +159,7 @@ "results.highlights.explanation": "בחרו את התוצאות שאתם רוצים להדגיש", "results.button.showHighlights": "", "results.button.showAll": "", + "results.filter.placeholder": "", "forms.errors.maxWeapons": "הגעה לכמות מקסימלית של מספר נשקים", "search.info": "", "search.noResults": "לא נמצאו משתמשים התואמים '{{query}}'", diff --git a/locales/it/user.json b/locales/it/user.json index 5741c9e9c..53c29ef19 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -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}}'", diff --git a/locales/ja/user.json b/locales/ja/user.json index 3e258c267..a29b526df 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -159,6 +159,7 @@ "results.highlights.explanation": "戦績として選択したい結果を選ぶ", "results.button.showHighlights": "ハイライトを表示", "results.button.showAll": "全て表示", + "results.filter.placeholder": "", "forms.errors.maxWeapons": "最大ブキ数を超えました", "search.info": "", "search.noResults": "該当ユーザーが見つかりません '{{query}}'", diff --git a/locales/ko/user.json b/locales/ko/user.json index 7b9359991..e911fe35a 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -159,6 +159,7 @@ "results.highlights.explanation": "", "results.button.showHighlights": "", "results.button.showAll": "", + "results.filter.placeholder": "", "forms.errors.maxWeapons": "", "search.info": "", "search.noResults": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 8a52b524f..a5ed8f29b 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -159,6 +159,7 @@ "results.highlights.explanation": "", "results.button.showHighlights": "", "results.button.showAll": "", + "results.filter.placeholder": "", "forms.errors.maxWeapons": "", "search.info": "", "search.noResults": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 63b7fb78a..564944d61 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -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}}'", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index c98d5279d..4b73f1b6e 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -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}}'", diff --git a/locales/ru/user.json b/locales/ru/user.json index c8b93a193..c0ee78445 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -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}}' пользователь не найден", diff --git a/locales/zh/user.json b/locales/zh/user.json index da4c7b5b1..b609a38d2 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -159,6 +159,7 @@ "results.highlights.explanation": "选择您想强调的高光成绩", "results.button.showHighlights": "显示高光成绩", "results.button.showAll": "显示全部成绩", + "results.filter.placeholder": "", "forms.errors.maxWeapons": "已达到武器数量上限", "search.info": "", "search.noResults": "没有符合 '{{query}}' 的用户",