fix(web): lecteurs EPUB/PDF — gestion d'erreurs, worker pdf.js local
Composant ReaderError avec diagnostic et actions de repli, worker pdf.js servi localement (pdfWorker) pour éviter les CDN, typage du view foliate, navigation retour vers la fiche livre et styles lecteur associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -41,6 +41,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
||||||
|
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||||
const savePdfPage = useCallback(
|
const savePdfPage = useCallback(
|
||||||
(nextPage: number, pages: number) => {
|
(nextPage: number, pages: number) => {
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
@ -61,7 +62,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
return (
|
return (
|
||||||
<div className="reader-page">
|
<div className="reader-page">
|
||||||
<header className="reader-topbar">
|
<header className="reader-topbar">
|
||||||
<button className="ghost-button" onClick={() => navigate(book ? `/book/${book.id}` : "/home")}>
|
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||||
<ArrowLeft size={17} />
|
<ArrowLeft size={17} />
|
||||||
Fiche
|
Fiche
|
||||||
</button>
|
</button>
|
||||||
@ -87,11 +88,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : book.format === "pdf" ? (
|
) : book.format === "pdf" ? (
|
||||||
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
<PdfReader url={fileUrl} page={page} backHref={backHref} onPageCommit={savePdfPage} />
|
||||||
) : book.format === "cbz" || book.format === "cbr" ? (
|
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
||||||
) : (
|
) : (
|
||||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
<EpubReader url={fileUrl} locator={progress?.locator} backHref={backHref} onLocatorChange={saveEpubLocator} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,43 +1,145 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
type FoliateModule = {
|
type FoliateLocation = {
|
||||||
EPUB?: unknown;
|
cfi?: string;
|
||||||
default?: unknown;
|
fraction?: number;
|
||||||
|
current?: number;
|
||||||
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
|
type FoliateView = HTMLElement & {
|
||||||
|
open(input: File | Blob | string): Promise<void>;
|
||||||
|
close(): void;
|
||||||
|
goLeft(): Promise<void>;
|
||||||
|
goRight(): Promise<void>;
|
||||||
|
goTo(target: string): Promise<unknown>;
|
||||||
|
next(): Promise<void>;
|
||||||
|
lastLocation?: FoliateLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function epubFileName(url: string): string {
|
||||||
|
try {
|
||||||
|
const base = globalThis.location?.href ?? "http://readabook.local/";
|
||||||
|
const pathname = new URL(url, base).pathname;
|
||||||
|
const name = pathname.split("/").filter(Boolean).at(-1);
|
||||||
|
return name && name.includes(".") ? name : "book.epub";
|
||||||
|
} catch {
|
||||||
|
return "book.epub";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationPercent(location: FoliateLocation): number {
|
||||||
|
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
|
||||||
|
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
|
||||||
|
return Math.max(0, Math.min(100, (location.current / location.total) * 100));
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EpubReader({
|
||||||
|
url,
|
||||||
|
locator,
|
||||||
|
backHref,
|
||||||
|
onLocatorChange
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
locator?: string;
|
||||||
|
backHref: string;
|
||||||
|
onLocatorChange: (locator: string, percent: number) => void;
|
||||||
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [frameKey, setFrameKey] = useState(0);
|
const viewRef = useRef<FoliateView | null>(null);
|
||||||
const [status, setStatus] = useState("Ouverture EPUB");
|
const locatorRef = useRef(locator);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
locatorRef.current = locator;
|
||||||
|
}, [locator]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let view: FoliateView | null = null;
|
||||||
|
|
||||||
async function mount() {
|
async function mount() {
|
||||||
try {
|
try {
|
||||||
const module = (await import("foliate-js/epub.js")) as FoliateModule;
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
await import("foliate-js/view.js");
|
||||||
if (cancelled || !hostRef.current) return;
|
if (cancelled || !hostRef.current) return;
|
||||||
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
|
|
||||||
setStatus("EPUB pret");
|
const response = await fetch(url, { credentials: "include" });
|
||||||
} catch {
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||||
setStatus("Apercu EPUB indisponible dans ce navigateur");
|
const blob = await response.blob();
|
||||||
|
if (cancelled || !hostRef.current) return;
|
||||||
|
|
||||||
|
view = document.createElement("foliate-view") as FoliateView;
|
||||||
|
view.classList.add("epub-view");
|
||||||
|
view.addEventListener("relocate", (event) => {
|
||||||
|
const location = (event as CustomEvent<FoliateLocation>).detail;
|
||||||
|
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
|
||||||
|
});
|
||||||
|
hostRef.current.replaceChildren(view);
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
|
||||||
|
await view.open(file);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (locatorRef.current) await view.goTo(locatorRef.current);
|
||||||
|
else await view.next();
|
||||||
|
setLoading(false);
|
||||||
|
} catch (mountError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(readerErrorMessage(mountError, "EPUB indisponible"));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mount();
|
|
||||||
|
void mount();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
view?.close?.();
|
||||||
|
view?.remove();
|
||||||
|
if (viewRef.current === view) viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [attempt, onLocatorChange, url]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="epub-reader">
|
||||||
|
<ReaderError
|
||||||
|
title="Lecture EPUB indisponible"
|
||||||
|
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
||||||
|
technicalDetail={error}
|
||||||
|
downloadUrl={url}
|
||||||
|
backHref={backHref}
|
||||||
|
onRetry={() => setAttempt((value) => value + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="epub-reader" ref={hostRef}>
|
<div className="epub-reader">
|
||||||
<iframe key={frameKey} title="EPUB" src={url} />
|
{loading && (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>{status}</span>
|
<span>Ouverture EPUB</span>
|
||||||
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
|
</div>
|
||||||
Marquer la position
|
)}
|
||||||
|
<div className="epub-host" ref={hostRef} />
|
||||||
|
<div className="reader-stepper">
|
||||||
|
<button className="ghost-button" onClick={() => void viewRef.current?.goLeft()} disabled={loading}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Précédent
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost-button" onClick={() => setFrameKey((value) => value + 1)}>
|
<span>{loading ? "Chargement" : "Lecture intégrée"}</span>
|
||||||
Recharger
|
<button className="ghost-button" onClick={() => void viewRef.current?.goRight()} disabled={loading}>
|
||||||
|
Suivant
|
||||||
|
<ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +1,36 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
|
import { pdfWorkerSrc } from "./pdfWorker";
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||||
|
|
||||||
export function PdfReader({ url, page, onPageCommit }: { url: string; page: number; onPageCommit: (page: number, pages: number) => void }) {
|
export function PdfReader({
|
||||||
|
url,
|
||||||
|
page,
|
||||||
|
backHref,
|
||||||
|
onPageCommit
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
page: number;
|
||||||
|
backHref: string;
|
||||||
|
onPageCommit: (page: number, pages: number) => void;
|
||||||
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
||||||
|
let renderTask: pdfjs.RenderTask | undefined;
|
||||||
async function render() {
|
async function render() {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||||
const document = await loadingTask.promise;
|
const document = await loadingTask.promise;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPages(document.numPages);
|
setPages(document.numPages);
|
||||||
@ -27,37 +42,53 @@ export function PdfReader({ url, page, onPageCommit }: { url: string; page: numb
|
|||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||||
|
await renderTask.promise;
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
} catch (renderError) {
|
} catch (renderError) {
|
||||||
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
|
if (!cancelled) {
|
||||||
|
setError(readerErrorMessage(renderError, "PDF indisponible"));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render();
|
void render();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
renderTask?.cancel();
|
||||||
|
void loadingTask?.destroy();
|
||||||
};
|
};
|
||||||
}, [url, page, attempt]);
|
}, [url, page, attempt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pdf-reader">
|
<div className="pdf-reader">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="reader-fallback">
|
<ReaderError
|
||||||
<span>{error}</span>
|
title="Lecture PDF indisponible"
|
||||||
<button className="ghost-button" onClick={() => setAttempt((value) => value + 1)}>
|
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
||||||
Reessayer
|
technicalDetail={error}
|
||||||
</button>
|
downloadUrl={url}
|
||||||
</div>
|
backHref={backHref}
|
||||||
|
onRetry={() => setAttempt((value) => value + 1)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<canvas ref={canvasRef} />
|
<>
|
||||||
|
{loading && (
|
||||||
|
<div className="reader-fallback">
|
||||||
|
<span>Ouverture PDF</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="reader-stepper">
|
<div className="reader-stepper">
|
||||||
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)}>
|
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
|
||||||
Precedent
|
Précédent
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
{page} / {pages}
|
{page} / {pages}
|
||||||
</span>
|
</span>
|
||||||
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)}>
|
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)} disabled={Boolean(error) || loading}>
|
||||||
Suivant
|
Suivant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
53
apps/web/src/reader/ReaderError.tsx
Normal file
53
apps/web/src/reader/ReaderError.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { ArrowLeft, Download, RotateCcw } from "lucide-react";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
|
||||||
|
export function readerErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim()) return error.message;
|
||||||
|
if (typeof error === "string" && error.trim()) return error;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReaderError({
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
technicalDetail,
|
||||||
|
downloadUrl,
|
||||||
|
backHref,
|
||||||
|
onRetry
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
technicalDetail?: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
backHref: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="reader-error">
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{detail}</p>
|
||||||
|
</div>
|
||||||
|
<div className="reader-error-actions">
|
||||||
|
<button className="ghost-button" onClick={onRetry}>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Retour à la fiche
|
||||||
|
</button>
|
||||||
|
<a className="ghost-button" href={downloadUrl} download>
|
||||||
|
<Download size={16} />
|
||||||
|
Télécharger
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{technicalDetail && (
|
||||||
|
<details>
|
||||||
|
<summary>Détail technique</summary>
|
||||||
|
<pre>{technicalDetail}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/web/src/reader/pdfWorker.ts
Normal file
1
apps/web/src/reader/pdfWorker.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||||
20
apps/web/src/reader/readerRuntime.test.ts
Normal file
20
apps/web/src/reader/readerRuntime.test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { epubFileName } from "./EpubReader";
|
||||||
|
import { pdfWorkerSrc } from "./pdfWorker";
|
||||||
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
|
describe("reader runtime helpers", () => {
|
||||||
|
it("extracts an EPUB file name from the file URL", () => {
|
||||||
|
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
|
||||||
|
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps PDF.js worker source on the bundled module worker", () => {
|
||||||
|
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes reader technical errors", () => {
|
||||||
|
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||||
|
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -676,8 +676,23 @@ select {
|
|||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.epub-host {
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 980px);
|
||||||
|
height: calc(100vh - 170px);
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epub-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #f7f0df;
|
||||||
|
color: #17110d;
|
||||||
|
}
|
||||||
|
|
||||||
.pdf-reader canvas,
|
.pdf-reader canvas,
|
||||||
.epub-reader iframe,
|
|
||||||
.cbz-reader img {
|
.cbz-reader img {
|
||||||
max-width: min(100%, 980px);
|
max-width: min(100%, 980px);
|
||||||
max-height: calc(100vh - 170px);
|
max-height: calc(100vh - 170px);
|
||||||
@ -691,11 +706,6 @@ select {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.epub-reader iframe {
|
|
||||||
width: min(100%, 980px);
|
|
||||||
height: calc(100vh - 170px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-fallback {
|
.reader-fallback {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -712,6 +722,46 @@ select {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-error {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
width: min(100%, 620px);
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(169, 72, 52, 0.72);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(169, 72, 52, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error details {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error pre {
|
||||||
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
@ -786,18 +836,21 @@ select {
|
|||||||
.provider-row,
|
.provider-row,
|
||||||
.provider-local,
|
.provider-local,
|
||||||
.schedule-controls,
|
.schedule-controls,
|
||||||
.save-bar {
|
.save-bar,
|
||||||
|
.reader-error-actions {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-bar,
|
.save-bar,
|
||||||
.save-bar div,
|
.save-bar div,
|
||||||
.provider-actions {
|
.provider-actions,
|
||||||
|
.reader-error-actions {
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-bar div,
|
.save-bar div,
|
||||||
.provider-actions {
|
.provider-actions,
|
||||||
|
.reader-error-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/web/src/vite-env.d.ts
vendored
5
apps/web/src/vite-env.d.ts
vendored
@ -5,3 +5,8 @@ declare module "foliate-js/epub.js" {
|
|||||||
export default module;
|
export default module;
|
||||||
export const EPUB: unknown;
|
export const EPUB: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "foliate-js/view.js" {
|
||||||
|
const module: unknown;
|
||||||
|
export default module;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user