import { GetStaticProps } from "next"; import { useState, useMemo, useCallback } from "react"; import { useBoolean } from "usehooks-ts"; import naturalCompare from "string-natural-compare"; import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { Select } from "components/Inputs/Select"; import { Switch } from "components/Inputs/Switch"; import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { ContentPanel, ContentPanelWidthSizes } from "components/Panels/ContentPanel"; import { SubPanel } from "components/Panels/SubPanel"; import { GetLibraryItemsPreviewQuery } from "graphql/generated"; import { getReadySdk } from "graphql/sdk"; import { prettyInlineTitle, prettyItemSubType } from "helpers/formatters"; import { LibraryItemUserStatus } from "types/types"; import { Icon } from "components/Ico"; import { WithLabel } from "components/Inputs/WithLabel"; import { TextInput } from "components/Inputs/TextInput"; import { Button } from "components/Inputs/Button"; import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs"; import { isUntangibleGroupItem } from "helpers/libraryItem"; import { PreviewCard } from "components/PreviewCard"; import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { ButtonGroup } from "components/Inputs/ButtonGroup"; import { filterHasAttributes, isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/others"; import { useAppLayout } from "contexts/AppLayoutContext"; import { convertPrice } from "helpers/numbers"; import { SmartList } from "components/SmartList"; import { SelectiveNonNullable } from "types/SelectiveNonNullable"; import { getOpenGraph } from "helpers/openGraph"; import { compareDate } from "helpers/date"; import { HorizontalLine } from "components/HorizontalLine"; import { useIsContentPanelAtLeast } from "hooks/useContainerQuery"; import { cIf, cJoin } from "helpers/className"; import { useCurrencies } from "hooks/useLocalData"; import { getLangui } from "graphql/fetchLocalData"; import { sendAnalytics } from "helpers/analytics"; /* * ╭─────────────╮ * ────────────────────────────────────────╯ CONSTANTS ╰────────────────────────────────────────── */ const DEFAULT_FILTERS_STATE = { searchName: "", showSubitems: false, showPrimaryItems: true, showSecondaryItems: false, sortingMethod: 0, groupingMethod: -1, keepInfoVisible: false, filterUserStatus: undefined, }; /* * ╭────────╮ * ──────────────────────────────────────────╯ PAGE ╰───────────────────────────────────────────── */ interface Props extends AppLayoutRequired { items: NonNullable<GetLibraryItemsPreviewQuery["libraryItems"]>["data"]; } const Library = ({ items, ...otherProps }: Props): JSX.Element => { const hoverable = useDeviceSupportsHover(); const currencies = useCurrencies(); const { langui } = useAppLayout(); const { libraryItemUserStatus } = useAppLayout(); const isContentPanelAtLeast4xl = useIsContentPanelAtLeast("4xl"); const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName); const { value: showSubitems, toggle: toggleShowSubitems, setValue: setShowSubitems, } = useBoolean(DEFAULT_FILTERS_STATE.showSubitems); const { value: showPrimaryItems, toggle: toggleShowPrimaryItems, setValue: setShowPrimaryItems, } = useBoolean(DEFAULT_FILTERS_STATE.showPrimaryItems); const { value: showSecondaryItems, toggle: toggleShowSecondaryItems, setValue: setShowSecondaryItems, } = useBoolean(DEFAULT_FILTERS_STATE.showSecondaryItems); const { value: keepInfoVisible, toggle: toggleKeepInfoVisible, setValue: setKeepInfoVisible, } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); const [sortingMethod, setSortingMethod] = useState<number>(DEFAULT_FILTERS_STATE.sortingMethod); const [groupingMethod, setGroupingMethod] = useState<number>( DEFAULT_FILTERS_STATE.groupingMethod ); const [filterUserStatus, setFilterUserStatus] = useState<LibraryItemUserStatus | undefined>( DEFAULT_FILTERS_STATE.filterUserStatus ); const filteringFunction = useCallback( (item: SelectiveNonNullable<Props["items"][number], "attributes" | "id">) => { if (!showSubitems && !item.attributes.root_item) return false; if (showSubitems && isUntangibleGroupItem(item.attributes.metadata?.[0])) { return false; } if (item.attributes.primary && !showPrimaryItems) return false; if (!item.attributes.primary && !showSecondaryItems) return false; if (isDefined(filterUserStatus) && item.id) { if (isUntangibleGroupItem(item.attributes.metadata?.[0])) { return false; } if (filterUserStatus === LibraryItemUserStatus.None) { if (libraryItemUserStatus[item.id]) { return false; } } else if (filterUserStatus !== libraryItemUserStatus[item.id]) { return false; } } return true; }, [libraryItemUserStatus, filterUserStatus, showPrimaryItems, showSecondaryItems, showSubitems] ); const sortingFunction = useCallback( ( a: SelectiveNonNullable<Props["items"][number], "attributes" | "id">, b: SelectiveNonNullable<Props["items"][number], "attributes" | "id"> ) => { switch (sortingMethod) { case 0: { const titleA = prettyInlineTitle("", a.attributes.title, a.attributes.subtitle); const titleB = prettyInlineTitle("", b.attributes.title, b.attributes.subtitle); return naturalCompare(titleA, titleB); } case 1: { const priceA = a.attributes.price ? convertPrice(a.attributes.price, currencies[0]) : Infinity; const priceB = b.attributes.price ? convertPrice(b.attributes.price, currencies[0]) : Infinity; return priceA - priceB; } case 2: { return compareDate(a.attributes.release_date, b.attributes.release_date); } default: return 0; } }, [currencies, sortingMethod] ); const groupingFunction = useCallback( (item: SelectiveNonNullable<Props["items"][number], "attributes" | "id">): string[] => { switch (groupingMethod) { case 0: { const categories = filterHasAttributes(item.attributes.categories?.data, [ "attributes", ] as const); if (categories.length > 0) { return categories.map((category) => category.attributes.name); } return [langui.no_category ?? "No category"]; } case 1: { if (item.attributes.metadata && item.attributes.metadata.length > 0) { switch (item.attributes.metadata[0]?.__typename) { case "ComponentMetadataAudio": return [langui.audio ?? "Audio"]; case "ComponentMetadataGame": return [langui.game ?? "Game"]; case "ComponentMetadataBooks": return [langui.textual ?? "Textual"]; case "ComponentMetadataVideo": return [langui.video ?? "Video"]; case "ComponentMetadataOther": return [langui.other ?? "Other"]; case "ComponentMetadataGroup": { switch (item.attributes.metadata[0]?.subitems_type?.data?.attributes?.slug) { case "audio": return [langui.audio ?? "Audio"]; case "video": return [langui.video ?? "Video"]; case "game": return [langui.game ?? "Game"]; case "textual": return [langui.textual ?? "Textual"]; case "mixed": return [langui.group ?? "Group"]; default: { return [langui.no_type ?? "No type"]; } } } default: return [langui.no_type ?? "No type"]; } } else { return [langui.no_type ?? "No type"]; } } case 2: { if (item.attributes.release_date?.year) { return [item.attributes.release_date.year.toString()]; } return [langui.no_year ?? "No year"]; } default: return [""]; } }, [groupingMethod, langui] ); const subPanel = useMemo( () => ( <SubPanel> <PanelHeader icon={Icon.LibraryBooks} title={langui.library} description={langui.library_description} /> <HorizontalLine /> <TextInput className="mb-6 w-full" placeholder={langui.search_title ?? "Search..."} value={searchName} onChange={(name) => { setSearchName(name); if (isDefinedAndNotEmpty(name)) { sendAnalytics("Library", "Change search term"); } else { sendAnalytics("Library", "Clear search term"); } }} /> <WithLabel label={langui.group_by}> <Select className="w-full" options={[ langui.category ?? "Category", langui.type ?? "Type", langui.release_year ?? "Year", ]} value={groupingMethod} onChange={(value) => { setGroupingMethod(value); sendAnalytics( "Library", `Change grouping method (${["none", "category", "type", "year"][value + 1]})` ); }} allowEmpty /> </WithLabel> <WithLabel label={langui.order_by}> <Select className="w-full" options={[ langui.name ?? "Name", langui.price ?? "Price", langui.release_date ?? "Release date", ]} value={sortingMethod} onChange={(value) => { setSortingMethod(value); sendAnalytics( "Library", `Change sorting method (${["name", "price", "release date"][value]})` ); }} /> </WithLabel> <WithLabel label={langui.show_subitems}> <Switch value={showSubitems} onClick={() => { toggleShowSubitems(); sendAnalytics("Library", `${showSubitems ? "Hide" : "Show"} subitems`); }} /> </WithLabel> <WithLabel label={langui.show_primary_items}> <Switch value={showPrimaryItems} onClick={() => { toggleShowPrimaryItems(); sendAnalytics("Library", `${showPrimaryItems ? "Hide" : "Show"} primary items`); }} /> </WithLabel> <WithLabel label={langui.show_secondary_items}> <Switch value={showSecondaryItems} onClick={() => { toggleShowSecondaryItems(); sendAnalytics("Library", `${showSecondaryItems ? "Hide" : "Show"} secondary items`); }} /> </WithLabel> {hoverable && ( <WithLabel label={langui.always_show_info}> <Switch value={keepInfoVisible} onClick={() => { toggleKeepInfoVisible(); sendAnalytics("Library", `Always ${keepInfoVisible ? "hide" : "show"} info`); }} /> </WithLabel> )} <ButtonGroup className="mt-4" buttonsProps={[ { tooltip: langui.only_display_items_i_want, icon: Icon.Favorite, onClick: () => { setFilterUserStatus(LibraryItemUserStatus.Want); sendAnalytics("Library", "Set filter status (I want)"); }, active: filterUserStatus === LibraryItemUserStatus.Want, }, { tooltip: langui.only_display_items_i_have, icon: Icon.BackHand, onClick: () => { setFilterUserStatus(LibraryItemUserStatus.Have); sendAnalytics("Library", "Set filter status (I have)"); }, active: filterUserStatus === LibraryItemUserStatus.Have, }, { tooltip: langui.only_display_unmarked_items, icon: Icon.RadioButtonUnchecked, onClick: () => { setFilterUserStatus(LibraryItemUserStatus.None); sendAnalytics("Library", "Set filter status (unmarked)"); }, active: filterUserStatus === LibraryItemUserStatus.None, }, { tooltip: langui.only_display_unmarked_items, text: langui.all, onClick: () => { setFilterUserStatus(undefined); sendAnalytics("Library", "Set filter status (all)"); }, active: isUndefined(filterUserStatus), }, ]} /> <Button className="mt-8" text={langui.reset_all_filters} icon={Icon.Replay} onClick={() => { setSearchName(DEFAULT_FILTERS_STATE.searchName); setShowSubitems(DEFAULT_FILTERS_STATE.showSubitems); setShowPrimaryItems(DEFAULT_FILTERS_STATE.showPrimaryItems); setShowSecondaryItems(DEFAULT_FILTERS_STATE.showSecondaryItems); setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod); setGroupingMethod(DEFAULT_FILTERS_STATE.groupingMethod); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); setFilterUserStatus(DEFAULT_FILTERS_STATE.filterUserStatus); sendAnalytics("Library", "Reset all filters"); }} /> </SubPanel> ), [ filterUserStatus, groupingMethod, hoverable, keepInfoVisible, langui, searchName, setKeepInfoVisible, setShowPrimaryItems, setShowSecondaryItems, setShowSubitems, showPrimaryItems, showSecondaryItems, showSubitems, sortingMethod, toggleKeepInfoVisible, toggleShowPrimaryItems, toggleShowSecondaryItems, toggleShowSubitems, ] ); const contentPanel = useMemo( () => ( <ContentPanel width={ContentPanelWidthSizes.Full}> <SmartList items={filterHasAttributes(items, ["id", "attributes"] as const)} getItemId={(item) => item.id} renderItem={({ item }) => ( <PreviewCard href={`/library/${item.attributes.slug}`} title={item.attributes.title} subtitle={item.attributes.subtitle} thumbnail={item.attributes.thumbnail?.data?.attributes} thumbnailAspectRatio="21/29.7" thumbnailRounded={false} keepInfoVisible={keepInfoVisible} topChips={ item.attributes.metadata && item.attributes.metadata.length > 0 && item.attributes.metadata[0] ? [prettyItemSubType(item.attributes.metadata[0])] : [] } bottomChips={item.attributes.categories?.data.map( (category) => category.attributes?.short ?? "" )} metadata={{ releaseDate: item.attributes.release_date, price: item.attributes.price, position: "Bottom", }} infoAppend={ !isUntangibleGroupItem(item.attributes.metadata?.[0]) && ( <PreviewCardCTAs id={item.id} /> ) } /> )} className={cJoin( "grid-cols-2 items-end", cIf(isContentPanelAtLeast4xl, "grid-cols-[repeat(auto-fill,_minmax(13rem,1fr))]") )} searchingTerm={searchName} sortingFunction={sortingFunction} groupingFunction={groupingFunction} searchingBy={(item) => prettyInlineTitle("", item.attributes.title, item.attributes.subtitle) } filteringFunction={filteringFunction} paginationItemPerPage={25} /> </ContentPanel> ), [ filteringFunction, groupingFunction, isContentPanelAtLeast4xl, items, keepInfoVisible, searchName, sortingFunction, ] ); return ( <AppLayout subPanel={subPanel} contentPanel={contentPanel} subPanelIcon={Icon.Search} {...otherProps} /> ); }; export default Library; /* * ╭──────────────────────╮ * ───────────────────────────────────╯ NEXT DATA FETCHING ╰────────────────────────────────────── */ export const getStaticProps: GetStaticProps = async (context) => { const sdk = getReadySdk(); const langui = getLangui(context.locale); const items = await sdk.getLibraryItemsPreview({ language_code: context.locale ?? "en", }); if (!items.libraryItems?.data) return { notFound: true }; const props: Props = { items: items.libraryItems.data, openGraph: getOpenGraph(langui, langui.library ?? "Library"), }; return { props: props, }; };