493 lines
17 KiB
TypeScript
493 lines
17 KiB
TypeScript
import { GetStaticProps } from "next";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useBoolean } from "usehooks-ts";
|
|
import { z } from "zod";
|
|
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/Containers/ContentPanel";
|
|
import { SubPanel } from "components/Containers/SubPanel";
|
|
import { LibraryItemUserStatus } from "types/types";
|
|
import { WithLabel } from "components/Inputs/WithLabel";
|
|
import { TextInput } from "components/Inputs/TextInput";
|
|
import { Button } from "components/Inputs/Button";
|
|
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
|
|
import { ButtonGroup } from "components/Inputs/ButtonGroup";
|
|
import {
|
|
filterDefined,
|
|
filterHasAttributes,
|
|
isDefined,
|
|
isDefinedAndNotEmpty,
|
|
isUndefined,
|
|
} from "helpers/asserts";
|
|
import { getOpenGraph } from "helpers/openGraph";
|
|
import { HorizontalLine } from "components/HorizontalLine";
|
|
import { sendAnalytics } from "helpers/analytics";
|
|
import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search";
|
|
import { MeiliIndices, MeiliLibraryItem } from "shared/meilisearch-graphql-typings/meiliTypes";
|
|
import { useTypedRouter } from "hooks/useTypedRouter";
|
|
import { TranslatedPreviewCard } from "components/PreviewCard";
|
|
import { prettyItemSubType } from "helpers/formatters";
|
|
import { isUntangibleGroupItem } from "helpers/libraryItem";
|
|
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
|
|
import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
|
|
import { Paginator } from "components/Containers/Paginator";
|
|
import { useFormat } from "hooks/useFormat";
|
|
import { getFormat } from "helpers/i18n";
|
|
|
|
/*
|
|
* ╭─────────────╮
|
|
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
|
*/
|
|
|
|
const DEFAULT_FILTERS_STATE = {
|
|
query: "",
|
|
showSubitems: false,
|
|
showPrimaryItems: true,
|
|
showSecondaryItems: false,
|
|
page: 1,
|
|
sortingMethod: 0,
|
|
keepInfoVisible: false,
|
|
filterUserStatus: undefined,
|
|
};
|
|
|
|
const queryParamSchema = z.object({
|
|
query: z.coerce.string().optional(),
|
|
page: z.coerce.number().positive().optional(),
|
|
sort: z.coerce.number().min(0).max(5).optional(),
|
|
subitems: z.coerce.boolean().optional(),
|
|
primary: z.coerce.boolean().optional(),
|
|
secondary: z.coerce.boolean().optional(),
|
|
status: z.coerce.string().optional(),
|
|
});
|
|
|
|
/*
|
|
* ╭────────╮
|
|
* ──────────────────────────────────────────╯ PAGE ╰─────────────────────────────────────────────
|
|
*/
|
|
|
|
interface Props extends AppLayoutRequired {}
|
|
|
|
const Library = (props: Props): JSX.Element => {
|
|
const hoverable = useDeviceSupportsHover();
|
|
const { format } = useFormat();
|
|
const { libraryItemUserStatus } = useLibraryItemUserStatus();
|
|
|
|
const sortingMethods = useMemo(
|
|
() => [
|
|
{ meiliAttribute: "sortable_name:asc", displayedName: format("name") },
|
|
{ meiliAttribute: "sortable_date:asc", displayedName: format("release_date") },
|
|
{ meiliAttribute: "sortable_price:asc", displayedName: format("price") },
|
|
],
|
|
[format]
|
|
);
|
|
|
|
const router = useTypedRouter(queryParamSchema);
|
|
const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
|
|
const [libraryItems, setLibraryItems] = useState<CustomSearchResponse<MeiliLibraryItem>>();
|
|
const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query);
|
|
|
|
const {
|
|
value: showSubitems,
|
|
toggle: toggleShowSubitems,
|
|
setValue: setShowSubitems,
|
|
} = useBoolean(router.query.subitems ?? DEFAULT_FILTERS_STATE.showSubitems);
|
|
|
|
const {
|
|
value: showPrimaryItems,
|
|
toggle: toggleShowPrimaryItems,
|
|
setValue: setShowPrimaryItems,
|
|
} = useBoolean(router.query.primary ?? DEFAULT_FILTERS_STATE.showPrimaryItems);
|
|
|
|
const {
|
|
value: showSecondaryItems,
|
|
toggle: toggleShowSecondaryItems,
|
|
setValue: setShowSecondaryItems,
|
|
} = useBoolean(router.query.secondary ?? DEFAULT_FILTERS_STATE.showSecondaryItems);
|
|
|
|
const {
|
|
value: keepInfoVisible,
|
|
toggle: toggleKeepInfoVisible,
|
|
setValue: setKeepInfoVisible,
|
|
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
|
|
|
const [sortingMethod, setSortingMethod] = useState<number>(
|
|
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
|
|
);
|
|
|
|
const [filterUserStatus, setFilterUserStatus] = useState<LibraryItemUserStatus | undefined>(
|
|
fromStringToLibraryItemUserStatus(router.query.status) ?? DEFAULT_FILTERS_STATE.filterUserStatus
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fetchLibraryItems = async () => {
|
|
const currentSortingMethod = sortingMethods[sortingMethod];
|
|
const filter: string[] = [];
|
|
|
|
if (!showPrimaryItems && !showSecondaryItems) {
|
|
filter.push("primary NOT EXISTS");
|
|
} else if (showPrimaryItems && !showSecondaryItems) {
|
|
filter.push("primary = true");
|
|
} else if (!showPrimaryItems && showSecondaryItems) {
|
|
filter.push("primary = false");
|
|
}
|
|
|
|
if (showSubitems) {
|
|
filter.push("untangible_group_item = false");
|
|
} else {
|
|
filter.push("root_item = true");
|
|
}
|
|
|
|
if (isDefined(filterUserStatus)) {
|
|
filter.push("untangible_group_item = false");
|
|
if (filterUserStatus === LibraryItemUserStatus.None) {
|
|
filter.push(
|
|
`id NOT IN [${Object.entries(libraryItemUserStatus)
|
|
.filter(([, value]) => value !== filterUserStatus)
|
|
.map(([id]) => id)
|
|
.join(", ")}]`
|
|
);
|
|
} else {
|
|
filter.push(
|
|
`id IN [${Object.entries(libraryItemUserStatus)
|
|
.filter(([, value]) => value === filterUserStatus)
|
|
.map(([id]) => id)
|
|
.join(", ")}]`
|
|
);
|
|
}
|
|
}
|
|
|
|
const searchResult = await meiliSearch(MeiliIndices.LIBRARY_ITEM, query, {
|
|
hitsPerPage: 25,
|
|
page,
|
|
attributesToRetrieve: [
|
|
"title",
|
|
"subtitle",
|
|
"descriptions",
|
|
"id",
|
|
"slug",
|
|
"thumbnail",
|
|
"release_date",
|
|
"price",
|
|
"categories",
|
|
"metadata",
|
|
],
|
|
attributesToHighlight: ["title", "subtitle", "descriptions"],
|
|
attributesToCrop: ["descriptions"],
|
|
sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
|
|
filter,
|
|
});
|
|
searchResult.hits = searchResult.hits.map((item) => {
|
|
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("descriptions"))) {
|
|
item._formatted.descriptions = filterDefined(item._formatted.descriptions).filter(
|
|
(description) => containsHighlight(JSON.stringify(description))
|
|
);
|
|
}
|
|
return item;
|
|
});
|
|
setLibraryItems(searchResult);
|
|
};
|
|
fetchLibraryItems();
|
|
}, [
|
|
filterUserStatus,
|
|
libraryItemUserStatus,
|
|
page,
|
|
query,
|
|
showPrimaryItems,
|
|
showSecondaryItems,
|
|
showSubitems,
|
|
sortingMethod,
|
|
sortingMethods,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (router.isReady) {
|
|
router.updateQuery({
|
|
page,
|
|
query,
|
|
sort: sortingMethod,
|
|
primary: showPrimaryItems,
|
|
secondary: showSecondaryItems,
|
|
subitems: showSubitems,
|
|
status: fromLibraryItemUserStatusToString(filterUserStatus),
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
page,
|
|
query,
|
|
sortingMethod,
|
|
router.isReady,
|
|
showPrimaryItems,
|
|
showSecondaryItems,
|
|
showSubitems,
|
|
filterUserStatus,
|
|
]);
|
|
|
|
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.primary)) setShowPrimaryItems(router.query.primary);
|
|
if (isDefined(router.query.secondary)) setShowSecondaryItems(router.query.secondary);
|
|
if (isDefined(router.query.subitems)) setShowSubitems(router.query.subitems);
|
|
if (isDefined(router.query.status))
|
|
setFilterUserStatus(fromStringToLibraryItemUserStatus(router.query.status));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [router.isReady]);
|
|
|
|
useEffect(() => {
|
|
const totalPages = libraryItems?.totalPages;
|
|
if (isDefined(totalPages) && totalPages < page && totalPages >= 1) setPage(totalPages);
|
|
}, [libraryItems?.totalPages, page]);
|
|
|
|
const subPanel = (
|
|
<SubPanel>
|
|
<PanelHeader
|
|
icon="auto_stories"
|
|
title={format("library")}
|
|
description={format("library_description")}
|
|
/>
|
|
|
|
<HorizontalLine />
|
|
|
|
<TextInput
|
|
className="mb-6 w-full"
|
|
placeholder={format("search_title")}
|
|
value={query}
|
|
onChange={(name) => {
|
|
setPage(1);
|
|
setQuery(name);
|
|
if (isDefinedAndNotEmpty(name)) {
|
|
sendAnalytics("Library", "Change search term");
|
|
} else {
|
|
sendAnalytics("Library", "Clear search term");
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<WithLabel label={format("order_by")}>
|
|
<Select
|
|
className="w-full"
|
|
options={sortingMethods.map((item) => item.displayedName)}
|
|
value={sortingMethod}
|
|
onChange={(newSort) => {
|
|
setPage(1);
|
|
setSortingMethod(newSort);
|
|
sendAnalytics(
|
|
"Library",
|
|
`Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
|
|
);
|
|
}}
|
|
/>
|
|
</WithLabel>
|
|
|
|
<WithLabel label={format("show_subitems")}>
|
|
<Switch
|
|
value={showSubitems}
|
|
onClick={() => {
|
|
setPage(1);
|
|
toggleShowSubitems();
|
|
sendAnalytics("Library", `${showSubitems ? "Hide" : "Show"} subitems`);
|
|
}}
|
|
/>
|
|
</WithLabel>
|
|
|
|
<WithLabel label={format("show_primary_items")}>
|
|
<Switch
|
|
value={showPrimaryItems}
|
|
onClick={() => {
|
|
setPage(1);
|
|
toggleShowPrimaryItems();
|
|
sendAnalytics("Library", `${showPrimaryItems ? "Hide" : "Show"} primary items`);
|
|
}}
|
|
/>
|
|
</WithLabel>
|
|
|
|
<WithLabel label={format("show_secondary_items")}>
|
|
<Switch
|
|
value={showSecondaryItems}
|
|
onClick={() => {
|
|
setPage(1);
|
|
toggleShowSecondaryItems();
|
|
sendAnalytics("Library", `${showSecondaryItems ? "Hide" : "Show"} secondary items`);
|
|
}}
|
|
/>
|
|
</WithLabel>
|
|
|
|
{hoverable && (
|
|
<WithLabel label={format("always_show_info")}>
|
|
<Switch
|
|
value={keepInfoVisible}
|
|
onClick={() => {
|
|
toggleKeepInfoVisible();
|
|
sendAnalytics("Library", `Always ${keepInfoVisible ? "hide" : "show"} info`);
|
|
}}
|
|
/>
|
|
</WithLabel>
|
|
)}
|
|
|
|
<ButtonGroup
|
|
className="mt-4"
|
|
buttonsProps={[
|
|
{
|
|
tooltip: format("only_display_items_i_want"),
|
|
icon: "favorite",
|
|
onClick: () => {
|
|
setPage(1);
|
|
setFilterUserStatus(LibraryItemUserStatus.Want);
|
|
sendAnalytics("Library", "Set filter status (I want)");
|
|
},
|
|
active: filterUserStatus === LibraryItemUserStatus.Want,
|
|
},
|
|
{
|
|
tooltip: format("only_display_items_i_have"),
|
|
icon: "back_hand",
|
|
onClick: () => {
|
|
setPage(1);
|
|
setFilterUserStatus(LibraryItemUserStatus.Have);
|
|
sendAnalytics("Library", "Set filter status (I have)");
|
|
},
|
|
active: filterUserStatus === LibraryItemUserStatus.Have,
|
|
},
|
|
{
|
|
tooltip: format("only_display_unmarked_items"),
|
|
icon: "nearby_off",
|
|
onClick: () => {
|
|
setPage(1);
|
|
setFilterUserStatus(LibraryItemUserStatus.None);
|
|
sendAnalytics("Library", "Set filter status (unmarked)");
|
|
},
|
|
active: filterUserStatus === LibraryItemUserStatus.None,
|
|
},
|
|
{
|
|
tooltip: format("only_display_unmarked_items"),
|
|
text: format("all"),
|
|
onClick: () => {
|
|
setPage(1);
|
|
setFilterUserStatus(undefined);
|
|
sendAnalytics("Library", "Set filter status (all)");
|
|
},
|
|
active: isUndefined(filterUserStatus),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Button
|
|
className="mt-8"
|
|
text={format("reset_all_filters")}
|
|
icon="settings_backup_restore"
|
|
onClick={() => {
|
|
setQuery(DEFAULT_FILTERS_STATE.query);
|
|
setShowSubitems(DEFAULT_FILTERS_STATE.showSubitems);
|
|
setShowPrimaryItems(DEFAULT_FILTERS_STATE.showPrimaryItems);
|
|
setShowSecondaryItems(DEFAULT_FILTERS_STATE.showSecondaryItems);
|
|
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
|
|
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
|
setFilterUserStatus(DEFAULT_FILTERS_STATE.filterUserStatus);
|
|
sendAnalytics("Library", "Reset all filters");
|
|
}}
|
|
/>
|
|
</SubPanel>
|
|
);
|
|
|
|
const contentPanel = (
|
|
<ContentPanel width={ContentPanelWidthSizes.Full}>
|
|
<Paginator page={page} onPageChange={setPage} totalNumberOfPages={libraryItems?.totalPages}>
|
|
<div
|
|
className="grid grid-cols-[repeat(auto-fill,_minmax(12rem,1fr))] items-end
|
|
gap-x-6 gap-y-8">
|
|
{libraryItems?.hits.map((item) => (
|
|
<TranslatedPreviewCard
|
|
key={item.id}
|
|
href={`/library/${item.slug}`}
|
|
translations={filterHasAttributes(item._formatted.descriptions, [
|
|
"language.data.attributes.code",
|
|
] as const).map((translation) => ({
|
|
language: translation.language.data.attributes.code,
|
|
title: item.title,
|
|
subtitle: item.subtitle,
|
|
description: containsHighlight(translation.description)
|
|
? translation.description
|
|
: undefined,
|
|
}))}
|
|
fallback={{ title: item._formatted.title, subtitle: item._formatted.subtitle }}
|
|
thumbnail={item.thumbnail?.data?.attributes}
|
|
thumbnailAspectRatio="21/29.7"
|
|
thumbnailRounded={false}
|
|
keepInfoVisible={keepInfoVisible}
|
|
topChips={
|
|
item.metadata && item.metadata.length > 0 && item.metadata[0]
|
|
? [prettyItemSubType(item.metadata[0])]
|
|
: []
|
|
}
|
|
bottomChips={item.categories?.data.map(
|
|
(category) => category.attributes?.short ?? ""
|
|
)}
|
|
metadata={{
|
|
releaseDate: item.release_date,
|
|
price: item.price,
|
|
position: "Bottom",
|
|
}}
|
|
infoAppend={
|
|
!isUntangibleGroupItem(item.metadata?.[0]) && <PreviewCardCTAs id={item.id} />
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Paginator>
|
|
</ContentPanel>
|
|
);
|
|
|
|
return (
|
|
<AppLayout subPanel={subPanel} contentPanel={contentPanel} subPanelIcon="search" {...props} />
|
|
);
|
|
};
|
|
export default Library;
|
|
|
|
/*
|
|
* ╭──────────────────────╮
|
|
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
|
|
*/
|
|
|
|
export const getStaticProps: GetStaticProps = (context) => {
|
|
const { format } = getFormat(context.locale);
|
|
const props: Props = {
|
|
openGraph: getOpenGraph(format, format("library")),
|
|
};
|
|
return {
|
|
props: props,
|
|
};
|
|
};
|
|
|
|
const fromLibraryItemUserStatusToString = (status: LibraryItemUserStatus | undefined): string => {
|
|
switch (status) {
|
|
case LibraryItemUserStatus.None:
|
|
return "none";
|
|
case LibraryItemUserStatus.Have:
|
|
return "have";
|
|
case LibraryItemUserStatus.Want:
|
|
return "want";
|
|
default:
|
|
return "all";
|
|
}
|
|
};
|
|
|
|
const fromStringToLibraryItemUserStatus = (
|
|
status: string | undefined
|
|
): LibraryItemUserStatus | undefined => {
|
|
switch (status) {
|
|
case "none":
|
|
return LibraryItemUserStatus.None;
|
|
case "have":
|
|
return LibraryItemUserStatus.Have;
|
|
case "want":
|
|
return LibraryItemUserStatus.Want;
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|