981 lines
36 KiB
TypeScript
981 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() : "",
|
|
language_code: context.locale ?? "en",
|
|
});
|
|
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 recorder={scanner.attributes} />
|
|
</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 recorder={cleaner.attributes} />
|
|
</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 recorder={typesetter.attributes} />
|
|
</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} />;
|
|
};
|