Add download button for scans archives

This commit is contained in:
DrMint 2023-05-11 11:24:36 +02:00
parent 5d2fe252ec
commit f8f98ec41e
11 changed files with 238 additions and 166 deletions

45
.env.example Executable file
View File

@ -0,0 +1,45 @@
# /!\ For URLs, don't include the traling '/'
# ┌─────────────────────┐
# │ PRIVATE VARIABLES │
# └─────────────────────┘
## STRAPI
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
ACCESS_TOKEN=abcdef0123456789
REVALIDATION_TOKEN=abcdef0123456789
## MAILING
SMTP_HOST=email.provider.com
SMTP_USER=email@example.com
SMTP_PASSWORD=mypassword123
# ┌────────────────────┐
# │ PUBLIC VARIABLES │
# └────────────────────┘
## ASSETS
NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com
NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com
NEXT_PUBLIC_URL_WATCH=https://url-to.watch-accords-library.com
NEXT_PUBLIC_URL_SELF=https://url-to-front-accords-library.com
NEXT_PUBLIC_URL_SCANS_DOWNLOAD=https://url-to.search-accords-library.com
## MEILISEARCH
NEXT_PUBLIC_URL_MEILISEARCH=https://url-to.search-accords-library.com
NEXT_PUBLIC_MEILISEARCH_KEY=abcdef0123456789
## UMAMI
NEXT_PUBLIC_UMAMI_URL=https://url-to.umami-accords-library.com
NEXT_PUBLIC_UMAMI_ID=abcdef0123456789
## OCR.SPACE
NEXT_PUBLIC_API_OCR_KEY=abcdef0123456789

View File

@ -134,10 +134,11 @@ A detailled look at the technologies used in this repository:
- [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants... - [ts-unused-exports](https://www.npmjs.com/package/ts-unused-exports) to find unused exported functions/constants...
- Other - Other
- Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader) - Custom book reader based on [Okuma-Reader](https://github.com/DrMint/Okuma-Reader)
- Support for [Material Symbols](https://fonts.google.com/icons) - Support for [Material Symbols](https://fonts.google.com/icons)
- Custom lightbox using [react-zoom-pan-pinch](https://www.npmjs.com/package/react-zoom-pan-pinch) - Custom lightbox using [react-zoom-pan-pinch](https://www.npmjs.com/package/react-zoom-pan-pinch)
- Handle query params using [Zod](https://zod.dev/) - Handle query params type-validation using [Zod](https://zod.dev/)
- A secret "Terminal" mode. Can you find it? - A secret "Terminal" mode. Can you find it?
## Installation ## Installation
@ -148,31 +149,14 @@ cd accords-library.com
npm install npm install
``` ```
Create a env file: Create a env file based on the example one:
```bash ```bash
cp .env.example .env.local
nano .env.local nano .env.local
``` ```
Enter the following information: Change the variables
```
URL_GRAPHQL=https://url-to.strapi-accords-library.com/graphql
ACCESS_TOKEN=abcdef0123456789
REVALIDATION_TOKEN=abcdef0123456789
SMTP_HOST=email.provider.com
SMTP_USER=email@example.com
SMTP_PASSWORD=mypassword123
NEXT_PUBLIC_URL_CMS=https://url-to.strapi-accords-library.com
NEXT_PUBLIC_URL_IMG=https://url-to.img-accords-library.com
NEXT_PUBLIC_URL_WATCH=https://url-to.watch-accords-library.com
NEXT_PUBLIC_URL_SELF=https://url-to-front-accords-library.com
NEXT_PUBLIC_URL_MEILISEARCH=https://url-to.search-accords-library.com
NEXT_PUBLIC_MEILISEARCH_KEY=abcdef0123456789
NEXT_PUBLIC_UMAMI_URL=https://url-to.umami-accords-library.com
NEXT_PUBLIC_UMAMI_ID=abcdef0123456789
NEXT_PUBLIC_API_OCR_KEY=abcdef0123456789
```
Run in dev mode: Run in dev mode:

View File

@ -185,7 +185,8 @@
"weapons_description": "A list of all the weapons across all of the games. All distinguished weapons come with an “account.” Its a document with various details like how the weapon was forged and how its been used in the past.", "weapons_description": "A list of all the weapons across all of the games. All distinguished weapons come with an “account.” Its a document with various details like how the weapon was forged and how its been used in the past.",
"level_x": "Level {x}", "level_x": "Level {x}",
"story_x": "Story {x}", "story_x": "Story {x}",
"player_name_tooltip": "Certain in-game texts use the player's name as part of the dialogue/narration. If you want to see your name in the transcript found on this website, feel free to enter your player's name here. If left empty, '(player)' will be used instead." "player_name_tooltip": "Certain in-game texts use the player's name as part of the dialogue/narration. If you want to see your name in the transcript found on this website, feel free to enter your player's name here. If left empty, '(player)' will be used instead.",
"download_scans": "Download scans"
} }
}, },
{ {
@ -372,7 +373,8 @@
"weapons_description": "Une liste de toutes les armes présentes dans tous les jeux. Toutes les armes distinguées sont accompagnées d'un \"compte\". Il s'agit d'un document contenant divers détails tels que la façon dont l'arme a été forgée et comment elle a été utilisée dans le passé.", "weapons_description": "Une liste de toutes les armes présentes dans tous les jeux. Toutes les armes distinguées sont accompagnées d'un \"compte\". Il s'agit d'un document contenant divers détails tels que la façon dont l'arme a été forgée et comment elle a été utilisée dans le passé.",
"level_x": "Niveau {x}", "level_x": "Niveau {x}",
"story_x": "Histoire {x}", "story_x": "Histoire {x}",
"player_name_tooltip": "Certains textes dans les jeux utilisent le nom du joueur dans les dialogue/la narration. Si vous voulez voir votre nom dans les transcriptions se trouvant sur ce site web, n'hésitez pas à entrer votre nom de joueur ici. S'il n'est pas renseigné, '(player)' sera utilisé à la place." "player_name_tooltip": "Certains textes dans les jeux utilisent le nom du joueur dans les dialogue/la narration. Si vous voulez voir votre nom dans les transcriptions se trouvant sur ce site web, n'hésitez pas à entrer votre nom de joueur ici. S'il n'est pas renseigné, '(player)' sera utilisé à la place.",
"download_scans": "Télécharger les scans"
} }
}, },
{ {
@ -559,7 +561,8 @@
"weapons_description": null, "weapons_description": null,
"level_x": null, "level_x": null,
"story_x": null, "story_x": null,
"player_name_tooltip": null "player_name_tooltip": null,
"download_scans": null
} }
}, },
{ {
@ -746,7 +749,8 @@
"weapons_description": null, "weapons_description": null,
"level_x": null, "level_x": null,
"story_x": null, "story_x": null,
"player_name_tooltip": null "player_name_tooltip": null,
"download_scans": null
} }
}, },
{ {
@ -933,7 +937,8 @@
"weapons_description": null, "weapons_description": null,
"level_x": null, "level_x": null,
"story_x": null, "story_x": null,
"player_name_tooltip": null "player_name_tooltip": null,
"download_scans": null
} }
} }
] ]

View File

@ -204,7 +204,7 @@ export const PreviewCard = ({
)} )}
{subtitle && <Markdown text={subtitle} className="leading-none break-words" />} {subtitle && <Markdown text={subtitle} className="leading-none break-words" />}
</div> </div>
{description && <Markdown text={description} className="break-words overflow-hidden" />} {description && <Markdown text={description} className="overflow-hidden break-words" />}
{bottomChips && bottomChips.length > 0 && ( {bottomChips && bottomChips.length > 0 && (
<div <div
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll

View File

@ -184,4 +184,5 @@ export interface ICUParams {
level_x: { x: Date | boolean | number | string }; level_x: { x: Date | boolean | number | string };
story_x: { x: Date | boolean | number | string }; story_x: { x: Date | boolean | number | string };
player_name_tooltip: never; player_name_tooltip: never;
download_scans: never;
} }

View File

@ -9,6 +9,7 @@ query getLibraryItem($slug: String, $language_code: String) {
root_item root_item
primary primary
digital digital
download_available
thumbnail { thumbnail {
data { data {
attributes { attributes {

View File

@ -6,6 +6,7 @@ query getLibraryItemScans($slug: String, $language_code: String) {
slug slug
title title
subtitle subtitle
download_available
images(pagination: { limit: -1 }) { images(pagination: { limit: -1 }) {
status status
language { language {

View File

@ -191,6 +191,7 @@ query localDataGetWebsiteInterfaces {
level_x level_x
story_x story_x
player_name_tooltip player_name_tooltip
download_scans
} }
} }
} }

View File

@ -1,4 +1,5 @@
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next"; import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { useMemo } from "react";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
@ -20,7 +21,6 @@ import { TranslatedPreviewFolder } from "components/Contents/PreviewFolder";
import { useFormat } from "hooks/useFormat"; import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
import { Chip } from "components/Chip"; import { Chip } from "components/Chip";
import { useMemo } from "react";
/* /*
* *
@ -268,8 +268,8 @@ const NoContentNorFolderMessage = () => {
return ( return (
<div className="grid place-content-center"> <div className="grid place-content-center">
<div <div
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted className="mt-12 grid grid-flow-col place-items-center gap-9 rounded-2xl border-2
border-dark p-8 text-dark opacity-40 mt-12"> border-dotted border-dark p-8 text-dark opacity-40">
<p className="max-w-xs text-2xl">{format("empty_folder_message")}</p> <p className="max-w-xs text-2xl">{format("empty_folder_message")}</p>
</div> </div>
</div> </div>

View File

@ -41,7 +41,6 @@ import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { isUntangibleGroupItem } from "helpers/libraryItem"; import { isUntangibleGroupItem } from "helpers/libraryItem";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { Ico } from "components/Ico";
import { cJoin, cIf } from "helpers/className"; import { cJoin, cIf } from "helpers/className";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
@ -50,10 +49,10 @@ import { useIntersectionList } from "hooks/useIntersectionList";
import { Ids } from "types/ids"; import { Ids } from "types/ids";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { Link } from "components/Inputs/Link";
import { useFormat } from "hooks/useFormat"; import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
import { ElementsSeparator } from "helpers/component"; import { ElementsSeparator } from "helpers/component";
import { ToolTip } from "components/ToolTip";
/* /*
* *
@ -95,10 +94,13 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
item.metadata?.[0]?.__typename === "ComponentMetadataGroup" && item.metadata?.[0]?.__typename === "ComponentMetadataGroup" &&
item.metadata[0].subtype?.data?.attributes?.slug === "variant-set"; item.metadata[0].subtype?.data?.attributes?.slug === "variant-set";
const displayOpenScans = item.contents?.data.some( const hasContentScans = item.contents?.data.some(
(content) => content.attributes?.scan_set && content.attributes.scan_set.length > 0 (content) => content.attributes?.scan_set && content.attributes.scan_set.length > 0
); );
const hasContentSection =
(item.contents && item.contents.data.length > 0) || item.download_available;
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout); const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const subPanel = ( const subPanel = (
@ -205,11 +207,12 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
item.subitem_of.data[0].attributes.title, item.subitem_of.data[0].attributes.title,
item.subitem_of.data[0].attributes.subtitle item.subitem_of.data[0].attributes.subtitle
)} )}
size="small"
/> />
</div> </div>
)} )}
<div className="grid place-items-center text-center"> <div className="grid place-items-center text-center">
<h1 className="text-3xl">{item.title}</h1> <h1 className="text-4xl">{item.title}</h1>
{isDefinedAndNotEmpty(item.subtitle) && <h2 className="text-2xl">{item.subtitle}</h2>} {isDefinedAndNotEmpty(item.subtitle) && <h2 className="text-2xl">{item.subtitle}</h2>}
</div> </div>
@ -517,58 +520,75 @@ const LibrarySlug = ({ item, itemId, ...otherProps }: Props): JSX.Element => {
</div> </div>
)} )}
{item.contents && item.contents.data.length > 0 && ( {hasContentSection && (
<div id={intersectionIds[4]} className="grid w-full place-items-center gap-8"> <div id={intersectionIds[4]} className="grid w-full place-items-center gap-8">
<h2 className="-mb-6 text-2xl">{format("contents")}</h2> <h2 className="-mb-6 text-2xl">{format("contents")}</h2>
{displayOpenScans && ( <div className="grid grid-flow-col gap-4">
<div className="grid grid-flow-col gap-4"> {hasContentScans && (
<Button href={`/library/${item.slug}/reader`} text={format("view_scans")} /> <Button
</div> href={`/library/${item.slug}/reader`}
)} icon="auto_stories"
<div className="max-w- grid w-full gap-4"> text={format("view_scans")}
{filterHasAttributes(item.contents.data, ["attributes"]).map((rangedContent) => (
<ContentLine
content={
rangedContent.attributes.content?.data?.attributes
? {
translations: filterDefined(
rangedContent.attributes.content.data.attributes.translations
).map((translation) => ({
pre_title: translation.pre_title,
title: translation.title,
subtitle: translation.subtitle,
language: translation.language?.data?.attributes?.code,
})),
categories: filterHasAttributes(
rangedContent.attributes.content.data.attributes.categories?.data,
["attributes"]
).map((category) => category.attributes.short),
type:
rangedContent.attributes.content.data.attributes.type?.data?.attributes
?.titles?.[0]?.title ??
prettySlug(
rangedContent.attributes.content.data.attributes.type?.data
?.attributes?.slug
),
slug: rangedContent.attributes.content.data.attributes.slug,
}
: undefined
}
rangeStart={
rangedContent.attributes.range[0]?.__typename === "ComponentRangePageRange"
? `${rangedContent.attributes.range[0].starting_page}`
: ""
}
slug={rangedContent.attributes.slug}
parentSlug={item.slug}
key={rangedContent.id}
hasScanSet={
isDefined(rangedContent.attributes.scan_set) &&
rangedContent.attributes.scan_set.length > 0
}
condensed={!isContentPanelAtLeast3xl}
/> />
))} )}
{item.download_available && (
<Button
href={`${process.env.NEXT_PUBLIC_URL_SCANS_DOWNLOAD}/library/scans/${item.slug}.zip`}
icon="download"
text={format("download_scans")}
/>
)}
</div>
<div className="grid w-full gap-4">
<div
className={cJoin(
"grid items-center",
cIf(isContentPanelAtLeast3xl, "grid-cols-[1fr_auto_auto_auto] gap-4", "gap-4")
)}>
{filterHasAttributes(item.contents?.data, ["attributes"]).map((rangedContent) => (
<ContentItem
content={
rangedContent.attributes.content?.data?.attributes
? {
translations: filterDefined(
rangedContent.attributes.content.data.attributes.translations
).map((translation) => ({
pre_title: translation.pre_title,
title: translation.title,
subtitle: translation.subtitle,
language: translation.language?.data?.attributes?.code,
})),
categories: filterHasAttributes(
rangedContent.attributes.content.data.attributes.categories?.data,
["attributes"]
).map((category) => category.attributes.short),
type:
rangedContent.attributes.content.data.attributes.type?.data
?.attributes?.titles?.[0]?.title ??
prettySlug(
rangedContent.attributes.content.data.attributes.type?.data
?.attributes?.slug
),
slug: rangedContent.attributes.content.data.attributes.slug,
}
: undefined
}
rangeStart={
rangedContent.attributes.range[0]?.__typename === "ComponentRangePageRange"
? `${rangedContent.attributes.range[0].starting_page}`
: ""
}
slug={rangedContent.attributes.slug}
parentSlug={item.slug}
key={rangedContent.id}
hasScanSet={
isDefined(rangedContent.attributes.scan_set) &&
rangedContent.attributes.scan_set.length > 0
}
displayType={isContentPanelAtLeast3xl ? "row" : "card"}
/>
))}
</div>
</div> </div>
</div> </div>
)} )}
@ -647,7 +667,7 @@ export const getStaticPaths: GetStaticPaths = async (context) => {
* PRIVATE COMPONENTS * PRIVATE COMPONENTS
*/ */
interface ContentLineProps { interface ContentItemProps {
content?: { content?: {
translations: { translations: {
pre_title: string | null | undefined; pre_title: string | null | undefined;
@ -662,84 +682,32 @@ interface ContentLineProps {
rangeStart: string; rangeStart: string;
parentSlug: string; parentSlug: string;
slug: string; slug: string;
hasScanSet: boolean; hasScanSet: boolean;
condensed: boolean; displayType: "card" | "row";
} }
const ContentLine = ({ const ContentItem = ({
rangeStart, rangeStart,
content, content,
hasScanSet, hasScanSet,
slug, slug,
parentSlug, parentSlug,
condensed, displayType,
}: ContentLineProps): JSX.Element => { }: ContentItemProps): JSX.Element => {
const { format } = useFormat(); const { format } = useFormat();
const { value: isOpened, toggle: toggleOpened } = useBoolean(false);
const [selectedTranslation] = useSmartLanguage({ const [selectedTranslation] = useSmartLanguage({
items: content?.translations ?? [], items: content?.translations ?? [],
languageExtractor: useCallback( languageExtractor: useCallback(
(item: NonNullable<ContentLineProps["content"]>["translations"][number]) => item.language, (item: NonNullable<ContentItemProps["content"]>["translations"][number]) => item.language,
[] []
), ),
}); });
if (condensed) { if (displayType === "card") {
return ( return (
<div className="my-4 grid gap-2"> <div className="grid w-full gap-3 rounded-xl bg-light p-8 shadow-sm shadow-shade">
<div className="flex gap-2"> <div className="grid grid-cols-[auto_1fr_auto] items-center gap-3">
{content?.type && <Chip text={content.type} />} <h3 className="text-lg">
<p className="h-4 w-full border-b-2 border-dotted border-black opacity-30" />
<p>{rangeStart}</p>
</div>
<h3 className="flex flex-wrap place-items-center gap-2">
{selectedTranslation
? prettyInlineTitle(
selectedTranslation.pre_title,
selectedTranslation.title,
selectedTranslation.subtitle
)
: content
? prettySlug(content.slug, parentSlug)
: prettySlug(slug, parentSlug)}
</h3>
<div className="flex flex-row flex-wrap gap-1">
{content?.categories?.map((category, index) => (
<Chip key={index} text={category} />
))}
</div>
<div className="grid grid-cols-2 gap-3">
{hasScanSet || isDefined(content) ? (
<>
{hasScanSet && (
<Button
href={`/library/${parentSlug}/reader?page=${rangeStart}`}
text={format("view_scans")}
/>
)}
{isDefined(content) && (
<Button href={`/contents/${content.slug}`} text={format("open_content")} />
)}
</>
) : (
format("content_is_not_available")
)}
</div>
</div>
);
}
return (
<div
className={cJoin(
"grid gap-2 rounded-lg px-4",
cIf(isOpened, "my-2 h-auto bg-mid py-3 shadow-inner-sm shadow-shade")
)}>
<div className="grid grid-cols-[auto_auto_1fr_auto_12ch] place-items-center gap-4">
<Link href={""} linkStyled>
<h3 className="cursor-pointer" onClick={toggleOpened}>
{selectedTranslation {selectedTranslation
? prettyInlineTitle( ? prettyInlineTitle(
selectedTranslation.pre_title, selectedTranslation.pre_title,
@ -750,38 +718,95 @@ const ContentLine = ({
? prettySlug(content.slug, parentSlug) ? prettySlug(content.slug, parentSlug)
: prettySlug(slug, parentSlug)} : prettySlug(slug, parentSlug)}
</h3> </h3>
</Link> <p className="h-4 w-full border-b-2 border-dotted border-mid" />
<div className="flex flex-row flex-wrap gap-1"> <p className="text-right">{rangeStart}</p>
{content?.categories?.map((category, index) => (
<Chip key={index} text={category} />
))}
</div> </div>
<p className="h-4 w-full border-b-2 border-dotted border-black opacity-30" />
<p>{rangeStart}</p>
{content?.type && <Chip className="justify-self-end" text={content.type} />}
</div>
<div
className={`grid-flow-col place-content-start place-items-center gap-2 ${
isOpened ? "grid" : "hidden"
}`}>
<Ico icon={"subdirectory_arrow_right"} className="text-dark" />
{hasScanSet || isDefined(content) ? ( {content && (
<> <div>
{content.categories && (
<div className="flex items-center gap-2">
<p>{format("category", { count: content.categories.length })}</p>
<div className="flex flex-row flex-wrap gap-1">
{content.categories.map((category, index) => (
<Chip key={index} text={category} />
))}
</div>
</div>
)}
{content.type && (
<div className="flex items-center gap-2">
<p>{format("type", { count: 1 })}</p>
<Chip className="justify-self-end" text={content.type} />
</div>
)}
</div>
)}
{(hasScanSet || content) && (
<div className="flex flex-wrap gap-3">
{hasScanSet && ( {hasScanSet && (
<Button <Button
href={`/library/${parentSlug}/reader?page=${rangeStart}`} href={`/library/${parentSlug}/reader?page=${rangeStart}`}
icon="auto_stories"
text={format("view_scans")} text={format("view_scans")}
/> />
)} )}
{isDefined(content) && ( {isDefined(content) && (
<Button href={`/contents/${content.slug}`} text={format("open_content")} /> <Button
href={`/contents/${content.slug}`}
icon="subject"
text={format("open_content")}
/>
)} )}
</> </div>
) : (
format("content_is_not_available")
)} )}
</div> </div>
</div> );
}
return (
<>
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-3">
<h3>
{selectedTranslation
? prettyInlineTitle(
selectedTranslation.pre_title,
selectedTranslation.title,
selectedTranslation.subtitle
)
: content
? prettySlug(content.slug, parentSlug)
: prettySlug(slug, parentSlug)}
</h3>
<div className="flex flex-wrap place-content-center gap-1">
{content?.categories?.map((category, index) => (
<Chip key={index} text={category} />
))}
</div>
<p className="h-4 w-full border-b-2 border-dotted border-mid" />
{content?.type && <Chip className="justify-self-end" text={content.type} />}
</div>
<p className="text-right">{rangeStart}</p>
<div>
{hasScanSet && (
<ToolTip content={format("view_scans")}>
<Button
href={`/library/${parentSlug}/reader?page=${rangeStart}`}
icon="auto_stories"
size="small"
/>
</ToolTip>
)}
</div>
<div>
{isDefined(content) && (
<ToolTip content={format("open_content")}>
<Button href={`/contents/${content.slug}`} icon="subject" size="small" />
</ToolTip>
)}
</div>
</>
); );
}; };

View File

@ -366,6 +366,14 @@ const LibrarySlug = ({
sendAnalytics("Reader", "Reset all options"); sendAnalytics("Reader", "Reset all options");
}} }}
/> />
{item.download_available && (
<Button
href={`${process.env.NEXT_PUBLIC_URL_SCANS_DOWNLOAD}/library/scans/${item.slug}.zip`}
icon="download"
text={format("download_scans")}
/>
)}
</SubPanel> </SubPanel>
); );
@ -855,6 +863,7 @@ const ScanSet = ({ onClickOnImage, scanSet, id, title, content }: ScanSetProps):
{content?.data?.attributes && isDefinedAndNotEmpty(content.data.attributes.slug) && ( {content?.data?.attributes && isDefinedAndNotEmpty(content.data.attributes.slug) && (
<Button <Button
href={`/contents/${content.data.attributes.slug}`} href={`/contents/${content.data.attributes.slug}`}
icon="subject"
text={format("open_content")} text={format("open_content")}
/> />
)} )}