accords-library.com/src/pages/library/index.tsx

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;
}
};