2022-08-08 22:12:18 +02:00

805 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useCallback, useMemo } from "react";
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Chip } from "components/Chip";
import { Img } from "components/Img";
import { Button } from "components/Inputs/Button";
import { Switch } from "components/Inputs/Switch";
import { InsetBox } from "components/InsetBox";
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
import { NavOption } from "components/PanelComponents/NavOption";
import {
ReturnButton,
ReturnButtonType,
} from "components/PanelComponents/ReturnButton";
import {
ContentPanel,
ContentPanelWidthSizes,
} from "components/Panels/ContentPanel";
import { SubPanel } from "components/Panels/SubPanel";
import { PreviewCard } from "components/PreviewCard";
import { useAppLayout } from "contexts/AppLayoutContext";
import {
Enum_Componentmetadatabooks_Binding_Type,
Enum_Componentmetadatabooks_Page_Order,
GetLibraryItemQuery,
} from "graphql/generated";
import { AppStaticProps, getAppStaticProps } from "graphql/getAppStaticProps";
import { getReadySdk } from "graphql/sdk";
import {
prettyDate,
prettyInlineTitle,
prettyItemSubType,
prettyItemType,
prettyPrice,
prettySlug,
prettyURL,
} from "helpers/formatters";
import { getAssetURL, ImageQuality } from "helpers/img";
import { convertMmToInch } from "helpers/numbers";
import {
filterDefined,
filterHasAttributes,
isDefined,
isDefinedAndNotEmpty,
sortRangedContent,
} from "helpers/others";
import { useLightBox } from "hooks/useLightBox";
import { AnchorIds, useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { isUntangibleGroupItem } from "helpers/libraryItem";
import { useMediaHoverable } from "hooks/useMediaQuery";
import { WithLabel } from "components/Inputs/WithLabel";
import { Ico, Icon } from "components/Ico";
import { cJoin, cIf } from "helpers/className";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { useBoolean } from "hooks/useBoolean";
import { getOpenGraph } from "helpers/openGraph";
import { getDescription } from "helpers/description";
/*
* ╭────────╮
* ──────────────────────────────────────────╯ PAGE ╰─────────────────────────────────────────────
*/
interface Props extends AppStaticProps, AppLayoutRequired {
item: NonNullable<
NonNullable<
GetLibraryItemQuery["libraryItems"]
>["data"][number]["attributes"]
>;
itemId: NonNullable<
GetLibraryItemQuery["libraryItems"]
>["data"][number]["id"];
}
const LibrarySlug = ({
item,
itemId,
langui,
currencies,
languages,
...otherProps
}: Props): JSX.Element => {
const { currency } = useAppLayout();
const hoverable = useMediaHoverable();
const router = useRouter();
const [openLightBox, LightBox] = useLightBox();
const { state: keepInfoVisible, toggleState: toggleKeepInfoVisible } =
useBoolean(false);
useScrollTopOnChange(AnchorIds.ContentPanel, [item]);
const isVariantSet = useMemo(
() =>
item.metadata?.[0]?.__typename === "ComponentMetadataGroup" &&
item.metadata[0].subtype?.data?.attributes?.slug === "variant-set",
[item.metadata]
);
const displayOpenScans = useMemo(
() =>
item.contents?.data.some(
(content) =>
content.attributes?.scan_set && content.attributes.scan_set.length > 0
),
[item.contents?.data]
);
const subPanel = useMemo(
() => (
<SubPanel>
<ReturnButton
href="/library/"
title={langui.library}
langui={langui}
displayOn={ReturnButtonType.Desktop}
horizontalLine
/>
<div className="grid gap-4">
<NavOption title={langui.summary} url="#summary" border />
{item.gallery && item.gallery.data.length > 0 && (
<NavOption title={langui.gallery} url="#gallery" border />
)}
<NavOption title={langui.details} url="#details" border />
{item.subitems && item.subitems.data.length > 0 && (
<NavOption
title={isVariantSet ? langui.variants : langui.subitems}
url={isVariantSet ? "#variants" : "#subitems"}
border
/>
)}
{item.contents && item.contents.data.length > 0 && (
<NavOption title={langui.contents} url="#contents" border />
)}
</div>
</SubPanel>
),
[isVariantSet, item.contents, item.gallery, item.subitems, langui]
);
const contentPanel = useMemo(
() => (
<ContentPanel width={ContentPanelWidthSizes.Full}>
<LightBox />
<ReturnButton
href="/library/"
title={langui.library}
langui={langui}
displayOn={ReturnButtonType.Mobile}
className="mb-10"
/>
<div className="grid place-items-center gap-12">
<div
className="relative h-[50vh] w-full
cursor-pointer drop-shadow-shade-xl desktop:mb-16 mobile:h-[60vh]"
onClick={() => {
if (item.thumbnail?.data?.attributes) {
openLightBox([
getAssetURL(
item.thumbnail.data.attributes.url,
ImageQuality.Large
),
]);
}
}}
>
{item.thumbnail?.data?.attributes ? (
<Img
image={item.thumbnail.data.attributes}
quality={ImageQuality.Large}
className="h-full w-full object-contain"
/>
) : (
<div className="aspect-[21/29.7] w-full rounded-xl bg-light"></div>
)}
</div>
<InsetBox id="summary" className="grid place-items-center">
<div className="grid w-[clamp(0px,100%,42rem)] place-items-center gap-8">
{item.subitem_of?.data[0]?.attributes && (
<div className="grid place-items-center">
<p>{langui.subitem_of}</p>
<Button
href={`/library/${item.subitem_of.data[0].attributes.slug}`}
text={prettyInlineTitle(
"",
item.subitem_of.data[0].attributes.title,
item.subitem_of.data[0].attributes.subtitle
)}
/>
</div>
)}
<div className="grid place-items-center text-center">
<h1 className="text-3xl">{item.title}</h1>
{isDefinedAndNotEmpty(item.subtitle) && (
<h2 className="text-2xl">{item.subtitle}</h2>
)}
</div>
{!isUntangibleGroupItem(item.metadata?.[0]) &&
isDefinedAndNotEmpty(itemId) && (
<PreviewCardCTAs id={itemId} langui={langui} expand />
)}
{item.descriptions?.[0] && (
<p className="text-justify">
{item.descriptions[0].description}
</p>
)}
{!(
item.metadata &&
item.metadata[0]?.__typename === "ComponentMetadataGroup" &&
(item.metadata[0].subtype?.data?.attributes?.slug ===
"variant-set" ||
item.metadata[0].subtype?.data?.attributes?.slug ===
"relation-set")
) && (
<>
{item.urls?.length ? (
<div className="flex flex-row place-items-center gap-3">
<p>{langui.available_at}</p>
{filterHasAttributes(item.urls, ["url"] as const).map(
(url, index) => (
<Fragment key={index}>
<Button
href={url.url}
text={prettyURL(url.url)}
alwaysNewTab
/>
</Fragment>
)
)}
</div>
) : (
<p>{langui.item_not_available}</p>
)}
</>
)}
</div>
</InsetBox>
{item.gallery && item.gallery.data.length > 0 && (
<div id="gallery" className="grid w-full place-items-center gap-8">
<h2 className="text-2xl">{langui.gallery}</h2>
<div
className="grid w-full grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-end
gap-8"
>
{filterHasAttributes(item.gallery.data, [
"id",
"attributes",
] as const).map((galleryItem, index) => (
<Fragment key={galleryItem.id}>
<div
className="relative aspect-square cursor-pointer
transition-transform hover:scale-[1.02]"
onClick={() => {
const images: string[] = filterHasAttributes(
item.gallery?.data,
["attributes"] as const
).map((image) =>
getAssetURL(image.attributes.url, ImageQuality.Large)
);
openLightBox(images, index);
}}
>
<Img
className="h-full w-full rounded-lg
bg-light object-cover drop-shadow-shade-md"
image={galleryItem.attributes}
/>
</div>
</Fragment>
))}
</div>
</div>
)}
<InsetBox id="details" className="grid place-items-center">
<div className="place-items grid w-[clamp(0px,100%,42rem)] gap-8">
<h2 className="text-center text-2xl">{langui.details}</h2>
<div
className="grid place-items-center gap-y-8
desktop:grid-flow-col desktop:place-content-between"
>
{item.metadata?.[0] && (
<div className="grid place-content-start place-items-center">
<h3 className="text-xl">{langui.type}</h3>
<div className="grid grid-flow-col gap-1">
<Chip text={prettyItemType(item.metadata[0], langui)} />
{""}
<Chip text={prettyItemSubType(item.metadata[0])} />
</div>
</div>
)}
{item.release_date && (
<div className="grid place-content-start place-items-center">
<h3 className="text-xl">{langui.release_date}</h3>
<p>{prettyDate(item.release_date, router.locale)}</p>
</div>
)}
{item.price && (
<div className="grid place-content-start place-items-center text-center">
<h3 className="text-xl">{langui.price}</h3>
<p>
{prettyPrice(
item.price,
currencies,
item.price.currency?.data?.attributes?.code
)}
</p>
{item.price.currency?.data?.attributes?.code !==
currency && (
<p>
{prettyPrice(item.price, currencies, currency)} <br />(
{langui.calculated?.toLowerCase()})
</p>
)}
</div>
)}
</div>
{item.categories && item.categories.data.length > 0 && (
<div className="flex flex-col place-items-center gap-2">
<h3 className="text-xl">{langui.categories}</h3>
<div className="flex flex-row flex-wrap place-content-center gap-2">
{filterHasAttributes(item.categories.data, [
"attributes",
] as const).map((category) => (
<Chip key={category.id} text={category.attributes.name} />
))}
</div>
</div>
)}
{item.size && (
<div className="grid gap-8 mobile:place-items-center">
<h3 className="text-xl">{langui.size}</h3>
<div
className="grid w-full grid-flow-col place-content-between thin:grid-flow-row
thin:place-content-center thin:gap-8"
>
<div
className="grid place-items-center gap-x-4 desktop:grid-flow-col
desktop:place-items-start"
>
<p className="font-bold">{langui.width}:</p>
<div>
<p>{item.size.width} mm</p>
<p>{convertMmToInch(item.size.width)} in</p>
</div>
</div>
<div
className="grid place-items-center gap-x-4 desktop:grid-flow-col
desktop:place-items-start"
>
<p className="font-bold">{langui.height}:</p>
<div>
<p>{item.size.height} mm</p>
<p>{convertMmToInch(item.size.height)} in</p>
</div>
</div>
{isDefined(item.size.thickness) && (
<div
className="grid place-items-center gap-x-4 desktop:grid-flow-col
desktop:place-items-start"
>
<p className="font-bold">{langui.thickness}:</p>
<div>
<p>{item.size.thickness} mm</p>
<p>{convertMmToInch(item.size.thickness)} in</p>
</div>
</div>
)}
</div>
</div>
)}
{item.metadata?.[0]?.__typename !== "ComponentMetadataGroup" &&
item.metadata?.[0]?.__typename !== "ComponentMetadataOther" && (
<>
<h3 className="text-xl">{langui.type_information}</h3>
<div className="grid w-full grid-cols-2 place-content-between">
{item.metadata?.[0]?.__typename ===
"ComponentMetadataBooks" && (
<>
<div className="flex flex-row place-content-start gap-4">
<p className="font-bold">{langui.pages}:</p>
<p>{item.metadata[0].page_count}</p>
</div>
<div className="flex flex-row place-content-start gap-4">
<p className="font-bold">{langui.binding}:</p>
<p>
{item.metadata[0].binding_type ===
Enum_Componentmetadatabooks_Binding_Type.Paperback
? langui.paperback
: item.metadata[0].binding_type ===
Enum_Componentmetadatabooks_Binding_Type.Hardcover
? langui.hardcover
: ""}
</p>
</div>
<div className="flex flex-row place-content-start gap-4">
<p className="font-bold">{langui.page_order}:</p>
<p>
{item.metadata[0].page_order ===
Enum_Componentmetadatabooks_Page_Order.LeftToRight
? langui.left_to_right
: langui.right_to_left}
</p>
</div>
<div className="flex flex-row place-content-start gap-4">
<p className="font-bold">{langui.languages}:</p>
{item.metadata[0]?.languages?.data.map((lang) => (
<p key={lang.attributes?.code}>
{lang.attributes?.name}
</p>
))}
</div>
</>
)}
</div>
</>
)}
</div>
</InsetBox>
{item.subitems && item.subitems.data.length > 0 && (
<div
id={isVariantSet ? "variants" : "subitems"}
className="grid w-full place-items-center gap-8"
>
<h2 className="text-2xl">
{isVariantSet ? langui.variants : langui.subitems}
</h2>
{hoverable && (
<WithLabel
label={langui.always_show_info}
input={
<Switch
onClick={toggleKeepInfoVisible}
value={keepInfoVisible}
/>
}
/>
)}
<div
className="grid w-full grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]
items-end gap-8 mobile:grid-cols-2 thin:grid-cols-1"
>
{filterHasAttributes(item.subitems.data, [
"id",
"attributes",
] as const).map((subitem) => (
<Fragment key={subitem.id}>
<PreviewCard
href={`/library/${subitem.attributes.slug}`}
title={subitem.attributes.title}
subtitle={subitem.attributes.subtitle}
thumbnail={subitem.attributes.thumbnail?.data?.attributes}
thumbnailAspectRatio="21/29.7"
thumbnailRounded={false}
keepInfoVisible={keepInfoVisible}
topChips={
subitem.attributes.metadata &&
subitem.attributes.metadata.length > 0 &&
subitem.attributes.metadata[0]
? [prettyItemSubType(subitem.attributes.metadata[0])]
: []
}
bottomChips={subitem.attributes.categories?.data.map(
(category) => category.attributes?.short ?? ""
)}
metadata={{
currencies: currencies,
releaseDate: subitem.attributes.release_date,
price: subitem.attributes.price,
position: "Bottom",
}}
infoAppend={
!isUntangibleGroupItem(
subitem.attributes.metadata?.[0]
) && <PreviewCardCTAs id={subitem.id} langui={langui} />
}
/>
</Fragment>
))}
</div>
</div>
)}
{item.contents && item.contents.data.length > 0 && (
<div id="contents" className="grid w-full place-items-center gap-8">
<h2 className="-mb-6 text-2xl">{langui.contents}</h2>
{displayOpenScans && (
<Button
href={`/library/${item.slug}/scans`}
text={langui.view_scans}
/>
)}
<div className="grid w-full gap-4">
{filterHasAttributes(item.contents.data, [
"attributes",
] as const).map((rangedContent) => (
<ContentLine
content={
rangedContent.attributes.content?.data?.attributes
? {
translations: filterDefined(
rangedContent.attributes.content.data.attributes
.translations
).map((translation) => ({
pre_title: translation.pre_title,
title: translation.title,
subtitle: translation.subtitle,
language:
translation.language?.data?.attributes?.code,
})),
categories: filterHasAttributes(
rangedContent.attributes.content.data.attributes
.categories?.data,
["attributes"]
).map((category) => category.attributes.short),
type:
rangedContent.attributes.content.data.attributes
.type?.data?.attributes?.titles?.[0]?.title ??
prettySlug(
rangedContent.attributes.content.data.attributes
.type?.data?.attributes?.slug
),
slug: rangedContent.attributes.content.data
.attributes.slug,
}
: undefined
}
langui={langui}
rangeStart={
rangedContent.attributes.range[0]?.__typename ===
"ComponentRangePageRange"
? `${rangedContent.attributes.range[0].starting_page}`
: ""
}
slug={rangedContent.attributes.slug}
parentSlug={item.slug}
key={rangedContent.id}
languages={languages}
hasScanSet={
isDefined(rangedContent.attributes.scan_set) &&
rangedContent.attributes.scan_set.length > 0
}
/>
))}
</div>
</div>
)}
</div>
</ContentPanel>
),
[
LightBox,
langui,
item.thumbnail?.data?.attributes,
item.subitem_of?.data,
item.title,
item.subtitle,
item.metadata,
item.descriptions,
item.urls,
item.gallery,
item.release_date,
item.price,
item.categories,
item.size,
item.subitems,
item.contents,
item.slug,
itemId,
router.locale,
currencies,
currency,
isVariantSet,
hoverable,
toggleKeepInfoVisible,
keepInfoVisible,
displayOpenScans,
openLightBox,
languages,
]
);
return (
<AppLayout
contentPanel={contentPanel}
subPanel={subPanel}
currencies={currencies}
languages={languages}
langui={langui}
{...otherProps}
/>
);
};
export default LibrarySlug;
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
*/
export const getStaticProps: GetStaticProps = async (context) => {
const sdk = getReadySdk();
const item = await sdk.getLibraryItem({
slug:
context.params && isDefined(context.params.slug)
? context.params.slug.toString()
: "",
language_code: context.locale ?? "en",
});
if (!item.libraryItems?.data[0]?.attributes) return { notFound: true };
sortRangedContent(item.libraryItems.data[0].attributes.contents);
const appStaticProps = await getAppStaticProps(context);
const { title, thumbnail } = item.libraryItems.data[0].attributes;
const description = getDescription(
item.libraryItems.data[0].attributes.descriptions?.[0]?.description,
{
[appStaticProps.langui.categories ?? "Categories"]: filterHasAttributes(
item.libraryItems.data[0].attributes.categories?.data,
["attributes.short"]
).map((category) => category.attributes.short),
[appStaticProps.langui.type ?? "Type"]: item.libraryItems.data[0]
.attributes.metadata?.[0]
? [prettyItemSubType(item.libraryItems.data[0].attributes.metadata[0])]
: [],
[appStaticProps.langui.release_date ?? "Release date"]: [
item.libraryItems.data[0].attributes.release_date
? prettyDate(
item.libraryItems.data[0].attributes.release_date,
context.locale
)
: undefined,
],
}
);
const props: Props = {
...appStaticProps,
item: item.libraryItems.data[0].attributes,
itemId: item.libraryItems.data[0].id,
openGraph: getOpenGraph(
appStaticProps.langui,
title,
description,
thumbnail?.data?.attributes
),
};
return {
props: props,
};
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const getStaticPaths: GetStaticPaths = async (context) => {
const sdk = getReadySdk();
const libraryItems = await sdk.getLibraryItemsSlugs();
const paths: GetStaticPathsResult["paths"] = [];
filterHasAttributes(libraryItems.libraryItems?.data, [
"attributes",
] as const).map((item) => {
context.locales?.map((local) =>
paths.push({ params: { slug: item.attributes.slug }, locale: local })
);
});
return {
paths,
fallback: "blocking",
};
};
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
*/
interface ContentLineProps {
content?: {
translations: {
pre_title: string | null | undefined;
title: string;
subtitle: string | null | undefined;
language: string | undefined;
}[];
categories?: string[];
type?: string;
slug: string;
};
rangeStart: string;
parentSlug: string;
slug: string;
langui: AppStaticProps["langui"];
languages: AppStaticProps["languages"];
hasScanSet: boolean;
}
const ContentLine = ({
rangeStart,
content,
langui,
languages,
hasScanSet,
slug,
parentSlug,
}: ContentLineProps): JSX.Element => {
const { state: isOpened, toggleState: toggleOpened } = useBoolean(false);
const [selectedTranslation] = useSmartLanguage({
items: content?.translations ?? [],
languages: languages,
languageExtractor: useCallback(
(
item: NonNullable<ContentLineProps["content"]>["translations"][number]
) => item.language,
[]
),
});
return (
<div
className={cJoin(
"grid gap-2 rounded-lg px-4",
cIf(isOpened, "my-2 h-auto bg-mid py-3 shadow-inner-sm shadow-shade")
)}
>
<div
className="grid grid-cols-[auto_auto_1fr_auto_12ch] place-items-center
gap-4 thin:grid-cols-[auto_auto_1fr_auto]"
>
<a>
<h3 className="cursor-pointer" onClick={toggleOpened}>
{selectedTranslation
? prettyInlineTitle(
selectedTranslation.pre_title,
selectedTranslation.title,
selectedTranslation.subtitle
)
: content
? prettySlug(content.slug, parentSlug)
: prettySlug(slug, parentSlug)}
</h3>
</a>
<div className="flex flex-row flex-wrap gap-1">
{content?.categories?.map((category, index) => (
<Chip key={index} text={category} />
))}
</div>
<p className="h-4 w-full border-b-2 border-dotted border-black opacity-30"></p>
<p>{rangeStart}</p>
{content?.type && (
<Chip className="justify-self-end thin:hidden" text={content.type} />
)}
</div>
<div
className={`grid-flow-col place-content-start place-items-center gap-2 ${
isOpened ? "grid" : "hidden"
}`}
>
<Ico icon={Icon.SubdirectoryArrowRight} className="text-dark" />
{hasScanSet || isDefined(content) ? (
<>
{hasScanSet && (
<Button
href={`/library/${parentSlug}/scans#${slug}`}
text={langui.view_scans}
/>
)}
{isDefined(content) && (
<Button
href={`/contents/${content.slug}`}
text={langui.open_content}
/>
)}
</>
) : (
"The content is not available"
)}
</div>
</div>
);
};