980 lines
36 KiB
TypeScript

import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { Fragment, useCallback, useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import Slider from "rc-slider";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import { z } from "zod";
import { atom } from "jotai";
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import {
Enum_Componentmetadatabooks_Page_Order as PageOrder,
GetLibraryItemScansQuery,
UploadImageFragment,
} from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { sortRangedContent } from "helpers/others";
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { getOpenGraph } from "helpers/openGraph";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { Img } from "components/Img";
import { getAssetFilename, ImageQuality } from "helpers/img";
import { cIf, cJoin } from "helpers/className";
import { clamp, isInteger } from "helpers/numbers";
import { SubPanel } from "components/Containers/SubPanel";
import { Button } from "components/Inputs/Button";
import { Ids } from "types/ids";
import { Switch } from "components/Inputs/Switch";
import { WithLabel } from "components/Inputs/WithLabel";
import { sendAnalytics } from "helpers/analytics";
import { ReturnButton } from "components/PanelComponents/ReturnButton";
import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { Chip } from "components/Chip";
import { RecorderChip } from "components/RecorderChip";
import { ToolTip } from "components/ToolTip";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { TranslatedProps } from "types/TranslatedProps";
import { prettyInlineTitle, prettySlug } from "helpers/formatters";
import { useFullscreen } from "hooks/useFullscreen";
import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms";
import { FilterSettings, useReaderSettings } from "hooks/useReaderSettings";
import { useTypedRouter } from "hooks/useTypedRouter";
import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n";
import { getScanArchiveURL } from "helpers/libraryItem";
type BookType = "book" | "manga";
type DisplayMode = "double" | "single";
/*
* ╭─────────────╮
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
*/
const CUSTOM_DARK_DROPSHADOW = `
drop-shadow(0 0 0.5em rgb(var(--theme-color-shade) / 30%))
drop-shadow(0 1em 1em rgb(var(--theme-color-shade) / 40%))
drop-shadow(0 2em 2em rgb(var(--theme-color-shade) / 60%))
drop-shadow(0 12em 12em rgb(var(--theme-color-shade) / 80%))`;
const CUSTOM_LIGHT_DROPSHADOW = `
drop-shadow(0 0.3em 0.5em rgb(var(--theme-color-shade) / 70%))
drop-shadow(0 1em 1em rgb(var(--theme-color-shade) / 30%))
drop-shadow(0 2em 2em rgb(var(--theme-color-shade) / 30%))
drop-shadow(0 12em 12em rgb(var(--theme-color-shade) / 60%))`;
const SIDEPAGES_PAGE_COUNT_ON_TEXTURE = 200;
const SIDEPAGES_PAGE_WIDTH = 0.02;
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().optional(),
});
const isWebKitAtom = atom((get) => get(atoms.userAgent.engine) === "WebKit");
/*
* ╭────────╮
* ──────────────────────────────────────────╯ PAGE ╰─────────────────────────────────────────────
*/
interface Props extends AppLayoutRequired {
item: NonNullable<
NonNullable<GetLibraryItemScansQuery["libraryItems"]>["data"][number]["attributes"]
>;
pages: UploadImageFragment[];
pageOrder: PageOrder;
bookType: BookType;
pageWidth: number;
pageRatio: string;
itemSlug: string;
}
const LibrarySlug = ({
pages,
bookType,
pageOrder,
pageWidth,
itemSlug,
item,
...otherProps
}: Props): JSX.Element => {
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const { format } = useFormat();
const isDarkMode = useAtomGetter(atoms.settings.darkMode);
const {
filterSettings,
isSidePagesEnabled,
pageQuality,
toggleBookFold,
toggleLighting,
togglePaperTexture,
toggleDropShadow,
toggleIsSidePagesEnabled,
setPageQuality,
setTeint,
resetReaderSettings,
} = useReaderSettings();
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const [currentZoom, setCurrentZoom] = useState(1);
const [isGalleryMode, setIsGalleryMode] = useState(false);
const [displayMode, setDisplayMode] = useState<DisplayMode>(
is1ColumnLayout ? "single" : "double"
);
const router = useTypedRouter(queryParamSchema);
const isWebKit = useAtomGetter(isWebKitAtom);
const { isFullscreen, toggleFullscreen, requestFullscreen } = useFullscreen(Ids.ContentPanel);
const effectiveDisplayMode =
currentPageIndex === 0 || currentPageIndex === pages.length - 1 ? "single" : displayMode;
const ajustedSidepagesTotalWidth = pages.length * SIDEPAGES_PAGE_WIDTH * (120 / pageWidth);
const changeCurrentPageIndex = useCallback(
(callbackFn: (current: number) => number) => {
setCurrentPageIndex((current) => clamp(callbackFn(current), 0, pages.length - 1));
},
[pages.length]
);
useEffect(() => setDisplayMode(is1ColumnLayout ? "single" : "double"), [is1ColumnLayout]);
useEffect(() => {
if (router.isReady)
router.updateQuery({
page: currentPageIndex - 1,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageIndex, router.isReady]);
useEffect(() => {
if (router.isReady) {
if (isDefined(router.query.page)) setCurrentPageIndex(router.query.page + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
const changeDisplayMode = useCallback(
(newDisplayMode: DisplayMode) => {
setDisplayMode(newDisplayMode);
changeCurrentPageIndex((current) => current);
},
[changeCurrentPageIndex]
);
const handlePageNavigation = useCallback(
(navigateTo: "left" | "next" | "previous" | "right") => {
if (
(navigateTo === "right" && pageOrder === PageOrder.LeftToRight) ||
(navigateTo === "left" && pageOrder === PageOrder.RightToLeft) ||
navigateTo === "next"
) {
changeCurrentPageIndex((current) => current + (effectiveDisplayMode === "single" ? 1 : 2));
} else {
changeCurrentPageIndex((current) => current - (effectiveDisplayMode === "single" ? 1 : 2));
}
},
[changeCurrentPageIndex, effectiveDisplayMode, pageOrder]
);
useHotkeys("left", () => handlePageNavigation("left"), { enabled: !isGalleryMode }, [
handlePageNavigation,
]);
useHotkeys("up", () => setIsGalleryMode(true), { enabled: !isGalleryMode }, [setIsGalleryMode]);
useHotkeys("down", () => setIsGalleryMode(false), { enabled: isGalleryMode }, [setIsGalleryMode]);
useHotkeys("f", () => requestFullscreen(), { enabled: !isFullscreen }, [requestFullscreen]);
useHotkeys("right", () => handlePageNavigation("right"), { enabled: !isGalleryMode }, [
handlePageNavigation,
]);
const firstPage =
pages[
effectiveDisplayMode === "double" && currentPageIndex % 2 === 0
? currentPageIndex - 1
: currentPageIndex
];
const secondPage =
pages[
effectiveDisplayMode === "double" && currentPageIndex % 2 === 0
? currentPageIndex
: currentPageIndex + 1
];
const leftSidePagesCount =
pageOrder === PageOrder.LeftToRight ? currentPageIndex : pages.length - 1 - currentPageIndex;
const rightSidePagesCount =
pageOrder === PageOrder.LeftToRight ? pages.length - 1 - currentPageIndex : currentPageIndex;
const leftSidePagesWidth = `${
pageOrder === PageOrder.LeftToRight
? (currentPageIndex / pages.length) * ajustedSidepagesTotalWidth
: ajustedSidepagesTotalWidth - (currentPageIndex / pages.length) * ajustedSidepagesTotalWidth
}vmin`;
const rightSidePagesWidth = `${
pageOrder === PageOrder.LeftToRight
? ajustedSidepagesTotalWidth - (currentPageIndex / pages.length) * ajustedSidepagesTotalWidth
: (currentPageIndex / pages.length) * ajustedSidepagesTotalWidth
}vmin`;
const leftSideClipPath = `polygon(
${
isSidePagesEnabled
? `
${leftSidePagesWidth} 100%,
0% calc(100% - ${leftSidePagesWidth} / 3),
0% calc(${leftSidePagesWidth} / 3),
${leftSidePagesWidth} 0%,`
: "0% 100%, 0% 0%,"
}
70% 0%,
${
filterSettings.bookFold
? `90% .25%,
95% .5%,
98% .8%,
99% 1%,
101% 2%,
101% 98%,
99% 99%,
98% 99.2%,
95% 99.5%,
90% 99.75%,`
: "101% 0%, 101% 100%,"
}
70% 100%
)`;
const rightSideClipPath = `polygon(
${
isSidePagesEnabled
? `calc(100% - ${rightSidePagesWidth}) 0%,
100% calc(${rightSidePagesWidth} / 3),
100% calc(100% - ${rightSidePagesWidth} / 3),
calc(100% - ${rightSidePagesWidth}) 100%,`
: "100% 0%, 100% 100%,"
}
30% 100%,
${
filterSettings.bookFold
? `10% 99.75%,
5% 99.5%,
2% 99.2%,
1% 99%,
-1% 98%,
-1% 2%,
1% 1%,
2% .8%,
5% .5%,
10% .25%,`
: "-1% 100%, -1% 0%,"
}
30% 0%
)`;
const pageHeight = `calc(100vh - ${is1ColumnLayout ? 5 : 4}rem - 3rem)`;
const subPanel = (
<SubPanel>
<ReturnButton title={format("item", { count: 1 })} href={`/library/${itemSlug}`} />
<div className="mt-4 grid gap-2">
<WithLabel label={format("paper_texture")}>
<Switch value={filterSettings.paperTexture} onClick={togglePaperTexture} />
</WithLabel>
<WithLabel label={format("book_fold")}>
<Switch value={filterSettings.bookFold} onClick={toggleBookFold} />
</WithLabel>
<WithLabel label={format("lighting")}>
<Switch value={filterSettings.lighting} onClick={toggleLighting} />
</WithLabel>
<WithLabel label={format("side_pages")}>
<Switch value={isSidePagesEnabled} onClick={toggleIsSidePagesEnabled} />
</WithLabel>
{!isWebKit && (
<WithLabel label={format("shadow")}>
<Switch value={filterSettings.dropShadow} onClick={toggleDropShadow} />
</WithLabel>
)}
</div>
<div className="mt-4 grid">
<p>{format("night_reader")}:</p>
<Slider
min={0}
max={10}
value={filterSettings.teint * 10}
onChange={(event) => {
const value = (Array.isArray(event) ? event[0] : event) ?? 0;
setTeint(value / 10);
}}
/>
</div>
<div className="mt-8 grid gap-2">
<p>{format("reading_layout")}:</p>
<ButtonGroup
buttonsProps={[
{
icon: "description",
tooltip: format("single_page_view"),
active: displayMode === "single",
onClick: () => changeDisplayMode("single"),
},
{
icon: "auto_stories",
tooltip: format("double_page_view"),
active: displayMode === "double",
onClick: () => changeDisplayMode("double"),
},
]}
/>
</div>
<div className="mt-4 grid gap-2">
<p>{format("quality")}:</p>
<ButtonGroup
buttonsProps={[
{
text: "SD",
active: pageQuality === ImageQuality.Medium,
onClick: () => setPageQuality(ImageQuality.Medium),
},
{
text: "HD",
active: pageQuality === ImageQuality.Large,
onClick: () => setPageQuality(ImageQuality.Large),
},
]}
/>
</div>
<Button
className="mt-8"
text={format("reset_all_options")}
icon="settings_backup_restore"
onClick={() => {
resetReaderSettings();
setDisplayMode(is1ColumnLayout ? "single" : "double");
sendAnalytics("Reader", "Reset all options");
}}
/>
{item.download_available && (
<Button
href={getScanArchiveURL(item.slug)}
icon="download"
text={format("download_archive")}
/>
)}
</SubPanel>
);
const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full} className="grid place-content-center !p-0">
<div className={cJoin("mb-12 grid", cIf(is1ColumnLayout, "!p-0", "!p-8"))}>
<TransformWrapper
onZoom={(zoom) => setCurrentZoom(zoom.state.scale)}
panning={{ disabled: currentZoom <= 1, velocityDisabled: false }}
doubleClick={{ disabled: true, mode: "reset" }}
velocityAnimation={{ animationTime: 0, equalToMove: true }}>
<TransformComponent
wrapperStyle={{ overflow: "visible", placeSelf: "center" }}
contentStyle={{
height: "100%",
gridAutoFlow: "column",
display: "grid",
placeContent: "center",
filter:
!filterSettings.dropShadow || isWebKit
? undefined
: isDarkMode
? CUSTOM_DARK_DROPSHADOW
: CUSTOM_LIGHT_DROPSHADOW,
}}>
{effectiveDisplayMode === "single" && isDefined(firstPage) ? (
<div
className={cJoin(
"relative grid grid-flow-col",
cIf(currentZoom <= 1, "cursor-pointer", "cursor-move")
)}>
<Img
style={{ maxHeight: pageHeight, width: "auto" }}
src={firstPage}
quality={pageQuality}
/>
<PageFilters page="single" bookType={bookType} options={filterSettings} />
<div
className="absolute bottom-0 left-0 top-0 w-1/2"
onClick={() => currentZoom <= 1 && handlePageNavigation("left")}
/>
<div
className="absolute bottom-0 right-0 top-0 w-1/2"
onClick={() => currentZoom <= 1 && handlePageNavigation("right")}
/>
</div>
) : (
isDefined(firstPage) &&
isDefined(secondPage) && (
<>
<div
className={cJoin(
"relative grid grid-flow-col",
cIf(currentZoom <= 1, "cursor-pointer", "cursor-move")
)}
onClick={() => currentZoom <= 1 && handlePageNavigation("left")}
style={{
clipPath: leftSideClipPath,
}}>
{isSidePagesEnabled && (
<div
style={{
width: leftSidePagesWidth,
backgroundImage: `url(/reader/sidepages-${bookType}.webp)`,
backgroundSize: `${
(SIDEPAGES_PAGE_COUNT_ON_TEXTURE / leftSidePagesCount) * 100
}% 100%`,
}}
/>
)}
<Img
style={{ maxHeight: pageHeight, width: "auto" }}
src={pageOrder === PageOrder.LeftToRight ? firstPage : secondPage}
quality={pageQuality}
/>
<PageFilters page="left" bookType={bookType} options={filterSettings} />
</div>
<div
className={cJoin(
"relative grid grid-flow-col",
cIf(currentZoom <= 1, "cursor-pointer", "cursor-move")
)}
onClick={() => currentZoom <= 1 && handlePageNavigation("right")}
style={{
clipPath: rightSideClipPath,
}}>
<Img
style={{ maxHeight: pageHeight, width: "auto" }}
className={cIf(
is1ColumnLayout,
`max-h-[calc(100vh-5rem)]`,
"max-h-[calc(100vh-4rem)]"
)}
src={pageOrder === PageOrder.LeftToRight ? secondPage : firstPage}
quality={pageQuality}
/>
{isSidePagesEnabled && (
<div
style={{
width: rightSidePagesWidth,
backgroundImage: `url(/reader/sidepages-${bookType}.webp)`,
backgroundPositionX: "right",
backgroundSize: `${
(SIDEPAGES_PAGE_COUNT_ON_TEXTURE / rightSidePagesCount) * 100
}% 100%`,
}}
/>
)}
<PageFilters page="right" bookType={bookType} options={filterSettings} />
</div>
</>
)
)}
</TransformComponent>
</TransformWrapper>
</div>
<div
className={cJoin(
`absolute inset-0 bg-light
transition-transform duration-500`,
cIf(isGalleryMode, "translate-y-0", "translate-y-[calc(100%-3rem)]")
)}>
<div
className="mb-4 mt-3 grid grid-flow-col grid-cols-[auto,1fr,auto]
place-content-center place-items-center gap-4 px-4">
<p className="text-dark">
{currentPageIndex - 1} / {pages.length - 2}
</p>
<Slider
reverse={pageOrder === PageOrder.RightToLeft}
min={0}
max={pages.length - 1}
value={currentPageIndex - 1}
onChange={(event) => {
const value = (Array.isArray(event) ? event[0] : event) ?? 0;
changeCurrentPageIndex(() => value);
}}
/>
<div className="flex gap-2">
<Button
icon={isGalleryMode ? "expand_more" : "expand_less"}
active={isGalleryMode}
onClick={() => setIsGalleryMode((current) => !current)}
size="small"
/>
<Button
icon={isFullscreen ? "fullscreen_exit" : "fullscreen"}
active={isFullscreen}
onClick={toggleFullscreen}
size="small"
/>
</div>
</div>
<div className="h-[calc(100vh-4rem)] overflow-y-scroll px-8">
{item.contents?.data.map((content) => (
<Fragment key={content.id}>
{content.attributes?.scan_set?.[0] && (
<TranslatedScanSet
scanSet={content.attributes.scan_set}
onClickOnImage={(index) => {
const range = content.attributes?.range[0];
let newPageIndex = index + 1;
if (range?.__typename === "ComponentRangePageRange") {
newPageIndex += range.starting_page;
}
changeCurrentPageIndex(() => newPageIndex);
setIsGalleryMode(false);
}}
id={content.attributes.slug}
translations={filterHasAttributes(
content.attributes.content?.data?.attributes?.translations,
["language.data.attributes"]
).map((translation) => ({
language: translation.language.data.attributes.code,
title: prettyInlineTitle(
translation.pre_title,
translation.title,
translation.subtitle
),
}))}
fallback={{
title: prettySlug(content.attributes.slug, item.slug),
}}
content={content.attributes.content}
/>
)}
</Fragment>
))}
</div>
</div>
</ContentPanel>
);
return (
<AppLayout
contentPanel={contentPanel}
subPanel={subPanel}
{...otherProps}
contentPanelScroolbar={false}
/>
);
};
export default LibrarySlug;
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
*/
export const getStaticProps: GetStaticProps = async (context) => {
const sdk = getReadySdk();
const { format } = getFormat(context.locale);
const item = await sdk.getLibraryItemScans({
slug: context.params && isDefined(context.params.slug) ? context.params.slug.toString() : "",
});
if (!item.libraryItems?.data[0]?.attributes || !item.libraryItems.data[0]?.id)
return { notFound: true };
sortRangedContent(item.libraryItems.data[0].attributes.contents);
const pages: UploadImageFragment[] = [];
const cover = item.libraryItems.data[0].attributes.images?.[0]?.cover;
if (cover) {
if (cover.front?.data?.attributes) pages.push(cover.front.data.attributes);
if (cover.inside_front?.data?.attributes) pages.push(cover.inside_front.data.attributes);
}
filterHasAttributes(item.libraryItems.data[0].attributes.contents?.data, [
"attributes.scan_set",
]).forEach((content) =>
filterHasAttributes(content.attributes.scan_set, ["pages.data"]).forEach((scanSet) =>
filterHasAttributes(scanSet.pages.data, ["attributes"])
.sort((a, b) => {
if (isDefinedAndNotEmpty(a.attributes.url) && isDefinedAndNotEmpty(b.attributes.url)) {
let aName = getAssetFilename(a.attributes.url);
let bName = getAssetFilename(b.attributes.url);
/*
* If the number is a succession of 0s, make the number
* incrementally smaller than 0 (i.e: 00 becomes -1)
*/
if (aName.replaceAll("0", "").length === 0) {
aName = (1 - aName.length).toString(10);
}
if (bName.replaceAll("0", "").length === 0) {
bName = (1 - bName.length).toString(10);
}
if (isInteger(aName) && isInteger(bName)) {
return parseInt(aName, 10) - parseInt(bName, 10);
}
return a.attributes.url.localeCompare(b.attributes.url);
}
return 0;
})
.forEach((page) => pages.push(page.attributes))
)
);
if (cover) {
if (cover.inside_back?.data?.attributes) pages.push(cover.inside_back.data.attributes);
if (cover.back?.data?.attributes) pages.push(cover.back.data.attributes);
}
const pageOrder: Props["pageOrder"] = (() => {
const { metadata } = item.libraryItems.data[0].attributes;
if (metadata?.[0]?.__typename === "ComponentMetadataBooks") {
return metadata[0].page_order;
}
return PageOrder.LeftToRight;
})();
const bookType =
item.libraryItems.data[0].attributes.metadata?.[0]?.__typename === "ComponentMetadataBooks" &&
item.libraryItems.data[0].attributes.metadata[0].subtype?.data?.attributes?.slug === "manga"
? "manga"
: "book";
const pageWidth = item.libraryItems.data[0].attributes.size?.width ?? 120;
if (pages.length === 0) return { notFound: true };
const props: Props = {
item: item.libraryItems.data[0].attributes,
pages,
pageOrder,
bookType,
pageWidth,
itemSlug: item.libraryItems.data[0].attributes.slug,
pageRatio: `${pages[0]?.width ?? 21} / ${pages[0]?.height ?? 29.7}`,
openGraph: getOpenGraph(
format,
item.libraryItems.data[0].attributes.title,
undefined,
item.libraryItems.data[0].attributes.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"]).map((item) => {
context.locales?.map((local) =>
paths.push({ params: { slug: item.attributes.slug }, locale: local })
);
});
return {
paths,
fallback: "blocking",
};
};
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
*/
interface PageFiltersProps {
page: "left" | "right" | "single";
bookType: BookType;
options: FilterSettings;
}
const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
const commonCss = cJoin(
"absolute inset-0 dark:opacity-100",
cIf(page === "right", "[background-position-x:-100%]")
);
return (
<>
{options.paperTexture && (
<div
className={cJoin(
commonCss,
`mix-blend-exclusion [background-image:url(/reader/paper.webp)]
[background-size:20vmin_20vmin]`,
cIf(bookType === "book", "opacity-60 dark:opacity-60")
)}
/>
)}
{options.bookFold && (
<div
className={cJoin(
commonCss,
`opacity-50 mix-blend-multiply
[background-image:url(/reader/book-fold.webp)] [background-size:200%_100%]`
)}
/>
)}
{options.lighting && (
<>
<div
className={cJoin(
commonCss,
"opacity-50 mix-blend-multiply [background-size:200%_100%]",
cIf(
page === "single",
"[background-image:url(/reader/lighting-single-page.webp)]",
"[background-image:url(/reader/lighting-double-page.webp)]"
)
)}
/>
<div
className={cJoin(
commonCss,
`bg-[#FFF]/30 bg-blend-lighten mix-blend-soft-light [background-size:200%_100%]
dark:bg-[#000]`,
cIf(
page === "single",
"[background-image:url(/reader/specular-single-page.webp)]",
"[background-image:url(/reader/specular-double-page.webp)]"
)
)}
/>
</>
)}
<div
className={cJoin(commonCss, "bg-[#ffbf7f] mix-blend-multiply")}
style={{ opacity: options.teint }}
/>
</>
);
};
interface ScanSetProps {
onClickOnImage: (index: number) => void;
scanSet: NonNullable<
NonNullable<
NonNullable<
NonNullable<
NonNullable<GetLibraryItemScansQuery["libraryItems"]>["data"][number]["attributes"]
>["contents"]
>["data"][number]["attributes"]
>["scan_set"]
>;
id: string;
title: string;
content: NonNullable<
NonNullable<
NonNullable<
NonNullable<GetLibraryItemScansQuery["libraryItems"]>["data"][number]["attributes"]
>["contents"]
>["data"][number]["attributes"]
>["content"];
}
const ScanSet = ({ onClickOnImage, scanSet, id, title, content }: ScanSetProps): JSX.Element => {
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const { format, formatStatusDescription } = useFormat();
const [selectedScan, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: scanSet,
languageExtractor: useCallback(
(item: NonNullable<ScanSetProps["scanSet"][number]>) => item.language?.data?.attributes?.code,
[]
),
transform: useCallback((item: NonNullable<ScanSetProps["scanSet"][number]>) => {
item.pages?.data.sort((a, b) => {
if (
a.attributes &&
b.attributes &&
isDefinedAndNotEmpty(a.attributes.url) &&
isDefinedAndNotEmpty(b.attributes.url)
) {
let aName = getAssetFilename(a.attributes.url);
let bName = getAssetFilename(b.attributes.url);
/*
* If the number is a succession of 0s, make the number
* incrementally smaller than 0 (i.e: 00 becomes -1)
*/
if (aName.replaceAll("0", "").length === 0) {
aName = (1 - aName.length).toString(10);
}
if (bName.replaceAll("0", "").length === 0) {
bName = (1 - bName.length).toString(10);
}
if (isInteger(aName) && isInteger(bName)) {
return parseInt(aName, 10) - parseInt(bName, 10);
}
return a.attributes.url.localeCompare(b.attributes.url);
}
return 0;
});
return item;
}, []),
});
const pages = filterHasAttributes(selectedScan?.pages?.data, ["attributes"]);
return (
<>
{selectedScan && isDefined(pages) && (
<div>
<div
className="flex flex-row flex-wrap place-items-center
gap-6 pt-10 text-base first-of-type:pt-0">
<h2 id={id} className="text-2xl">
{title}
</h2>
<Chip
text={
selectedScan.language?.data?.attributes?.code ===
selectedScan.source_language?.data?.attributes?.code
? format("scan")
: format("scanlation")
}
/>
</div>
<div className="flex flex-row flex-wrap place-items-center gap-4 pb-6">
{content?.data?.attributes && isDefinedAndNotEmpty(content.data.attributes.slug) && (
<Button
href={`/contents/${content.data.attributes.slug}`}
icon="subject"
text={format("open_content")}
/>
)}
{languageSwitcherProps.locales.size > 1 && (
<LanguageSwitcher {...languageSwitcherProps} />
)}
<div className="grid place-content-center place-items-center">
<p className="font-headers font-bold">{format("status")}:</p>
<ToolTip content={formatStatusDescription(selectedScan.status)} maxWidth={"20rem"}>
<Chip text={selectedScan.status} />
</ToolTip>
</div>
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
<div>
<p className="font-headers font-bold">{format("scanners")}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.scanners.data, ["id", "attributes"]).map(
(scanner) => (
<Fragment key={scanner.id}>
<RecorderChip username={scanner.attributes.username} />
</Fragment>
)
)}
</div>
</div>
)}
{selectedScan.cleaners && selectedScan.cleaners.data.length > 0 && (
<div>
<p className="font-headers font-bold">{format("cleaners")}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.cleaners.data, ["id", "attributes"]).map(
(cleaner) => (
<Fragment key={cleaner.id}>
<RecorderChip username={cleaner.attributes.username} />
</Fragment>
)
)}
</div>
</div>
)}
{selectedScan.typesetters && selectedScan.typesetters.data.length > 0 && (
<div>
<p className="font-headers font-bold">{format("typesetters")}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.typesetters.data, ["id", "attributes"]).map(
(typesetter) => (
<Fragment key={typesetter.id}>
<RecorderChip username={typesetter.attributes.username} />
</Fragment>
)
)}
</div>
</div>
)}
{isDefinedAndNotEmpty(selectedScan.notes) && (
<ToolTip content={selectedScan.notes}>
<Chip text={format("notes")} />
</ToolTip>
)}
</div>
<div
className={cJoin(
`grid items-end gap-8 border-b-2 border-dotted pb-12
last-of-type:border-0`,
cIf(
is1ColumnLayout,
"grid-cols-3 gap-[4vmin]",
"grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))]"
)
)}>
{pages.map((page, index) => (
<div
key={page.id}
className="cursor-pointer drop-shadow-lg
transition-transform shadow-shade hover:scale-102"
onClick={() => {
onClickOnImage(index);
}}>
<Img src={page.attributes} quality={ImageQuality.Small} />
</div>
))}
</div>
</div>
)}
</>
);
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const TranslatedScanSet = ({
translations,
fallback,
...otherProps
}: TranslatedProps<ScanSetProps, "title">): JSX.Element => {
const [selectedTranslation] = useSmartLanguage({
items: translations,
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
});
return <ScanSet title={selectedTranslation?.title ?? fallback.title} {...otherProps} />;
};