Added reader

This commit is contained in:
DrMint 2022-09-25 22:36:52 +02:00
parent 2dacf190d2
commit 0328e730e1
23 changed files with 1556 additions and 660 deletions

106
package-lock.json generated
View File

@ -19,10 +19,12 @@
"meilisearch": "^0.27.0",
"next": "^12.3.0",
"nodemailer": "^6.7.8",
"rc-slider": "^10.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-keys": "^2.7.2",
"react-swipeable": "^7.0.0",
"react-zoom-pan-pinch": "^2.1.3",
"string-natural-compare": "^3.0.1",
"throttle-debounce": "^5.0.0",
"tippy.js": "^6.3.7",
@ -979,9 +981,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
"integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
@ -3675,6 +3677,11 @@
"node": ">= 6"
}
},
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -7676,6 +7683,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rc-slider": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.0.1.tgz",
"integrity": "sha512-igTKF3zBet7oS/3yNiIlmU8KnZ45npmrmHlUUio8PNbIhzMcsh+oE/r2UD42Y6YD2D/s+kzCQkzQrPD6RY435Q==",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.5",
"rc-util": "^5.18.1",
"shallowequal": "^1.1.0"
},
"engines": {
"node": ">=8.x"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-util": {
"version": "5.24.4",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.24.4.tgz",
"integrity": "sha512-2a4RQnycV9eV7lVZPEJ7QwJRPlZNc06J7CwcwZo4vIHr3PfUqtYgl1EkUV9ETAc6VRRi8XZOMFhYG63whlIC9Q==",
"dependencies": {
"@babel/runtime": "^7.18.3",
"react-is": "^16.12.0",
"shallowequal": "^1.1.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
@ -7726,6 +7765,19 @@
"react": "^16.8.3 || ^17 || ^18"
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-2.1.3.tgz",
"integrity": "sha512-a5AChOWhjo0RmxsNZXGQIlNh3e3nLU6m4V6M+6dlbPNk5d+MtMxgKWyA5zpR06Lp3OZkZVF9nR8JeWSvKwck9g==",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -8043,6 +8095,11 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -9755,9 +9812,9 @@
}
},
"@babel/runtime": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
"integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@ -11860,6 +11917,11 @@
}
}
},
"classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -14751,6 +14813,27 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true
},
"rc-slider": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.0.1.tgz",
"integrity": "sha512-igTKF3zBet7oS/3yNiIlmU8KnZ45npmrmHlUUio8PNbIhzMcsh+oE/r2UD42Y6YD2D/s+kzCQkzQrPD6RY435Q==",
"requires": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.5",
"rc-util": "^5.18.1",
"shallowequal": "^1.1.0"
}
},
"rc-util": {
"version": "5.24.4",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.24.4.tgz",
"integrity": "sha512-2a4RQnycV9eV7lVZPEJ7QwJRPlZNc06J7CwcwZo4vIHr3PfUqtYgl1EkUV9ETAc6VRRi8XZOMFhYG63whlIC9Q==",
"requires": {
"@babel/runtime": "^7.18.3",
"react-is": "^16.12.0",
"shallowequal": "^1.1.0"
}
},
"react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
@ -14788,6 +14871,12 @@
"integrity": "sha512-NI7KGfQ6gwNFN0Hor3vytYW3iRfMMaivGEuxcADOOfBCx/kqwXE8IfHFxEcxSUkxCYf38COLKYd9EMYZghqaUA==",
"requires": {}
},
"react-zoom-pan-pinch": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-2.1.3.tgz",
"integrity": "sha512-a5AChOWhjo0RmxsNZXGQIlNh3e3nLU6m4V6M+6dlbPNk5d+MtMxgKWyA5zpR06Lp3OZkZVF9nR8JeWSvKwck9g==",
"requires": {}
},
"read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -15026,6 +15115,11 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -30,10 +30,12 @@
"meilisearch": "^0.27.0",
"next": "^12.3.0",
"nodemailer": "^6.7.8",
"rc-slider": "^10.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-keys": "^2.7.2",
"react-swipeable": "^7.0.0",
"react-zoom-pan-pinch": "^2.1.3",
"string-natural-compare": "^3.0.1",
"throttle-debounce": "^5.0.0",
"tippy.js": "^6.3.7",
@ -67,5 +69,11 @@
"tailwindcss": "^3.1.8",
"ts-unused-exports": "^8.0.0",
"typescript": "^4.8.3"
},
"overrides": {
"react-zoom-pan-pinch": {
"react": "$react",
"react-dom": "$react-dom"
}
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/reader/paper.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -246,13 +246,12 @@ export const AppLayout = ({
<div
id={Ids.SubPanel}
className={cJoin(
`texture-paper-dots overflow-y-scroll border-r-[1px] border-dark/50 bg-light
transition-transform duration-300 [scrollbar-width:none]
`texture-paper-dots z-20 overflow-y-scroll border-r-[1px] border-dark/50
bg-light transition-transform duration-300 [scrollbar-width:none]
webkit-scrollbar:w-0`,
cIf(
is1ColumnLayout,
`z-10 justify-self-end border-r-0
[grid-area:content]`,
"justify-self-end border-r-0 [grid-area:content]",
"[grid-area:sub]"
),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l-[1px]"),
@ -266,9 +265,9 @@ export const AppLayout = ({
{/* Main panel */}
<div
className={cJoin(
`texture-paper-dots overflow-y-scroll border-r-[1px] border-dark/50 bg-light
transition-transform duration-300 [scrollbar-width:none] webkit-scrollbar:w-0`,
cIf(is1ColumnLayout, "z-10 justify-self-start [grid-area:content]", "[grid-area:main]"),
`texture-paper-dots z-30 overflow-y-scroll border-r-[1px] border-dark/50
bg-light transition-transform duration-300 [scrollbar-width:none] webkit-scrollbar:w-0`,
cIf(is1ColumnLayout, "justify-self-start [grid-area:content]", "[grid-area:main]"),
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
cIf(!mainPanelOpen && is1ColumnLayout, "-translate-x-full")
)}>

View File

@ -1,6 +1,6 @@
import { DetailedHTMLProps, ImgHTMLAttributes } from "react";
import { UploadImageFragment } from "graphql/generated";
import { getAssetURL, ImageQuality } from "helpers/img";
import { getAssetURL, getImgSizesByQuality, ImageQuality } from "helpers/img";
/*
*
@ -11,18 +11,36 @@ interface Props
extends Omit<DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "src"> {
src: UploadImageFragment | string;
quality?: ImageQuality;
sizeMultiplicator?: number;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Img = ({
className,
src: rawSrc,
src: propsSrc,
quality = ImageQuality.Small,
alt,
loading = "lazy",
height,
width,
...otherProps
}: Props): JSX.Element => {
const src = typeof rawSrc === "string" ? rawSrc : getAssetURL(rawSrc.url, quality);
return <img className={className} src={src} alt={alt} loading={loading} {...otherProps} />;
const src = typeof propsSrc === "string" ? propsSrc : getAssetURL(propsSrc.url, quality);
const size =
typeof propsSrc === "string"
? { width, height }
: getImgSizesByQuality(propsSrc.width ?? 0, propsSrc.height ?? 0, quality);
return (
<img
className={className}
src={src}
alt={alt}
loading={loading}
height={size.height}
width={size.width}
{...otherProps}
/>
);
};

View File

@ -30,7 +30,7 @@ export const ContentPanel = ({
<div className="grid h-full">
<main
className={cJoin(
"justify-self-center px-4 pt-10 pb-20",
"justify-self-center px-4 pt-10 pb-20 relative",
cIf(isContentPanelAtLeast3xl, "px-10 pt-20 pb-32"),
width === ContentPanelWidthSizes.Default
? "max-w-2xl"

View File

@ -47,6 +47,34 @@ query getLibraryItemScans($slug: String, $language_code: String) {
}
}
cover {
front {
data {
attributes {
...uploadImage
}
}
}
spine {
data {
attributes {
...uploadImage
}
}
}
back {
data {
attributes {
...uploadImage
}
}
}
front {
data {
attributes {
...uploadImage
}
}
}
full {
data {
attributes {
@ -61,6 +89,20 @@ query getLibraryItemScans($slug: String, $language_code: String) {
}
}
}
inside_front {
data {
attributes {
...uploadImage
}
}
}
inside_back {
data {
attributes {
...uploadImage
}
}
}
}
dust_jacket {
full {
@ -108,6 +150,9 @@ query getLibraryItemScans($slug: String, $language_code: String) {
price {
...pricePicker
}
size {
width
}
categories(pagination: { limit: -1 }) {
data {
id
@ -120,6 +165,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
metadata {
__typename
... on ComponentMetadataBooks {
page_order
subtype {
data {
attributes {

View File

@ -180,6 +180,17 @@ query localDataGetWebsiteInterfaces {
switch_to_grid_view
switch_to_folder_view
content_is_not_available
paper_texture
book_fold
lighting
side_pages
shadow
night_reader
single_page_view
double_page_view
reset_all_options
reading_layout
quality
}
}
}

View File

@ -58,10 +58,10 @@ const getImgSizesByMaxSize = (
): { width: number; height: number } => {
if (width > height) {
if (width < maxSize) return { width: width, height: height };
return { width: maxSize, height: (height / width) * maxSize };
return { width: maxSize, height: Math.ceil((height / width) * maxSize) };
}
if (height < maxSize) return { width: width, height: height };
return { width: (width / height) * maxSize, height: maxSize };
return { width: Math.ceil((width / height) * maxSize), height: maxSize };
};
export const getImgSizesByQuality = (

View File

@ -20,3 +20,6 @@ export const randomInt = (min: number, max: number): number =>
Math.floor(Math.random() * (max - min)) + min;
export const isInteger = (value: string): boolean => /^[+-]?[0-9]+$/u.test(value);
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);

View File

@ -0,0 +1,32 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useIsClient } from "usehooks-ts";
import { isDefined } from "helpers/others";
export const useFullscreen = (
id: string
): { isFullscreen: boolean; toggleFullscreen: () => void } => {
const [isFullscreen, setIsFullscreen] = useState(false);
const isClient = useIsClient();
const elem = useMemo(() => (isClient ? document.querySelector(`#${id}`) : null), [id, isClient]);
const toggleFullscreen = useCallback(() => {
if (elem) {
if (isFullscreen) {
document.exitFullscreen();
} else {
elem.requestFullscreen();
}
}
}, [elem, isFullscreen]);
useEffect(() => {
const onFullscreenChanged = () => {
setIsFullscreen(isDefined(document.fullscreenElement));
};
addEventListener("fullscreenchange", onFullscreenChanged);
return () => removeEventListener("fullscreenchange", onFullscreenChanged);
}, []);
return { isFullscreen, toggleFullscreen };
};

View File

@ -510,7 +510,9 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
<div id={intersectionIds[4]} 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 grid-flow-col gap-4">
<Button href={`/library/${item.slug}/reader`} text={langui.view_scans} />
</div>
)}
<div className="max-w- grid w-full gap-4">
{filterHasAttributes(item.contents.data, ["attributes"] as const).map(
@ -735,7 +737,10 @@ const ContentLine = ({
{hasScanSet || isDefined(content) ? (
<>
{hasScanSet && (
<Button href={`/library/${parentSlug}/scans#${slug}`} text={langui.view_scans} />
<Button
href={`/library/${parentSlug}/reader?page=${rangeStart}`}
text={langui.view_scans}
/>
)}
{isDefined(content) && (
<Button href={`/contents/${content.slug}`} text={langui.open_content} />
@ -787,7 +792,10 @@ const ContentLine = ({
{hasScanSet || isDefined(content) ? (
<>
{hasScanSet && (
<Button href={`/library/${parentSlug}/scans#${slug}`} text={langui.view_scans} />
<Button
href={`/library/${parentSlug}/reader?page=${rangeStart}`}
text={langui.view_scans}
/>
)}
{isDefined(content) && (
<Button href={`/contents/${content.slug}`} text={langui.open_content} />

File diff suppressed because it is too large Load Diff

View File

@ -1,636 +0,0 @@
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { Fragment, useCallback, useMemo } from "react";
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { ReturnButton } from "components/PanelComponents/ReturnButton";
import { ContentPanel, ContentPanelWidthSizes } from "components/Panels/ContentPanel";
import { SubPanel } from "components/Panels/SubPanel";
import { GetLibraryItemScansQuery, UploadImageFragment } from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { prettyInlineTitle, prettySlug, prettyItemSubType } from "helpers/formatters";
import {
filterHasAttributes,
getStatusDescription,
isDefined,
isDefinedAndNotEmpty,
sortRangedContent,
} from "helpers/others";
import { useLightBox } from "hooks/useLightBox";
import { isUntangibleGroupItem } from "helpers/libraryItem";
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
import { PreviewCard } from "components/PreviewCard";
import { HorizontalLine } from "components/HorizontalLine";
import { getOpenGraph } from "helpers/openGraph";
import { Chip } from "components/Chip";
import { Img } from "components/Img";
import { Button } from "components/Inputs/Button";
import { RecorderChip } from "components/RecorderChip";
import { ToolTip } from "components/ToolTip";
import { getAssetFilename, getAssetURL, ImageQuality } from "helpers/img";
import { isInteger } from "helpers/numbers";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { TranslatedProps } from "types/TranslatedProps";
import { TranslatedNavOption } from "components/PanelComponents/NavOption";
import { useIntersectionList } from "hooks/useIntersectionList";
import { useIs1ColumnLayout, useIsContentPanelNoMoreThan } from "hooks/useContainerQuery";
import { cIf, cJoin } from "helpers/className";
import { useAppLayout } from "contexts/AppLayoutContext";
import { getLangui } from "graphql/fetchLocalData";
/*
*
* PAGE
*/
interface Props extends AppLayoutRequired {
item: NonNullable<
NonNullable<GetLibraryItemScansQuery["libraryItems"]>["data"][number]["attributes"]
>;
itemId: NonNullable<NonNullable<GetLibraryItemScansQuery["libraryItems"]>["data"][number]["id"]>;
}
const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
const [openLightBox, LightBox] = useLightBox();
const is1ColumnLayout = useIs1ColumnLayout();
const { langui } = useAppLayout();
const ids = useMemo(
() =>
filterHasAttributes(item.contents?.data, ["attributes.slug"] as const).map(
(content) => content.attributes.slug
),
[item.contents?.data]
);
const currentIntersection = useIntersectionList(ids);
const subPanel = useMemo(
() => (
<SubPanel>
<ReturnButton
href={`/library/${item.slug}`}
title={langui.item}
className="mb-4"
displayOnlyOn="3ColumnsLayout"
/>
<div className="grid place-items-center">
<div className={cIf(is1ColumnLayout, "w-3/4")}>
<PreviewCard
href={`/library/${item.slug}`}
title={item.title}
subtitle={item.subtitle}
thumbnail={item.thumbnail?.data?.attributes}
thumbnailAspectRatio="21/29.7"
thumbnailRounded={false}
topChips={
item.metadata && item.metadata.length > 0 && item.metadata[0]
? [prettyItemSubType(item.metadata[0])]
: []
}
bottomChips={filterHasAttributes(item.categories?.data, ["attributes"] as const).map(
(category) => category.attributes.short
)}
metadata={{
releaseDate: item.release_date,
price: item.price,
position: "Bottom",
}}
infoAppend={
!isUntangibleGroupItem(item.metadata?.[0]) && <PreviewCardCTAs id={itemId} />
}
/>
</div>
</div>
<HorizontalLine />
<p className="mb-4 font-headers text-2xl font-bold">{langui.contents}</p>
{filterHasAttributes(item.contents?.data, ["attributes"] as const).map((content, index) => (
<>
{content.attributes.scan_set && content.attributes.scan_set.length > 0 && (
<TranslatedNavOption
key={content.id}
url={`#${content.attributes.slug}`}
translations={filterHasAttributes(
content.attributes.content?.data?.attributes?.translations,
["language.data.attributes"] as const
).map((translation) => ({
language: translation.language.data.attributes.code,
title: prettyInlineTitle(
translation.pre_title,
translation.title,
translation.subtitle
),
subtitle:
content.attributes.range[0]?.__typename === "ComponentRangePageRange"
? `${content.attributes.range[0].starting_page}` +
`` +
`${content.attributes.range[0].ending_page}`
: undefined,
}))}
fallback={{
title: prettySlug(content.attributes.slug, item.slug),
subtitle:
content.attributes.range[0]?.__typename === "ComponentRangePageRange"
? `${content.attributes.range[0].starting_page}` +
`` +
`${content.attributes.range[0].ending_page}`
: undefined,
}}
border
active={index === currentIntersection}
/>
)}
</>
))}
</SubPanel>
),
[
currentIntersection,
item.categories?.data,
item.contents?.data,
item.metadata,
item.price,
item.release_date,
item.slug,
item.subtitle,
item.thumbnail?.data?.attributes,
item.title,
itemId,
langui,
is1ColumnLayout,
]
);
const contentPanel = useMemo(
() => (
<ContentPanel width={ContentPanelWidthSizes.Full}>
<LightBox />
<ReturnButton
href={`/library/${item.slug}`}
title={langui.item}
displayOnlyOn="1ColumnLayout"
className="mb-10"
/>
{item.images && <ScanSetCover images={item.images} openLightBox={openLightBox} />}
{item.contents?.data.map((content) => (
<Fragment key={content.id}>
{content.attributes?.scan_set?.[0] && (
<TranslatedScanSet
scanSet={content.attributes.scan_set}
openLightBox={openLightBox}
id={content.attributes.slug}
translations={filterHasAttributes(
content.attributes.content?.data?.attributes?.translations,
["language.data.attributes"] as const
).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>
))}
</ContentPanel>
),
[LightBox, openLightBox, item.contents?.data, item.images, item.slug, langui]
);
return <AppLayout contentPanel={contentPanel} subPanel={subPanel} {...otherProps} />;
};
export default LibrarySlug;
/*
*
* NEXT DATA FETCHING
*/
export const getStaticProps: GetStaticProps = async (context) => {
const sdk = getReadySdk();
const langui = getLangui(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 props: Props = {
item: item.libraryItems.data[0].attributes,
itemId: item.libraryItems.data[0].id,
openGraph: getOpenGraph(
langui,
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"] as const).map((item) => {
context.locales?.map((local) =>
paths.push({ params: { slug: item.attributes.slug }, locale: local })
);
});
return {
paths,
fallback: "blocking",
};
};
/*
*
* PRIVATE COMPONENTS
*/
/*
*
* COMPONENT
*/
interface ScanSetProps {
openLightBox: (images: string[], 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 = ({ openLightBox, scanSet, id, title, content }: ScanSetProps): JSX.Element => {
const is1ColumnLayout = useIsContentPanelNoMoreThan("2xl");
const { langui } = useAppLayout();
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 = useMemo(
() => filterHasAttributes(selectedScan?.pages?.data, ["attributes"]),
[selectedScan]
);
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
? langui.scan ?? "Scan"
: langui.scanlation ?? "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}`}
text={langui.open_content}
/>
)}
{languageSwitcherProps.locales.size > 1 && (
<LanguageSwitcher {...languageSwitcherProps} />
)}
<div className="grid place-content-center place-items-center">
<p className="font-headers font-bold">{langui.status}:</p>
<ToolTip
content={getStatusDescription(selectedScan.status, langui)}
maxWidth={"20rem"}>
<Chip text={selectedScan.status} />
</ToolTip>
</div>
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
<div>
<p className="font-headers font-bold">{langui.scanners}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.scanners.data, [
"id",
"attributes",
] as const).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">{langui.cleaners}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.cleaners.data, [
"id",
"attributes",
] as const).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">{langui.typesetters}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.typesetters.data, [
"id",
"attributes",
] as const).map((typesetter) => (
<Fragment key={typesetter.id}>
<RecorderChip recorder={typesetter.attributes} />
</Fragment>
))}
</div>
</div>
)}
{isDefinedAndNotEmpty(selectedScan.notes) && (
<ToolTip content={selectedScan.notes}>
<Chip text={langui.notes ?? "Notes"} />
</ToolTip>
)}
</div>
<div
className={cJoin(
`grid items-end gap-8 border-b-[3px] border-dotted pb-12
last-of-type:border-0`,
cIf(
is1ColumnLayout,
"grid-cols-2 gap-[4vmin]",
"grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))]"
)
)}>
{pages.map((page, index) => (
<div
key={page.id}
className="cursor-pointer transition-transform
drop-shadow-shade-lg hover:scale-[1.02]"
onClick={() => {
const images = pages.map((image) =>
getAssetURL(image.attributes.url, ImageQuality.Large)
);
openLightBox(images, index);
}}>
<Img src={page.attributes} quality={ImageQuality.Small} />
</div>
))}
</div>
</div>
)}
</>
);
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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} />;
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface ScanSetCoverProps {
openLightBox: (images: string[], index?: number) => void;
images: NonNullable<
NonNullable<
NonNullable<GetLibraryItemScansQuery["libraryItems"]>["data"][number]["attributes"]
>["images"]
>;
}
const ScanSetCover = ({ openLightBox, images }: ScanSetCoverProps): JSX.Element => {
const is1ColumnLayout = useIsContentPanelNoMoreThan("4xl");
const { langui } = useAppLayout();
const [selectedScan, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: images,
languageExtractor: useCallback(
(item: NonNullable<ScanSetCoverProps["images"][number]>) =>
item.language?.data?.attributes?.code,
[]
),
});
const coverImages = useMemo(() => {
const memo: UploadImageFragment[] = [];
if (selectedScan?.obi_belt?.full?.data?.attributes)
memo.push(selectedScan.obi_belt.full.data.attributes);
if (selectedScan?.obi_belt?.inside_full?.data?.attributes)
memo.push(selectedScan.obi_belt.inside_full.data.attributes);
if (selectedScan?.dust_jacket?.full?.data?.attributes)
memo.push(selectedScan.dust_jacket.full.data.attributes);
if (selectedScan?.dust_jacket?.inside_full?.data?.attributes)
memo.push(selectedScan.dust_jacket.inside_full.data.attributes);
if (selectedScan?.cover?.full?.data?.attributes)
memo.push(selectedScan.cover.full.data.attributes);
if (selectedScan?.cover?.inside_full?.data?.attributes)
memo.push(selectedScan.cover.inside_full.data.attributes);
return memo;
}, [selectedScan]);
return (
<>
{coverImages.length > 0 && selectedScan && (
<div>
<div
className="flex flex-row flex-wrap place-items-center
gap-6 pt-10 text-base first-of-type:pt-0">
<h2 id={"cover"} className="text-2xl">
{langui.cover}
</h2>
<Chip
text={
selectedScan.language?.data?.attributes?.code ===
selectedScan.source_language?.data?.attributes?.code
? langui.scan ?? "Scan"
: langui.scanlation ?? "Scanlation"
}
/>
</div>
<div className="flex flex-row flex-wrap place-items-center gap-4 pb-6">
<LanguageSwitcher {...languageSwitcherProps} />
<div className="grid place-content-center place-items-center">
<p className="font-headers font-bold">{langui.status}:</p>
<ToolTip
content={getStatusDescription(selectedScan.status, langui)}
maxWidth={"20rem"}>
<Chip text={selectedScan.status} />
</ToolTip>
</div>
{selectedScan.scanners && selectedScan.scanners.data.length > 0 && (
<div>
<p className="font-headers font-bold">{langui.scanners}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.scanners.data, [
"id",
"attributes",
] as const).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">{langui.cleaners}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.cleaners.data, [
"id",
"attributes",
] as const).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">{langui.typesetters}:</p>
<div className="grid place-content-center place-items-center gap-2">
{filterHasAttributes(selectedScan.typesetters.data, [
"id",
"attributes",
] as const).map((typesetter) => (
<Fragment key={typesetter.id}>
<RecorderChip recorder={typesetter.attributes} />
</Fragment>
))}
</div>
</div>
)}
</div>
<div
className={cJoin(
`grid items-end gap-8 border-b-[3px] border-dotted pb-12
last-of-type:border-0`,
cIf(
is1ColumnLayout,
"grid-cols-2 gap-[4vmin]",
"grid-cols-[repeat(auto-fill,_minmax(10rem,1fr))]"
)
)}>
{coverImages.map((image, index) => (
<div
key={image.url}
className="cursor-pointer transition-transform
drop-shadow-shade-lg hover:scale-[1.02]"
onClick={() => {
const imgs = coverImages.map((img) => getAssetURL(img.url, ImageQuality.Large));
openLightBox(imgs, index);
}}>
<Img src={image} quality={ImageQuality.Small} />
</div>
))}
</div>
</div>
)}
</>
);
};

View File

@ -285,3 +285,226 @@ input[type="submit"] {
.tippy-content {
@apply relative z-10 px-6 py-4;
}
/* RC SLIDER */
.rc-slider {
position: relative;
width: 100%;
height: 14px;
padding: 5px 0;
border-radius: 6px;
touch-action: none;
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.rc-slider * {
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.rc-slider-rail {
@apply h-2 rounded-full bg-mid/80;
position: absolute;
width: 100%;
}
.rc-slider-track {
@apply h-2 rounded-full bg-mid bg-dark/20 shadow-inner-sm shadow-shade;
position: absolute;
}
.rc-slider-handle {
@apply h-4 w-4 -mt-1 rounded-full bg-dark transition-shadow;
position: absolute;
cursor: grab;
touch-action: pan-x;
}
.rc-slider-handle-dragging.rc-slider-handle-dragging.rc-slider-handle-dragging {
@apply shadow-sm shadow-shade;
}
.rc-slider-mark {
position: absolute;
top: 18px;
left: 0;
width: 100%;
font-size: 12px;
}
.rc-slider-mark-text {
position: absolute;
display: inline-block;
color: #999;
text-align: center;
vertical-align: middle;
cursor: pointer;
}
.rc-slider-mark-text-active {
color: #666;
}
.rc-slider-step {
position: absolute;
width: 100%;
height: 4px;
background: transparent;
pointer-events: none;
}
.rc-slider-dot {
position: absolute;
bottom: -2px;
width: 8px;
height: 8px;
vertical-align: middle;
background-color: #fff;
border: 2px solid #e9e9e9;
border-radius: 50%;
cursor: pointer;
}
.rc-slider-dot-active {
border-color: #96dbfa;
}
.rc-slider-dot-reverse {
margin-right: -4px;
}
.rc-slider-disabled {
background-color: #e9e9e9;
}
.rc-slider-disabled .rc-slider-track {
background-color: #ccc;
}
.rc-slider-disabled .rc-slider-handle,
.rc-slider-disabled .rc-slider-dot {
background-color: #fff;
border-color: #ccc;
box-shadow: none;
cursor: not-allowed;
}
.rc-slider-disabled .rc-slider-mark-text,
.rc-slider-disabled .rc-slider-dot {
cursor: not-allowed !important;
}
.rc-slider-vertical {
width: 14px;
height: 100%;
padding: 0 5px;
}
.rc-slider-vertical .rc-slider-rail {
width: 4px;
height: 100%;
}
.rc-slider-vertical .rc-slider-track {
bottom: 0;
left: 5px;
width: 4px;
}
.rc-slider-vertical .rc-slider-handle {
margin-top: 0;
margin-left: -5px;
touch-action: pan-y;
}
.rc-slider-vertical .rc-slider-mark {
top: 0;
left: 18px;
height: 100%;
}
.rc-slider-vertical .rc-slider-step {
width: 4px;
height: 100%;
}
.rc-slider-vertical .rc-slider-dot {
margin-left: -2px;
}
.rc-slider-tooltip-zoom-down-enter,
.rc-slider-tooltip-zoom-down-appear {
display: block !important;
animation-duration: 0.3s;
animation-fill-mode: both;
animation-play-state: paused;
}
.rc-slider-tooltip-zoom-down-leave {
display: block !important;
animation-duration: 0.3s;
animation-fill-mode: both;
animation-play-state: paused;
}
.rc-slider-tooltip-zoom-down-enter.rc-slider-tooltip-zoom-down-enter-active,
.rc-slider-tooltip-zoom-down-appear.rc-slider-tooltip-zoom-down-appear-active {
animation-name: rcSliderTooltipZoomDownIn;
animation-play-state: running;
}
.rc-slider-tooltip-zoom-down-leave.rc-slider-tooltip-zoom-down-leave-active {
animation-name: rcSliderTooltipZoomDownOut;
animation-play-state: running;
}
.rc-slider-tooltip-zoom-down-enter,
.rc-slider-tooltip-zoom-down-appear {
transform: scale(0, 0);
animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.rc-slider-tooltip-zoom-down-leave {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
}
@keyframes rcSliderTooltipZoomDownIn {
0% {
transform: scale(0, 0);
transform-origin: 50% 100%;
opacity: 0;
}
100% {
transform: scale(1, 1);
transform-origin: 50% 100%;
}
}
@keyframes rcSliderTooltipZoomDownOut {
0% {
transform: scale(1, 1);
transform-origin: 50% 100%;
}
100% {
transform: scale(0, 0);
transform-origin: 50% 100%;
opacity: 0;
}
}
.rc-slider-tooltip {
position: absolute;
top: -9999px;
left: -9999px;
visibility: visible;
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.rc-slider-tooltip * {
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.rc-slider-tooltip-hidden {
display: none;
}
.rc-slider-tooltip-placement-top {
padding: 4px 0 8px 0;
}
.rc-slider-tooltip-inner {
min-width: 24px;
height: 24px;
padding: 6px 2px;
color: #fff;
font-size: 12px;
line-height: 1;
text-align: center;
text-decoration: none;
background-color: #6c6c6c;
border-radius: 6px;
box-shadow: 0 0 4px #d9d9d9;
}
.rc-slider-tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow {
bottom: 4px;
left: 50%;
margin-left: -4px;
border-width: 4px 4px 0;
border-top-color: #6c6c6c;
}