diff --git a/src/components/Credits.tsx b/src/components/Credits.tsx index 66a35fe..fe49384 100644 --- a/src/components/Credits.tsx +++ b/src/components/Credits.tsx @@ -2,10 +2,7 @@ import { Chip } from "components/Chip"; import { Markdawn } from "components/Markdown/Markdawn"; import { RecorderChip } from "components/RecorderChip"; import { ToolTip } from "components/ToolTip"; -import { atoms } from "contexts/atoms"; import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts"; -import { useAtomGetter } from "helpers/atoms"; -import { prettyLanguage } from "helpers/formatters"; import { ContentStatus, useFormat } from "hooks/useFormat"; /* @@ -40,8 +37,7 @@ export const Credits = ({ authors = [], notes, }: Props): JSX.Element => { - const { format, formatStatusDescription, formatStatusLabel } = useFormat(); - const languages = useAtomGetter(atoms.localData.languages); + const { format, formatStatusDescription, formatStatusLabel, formatLanguage } = useFormat(); return (
@@ -54,7 +50,7 @@ export const Credits = ({

{format("translation_notice")}

{format("source_language")}:

- +
)} diff --git a/src/components/Inputs/LanguageSwitcher.tsx b/src/components/Inputs/LanguageSwitcher.tsx index f1818d8..54b6303 100644 --- a/src/components/Inputs/LanguageSwitcher.tsx +++ b/src/components/Inputs/LanguageSwitcher.tsx @@ -2,11 +2,9 @@ import { Fragment } from "react"; import { ToolTip } from "../ToolTip"; import { Button } from "./Button"; import { cJoin } from "helpers/className"; -import { prettyLanguage } from "helpers/formatters"; import { iterateMap } from "helpers/others"; import { sendAnalytics } from "helpers/analytics"; -import { atoms } from "contexts/atoms"; -import { useAtomGetter } from "helpers/atoms"; +import { useFormat } from "hooks/useFormat"; /* * ╭─────────────╮ @@ -32,7 +30,7 @@ export const LanguageSwitcher = ({ onLanguageChanged, showBadge = true, }: Props): JSX.Element => { - const languages = useAtomGetter(atoms.localData.languages); + const { formatLanguage } = useFormat(); return ( ))} diff --git a/src/components/Panels/SettingsPopup.tsx b/src/components/Panels/SettingsPopup.tsx index b371e34..a00e395 100644 --- a/src/components/Panels/SettingsPopup.tsx +++ b/src/components/Panels/SettingsPopup.tsx @@ -7,7 +7,6 @@ import { TextInput } from "components/Inputs/TextInput"; import { Popup } from "components/Containers/Popup"; import { sendAnalytics } from "helpers/analytics"; import { cJoin, cIf } from "helpers/className"; -import { prettyLanguage } from "helpers/formatters"; import { filterHasAttributes, isDefined } from "helpers/asserts"; import { atoms } from "contexts/atoms"; import { useAtomGetter, useAtomPair, useAtomSetter } from "helpers/atoms"; @@ -36,8 +35,7 @@ export const SettingsPopup = (): JSX.Element => { const perfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled); const isPerfModeToggleable = useAtomGetter(atoms.settings.isPerfModeToggleable); - const languages = useAtomGetter(atoms.localData.languages); - const { format } = useFormat(); + const { format, formatLanguage } = useFormat(); const currencies = useAtomGetter(atoms.localData.currencies); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); @@ -77,7 +75,7 @@ export const SettingsPopup = (): JSX.Element => { ({ code: locale, - name: prettyLanguage(locale, languages), + name: formatLanguage(locale), }))} insertLabels={[ { diff --git a/src/components/PreviewCard.tsx b/src/components/PreviewCard.tsx index e9e6155..0033ff1 100644 --- a/src/components/PreviewCard.tsx +++ b/src/components/PreviewCard.tsx @@ -1,5 +1,4 @@ import { MouseEventHandler, useCallback } from "react"; -import { useRouter } from "next/router"; import { Markdown } from "./Markdown/Markdown"; import { Chip } from "components/Chip"; import { Ico } from "components/Ico"; @@ -7,13 +6,14 @@ import { Img } from "components/Img"; import { UpPressable } from "components/Containers/UpPressable"; import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated"; import { cIf, cJoin } from "helpers/className"; -import { prettyDate, prettyDuration, prettyPrice, prettyShortenNumber } from "helpers/formatters"; +import { prettyDuration, prettyShortenNumber } from "helpers/formatters"; import { ImageQuality } from "helpers/img"; import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useSmartLanguage } from "hooks/useSmartLanguage"; import { TranslatedProps } from "types/TranslatedProps"; import { atoms } from "contexts/atoms"; import { useAtomGetter } from "helpers/atoms"; +import { useFormat } from "hooks/useFormat"; /* * ╭─────────────╮ @@ -77,11 +77,10 @@ export const PreviewCard = ({ disabled = false, onClick, }: Props): JSX.Element => { + const { formatPrice, formatDate } = useFormat(); const isPerfModeEnabled = useAtomGetter(atoms.settings.isPerfModeEnabled); - const currency = useAtomGetter(atoms.settings.currency); - const currencies = useAtomGetter(atoms.localData.currencies); + const preferredCurrency = useAtomGetter(atoms.settings.currency); const isHoverable = useDeviceSupportsHover(); - const router = useRouter(); const metadataJSX = ( <> @@ -90,13 +89,13 @@ export const PreviewCard = ({ {metadata.releaseDate && (

- {prettyDate(metadata.releaseDate, router.locale)} + {formatDate(metadata.releaseDate)}

)} {metadata.price && (

- {prettyPrice(metadata.price, currencies, currency, router.locale)} + {formatPrice(metadata.price, preferredCurrency)}

)} {metadata.views && ( diff --git a/src/graphql/fetchLocalData.ts b/src/graphql/fetchLocalData.ts index 9937503..d162628 100644 --- a/src/graphql/fetchLocalData.ts +++ b/src/graphql/fetchLocalData.ts @@ -4,6 +4,8 @@ import { readFileSync, writeFileSync } from "fs"; import { config } from "dotenv"; import { getReadySdk } from "./sdk"; import { + LocalDataGetCurrenciesQuery, + LocalDataGetLanguagesQuery, LocalDataGetTypesTranslationsQuery, LocalDataGetWebsiteInterfacesQuery, } from "./generated"; @@ -12,6 +14,10 @@ import { Langui, TypesTranslations, processTypesTranslations, + Currencies, + processCurrencies, + Languages, + processLanguages, } from "helpers/localData"; import { getLogger } from "helpers/logger"; @@ -86,3 +92,13 @@ export const getTypesTranslations = (): TypesTranslations => { const typesTranslations = readLocalData("typesTranslations"); return processTypesTranslations(typesTranslations); }; + +export const getCurrencies = (): Currencies => { + const currencies = readLocalData("currencies"); + return processCurrencies(currencies); +}; + +export const getLanguages = (): Languages => { + const languages = readLocalData("languages"); + return processLanguages(languages); +}; diff --git a/src/graphql/getPostStaticProps.ts b/src/graphql/getPostStaticProps.ts index 398c643..983584c 100644 --- a/src/graphql/getPostStaticProps.ts +++ b/src/graphql/getPostStaticProps.ts @@ -2,7 +2,7 @@ import { GetStaticProps } from "next"; import { getReadySdk } from "./sdk"; import { PostWithTranslations } from "types/types"; import { getOpenGraph } from "helpers/openGraph"; -import { prettyDate, prettySlug } from "helpers/formatters"; +import { prettySlug } from "helpers/formatters"; import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales"; import { filterHasAttributes } from "helpers/asserts"; import { getDescription } from "helpers/description"; @@ -17,7 +17,7 @@ export const getPostStaticProps = (slug: string): GetStaticProps => async (context) => { const sdk = getReadySdk(); - const { format, formatCategory } = getFormat(context.locale); + const { format, formatCategory, formatDate } = getFormat(context.locale); const post = await sdk.getPost({ slug: slug, }); @@ -35,7 +35,7 @@ export const getPostStaticProps = const title = selectedTranslation?.title ?? prettySlug(slug); const description = getDescription(selectedTranslation?.excerpt ?? selectedTranslation?.body, { - [format("release_date")]: [prettyDate(post.posts.data[0].attributes.date, context.locale)], + [format("release_date")]: [formatDate(post.posts.data[0].attributes.date)], [format("category", { count: Infinity })]: filterHasAttributes( post.posts.data[0].attributes.categories?.data, ["attributes"] diff --git a/src/helpers/formatters.ts b/src/helpers/formatters.ts index b437a7d..35722ac 100644 --- a/src/helpers/formatters.ts +++ b/src/helpers/formatters.ts @@ -1,43 +1,7 @@ import { convert } from "html-to-text"; import { sanitize } from "isomorphic-dompurify"; import { marked } from "marked"; -import { convertPrice } from "./numbers"; -import { isDefinedAndNotEmpty, isUndefined } from "./asserts"; -import { datePickerToDate } from "./date"; -import { Currencies, Languages } from "./localData"; -import { DatePickerFragment, PricePickerFragment } from "graphql/generated"; - -export const prettyDate = ( - datePicker: DatePickerFragment, - locale = "en", - dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium" -): string => datePickerToDate(datePicker).toLocaleString(locale, { dateStyle }); - -export const prettyPrice = ( - pricePicker: PricePickerFragment, - currencies: Currencies, - targetCurrencyCode?: string, - locale = "en" -): string => { - if (!targetCurrencyCode) return ""; - if (isUndefined(pricePicker.amount)) return ""; - - const targetCurrency = currencies.find( - (currency) => currency.attributes?.code === targetCurrencyCode - ); - - if (targetCurrency?.attributes) { - const amountInTargetCurrency = convertPrice(pricePicker, targetCurrency); - return amountInTargetCurrency.toLocaleString(locale, { - style: "currency", - currency: targetCurrency.attributes.code, - }); - } - return pricePicker.amount.toLocaleString(locale, { - style: "currency", - currency: pricePicker.currency?.data?.attributes?.code, - }); -}; +import { isDefinedAndNotEmpty } from "./asserts"; export const prettySlug = (slug?: string, parentSlug?: string): string => { let newSlug = slug; @@ -94,14 +58,6 @@ export const prettyDuration = (seconds: number): string => { return result; }; -export const prettyLanguage = (code: string, languages: Languages): string => { - let result = code; - languages.forEach((language) => { - if (language.attributes?.code === code) result = language.attributes.localized_name; - }); - return result; -}; - export const prettyURL = (url: string): string => { const domain = new URL(url); return domain.hostname.replace("www.", ""); diff --git a/src/helpers/i18n.ts b/src/helpers/i18n.ts index 51c35c7..145951a 100644 --- a/src/helpers/i18n.ts +++ b/src/helpers/i18n.ts @@ -1,10 +1,21 @@ import { IntlMessageFormat } from "intl-messageformat"; -import { LibraryItemMetadataDynamicZone } from "graphql/generated"; +import { + DatePickerFragment, + LibraryItemMetadataDynamicZone, + PricePickerFragment, +} from "graphql/generated"; import { ICUParams } from "graphql/icuParams"; -import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts"; -import { getLangui, getTypesTranslations } from "graphql/fetchLocalData"; +import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts"; +import { + getCurrencies, + getLanguages, + getLangui, + getTypesTranslations, +} from "graphql/fetchLocalData"; import { prettySlug } from "helpers/formatters"; import { LibraryItemMetadata } from "types/types"; +import { datePickerToDate } from "helpers/date"; +import { convertPrice } from "helpers/numbers"; type WordingKey = keyof ICUParams; type LibraryItemType = Exclude; @@ -45,10 +56,18 @@ export const getFormat = ( formatContentType: (slug: string) => string; formatWikiTag: (slug: string) => string; formatWeaponType: (slug: string) => string; + formatLanguage: (code: string) => string; + formatPrice: (price: PricePickerFragment, targetCurrencyCode?: string) => string; + formatDate: ( + datePicker: DatePickerFragment, + dateStyle?: Intl.DateTimeFormatOptions["dateStyle"] + ) => string; } => { const langui = getLangui(locale); const fallbackLangui = getLangui("en"); const typesTranslations = getTypesTranslations(); + const currencies = getCurrencies(); + const languages = getLanguages(); const format = ( key: WordingKey, @@ -214,6 +233,35 @@ export const getFormat = ( return findTranslation(locale) ?? findTranslation("en") ?? prettySlug(slug); }; + const formatLanguage = (code: string) => + languages.find((language) => language.attributes?.code === code)?.attributes?.localized_name ?? + code.toUpperCase(); + + const formatPrice = (price: PricePickerFragment, targetCurrencyCode?: string) => { + if (isUndefined(price.amount)) return ""; + + const targetCurrency = currencies.find( + (currency) => currency.attributes?.code === targetCurrencyCode + ); + + if (targetCurrency?.attributes) { + const amountInTargetCurrency = convertPrice(price, targetCurrency); + return amountInTargetCurrency.toLocaleString(locale, { + style: "currency", + currency: targetCurrency.attributes.code, + }); + } + return price.amount.toLocaleString(locale, { + style: "currency", + currency: price.currency?.data?.attributes?.code, + }); + }; + + const formatDate = ( + datePicker: DatePickerFragment, + dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium" + ): string => datePickerToDate(datePicker).toLocaleString(locale, { dateStyle }); + return { format, formatLibraryItemType, @@ -224,5 +272,8 @@ export const getFormat = ( formatContentType, formatWikiTag, formatWeaponType, + formatLanguage, + formatPrice, + formatDate, }; }; diff --git a/src/hooks/useFormat.ts b/src/hooks/useFormat.ts index 65c4ed2..5c4c4d8 100644 --- a/src/hooks/useFormat.ts +++ b/src/hooks/useFormat.ts @@ -3,12 +3,18 @@ import { useCallback } from "react"; import { useRouter } from "next/router"; import { atoms } from "contexts/atoms"; import { useAtomGetter } from "helpers/atoms"; -import { LibraryItemMetadataDynamicZone } from "graphql/generated"; +import { + DatePickerFragment, + LibraryItemMetadataDynamicZone, + PricePickerFragment, +} from "graphql/generated"; import { ICUParams } from "graphql/icuParams"; -import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts"; +import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts"; import { getLogger } from "helpers/logger"; import { prettySlug } from "helpers/formatters"; import { LibraryItemMetadata } from "types/types"; +import { convertPrice } from "helpers/numbers"; +import { datePickerToDate } from "helpers/date"; const logger = getLogger("🗺️ [I18n]"); @@ -69,10 +75,18 @@ export const useFormat = (): { formatContentType: (slug: string) => string; formatWikiTag: (slug: string) => string; formatWeaponType: (slug: string) => string; + formatLanguage: (code: string) => string; + formatPrice: (price: PricePickerFragment, targetCurrencyCode?: string) => string; + formatDate: ( + datePicker: DatePickerFragment, + dateStyle?: Intl.DateTimeFormatOptions["dateStyle"] + ) => string; } => { const langui = useAtomGetter(atoms.localData.langui); const fallbackLangui = useAtomGetter(atoms.localData.fallbackLangui); const typesTranslations = useAtomGetter(atoms.localData.typesTranslations); + const languages = useAtomGetter(atoms.localData.languages); + const currencies = useAtomGetter(atoms.localData.currencies); const { locale = "en" } = useRouter(); const format = useCallback( @@ -280,6 +294,44 @@ Falling back to en translation.` [locale, typesTranslations.weaponTypes] ); + const formatLanguage = useCallback( + (code: string) => + languages.find((language) => language.attributes?.code === code)?.attributes + ?.localized_name ?? code.toUpperCase(), + [languages] + ); + + const formatPrice = useCallback( + (price: PricePickerFragment, targetCurrencyCode?: string) => { + if (isUndefined(price.amount)) return ""; + + const targetCurrency = currencies.find( + (currency) => currency.attributes?.code === targetCurrencyCode + ); + + if (targetCurrency?.attributes) { + const amountInTargetCurrency = convertPrice(price, targetCurrency); + return amountInTargetCurrency.toLocaleString(locale, { + style: "currency", + currency: targetCurrency.attributes.code, + }); + } + return price.amount.toLocaleString(locale, { + style: "currency", + currency: price.currency?.data?.attributes?.code, + }); + }, + [currencies, locale] + ); + + const formatDate = useCallback( + ( + datePicker: DatePickerFragment, + dateStyle: Intl.DateTimeFormatOptions["dateStyle"] = "medium" + ) => datePickerToDate(datePicker).toLocaleString(locale, { dateStyle }), + [locale] + ); + return { format, formatLibraryItemType, @@ -290,5 +342,8 @@ Falling back to en translation.` formatContentType, formatWikiTag, formatWeaponType, + formatLanguage, + formatPrice, + formatDate, }; }; diff --git a/src/pages/archives/videos/v/[uid].tsx b/src/pages/archives/videos/v/[uid].tsx index 6b84331..6a5a740 100644 --- a/src/pages/archives/videos/v/[uid].tsx +++ b/src/pages/archives/videos/v/[uid].tsx @@ -1,5 +1,4 @@ import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next"; -import { useRouter } from "next/router"; import { useCallback } from "react"; import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { HorizontalLine } from "components/HorizontalLine"; @@ -12,7 +11,7 @@ import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/Cont import { SubPanel } from "components/Containers/SubPanel"; import { GetVideoQuery } from "graphql/generated"; import { getReadySdk } from "graphql/sdk"; -import { prettyDate, prettyShortenNumber } from "helpers/formatters"; +import { prettyShortenNumber } from "helpers/formatters"; import { filterHasAttributes, isDefined } from "helpers/asserts"; import { getVideoFile, getVideoThumbnailURL } from "helpers/videos"; import { getOpenGraph } from "helpers/openGraph"; @@ -37,8 +36,7 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => { const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); const closeSubPanel = useCallback(() => setSubPanelOpened(false), [setSubPanelOpened]); - const { format } = useFormat(); - const router = useRouter(); + const { format, formatDate } = useFormat(); const subPanel = ( @@ -85,7 +83,7 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => {

- {prettyDate(video.published_date, router.locale)} + {formatDate(video.published_date)}

diff --git a/src/pages/contents/all.tsx b/src/pages/contents/all.tsx index f3eeee6..bcb6ce8 100644 --- a/src/pages/contents/all.tsx +++ b/src/pages/contents/all.tsx @@ -42,12 +42,14 @@ const DEFAULT_FILTERS_STATE = { keepInfoVisible: true, query: "", page: 1, + lang: 0, }; const queryParamSchema = z.object({ query: z.coerce.string().optional(), page: z.coerce.number().positive().optional(), sort: z.coerce.number().min(0).max(5).optional(), + lang: z.coerce.number().min(0).optional(), }); /* @@ -59,7 +61,7 @@ interface Props extends AppLayoutRequired {} const Contents = (props: Props): JSX.Element => { const hoverable = useDeviceSupportsHover(); - const { format, formatCategory, formatContentType } = useFormat(); + const { format, formatCategory, formatContentType, formatLanguage } = useFormat(); const router = useTypedRouter(queryParamSchema); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); @@ -72,6 +74,17 @@ const Contents = (props: Props): JSX.Element => { [format] ); + const languageOptions = useMemo(() => { + const memo = + router.locales?.map((language) => ({ + meiliAttribute: language, + displayedName: formatLanguage(language), + })) ?? []; + + memo.unshift({ meiliAttribute: "", displayedName: format("all") }); + return memo; + }, [router.locales, formatLanguage, format]); + const [sortingMethod, setSortingMethod] = useState( router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod ); @@ -82,25 +95,41 @@ const Contents = (props: Props): JSX.Element => { setValue: setKeepInfoVisible, } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); - const [page, setPage] = useState(router.query.page ?? DEFAULT_FILTERS_STATE.page); const [contents, setContents] = useState>(); + const [page, setPage] = useState(router.query.page ?? DEFAULT_FILTERS_STATE.page); const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query); + const [languageOption, setLanguageOption] = useState( + router.query.lang ?? DEFAULT_FILTERS_STATE.lang + ); useEffect(() => { const fetchPosts = async () => { - const currentSortingMethod = sortingMethods[sortingMethod]; + const currentSortingMethod = sortingMethods[sortingMethod]?.meiliAttribute; + const currentLanguageOption = languageOptions[languageOption]?.meiliAttribute; + + const filter: string[] = []; + if (languageOption !== 0) { + filter.push(`filterable_languages = ${currentLanguageOption}`); + } + const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, { attributesToRetrieve: ["translations", "id", "slug", "categories", "type", "thumbnail"], attributesToHighlight: ["translations"], attributesToCrop: ["translations.displayable_description"], + filter, hitsPerPage: 25, page, - sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined, + sort: isDefined(currentSortingMethod) ? [currentSortingMethod] : undefined, }); - setContents(filterHitsWithHighlight(searchResult, "translations")); + + setContents( + languageOption === 0 + ? filterHitsWithHighlight(searchResult, "translations") + : searchResult + ); }; fetchPosts(); - }, [query, page, sortingMethod, sortingMethods]); + }, [query, page, sortingMethod, sortingMethods, languageOption, languageOptions]); useEffect(() => { if (router.isReady) @@ -108,15 +137,17 @@ const Contents = (props: Props): JSX.Element => { page, query, sort: sortingMethod, + lang: languageOption, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, query, sortingMethod, router.isReady]); + }, [page, query, languageOption, sortingMethod, router.isReady]); useEffect(() => { if (router.isReady) { if (isDefined(router.query.page)) setPage(router.query.page); if (isDefined(router.query.query)) setQuery(router.query.query); if (isDefined(router.query.sort)) setSortingMethod(router.query.sort); + if (isDefined(router.query.lang)) setLanguageOption(router.query.lang); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady]); @@ -173,6 +204,24 @@ const Contents = (props: Props): JSX.Element => { /> + + item.displayedName)} + value={languageOption} + onChange={(newLanguageOption) => { + setPage(1); + setLanguageOption(newLanguageOption); + sendAnalytics( + "News", + `Change language filter (${ + languageOptions.map((item) => item.meiliAttribute)[newLanguageOption] + })` + ); + }} + /> + + {hoverable && ( { text={format("reset_all_filters")} icon="settings_backup_restore" onClick={() => { + setPage(DEFAULT_FILTERS_STATE.page); setQuery(DEFAULT_FILTERS_STATE.query); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); + setLanguageOption(DEFAULT_FILTERS_STATE.lang); sendAnalytics("News", "Reset all filters"); }} /> @@ -168,13 +218,19 @@ const News = ({ ...otherProps }: Props): JSX.Element => { href={`/news/${item.slug}`} translations={filterHasAttributes(item._formatted.translations, [ "language.data.attributes.code", - ]).map(({ excerpt, displayable_description, language, ...otherAttributes }) => ({ - ...otherAttributes, - description: containsHighlight(displayable_description) - ? displayable_description - : excerpt, - language: language.data.attributes.code, - }))} + ]) + .map(({ excerpt, displayable_description, language, ...otherAttributes }) => ({ + ...otherAttributes, + description: containsHighlight(displayable_description) + ? displayable_description + : excerpt, + language: language.data.attributes.code, + })) + .filter( + ({ language }) => + languageOption === 0 || + language === languageOptions[languageOption]?.meiliAttribute + )} fallback={{ title: prettySlug(item.slug) }} thumbnail={item.thumbnail?.data?.attributes} thumbnailAspectRatio="3/2" diff --git a/src/pages/wiki/index.tsx b/src/pages/wiki/index.tsx index fc2493d..c1c6abb 100644 --- a/src/pages/wiki/index.tsx +++ b/src/pages/wiki/index.tsx @@ -1,5 +1,5 @@ import { GetStaticProps } from "next"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useBoolean } from "usehooks-ts"; import { z } from "zod"; import { AppLayout, AppLayoutRequired } from "components/AppLayout"; @@ -20,12 +20,18 @@ import { TranslatedPreviewCard } from "components/PreviewCard"; import { sendAnalytics } from "helpers/analytics"; import { useTypedRouter } from "hooks/useTypedRouter"; import { MeiliIndices, MeiliWikiPage } from "shared/meilisearch-graphql-typings/meiliTypes"; -import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search"; +import { + containsHighlight, + CustomSearchResponse, + filterHitsWithHighlight, + meiliSearch, +} from "helpers/search"; import { Paginator } from "components/Containers/Paginator"; import { useFormat } from "hooks/useFormat"; import { getFormat } from "helpers/i18n"; import { useAtomSetter } from "helpers/atoms"; import { atoms } from "contexts/atoms"; +import { Select } from "components/Inputs/Select"; /* * ╭─────────────╮ @@ -36,11 +42,13 @@ const DEFAULT_FILTERS_STATE = { query: "", keepInfoVisible: true, page: 1, + lang: 0, }; const queryParamSchema = z.object({ query: z.coerce.string().optional(), page: z.coerce.number().positive().optional(), + lang: z.coerce.number().min(0).optional(), }); /* @@ -51,24 +59,44 @@ const queryParamSchema = z.object({ interface Props extends AppLayoutRequired {} const Wiki = (props: Props): JSX.Element => { - const { format, formatCategory, formatWikiTag } = useFormat(); + const { format, formatCategory, formatWikiTag, formatLanguage } = useFormat(); const hoverable = useDeviceSupportsHover(); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); const closeSubPanel = useCallback(() => setSubPanelOpened(false), [setSubPanelOpened]); const router = useTypedRouter(queryParamSchema); - const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query); + const languageOptions = useMemo(() => { + const memo = + router.locales?.map((language) => ({ + meiliAttribute: language, + displayedName: formatLanguage(language), + })) ?? []; + + memo.unshift({ meiliAttribute: "", displayedName: format("all") }); + return memo; + }, [router.locales, formatLanguage, format]); + + const [wikiPages, setWikiPages] = useState>(); + const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query); + const [page, setPage] = useState(router.query.page ?? DEFAULT_FILTERS_STATE.page); const { value: keepInfoVisible, toggle: toggleKeepInfoVisible, setValue: setKeepInfoVisible, } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); - - const [page, setPage] = useState(router.query.page ?? DEFAULT_FILTERS_STATE.page); - const [wikiPages, setWikiPages] = useState>(); + const [languageOption, setLanguageOption] = useState( + router.query.lang ?? DEFAULT_FILTERS_STATE.lang + ); useEffect(() => { const fetchWikiPages = async () => { + const currentLanguageOption = languageOptions[languageOption]?.meiliAttribute; + + const filter: string[] = []; + if (languageOption !== 0) { + filter.push(`filterable_languages = ${currentLanguageOption}`); + } + const searchResult = await meiliSearch(MeiliIndices.WIKI_PAGE, query, { hitsPerPage: 25, page, @@ -79,25 +107,32 @@ const Wiki = (props: Props): JSX.Element => { "translations.displayable_description", ], attributesToCrop: ["translations.displayable_description"], + filter, }); - setWikiPages(searchResult); + setWikiPages( + languageOption === 0 + ? filterHitsWithHighlight(searchResult, "translations") + : searchResult + ); }; fetchWikiPages(); - }, [query, page]); + }, [query, page, languageOptions, languageOption]); useEffect(() => { if (router.isReady) router.updateQuery({ page, query, + lang: languageOption, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, query, router.isReady]); + }, [page, query, router.isReady, languageOption]); useEffect(() => { if (router.isReady) { if (isDefined(router.query.page)) setPage(router.query.page); if (isDefined(router.query.query)) setQuery(router.query.query); + if (isDefined(router.query.lang)) setLanguageOption(router.query.lang); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady]); @@ -127,6 +162,24 @@ const Wiki = (props: Props): JSX.Element => { }} /> + + item.displayedName)} + value={languageOption} + onChange={(newLanguageOption) => { + setPage(1); + setLanguageOption(newLanguageOption); + sendAnalytics( + "Weapons", + `Change language filter (${ + languageOptions.map((item) => item.meiliAttribute)[newLanguageOption] + })` + ); + }} + /> + + {hoverable && ( { text={format("reset_all_filters")} icon="settings_backup_restore" onClick={() => { + setPage(DEFAULT_FILTERS_STATE.page); setQuery(DEFAULT_FILTERS_STATE.query); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); + setLanguageOption(DEFAULT_FILTERS_STATE.lang); sendAnalytics("Weapons", "Reset all filters"); }} /> @@ -176,12 +227,18 @@ const Weapons = (props: Props): JSX.Element => { href={`/wiki/weapons/${item.slug}`} translations={filterHasAttributes(item._formatted.translations, [ "language.data.attributes.code", - ]).map(({ description, language, names: [primaryName, ...aliases] }) => ({ - language: language.data.attributes.code, - title: primaryName, - subtitle: aliases.join("・"), - description: containsHighlight(description) ? description : undefined, - }))} + ]) + .map(({ description, language, names: [primaryName, ...aliases] }) => ({ + language: language.data.attributes.code, + title: primaryName, + subtitle: aliases.join("・"), + description: containsHighlight(description) ? description : undefined, + })) + .filter( + ({ language }) => + languageOption === 0 || + language === languageOptions[languageOption]?.meiliAttribute + )} fallback={{ title: prettySlug(item.slug) }} thumbnail={item.thumbnail?.data?.attributes} thumbnailAspectRatio="1/1" diff --git a/src/shared/meilisearch-graphql-typings/meiliTypes.ts b/src/shared/meilisearch-graphql-typings/meiliTypes.ts index b72e0af..7a70bfa 100644 --- a/src/shared/meilisearch-graphql-typings/meiliTypes.ts +++ b/src/shared/meilisearch-graphql-typings/meiliTypes.ts @@ -33,6 +33,7 @@ export interface MeiliContent displayable_description?: string | null; })[]; sortable_updated_date: number; + filterable_languages: string[]; } export interface MeiliVideo extends VideoAttributesFragment { @@ -50,6 +51,7 @@ export interface MeiliPost extends Omit > & { displayable_description?: string | null; })[]; + filterable_languages: string[]; } export interface MeiliWikiPage extends Omit { @@ -60,6 +62,7 @@ export interface MeiliWikiPage extends Omit & { displayable_description?: string | null; })[]; + filterable_languages: string[]; } type WeaponAttributesTranslation = NonNullable< @@ -79,6 +82,7 @@ export interface MeiliWeapon extends Omit