diff --git a/src/common/MainLayout/components/Header/Header.tsx b/src/common/MainLayout/components/Header/Header.tsx index 12d73dd..5cb7715 100644 --- a/src/common/MainLayout/components/Header/Header.tsx +++ b/src/common/MainLayout/components/Header/Header.tsx @@ -14,6 +14,7 @@ import { Link as MUILink, InputAdornment, IconButton, + AppBarProps, } from '@material-ui/core'; import { Search as SearchIcon, Input as InputIcon } from '@material-ui/icons'; import Link from '@common/Link/Link'; @@ -24,12 +25,14 @@ export interface Props { showLinkToHomePage?: boolean; hideVersionSelectorOnMobile?: boolean; defaultQ?: string; + appBarProps?: AppBarProps; } export default function Header({ showLinkToHomePage = true, hideVersionSelectorOnMobile = false, defaultQ = '', + appBarProps = {}, }: Props) { const [q, setQ] = useState(defaultQ); const { t } = useTranslation(NAMESPACES.COMMON); @@ -47,7 +50,7 @@ export default function Header({ ); return ( - +
diff --git a/src/config/app.ts b/src/config/app.ts index 8910572..f677a09 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -1,4 +1,4 @@ -export const DEFAULT_LANGUAGE = process.env.REACT_APP_DEFAULT_LANGUAGE ?? 'en'; +export const DEFAULT_LANGUAGE = process.env.REACT_APP_DEFAULT_LANGUAGE ?? 'pl'; export const NAME = 'TWHelp'; diff --git a/src/config/namespaces.ts b/src/config/namespaces.ts index 8cc45c5..6986526 100644 --- a/src/config/namespaces.ts +++ b/src/config/namespaces.ts @@ -2,6 +2,7 @@ export const COMMON = 'common'; export const TABLE = 'table'; export const LINE_CHART = 'line-chart'; export const INDEX_PAGE = 'index-page'; +export const SEARCH_PAGE = 'search-page'; export const NOT_FOUND_PAGE = 'not-found-page'; export const SERVER_PAGE = { COMMON: 'server-page/common', diff --git a/src/features/SearchPage/SearchPage.tsx b/src/features/SearchPage/SearchPage.tsx index 12b2c2b..e294804 100644 --- a/src/features/SearchPage/SearchPage.tsx +++ b/src/features/SearchPage/SearchPage.tsx @@ -1,17 +1,83 @@ import React from 'react'; -import { useQueryParams, withDefault, StringParam } from 'use-query-params'; +import { + useQueryParams, + withDefault, + StringParam, + NumberParam, +} from 'use-query-params'; +import { useTranslation } from 'react-i18next'; +import useTitle from '@libs/useTitle'; +import useScrollToElement from '@libs/useScrollToElement'; +import { validateRowsPerPage } from '@common/Table/helpers'; +import extractVersionCodeFromHostname from '@utils/extractVersionCodeFromHostname'; +import { SEARCH_PAGE } from '@config/namespaces'; +import { MODES, LIMIT } from './constants'; +import { Container, Paper, Tabs, Tab } from '@material-ui/core'; import MainLayout from '@common/MainLayout/MainLayout'; +import PlayerTable from './components/PlayerTable/PlayerTable'; +import TribeTable from './components/TribeTable/TribeTable'; function SearchPage() { - const [query] = useQueryParams({ + const [query, setQuery] = useQueryParams({ q: withDefault(StringParam, ''), + mode: withDefault(StringParam, MODES.PLAYER), + page: withDefault(NumberParam, 0), + limit: withDefault(NumberParam, LIMIT), }); + const limit = validateRowsPerPage(query.limit); + const version = extractVersionCodeFromHostname(window.location.hostname); + const { t } = useTranslation(SEARCH_PAGE); + useTitle(t('title', { query: query.q })); + useScrollToElement( + document.documentElement, + [query.q, query.mode, query.page, limit], + { behavior: 'auto', block: 'start' } + ); + const currentTab = Object.values(MODES).findIndex(m => query.mode === m); + + const handleTabChange = (_e: React.ChangeEvent<{}>, newTab: number) => { + const newMode = Object.values(MODES)[newTab]; + if (newMode !== query.mode) { + setQuery({ mode: Object.values(MODES)[newTab], page: 0, limit: LIMIT }); + } + }; + + const handlePageChange = (page: number) => { + setQuery({ page }); + }; + + const handleRowsPerPageChange = (rowsPerPage: number) => { + setQuery({ limit: rowsPerPage, page: 0 }); + }; + + const tableProps = { + t: t, + q: query.q, + limit: limit, + page: query.page, + version: version, + onChangePage: handlePageChange, + onChangeRowsPerPage: handleRowsPerPageChange, + }; + return ( - SearchPage + + + + + + + {query.mode === MODES.TRIBE ? ( + + ) : ( + + )} + + ); } diff --git a/src/features/SearchPage/components/PlayerTable/PlayerTable.tsx b/src/features/SearchPage/components/PlayerTable/PlayerTable.tsx new file mode 100644 index 0000000..27f9742 --- /dev/null +++ b/src/features/SearchPage/components/PlayerTable/PlayerTable.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import usePlayers from './usePlayers'; +import { COLUMNS } from './constants'; + +import Table from '@common/Table/Table'; +import { Props as TableFooterProps } from '@common/Table/TableFooter'; +import PlayerProfileLink from '@features/ServerPage/common/PlayerProfileLink/PlayerProfileLink'; + +import { TFunction } from 'i18next'; +import { Player } from './types'; + +export interface Props { + t: TFunction; + page: number; + limit: number; + q: string; + onChangePage: TableFooterProps['onChangePage']; + onChangeRowsPerPage: TableFooterProps['onChangeRowsPerPage']; + version: string; +} + +function PlayerTable({ + t, + q, + limit, + page, + onChangePage, + onChangeRowsPerPage, + version, +}: Props) { + const { players, total, loading } = usePlayers(version, page, limit, q); + + return ( + ({ + ...column, + valueFormatter: + index === 1 + ? (player: Player) => ( + + ) + : column.valueFormatter, + label: column.label ? t(column.label) : '', + }))} + loading={loading} + data={players} + size="small" + getRowKey={(row: Player) => row.server + row.id} + footerProps={{ + page, + rowsPerPage: limit, + count: total, + onChangePage, + onChangeRowsPerPage, + }} + /> + ); +} + +export default PlayerTable; diff --git a/src/features/SearchPage/components/PlayerTable/constants.ts b/src/features/SearchPage/components/PlayerTable/constants.ts new file mode 100644 index 0000000..25ffa8c --- /dev/null +++ b/src/features/SearchPage/components/PlayerTable/constants.ts @@ -0,0 +1,38 @@ +import formatNumber from '@utils/formatNumber'; +import { Column } from '@common/Table/types'; +import { Player } from './types'; + +export const COLUMNS: Column[] = [ + { + field: 'server', + label: 'playerTable.columns.server', + sortable: false, + }, + { + field: 'name', + label: 'playerTable.columns.name', + sortable: false, + }, + { + field: 'bestRank', + label: 'playerTable.columns.bestRank', + sortable: false, + valueFormatter: (player: Player) => player.bestRank, + }, + { + field: 'mostPoints', + label: 'playerTable.columns.mostPoints', + sortable: false, + valueFormatter: (player: Player) => + formatNumber('commas', player.mostPoints), + }, + { + field: 'mostVillages', + label: 'playerTable.columns.mostVillages', + sortable: false, + valueFormatter: (player: Player) => + formatNumber('commas', player.mostVillages), + }, +]; + +export const LIMIT = 25; diff --git a/src/features/SearchPage/components/PlayerTable/queries.ts b/src/features/SearchPage/components/PlayerTable/queries.ts new file mode 100644 index 0000000..78ba642 --- /dev/null +++ b/src/features/SearchPage/components/PlayerTable/queries.ts @@ -0,0 +1,33 @@ +import { gql } from '@apollo/client'; + +export const SEARCH_PLAYER = gql` + query searchPlayer( + $version: String! + $name: String + $id: Int + $sort: [String!] + $limit: Int + $offset: Int + ) { + foundPlayers: searchPlayer( + version: $version + name: $name + id: $id + sort: $sort + limit: $limit + offset: $offset + ) { + items { + server + id + name + bestRank + mostPoints + mostVillages + tribeID + tribeTag + } + total + } + } +`; diff --git a/src/features/SearchPage/components/PlayerTable/types.ts b/src/features/SearchPage/components/PlayerTable/types.ts new file mode 100644 index 0000000..8925bfb --- /dev/null +++ b/src/features/SearchPage/components/PlayerTable/types.ts @@ -0,0 +1,16 @@ +import { List } from '@libs/graphql/types'; + +export type Player = { + server: string; + id: number; + name: string; + bestRank: number; + mostPoints: number; + mostVillages: number; + tribeID: number; + tribeTag: string; +}; + +export type PlayerList = { + foundPlayers?: List; +}; diff --git a/src/features/SearchPage/components/PlayerTable/usePlayers.ts b/src/features/SearchPage/components/PlayerTable/usePlayers.ts new file mode 100644 index 0000000..0499e4b --- /dev/null +++ b/src/features/SearchPage/components/PlayerTable/usePlayers.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@apollo/client'; +import { SEARCH_PLAYER } from './queries'; + +import { SearchPlayerQueryVariables } from '@libs/graphql/types'; +import { Player, PlayerList } from './types'; + +export type QueryResult = { + players: Player[]; + loading: boolean; + total: number; +}; + +const usePlayers = ( + version: string, + page: number, + limit: number, + q: string +): QueryResult => { + const id = parseInt(q, 10); + const skip = q.trim() === ''; + const { loading: loadingPlayers, data } = useQuery< + PlayerList, + SearchPlayerQueryVariables + >(SEARCH_PLAYER, { + fetchPolicy: 'cache-and-network', + variables: { + limit, + offset: page * limit, + sort: ['server ASC', 'mostPoints DESC'], + version, + name: '%' + q + '%', + id: !isNaN(id) && id > 0 ? id : undefined, + }, + skip, + }); + const players = data?.foundPlayers?.items ?? []; + const loading = loadingPlayers && players.length === 0 && !skip; + const total = data?.foundPlayers?.total ?? 0; + + return { + players, + loading, + total, + }; +}; + +export default usePlayers; diff --git a/src/features/SearchPage/components/TribeTable/TribeTable.tsx b/src/features/SearchPage/components/TribeTable/TribeTable.tsx new file mode 100644 index 0000000..7feea51 --- /dev/null +++ b/src/features/SearchPage/components/TribeTable/TribeTable.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import useTribes from './useTribes'; +import * as ROUTES from '@config/routes'; +import { COLUMNS } from './constants'; + +import Table from '@common/Table/Table'; +import Link from '@common/Link/Link'; +import { Props as TableFooterProps } from '@common/Table/TableFooter'; + +import { TFunction } from 'i18next'; +import { Tribe } from './types'; + +export interface Props { + t: TFunction; + page: number; + limit: number; + q: string; + onChangePage: TableFooterProps['onChangePage']; + onChangeRowsPerPage: TableFooterProps['onChangeRowsPerPage']; + version: string; +} + +function TribeTable({ + t, + q, + limit, + page, + onChangePage, + onChangeRowsPerPage, + version, +}: Props) { + const { tribes, total, loading } = useTribes(version, page, limit, q); + + return ( +
({ + ...column, + valueFormatter: + index === 1 + ? (tribe: Tribe) => ( + + {tribe.name} ({tribe.tag}) + + ) + : column.valueFormatter, + label: column.label ? t(column.label) : '', + }))} + loading={loading} + data={tribes} + size="small" + getRowKey={(row: Tribe) => row.server + row.id} + footerProps={{ + page, + rowsPerPage: limit, + count: total, + onChangePage, + onChangeRowsPerPage, + }} + /> + ); +} + +export default TribeTable; diff --git a/src/features/SearchPage/components/TribeTable/constants.ts b/src/features/SearchPage/components/TribeTable/constants.ts new file mode 100644 index 0000000..c8e24ee --- /dev/null +++ b/src/features/SearchPage/components/TribeTable/constants.ts @@ -0,0 +1,37 @@ +import formatNumber from '@utils/formatNumber'; +import { Column } from '@common/Table/types'; +import { Tribe } from './types'; + +export const COLUMNS: Column[] = [ + { + field: 'server', + label: 'tribeTable.columns.server', + sortable: false, + }, + { + field: 'name', + label: 'tribeTable.columns.name', + sortable: false, + }, + { + field: 'bestRank', + label: 'tribeTable.columns.bestRank', + sortable: false, + valueFormatter: (tribe: Tribe) => tribe.bestRank, + }, + { + field: 'mostPoints', + label: 'tribeTable.columns.mostPoints', + sortable: false, + valueFormatter: (tribe: Tribe) => formatNumber('commas', tribe.mostPoints), + }, + { + field: 'mostVillages', + label: 'tribeTable.columns.mostVillages', + sortable: false, + valueFormatter: (tribe: Tribe) => + formatNumber('commas', tribe.mostVillages), + }, +]; + +export const LIMIT = 25; diff --git a/src/features/SearchPage/components/TribeTable/queries.ts b/src/features/SearchPage/components/TribeTable/queries.ts new file mode 100644 index 0000000..f33876e --- /dev/null +++ b/src/features/SearchPage/components/TribeTable/queries.ts @@ -0,0 +1,30 @@ +import { gql } from '@apollo/client'; + +export const SEARCH_TRIBE = gql` + query searchTribe( + $version: String! + $query: String! + $sort: [String!] + $limit: Int + $offset: Int + ) { + foundTribes: searchTribe( + version: $version + query: $query + sort: $sort + limit: $limit + offset: $offset + ) { + items { + server + id + tag + name + bestRank + mostPoints + mostVillages + } + total + } + } +`; diff --git a/src/features/SearchPage/components/TribeTable/types.ts b/src/features/SearchPage/components/TribeTable/types.ts new file mode 100644 index 0000000..afdfc97 --- /dev/null +++ b/src/features/SearchPage/components/TribeTable/types.ts @@ -0,0 +1,15 @@ +import { List } from '@libs/graphql/types'; + +export type Tribe = { + server: string; + id: number; + name: string; + tag: string; + bestRank: number; + mostPoints: number; + mostVillages: number; +}; + +export type TribeList = { + foundTribes?: List; +}; diff --git a/src/features/SearchPage/components/TribeTable/useTribes.ts b/src/features/SearchPage/components/TribeTable/useTribes.ts new file mode 100644 index 0000000..7b379db --- /dev/null +++ b/src/features/SearchPage/components/TribeTable/useTribes.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@apollo/client'; +import { SEARCH_TRIBE } from './queries'; + +import { SearchTribeQueryVariables } from '@libs/graphql/types'; +import { Tribe, TribeList } from './types'; + +export type QueryResult = { + tribes: Tribe[]; + loading: boolean; + total: number; +}; + +const useTribes = ( + version: string, + page: number, + limit: number, + q: string +): QueryResult => { + const skip = q.trim() === ''; + const { loading: loadingTribes, data } = useQuery< + TribeList, + SearchTribeQueryVariables + >(SEARCH_TRIBE, { + fetchPolicy: 'cache-and-network', + variables: { + limit, + offset: page * limit, + sort: ['server ASC', 'mostPoints DESC'], + version, + query: '%' + q + '%', + }, + skip, + }); + const tribes = data?.foundTribes?.items ?? []; + const loading = loadingTribes && tribes.length === 0 && !skip; + const total = data?.foundTribes?.total ?? 0; + + return { + tribes, + loading, + total, + }; +}; + +export default useTribes; diff --git a/src/features/SearchPage/constants.ts b/src/features/SearchPage/constants.ts new file mode 100644 index 0000000..8fc95ac --- /dev/null +++ b/src/features/SearchPage/constants.ts @@ -0,0 +1,6 @@ +export const MODES = { + PLAYER: 'player', + TRIBE: 'tribe', +}; + +export const LIMIT = 25; diff --git a/src/libs/graphql/createClient.ts b/src/libs/graphql/createClient.ts index e3fefd7..485dfcc 100644 --- a/src/libs/graphql/createClient.ts +++ b/src/libs/graphql/createClient.ts @@ -7,7 +7,16 @@ import { const createClient = (uri: string): ApolloClient => { return new ApolloClient({ uri: uri, - cache: new InMemoryCache(), + cache: new InMemoryCache({ + typePolicies: { + FoundPlayer: { + keyFields: ['server', 'id'], + }, + FoundTribe: { + keyFields: ['server', 'id'], + }, + }, + }), }); }; diff --git a/src/libs/graphql/types.ts b/src/libs/graphql/types.ts index 424e830..a8a2097 100644 --- a/src/libs/graphql/types.ts +++ b/src/libs/graphql/types.ts @@ -3,7 +3,7 @@ export type List = { items: T; }; -type QueryVariables = { +type QueryVariables = { sort?: string[]; limit?: number; offset?: number; @@ -153,3 +153,14 @@ export type VillageQueryVariables = { server: string; id: number; }; + +export type SearchPlayerQueryVariables = Omit & { + version: string; + name?: string; + id?: number; +}; + +export type SearchTribeQueryVariables = Omit & { + version: string; + query: string; +}; diff --git a/src/libs/i18n/en/index.ts b/src/libs/i18n/en/index.ts index 15ffc8a..f46ce8e 100644 --- a/src/libs/i18n/en/index.ts +++ b/src/libs/i18n/en/index.ts @@ -1,6 +1,7 @@ import * as NAMESPACES from '@config/namespaces'; import common from './common'; import indexPage from './index-page'; +import searchPage from './search-page'; import notFoundPage from './not-found-page'; import serverPage from './server-page'; import table from './table'; @@ -9,6 +10,7 @@ import lineChart from './line-chart'; const translations = { [NAMESPACES.COMMON]: common, [NAMESPACES.INDEX_PAGE]: indexPage, + [NAMESPACES.SEARCH_PAGE]: searchPage, [NAMESPACES.NOT_FOUND_PAGE]: notFoundPage, [NAMESPACES.TABLE]: table, [NAMESPACES.LINE_CHART]: lineChart, diff --git a/src/libs/i18n/en/search-page.ts b/src/libs/i18n/en/search-page.ts new file mode 100644 index 0000000..7aac49b --- /dev/null +++ b/src/libs/i18n/en/search-page.ts @@ -0,0 +1,27 @@ +const translations = { + title: 'Search - {{query}}', + modes: { + player: 'Player', + tribe: 'Tribe', + }, + playerTable: { + columns: { + server: 'Server', + name: 'Name', + bestRank: 'Best rank', + mostPoints: 'Most points', + mostVillages: 'Most villages', + }, + }, + tribeTable: { + columns: { + server: 'Server', + name: 'Name', + bestRank: 'Best rank', + mostPoints: 'Most points', + mostVillages: 'Most villages', + }, + }, +}; + +export default translations; diff --git a/src/libs/i18n/pl/index.ts b/src/libs/i18n/pl/index.ts index 5ea5911..f46ce8e 100644 --- a/src/libs/i18n/pl/index.ts +++ b/src/libs/i18n/pl/index.ts @@ -1,6 +1,7 @@ import * as NAMESPACES from '@config/namespaces'; import common from './common'; import indexPage from './index-page'; +import searchPage from './search-page'; import notFoundPage from './not-found-page'; import serverPage from './server-page'; import table from './table'; @@ -9,9 +10,10 @@ import lineChart from './line-chart'; const translations = { [NAMESPACES.COMMON]: common, [NAMESPACES.INDEX_PAGE]: indexPage, + [NAMESPACES.SEARCH_PAGE]: searchPage, [NAMESPACES.NOT_FOUND_PAGE]: notFoundPage, - [NAMESPACES.LINE_CHART]: lineChart, [NAMESPACES.TABLE]: table, + [NAMESPACES.LINE_CHART]: lineChart, ...serverPage, }; diff --git a/src/libs/i18n/pl/search-page.ts b/src/libs/i18n/pl/search-page.ts new file mode 100644 index 0000000..41557af --- /dev/null +++ b/src/libs/i18n/pl/search-page.ts @@ -0,0 +1,27 @@ +const translations = { + title: 'Wyszukiwanie - {{query}}', + modes: { + player: 'Gracz', + tribe: 'Plemię', + }, + playerTable: { + columns: { + server: 'Serwer', + name: 'Nazwa', + bestRank: 'Najlepszy ranking', + mostPoints: 'Najwięcej punktów', + mostVillages: 'Najwięcej wiosek', + }, + }, + tribeTable: { + columns: { + server: 'Serwer', + name: 'Nazwa', + bestRank: 'Najlepszy ranking', + mostPoints: 'Najwięcej punktów', + mostVillages: 'Najwięcej wiosek', + }, + }, +}; + +export default translations;