Compare commits
24 Commits
5fda6b8895
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 3faa7ca44b | |||
| d2557c14fa | |||
| 7e6c0bdfab | |||
| 2484fd1e09 | |||
| c17ea105a9 | |||
| af8ebef275 | |||
| 1bbf03d875 | |||
| 8d5d1bc2ff | |||
| 72f43c629d | |||
| 28ee33bd0b | |||
| c2cdb07f9a | |||
| 644a4b0183 | |||
| 11bb529322 | |||
| cf8779781f | |||
| 452a4b6455 | |||
| 1d0b389c1e | |||
| a9996cf15f | |||
| c4fe4d9de6 | |||
| 01723c6eb7 | |||
| 7b3b687035 | |||
| 9a5e78af72 | |||
| 03c8657df9 | |||
| 5f63faa648 | |||
| ab77d129d3 |
@ -1,5 +1,9 @@
|
|||||||
# Local compose defaults. Copy to .env when you need machine-specific paths.
|
# Local compose defaults. Copy to .env when you need machine-specific paths.
|
||||||
|
|
||||||
|
# Images used by docker-compose.yaml.
|
||||||
|
READABOOK_REGISTRY=gitea.anthonybouteiller.ovh/blomios/readabook
|
||||||
|
READABOOK_TAG=latest
|
||||||
|
|
||||||
# Directory mounted read-only as /library in the API container.
|
# Directory mounted read-only as /library in the API container.
|
||||||
READABOOK_LIBRARY_HOST_PATH=./data/library
|
READABOOK_LIBRARY_HOST_PATH=./data/library
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,15 @@ RUN pnpm --filter @readabook/api build
|
|||||||
RUN pnpm --filter @readabook/api deploy --prod /prod
|
RUN pnpm --filter @readabook/api deploy --prod /prod
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runtime
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
ARG OCI_IMAGE_CREATED
|
||||||
|
ARG OCI_IMAGE_REVISION
|
||||||
|
ARG OCI_IMAGE_VERSION=latest
|
||||||
|
LABEL org.opencontainers.image.title="ReadaBook API" \
|
||||||
|
org.opencontainers.image.description="ReadaBook NestJS API service" \
|
||||||
|
org.opencontainers.image.source="https://gitea.anthonybouteiller.ovh/blomios/ReadaBook" \
|
||||||
|
org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
|
||||||
|
org.opencontainers.image.revision="${OCI_IMAGE_REVISION}" \
|
||||||
|
org.opencontainers.image.version="${OCI_IMAGE_VERSION}"
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
35
README.md
35
README.md
@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale.
|
ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale.
|
||||||
|
|
||||||
|
## Dépôt distant
|
||||||
|
|
||||||
|
Le dépôt est hébergé sur un Gitea self-hosted et peut être cloné via :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.anthonybouteiller.ovh/blomios/ReadaBook.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Le déploiement git suit un git-flow simplifié : `main` (releases), `develop` (intégration), `feature/*` / `fix/*` (travail en cours).
|
||||||
|
|
||||||
## Fonctionnalités MVP présentes
|
## Fonctionnalités MVP présentes
|
||||||
|
|
||||||
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.
|
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.
|
||||||
@ -37,7 +47,8 @@ data/
|
|||||||
library/ Dossier local à scanner, monté en lecture seule dans compose
|
library/ Dossier local à scanner, monté en lecture seule dans compose
|
||||||
storage/ Cache backend, notamment couvertures extraites
|
storage/ Cache backend, notamment couvertures extraites
|
||||||
Dockerfile Image API
|
Dockerfile Image API
|
||||||
docker-compose.yaml Services API + web
|
docker-compose.yaml Services API + web depuis images publiees
|
||||||
|
docker-compose.build.yaml Override de build local des images API + web
|
||||||
pnpm-workspace.yaml Workspace monorepo
|
pnpm-workspace.yaml Workspace monorepo
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -62,7 +73,7 @@ Note sandbox constatée : sur Node 24 local, `better-sqlite3` peut nécessiter u
|
|||||||
## Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JWT_SECRET="replace-me" docker compose up --build
|
JWT_SECRET="replace-me" docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Services :
|
Services :
|
||||||
@ -75,6 +86,24 @@ Ports :
|
|||||||
- Hôte `3000` -> conteneur `web:80`.
|
- Hôte `3000` -> conteneur `web:80`.
|
||||||
- L’API est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`.
|
- L’API est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`.
|
||||||
|
|
||||||
|
Images par défaut :
|
||||||
|
|
||||||
|
- `${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}`
|
||||||
|
- `${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}`
|
||||||
|
|
||||||
|
Pour construire localement au lieu de tirer les images publiées :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
READABOOK_TAG=local docker compose -f docker-compose.yaml -f docker-compose.build.yaml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour publier les images vers le registry Gitea :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login gitea.anthonybouteiller.ovh
|
||||||
|
./scripts/publish.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend PWA
|
## Frontend PWA
|
||||||
|
|
||||||
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
|
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
|
||||||
@ -98,6 +127,8 @@ Volumes :
|
|||||||
|
|
||||||
Variables principales :
|
Variables principales :
|
||||||
|
|
||||||
|
- `READABOOK_REGISTRY` : préfixe registry/repository des images API et web.
|
||||||
|
- `READABOOK_TAG` : tag d’image utilisé par compose et par `scripts/publish.sh`.
|
||||||
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
||||||
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
||||||
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
|
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
|
||||||
|
|||||||
@ -17,6 +17,15 @@ RUN pnpm --filter @readabook/shared build
|
|||||||
RUN pnpm --filter @readabook/web build
|
RUN pnpm --filter @readabook/web build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
ARG OCI_IMAGE_CREATED
|
||||||
|
ARG OCI_IMAGE_REVISION
|
||||||
|
ARG OCI_IMAGE_VERSION=latest
|
||||||
|
LABEL org.opencontainers.image.title="ReadaBook Web" \
|
||||||
|
org.opencontainers.image.description="ReadaBook PWA served by Nginx" \
|
||||||
|
org.opencontainers.image.source="https://gitea.anthonybouteiller.ovh/blomios/ReadaBook" \
|
||||||
|
org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
|
||||||
|
org.opencontainers.image.revision="${OCI_IMAGE_REVISION}" \
|
||||||
|
org.opencontainers.image.version="${OCI_IMAGE_VERSION}"
|
||||||
COPY apps/web/nginx/default.conf /etc/nginx/conf.d/default.conf
|
COPY apps/web/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
23
apps/web/src/pages/BookErrorPages.test.ts
Normal file
23
apps/web/src/pages/BookErrorPages.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("book missing error pages", () => {
|
||||||
|
it("does not render fallback books on the book detail page", () => {
|
||||||
|
const source = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).not.toContain("getApiFallback");
|
||||||
|
expect(source).toContain("setBook(null)");
|
||||||
|
expect(source).toContain("Livre introuvable");
|
||||||
|
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the reader with a fallback book", () => {
|
||||||
|
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).not.toContain("getApiFallback");
|
||||||
|
expect(source).toContain("setBook(null)");
|
||||||
|
expect(source).toContain("reader-missing-page");
|
||||||
|
expect(source).toContain("Livre introuvable");
|
||||||
|
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { cleanBookDescription } from "../book/description";
|
import { cleanBookDescription } from "../book/description";
|
||||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||||
@ -17,14 +17,17 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]);
|
const nextBook = await api.book(bookId);
|
||||||
setBook(nextBook);
|
setBook(nextBook);
|
||||||
setProgress(nextProgress);
|
try {
|
||||||
} catch (loadError) {
|
setProgress(await api.progress(bookId));
|
||||||
const fallback = getApiFallback<BookDto>(loadError);
|
} catch {
|
||||||
setBook(fallback ?? null);
|
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible.");
|
}
|
||||||
|
} catch {
|
||||||
|
setBook(null);
|
||||||
|
setProgress(null);
|
||||||
|
setError("Ce livre n'existe pas dans le catalogue.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -43,13 +46,20 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
if (loading && !book) return <LoadingState />;
|
if (loading && !book) return <LoadingState />;
|
||||||
if (!book) {
|
if (!book) {
|
||||||
return (
|
return (
|
||||||
|
<section className="book-error-page" role="alert">
|
||||||
<Panel>
|
<Panel>
|
||||||
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} />
|
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
|
||||||
|
<div className="book-card-actions">
|
||||||
|
<button className="ghost-button" onClick={() => navigate("/home")}>
|
||||||
|
Retour à l'accueil
|
||||||
|
</button>
|
||||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||||
<RotateCcw size={17} />
|
<RotateCcw size={17} />
|
||||||
Reessayer
|
Reessayer
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,19 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
|
import type { DashboardData } from "../api/types";
|
||||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
|
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
|
||||||
import { BookCard } from "../components/BookCard";
|
|
||||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [state, setState] = useState<DashboardData | null>(null);
|
const [state, setState] = useState<DashboardData | null>(null);
|
||||||
const [fallback, setFallback] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
|
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
|
||||||
.then(([books, continueReading, libraries, jobs]) => {
|
.then(([books, continueReading, libraries, jobs]) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
|
|
||||||
setState({ books, continueReading, libraries, jobs });
|
setState({ books, continueReading, libraries, jobs });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@ -28,15 +25,33 @@ export function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!state) return <LoadingState />;
|
if (!state) return <LoadingState />;
|
||||||
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
|
|
||||||
|
const renderHomeBook = (
|
||||||
|
item: DashboardData["books"][number],
|
||||||
|
className = "home-book-card",
|
||||||
|
options: { href?: string; progressPercent?: number } = {}
|
||||||
|
) => {
|
||||||
|
const volumeLabel = bookVolumeLabel(item);
|
||||||
|
return (
|
||||||
|
<button key={item.id} className={className} onClick={() => navigate(options.href ?? `/book/${item.id}`)}>
|
||||||
|
<span className="home-book-cover" aria-hidden="true">
|
||||||
|
{item.coverPath ? <img src={api.bookCoverUrl(item.id)} alt="" /> : <BookOpen size={24} />}
|
||||||
|
</span>
|
||||||
|
<span className="home-book-copy">
|
||||||
|
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
|
||||||
|
<strong>{bookDisplayTitle(item)}</strong>
|
||||||
|
<span>{item.author ?? "Auteur inconnu"}</span>
|
||||||
|
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid home-page">
|
||||||
<section className="hero-band">
|
<section className="hero-band home-hero">
|
||||||
<div>
|
<div>
|
||||||
<p>Cabinet de curiosites numerique</p>
|
<h1>Reprendre la lecture.</h1>
|
||||||
<h1>Ouvrir, classer, reprendre.</h1>
|
|
||||||
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button" onClick={() => navigate("/search")}>
|
<button className="primary-button" onClick={() => navigate("/search")}>
|
||||||
<ScanLine size={18} />
|
<ScanLine size={18} />
|
||||||
@ -46,30 +61,17 @@ export function HomePage() {
|
|||||||
|
|
||||||
<Panel className="span-2">
|
<Panel className="span-2">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<h2>Reprise de lecture</h2>
|
<h2>Livres en cours</h2>
|
||||||
<span>{state.continueReading.length} traces</span>
|
<span>{state.continueReading.length} lectures</span>
|
||||||
</div>
|
</div>
|
||||||
{state.continueReading.length ? (
|
{state.continueReading.length ? (
|
||||||
<div className="continue-grid">
|
<div className="continue-grid">
|
||||||
{state.continueReading.map((item) => {
|
{state.continueReading.map((item) =>
|
||||||
const metadataState = bookMetadataState(item.book);
|
renderHomeBook(item.book, "home-book-card continue-tile", {
|
||||||
const publishedDate = displayPublishedDate(item.book.publishedDate);
|
href: `/reader/${item.book.id}`,
|
||||||
const volumeLabel = bookVolumeLabel(item.book);
|
progressPercent: item.progress.percent
|
||||||
return (
|
})
|
||||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
)}
|
||||||
<span className="continue-cover" aria-hidden="true">
|
|
||||||
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
|
|
||||||
</span>
|
|
||||||
<span className="continue-copy">
|
|
||||||
<strong>{bookDisplayTitle(item.book)}</strong>
|
|
||||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
|
||||||
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
|
|
||||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
|
|
||||||
<Meter value={item.progress.percent} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
||||||
@ -91,10 +93,8 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<section className="book-grid span-3">
|
<section className="home-book-grid span-3">
|
||||||
{state.books.map((book) => (
|
{state.books.map((book) => renderHomeBook(book))}
|
||||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
|
||||||
))}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
62
apps/web/src/pages/HomePageProgressScroll.test.ts
Normal file
62
apps/web/src/pages/HomePageProgressScroll.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("home progress list layout", () => {
|
||||||
|
it("keeps the continue reading list scrollable after three visible books", () => {
|
||||||
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? "";
|
||||||
|
|
||||||
|
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
|
||||||
|
expect(styles).toContain("--continue-tile-block-size: 112px");
|
||||||
|
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
|
||||||
|
expect(styles).toContain("overflow-y: auto");
|
||||||
|
expect(styles).toContain("scrollbar-gutter: stable");
|
||||||
|
expect(continueTile).toContain("block-size: var(--continue-tile-block-size)");
|
||||||
|
expect(continueTile).toContain("overflow: hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps home book cards scoped and simplified", () => {
|
||||||
|
const source = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("renderHomeBook");
|
||||||
|
expect(source).toContain("home-book-card");
|
||||||
|
expect(source).toContain("progressPercent");
|
||||||
|
expect(source).toContain("<Meter value={options.progressPercent} />");
|
||||||
|
expect(source).toContain("progressPercent: item.progress.percent");
|
||||||
|
expect(source).not.toContain("<BookCard");
|
||||||
|
expect(source).not.toContain("FormatPill");
|
||||||
|
expect(source).not.toContain("bookMetadataState");
|
||||||
|
expect(source).not.toContain("bookMetadataStateLabel");
|
||||||
|
expect(source).not.toContain("displayPublishedDate");
|
||||||
|
expect(source).not.toContain("book.description");
|
||||||
|
expect(source).not.toContain("book.language");
|
||||||
|
expect(source).not.toContain("API absente ou incomplete");
|
||||||
|
expect(source).not.toContain("specimens de demonstration actifs");
|
||||||
|
expect(source).not.toContain("Catalogue branche sur le serveur local");
|
||||||
|
expect(source).not.toContain("Bibliotheque personnelle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps visible book grids capped to five columns", () => {
|
||||||
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
const bookGrid = styles.match(/\.book-grid\s*\{[^}]+\}/)?.[0] ?? "";
|
||||||
|
const homeBookGrid = styles.match(/\.home-book-grid\s*\{[^}]+\}/)?.[0] ?? "";
|
||||||
|
|
||||||
|
expect(bookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
|
||||||
|
expect(homeBookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
|
||||||
|
expect(bookGrid).toContain("width: 100%");
|
||||||
|
expect(homeBookGrid).toContain("width: 100%");
|
||||||
|
expect(bookGrid).not.toContain("max-width");
|
||||||
|
expect(homeBookGrid).not.toContain("max-width");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the library list capped like continue reading", () => {
|
||||||
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
const libraryButton = styles.match(/\.library-list button\s*\{[^}]+\}/)?.[0] ?? "";
|
||||||
|
|
||||||
|
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
|
||||||
|
expect(styles).toContain("height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
|
||||||
|
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
|
||||||
|
expect(libraryButton).toContain("block-size: var(--continue-tile-block-size)");
|
||||||
|
expect(libraryButton).toContain("overflow: hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -24,7 +24,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
|||||||
setEmail((current) => current || nextStatus.initialAdminEmail);
|
setEmail((current) => current || nextStatus.initialAdminEmail);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (alive) setError("Statut d'authentification indisponible.");
|
// Auth status only enriches the unauthenticated screen; login must remain usable without it.
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
|||||||
14
apps/web/src/pages/LoginPageAuthStatusUnavailable.test.ts
Normal file
14
apps/web/src/pages/LoginPageAuthStatusUnavailable.test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("login page when auth status is unavailable", () => {
|
||||||
|
it("keeps the unauthenticated login screen usable without a blocking auth status error", () => {
|
||||||
|
const source = readFileSync(new URL("./LoginPage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("<form onSubmit={submit} className=\"stack-form\">");
|
||||||
|
expect(source).toContain('type="email" required');
|
||||||
|
expect(source).toContain('type="password" required');
|
||||||
|
expect(source).toContain('type="submit"');
|
||||||
|
expect(source).not.toContain("Statut d'authentification indisponible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -42,14 +42,24 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
|
|||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Panel className="profile-panel">
|
<Panel className="profile-panel">
|
||||||
|
<div className="profile-identity">
|
||||||
|
<span className="profile-avatar" aria-hidden="true">
|
||||||
<UserRound size={28} />
|
<UserRound size={28} />
|
||||||
|
</span>
|
||||||
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
|
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
|
||||||
<p>{session.user?.email ?? "Session non connectee"}</p>
|
<p>{session.user?.email ?? "Session non connectee"}</p>
|
||||||
<span>{session.user?.role ?? "vitrine"}</span>
|
<span>{session.user?.role ?? "vitrine"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="profile-actions">
|
||||||
|
<button className="ghost-button" onClick={() => document.getElementById("profile-security-form")?.scrollIntoView({ block: "start" })}>
|
||||||
|
<KeyRound size={17} />
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
<button className="ghost-button" onClick={logout}>
|
<button className="ghost-button" onClick={logout}>
|
||||||
<LogOut size={17} />
|
<LogOut size={17} />
|
||||||
Sortir
|
Sortir
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel className="span-2">
|
<Panel className="span-2">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@ -59,7 +69,7 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
|
|||||||
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
|
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
|
||||||
<ErrorRibbon message={error} />
|
<ErrorRibbon message={error} />
|
||||||
{success && <div className="success-ribbon">{success}</div>}
|
{success && <div className="success-ribbon">{success}</div>}
|
||||||
<form className="stack-form" onSubmit={updateSecurity}>
|
<form id="profile-security-form" className="stack-form" onSubmit={updateSecurity}>
|
||||||
<label>
|
<label>
|
||||||
Email
|
Email
|
||||||
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
|
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
|
||||||
|
|||||||
17
apps/web/src/pages/ProfilePagePolish.test.ts
Normal file
17
apps/web/src/pages/ProfilePagePolish.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("profile page polish", () => {
|
||||||
|
it("focuses the left column on identity and immediate actions", () => {
|
||||||
|
const source = readFileSync(new URL("./ProfilePage.tsx", import.meta.url), "utf8");
|
||||||
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("profile-identity");
|
||||||
|
expect(source).toContain("profile-avatar");
|
||||||
|
expect(source).toContain("profile-actions");
|
||||||
|
expect(source).toContain('id="profile-security-form"');
|
||||||
|
expect(source).toContain("scrollIntoView");
|
||||||
|
expect(styles).toContain(".profile-identity");
|
||||||
|
expect(styles).toContain(".profile-actions");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,16 +1,17 @@
|
|||||||
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
|
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
|
||||||
import type { BookDto } from "@readabook/shared";
|
import type { BookDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { CbzReader } from "../reader/CbzReader";
|
import { CbzReader } from "../reader/CbzReader";
|
||||||
import { EpubReader } from "../reader/EpubReader";
|
import { EpubReader } from "../reader/EpubReader";
|
||||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||||
import { PdfReader } from "../reader/PdfReader";
|
import { PdfReader } from "../reader/PdfReader";
|
||||||
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomControls } from "../reader/ReaderShell";
|
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell";
|
||||||
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
|
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
|
||||||
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
|
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||||
|
|
||||||
const idleControls: ReaderControls = {
|
const idleControls: ReaderControls = {
|
||||||
canPrevious: false,
|
canPrevious: false,
|
||||||
@ -74,10 +75,9 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
setBook(await api.book(bookId));
|
setBook(await api.book(bookId));
|
||||||
} catch (loadError) {
|
} catch {
|
||||||
const fallback = getApiFallback<BookDto>(loadError);
|
setBook(null);
|
||||||
setBook(fallback ?? null);
|
setError("Ce livre n'existe pas dans le catalogue.");
|
||||||
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -159,7 +159,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
setMode("horizontal");
|
setMode("horizontal");
|
||||||
saveReaderMode("horizontal");
|
saveReaderMode("horizontal");
|
||||||
}, [currentVisiblePage, page, saveReaderMode]);
|
}, [currentVisiblePage, page, saveReaderMode]);
|
||||||
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => {
|
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => {
|
||||||
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
||||||
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
|
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
|
||||||
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0;
|
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0;
|
||||||
@ -169,6 +169,14 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
|
if (anchor) {
|
||||||
|
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
|
||||||
|
if (!target) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
|
||||||
|
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
|
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
|
||||||
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
|
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
|
||||||
});
|
});
|
||||||
@ -179,7 +187,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
supportsZoom
|
supportsZoom
|
||||||
? {
|
? {
|
||||||
zoom,
|
zoom,
|
||||||
onZoomChange: (nextZoom) => changeZoom(nextZoom),
|
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
|
||||||
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
|
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
|
||||||
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
|
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
|
||||||
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
|
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
|
||||||
@ -198,6 +206,26 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[changeMode, mode, supportsMode]
|
[changeMode, mode, supportsMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loading && !book) return <LoadingState />;
|
||||||
|
|
||||||
|
if (!book) {
|
||||||
|
return (
|
||||||
|
<section className="reader-missing-page" role="alert">
|
||||||
|
<Panel>
|
||||||
|
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
|
||||||
|
<div className="reader-error-actions">
|
||||||
|
<button className="ghost-button" onClick={() => navigate("/home")}>
|
||||||
|
Retour à l'accueil
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderShell
|
<ReaderShell
|
||||||
title={book?.title ?? "Ouverture du lecteur"}
|
title={book?.title ?? "Ouverture du lecteur"}
|
||||||
@ -243,14 +271,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!book ? (
|
{book.format === "pdf" ? (
|
||||||
<div className="reader-fallback">
|
|
||||||
<span>{error ?? "Chargement du livre."}</span>
|
|
||||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
|
||||||
Reessayer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : book.format === "pdf" ? (
|
|
||||||
<PdfReader
|
<PdfReader
|
||||||
url={fileUrl}
|
url={fileUrl}
|
||||||
page={page}
|
page={page}
|
||||||
|
|||||||
@ -6,11 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
|
|||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
|
||||||
type PageCommitStrategy = "immediate" | "queued";
|
type PageCommitStrategy = "immediate" | "queued";
|
||||||
|
type PreloadedComicImage = {
|
||||||
|
image: HTMLImageElement;
|
||||||
|
status: "loading" | "loaded" | "error";
|
||||||
|
};
|
||||||
|
type PreloadPriority = "adjacent" | "deep";
|
||||||
|
type VerticalPreloadScheduler = {
|
||||||
|
active: number;
|
||||||
|
generation: number;
|
||||||
|
timer: number | null;
|
||||||
|
queuedPages: Set<number>;
|
||||||
|
queues: Record<PreloadPriority, number[]>;
|
||||||
|
};
|
||||||
|
|
||||||
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
||||||
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
||||||
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
||||||
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
|
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
|
||||||
|
const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3;
|
||||||
|
const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100;
|
||||||
|
const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90;
|
||||||
|
const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4];
|
||||||
|
const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"];
|
||||||
|
|
||||||
|
function scheduleReaderFrame(callback: FrameRequestCallback) {
|
||||||
|
if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback);
|
||||||
|
callback(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReaderFrame(frameId: number) {
|
||||||
|
if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVerticalPreloadScheduler(): VerticalPreloadScheduler {
|
||||||
|
return {
|
||||||
|
active: 0,
|
||||||
|
generation: 0,
|
||||||
|
timer: null,
|
||||||
|
queuedPages: new Set<number>(),
|
||||||
|
queues: {
|
||||||
|
adjacent: [],
|
||||||
|
deep: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueReaderPages(pages: Array<number | null>, currentPage: number, pageCount: number) {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
return pages
|
||||||
|
.map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null))
|
||||||
|
.filter((candidate): candidate is number => {
|
||||||
|
if (!candidate || candidate === currentPage || seen.has(candidate)) return false;
|
||||||
|
seen.add(candidate);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verticalImagePreloadPlan(currentPage: number, pageCount: number) {
|
||||||
|
const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount);
|
||||||
|
return {
|
||||||
|
adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount),
|
||||||
|
deep: uniqueReaderPages(
|
||||||
|
VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset),
|
||||||
|
currentPage,
|
||||||
|
pageCount
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function CbzReader({
|
export function CbzReader({
|
||||||
bookId,
|
bookId,
|
||||||
@ -39,6 +102,10 @@ export function CbzReader({
|
|||||||
const verticalInitialSyncDoneRef = useRef(false);
|
const verticalInitialSyncDoneRef = useRef(false);
|
||||||
const verticalAnchorAttemptRef = useRef(0);
|
const verticalAnchorAttemptRef = useRef(0);
|
||||||
const verticalAnchorStableFramesRef = useRef(0);
|
const verticalAnchorStableFramesRef = useRef(0);
|
||||||
|
const preloadedImagesRef = useRef(new Map<number, PreloadedComicImage>());
|
||||||
|
const preloadSchedulerRef = useRef(createVerticalPreloadScheduler());
|
||||||
|
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
|
||||||
|
const imageSizeFlushFrameRef = useRef<number | null>(null);
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
const [documentError, setDocumentError] = useState<string>();
|
const [documentError, setDocumentError] = useState<string>();
|
||||||
const [pageError, setPageError] = useState<string>();
|
const [pageError, setPageError] = useState<string>();
|
||||||
@ -91,9 +158,95 @@ export function CbzReader({
|
|||||||
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
|
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
|
||||||
verticalInitialSyncFrameRef.current = null;
|
verticalInitialSyncFrameRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
|
||||||
|
pendingImageSizesRef.current[pageNumber] = size;
|
||||||
|
if (imageSizeFlushFrameRef.current !== null) return;
|
||||||
|
imageSizeFlushFrameRef.current = scheduleReaderFrame(() => {
|
||||||
|
imageSizeFlushFrameRef.current = null;
|
||||||
|
const pending = pendingImageSizesRef.current;
|
||||||
|
pendingImageSizesRef.current = {};
|
||||||
|
setImageSizes((current) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...current };
|
||||||
|
for (const [pageKey, nextSize] of Object.entries(pending)) {
|
||||||
|
const pageNumber = Number(pageKey);
|
||||||
|
const currentSize = current[pageNumber];
|
||||||
|
if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue;
|
||||||
|
next[pageNumber] = nextSize;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? next : current;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const drainVerticalPreloadQueue = useCallback(() => {
|
||||||
|
const scheduler = preloadSchedulerRef.current;
|
||||||
|
scheduler.timer = null;
|
||||||
|
if (typeof Image === "undefined") return;
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
|
||||||
|
const pageNumber = scheduler.queues[priority].shift();
|
||||||
|
if (pageNumber) {
|
||||||
|
scheduler.queuedPages.delete(pageNumber);
|
||||||
|
return pageNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) {
|
||||||
|
const pageNumber = nextPage();
|
||||||
|
if (!pageNumber) return;
|
||||||
|
if (preloadedImagesRef.current.has(pageNumber)) continue;
|
||||||
|
|
||||||
|
const generation = scheduler.generation;
|
||||||
|
const image = new Image();
|
||||||
|
preloadedImagesRef.current.set(pageNumber, { image, status: "loading" });
|
||||||
|
scheduler.active += 1;
|
||||||
|
image.decoding = "async";
|
||||||
|
image.loading = "eager";
|
||||||
|
image.onload = () => {
|
||||||
|
if (generation !== scheduler.generation) return;
|
||||||
|
scheduler.active -= 1;
|
||||||
|
preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" });
|
||||||
|
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
||||||
|
queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight });
|
||||||
|
}
|
||||||
|
drainVerticalPreloadQueue();
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
if (generation !== scheduler.generation) return;
|
||||||
|
scheduler.active -= 1;
|
||||||
|
preloadedImagesRef.current.set(pageNumber, { image, status: "error" });
|
||||||
|
drainVerticalPreloadQueue();
|
||||||
|
};
|
||||||
|
image.src = api.cbzPageUrl(bookId, pageNumber);
|
||||||
|
}
|
||||||
|
}, [bookId, queueImageSize]);
|
||||||
|
const enqueueVerticalPreload = useCallback(
|
||||||
|
(plan: Record<PreloadPriority, number[]>) => {
|
||||||
|
const scheduler = preloadSchedulerRef.current;
|
||||||
|
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
|
||||||
|
if (priority === "deep") {
|
||||||
|
for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber);
|
||||||
|
scheduler.queues.deep = [];
|
||||||
|
}
|
||||||
|
for (const pageNumber of plan[priority]) {
|
||||||
|
if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue;
|
||||||
|
scheduler.queuedPages.add(pageNumber);
|
||||||
|
scheduler.queues[priority].push(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return;
|
||||||
|
scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS);
|
||||||
|
},
|
||||||
|
[drainVerticalPreloadQueue]
|
||||||
|
);
|
||||||
const commitVisiblePage = useCallback(
|
const commitVisiblePage = useCallback(
|
||||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||||
if (!pages || !verticalTrackingReadyRef.current) return;
|
if (!pages || !verticalTrackingReadyRef.current) return;
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
||||||
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
||||||
const stageRect = stage.getBoundingClientRect();
|
const stageRect = stage.getBoundingClientRect();
|
||||||
@ -192,8 +345,19 @@ export function CbzReader({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageSizes({});
|
setImageSizes({});
|
||||||
|
pendingImageSizesRef.current = {};
|
||||||
|
preloadedImagesRef.current.clear();
|
||||||
|
const scheduler = preloadSchedulerRef.current;
|
||||||
|
if (scheduler.timer !== null) window.clearTimeout(scheduler.timer);
|
||||||
|
preloadSchedulerRef.current = createVerticalPreloadScheduler();
|
||||||
|
preloadSchedulerRef.current.generation = scheduler.generation + 1;
|
||||||
}, [bookId, retryAttempt]);
|
}, [bookId, retryAttempt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
|
||||||
|
enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
|
||||||
|
}, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = frameRef.current;
|
const frame = frameRef.current;
|
||||||
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
||||||
@ -264,6 +428,7 @@ export function CbzReader({
|
|||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
const markUserScroll = () => {
|
const markUserScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
verticalUserScrollRef.current = true;
|
verticalUserScrollRef.current = true;
|
||||||
};
|
};
|
||||||
const markUserScrollKey = (event: KeyboardEvent) => {
|
const markUserScrollKey = (event: KeyboardEvent) => {
|
||||||
@ -275,6 +440,8 @@ export function CbzReader({
|
|||||||
commitVisiblePage(stage);
|
commitVisiblePage(stage);
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
|
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
|
||||||
if (frameId) return;
|
if (frameId) return;
|
||||||
frameId = requestAnimationFrame(updateVisiblePage);
|
frameId = requestAnimationFrame(updateVisiblePage);
|
||||||
};
|
};
|
||||||
@ -297,6 +464,12 @@ export function CbzReader({
|
|||||||
|
|
||||||
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
||||||
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
if (documentError) {
|
if (documentError) {
|
||||||
return (
|
return (
|
||||||
@ -348,14 +521,13 @@ export function CbzReader({
|
|||||||
<img
|
<img
|
||||||
src={api.cbzPageUrl(bookId, item.page)}
|
src={api.cbzPageUrl(bookId, item.page)}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
loading="lazy"
|
fetchPriority={item.page === currentPage ? "high" : "auto"}
|
||||||
|
loading={item.page === currentPage ? "eager" : "lazy"}
|
||||||
style={verticalImageStyle(item.page)}
|
style={verticalImageStyle(item.page)}
|
||||||
onLoad={(event) => {
|
onLoad={(event) => {
|
||||||
const { naturalWidth, naturalHeight } = event.currentTarget;
|
const { naturalWidth, naturalHeight } = event.currentTarget;
|
||||||
setImageSizes((current) => ({
|
preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
|
||||||
...current,
|
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
|
||||||
[item.page]: { width: naturalWidth, height: naturalHeight }
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -190,6 +190,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
const commitVisiblePage = useCallback(
|
const commitVisiblePage = useCallback(
|
||||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||||
if (!verticalTrackingReadyRef.current) return;
|
if (!verticalTrackingReadyRef.current) return;
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
||||||
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
||||||
const stageRect = stage.getBoundingClientRect();
|
const stageRect = stage.getBoundingClientRect();
|
||||||
@ -330,6 +331,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
const markUserScroll = () => {
|
const markUserScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
verticalUserScrollRef.current = true;
|
verticalUserScrollRef.current = true;
|
||||||
};
|
};
|
||||||
const markUserScrollKey = (event: KeyboardEvent) => {
|
const markUserScrollKey = (event: KeyboardEvent) => {
|
||||||
@ -341,6 +343,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
commitVisiblePage(stage);
|
commitVisiblePage(stage);
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
|
if (stage.dataset.readerPinchActive === "true") return;
|
||||||
if (frameId) return;
|
if (frameId) return;
|
||||||
frameId = requestAnimationFrame(updateVisiblePage);
|
frameId = requestAnimationFrame(updateVisiblePage);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export type ReaderControls = {
|
|||||||
|
|
||||||
export type ReaderZoomControls = {
|
export type ReaderZoomControls = {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
onZoomChange: (zoom: number) => void;
|
onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void;
|
||||||
onZoomOut: () => void;
|
onZoomOut: () => void;
|
||||||
onZoomIn: () => void;
|
onZoomIn: () => void;
|
||||||
onZoomReset: () => void;
|
onZoomReset: () => void;
|
||||||
@ -47,6 +47,64 @@ type ReaderShellProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReaderZoomAnchor = {
|
||||||
|
page: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
ratioX: number;
|
||||||
|
ratioY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReaderPinchGesture = {
|
||||||
|
distance: number;
|
||||||
|
zoom: number;
|
||||||
|
anchor: ReaderZoomAnchor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readerTouchPoint(touch: Touch): ReaderPoint {
|
||||||
|
return { x: touch.clientX, y: touch.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readerTouchMidpoint(first: Touch, second: Touch): ReaderPoint {
|
||||||
|
return {
|
||||||
|
x: (first.clientX + second.clientX) / 2,
|
||||||
|
y: (first.clientY + second.clientY) / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readerZoomAnchorAt(point: ReaderPoint): ReaderZoomAnchor | null {
|
||||||
|
const pageElement = document.elementFromPoint(point.x, point.y)?.closest("[data-reader-page]");
|
||||||
|
if (!(pageElement instanceof HTMLElement)) return null;
|
||||||
|
const page = Number(pageElement.dataset.readerPage);
|
||||||
|
const rect = pageElement.getBoundingClientRect();
|
||||||
|
if (!Number.isFinite(page) || rect.width <= 0 || rect.height <= 0) return null;
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
clientX: point.x,
|
||||||
|
clientY: point.y,
|
||||||
|
offsetX: point.x - rect.left,
|
||||||
|
offsetY: point.y - rect.top,
|
||||||
|
ratioX: Math.max(0, Math.min(1, (point.x - rect.left) / rect.width)),
|
||||||
|
ratioY: Math.max(0, Math.min(1, (point.y - rect.top) / rect.height))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreReaderZoomAnchor(stage: HTMLElement, anchor: ReaderZoomAnchor) {
|
||||||
|
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
|
||||||
|
if (!target) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
|
||||||
|
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomAnchor) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => restoreReaderZoomAnchor(stage, anchor));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
|
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
|
||||||
const readerRef = useRef<HTMLDivElement>(null);
|
const readerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
@ -54,7 +112,7 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
const headerHideTimerRef = useRef<number | undefined>(undefined);
|
const headerHideTimerRef = useRef<number | undefined>(undefined);
|
||||||
const tapStartRef = useRef<ReaderPoint | null>(null);
|
const tapStartRef = useRef<ReaderPoint | null>(null);
|
||||||
const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>());
|
const activeTouchPointsRef = useRef(new Map<number, ReaderPoint>());
|
||||||
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null);
|
const pinchRef = useRef<ReaderPinchGesture | null>(null);
|
||||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
||||||
const [headerVisible, setHeaderVisible] = useState(true);
|
const [headerVisible, setHeaderVisible] = useState(true);
|
||||||
@ -178,26 +236,35 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
if (!stage || !zoomControls) return;
|
if (!stage || !zoomControls) return;
|
||||||
|
|
||||||
const touchPoint = (touch: Touch): ReaderPoint => ({ x: touch.clientX, y: touch.clientY });
|
|
||||||
const startPinch = (event: TouchEvent) => {
|
const startPinch = (event: TouchEvent) => {
|
||||||
if (!isMobileReaderViewport() || event.touches.length !== 2) {
|
if (!isMobileReaderViewport() || event.touches.length !== 2) {
|
||||||
pinchRef.current = null;
|
pinchRef.current = null;
|
||||||
|
stage.removeAttribute("data-reader-pinch-active");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
|
event.preventDefault();
|
||||||
pinchRef.current = { distance, zoom: zoomControls.zoom };
|
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
|
||||||
|
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
|
||||||
|
stage.dataset.readerPinchActive = "true";
|
||||||
|
tapStartRef.current = null;
|
||||||
|
pinchRef.current = { distance, zoom: zoomControls.zoom, anchor: readerZoomAnchorAt(midpoint) };
|
||||||
};
|
};
|
||||||
const movePinch = (event: TouchEvent) => {
|
const movePinch = (event: TouchEvent) => {
|
||||||
if (!pinchRef.current || event.touches.length !== 2) return;
|
if (!pinchRef.current || event.touches.length !== 2) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
|
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
|
||||||
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance));
|
const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1]));
|
||||||
|
const anchor = pinchRef.current.anchor ? { ...pinchRef.current.anchor, clientX: midpoint.x, clientY: midpoint.y } : null;
|
||||||
|
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance), anchor ?? undefined);
|
||||||
|
if (anchor) scheduleReaderZoomAnchorRestore(stage, anchor);
|
||||||
};
|
};
|
||||||
const endPinch = (event: TouchEvent) => {
|
const endPinch = (event: TouchEvent) => {
|
||||||
if (event.touches.length < 2) pinchRef.current = null;
|
if (event.touches.length >= 2) return;
|
||||||
|
pinchRef.current = null;
|
||||||
|
stage.removeAttribute("data-reader-pinch-active");
|
||||||
};
|
};
|
||||||
|
|
||||||
stage.addEventListener("touchstart", startPinch, { passive: true });
|
stage.addEventListener("touchstart", startPinch, { passive: false });
|
||||||
stage.addEventListener("touchmove", movePinch, { passive: false });
|
stage.addEventListener("touchmove", movePinch, { passive: false });
|
||||||
stage.addEventListener("touchend", endPinch);
|
stage.addEventListener("touchend", endPinch);
|
||||||
stage.addEventListener("touchcancel", endPinch);
|
stage.addEventListener("touchcancel", endPinch);
|
||||||
|
|||||||
@ -222,11 +222,30 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
|
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unloaded CBZ vertical pages visually covered by a page-sized fallback", () => {
|
it("keeps CBZ vertical image loading on the rendered page elements", () => {
|
||||||
|
const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3");
|
||||||
|
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100");
|
||||||
|
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90");
|
||||||
|
expect(source).toContain('VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]');
|
||||||
|
expect(source).toContain("scheduler.queues.deep = []");
|
||||||
|
expect(source).toContain("!verticalUserScrollRef.current");
|
||||||
|
expect(source).toContain("preloadSchedulerRef");
|
||||||
|
expect(source).toContain("verticalImagePreloadPlan");
|
||||||
|
expect(source).toContain("new Image()");
|
||||||
|
expect(source).toContain('fetchPriority={item.page === currentPage ? "high" : "auto"}');
|
||||||
|
expect(source).toContain('loading={item.page === currentPage ? "eager" : "lazy"}');
|
||||||
|
expect(source).toContain("queueImageSize");
|
||||||
|
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unloaded PDF and CBZ vertical pages visually covered by a page-sized fallback", () => {
|
||||||
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(styles).toContain(".pdf-page-frame-vertical > canvas,\n.pdf-page-frame-vertical > .reader-fallback");
|
||||||
expect(styles).toContain(".comic-page-frame-vertical > img,\n.comic-page-frame-vertical > .reader-fallback");
|
expect(styles).toContain(".comic-page-frame-vertical > img,\n.comic-page-frame-vertical > .reader-fallback");
|
||||||
expect(styles).toContain(".comic-page-frame-vertical > .reader-fallback {\n width: 100%;");
|
expect(styles).toContain(".pdf-page-frame-vertical > .reader-fallback,\n.comic-page-frame-vertical > .reader-fallback {\n width: 100%;");
|
||||||
expect(styles).toContain("min-height: inherit");
|
expect(styles).toContain("min-height: inherit");
|
||||||
expect(styles).toContain("background: #f7f0df");
|
expect(styles).toContain("background: #f7f0df");
|
||||||
});
|
});
|
||||||
@ -259,6 +278,22 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
|
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
|
||||||
expect(source).toContain("readerPinchZoom");
|
expect(source).toContain("readerPinchZoom");
|
||||||
expect(source).toContain("zoomControls.onZoomChange");
|
expect(source).toContain("zoomControls.onZoomChange");
|
||||||
|
expect(source).toContain("data-reader-pinch-active");
|
||||||
|
expect(source).toContain("scheduleReaderZoomAnchorRestore");
|
||||||
|
expect(source).toContain("event.preventDefault()");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps mobile pinch zoom isolated from native zoom and vertical page commits", () => {
|
||||||
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
const shell = readFileSync(new URL("./ReaderShell.tsx", import.meta.url), "utf8");
|
||||||
|
const pdfReader = readFileSync(new URL("./PdfReader.tsx", import.meta.url), "utf8");
|
||||||
|
const cbzReader = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(styles).toContain("touch-action: pan-x pan-y");
|
||||||
|
expect(shell).toContain('stage.dataset.readerPinchActive = "true"');
|
||||||
|
expect(shell).toContain('stage.removeAttribute("data-reader-pinch-active")');
|
||||||
|
expect(pdfReader).toContain('if (stage.dataset.readerPinchActive === "true") return;');
|
||||||
|
expect(cbzReader).toContain('if (stage.dataset.readerPinchActive === "true") return;');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects fullscreen support and active fullscreen element", () => {
|
it("detects fullscreen support and active fullscreen element", () => {
|
||||||
|
|||||||
@ -161,9 +161,12 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.book-grid {
|
.book-grid {
|
||||||
|
--book-grid-column-min: 240px;
|
||||||
|
--book-grid-gap: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr));
|
||||||
gap: 16px;
|
gap: var(--book-grid-gap);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-card {
|
.book-card {
|
||||||
@ -237,6 +240,15 @@ h2 {
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-error-page,
|
||||||
|
.reader-missing-page {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
width: min(100%, 680px);
|
||||||
|
min-height: 60vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.book-card-description {
|
.book-card-description {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -356,6 +368,24 @@ h2 {
|
|||||||
background: rgba(169, 72, 52, 0.12);
|
background: rgba(169, 72, 52, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-page {
|
||||||
|
align-items: start;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
min-height: 176px;
|
||||||
|
border-color: rgba(247, 240, 223, 0.13);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(247, 240, 223, 0.08), rgba(247, 240, 223, 0.025)),
|
||||||
|
rgba(23, 17, 13, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero h1 {
|
||||||
|
max-width: 740px;
|
||||||
|
font-size: clamp(2.2rem, 5vw, 4.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
.continue-grid,
|
.continue-grid,
|
||||||
.library-list,
|
.library-list,
|
||||||
.job-list {
|
.job-list {
|
||||||
@ -363,6 +393,17 @@ h2 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.continue-grid,
|
||||||
|
.library-list {
|
||||||
|
--continue-visible-rows: 3;
|
||||||
|
--continue-tile-block-size: 112px;
|
||||||
|
height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
|
||||||
|
max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.job-list {
|
.job-list {
|
||||||
max-height: 350px;
|
max-height: 350px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -407,8 +448,95 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.continue-tile {
|
.continue-tile {
|
||||||
grid-template-columns: 52px minmax(0, 1fr);
|
block-size: var(--continue-tile-block-size);
|
||||||
align-items: start;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-list button {
|
||||||
|
block-size: var(--continue-tile-block-size);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-grid {
|
||||||
|
--book-grid-column-min: 190px;
|
||||||
|
--book-grid-gap: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr));
|
||||||
|
gap: var(--book-grid-gap);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 62px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(247, 240, 223, 0.12);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-card:hover {
|
||||||
|
border-color: rgba(213, 168, 77, 0.42);
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-cover {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(247, 240, 223, 0.14);
|
||||||
|
border-radius: 5px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(213, 168, 77, 0.16), rgba(45, 111, 99, 0.16)),
|
||||||
|
var(--paper-soft);
|
||||||
|
color: var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-copy strong,
|
||||||
|
.home-book-copy span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-copy strong {
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-copy > span {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-book-volume {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid rgba(213, 168, 77, 0.42);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #f5dfaa;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 900;
|
||||||
|
background: rgba(213, 168, 77, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.continue-cover {
|
.continue-cover {
|
||||||
@ -857,8 +985,54 @@ select {
|
|||||||
|
|
||||||
.profile-panel {
|
.profile-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
align-content: start;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity {
|
||||||
|
display: grid;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border: 1px solid rgba(247, 240, 223, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
color: var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity span:not(.profile-avatar) {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-page {
|
.reader-page {
|
||||||
@ -1023,6 +1197,7 @@ main.app-main-reader .reader-page {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-content {
|
.reader-content {
|
||||||
@ -1114,11 +1289,14 @@ main.app-main-reader .reader-page {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdf-page-frame-vertical > canvas,
|
||||||
|
.pdf-page-frame-vertical > .reader-fallback,
|
||||||
.comic-page-frame-vertical > img,
|
.comic-page-frame-vertical > img,
|
||||||
.comic-page-frame-vertical > .reader-fallback {
|
.comic-page-frame-vertical > .reader-fallback {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdf-page-frame-vertical > .reader-fallback,
|
||||||
.comic-page-frame-vertical > .reader-fallback {
|
.comic-page-frame-vertical > .reader-fallback {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: inherit;
|
min-height: inherit;
|
||||||
|
|||||||
20
docker-compose.build.yaml
Normal file
20
docker-compose.build.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
OCI_IMAGE_CREATED: ${OCI_IMAGE_CREATED:-local}
|
||||||
|
OCI_IMAGE_REVISION: ${OCI_IMAGE_REVISION:-local}
|
||||||
|
OCI_IMAGE_VERSION: ${READABOOK_TAG:-local}
|
||||||
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-local}
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
args:
|
||||||
|
OCI_IMAGE_CREATED: ${OCI_IMAGE_CREATED:-local}
|
||||||
|
OCI_IMAGE_REVISION: ${OCI_IMAGE_REVISION:-local}
|
||||||
|
OCI_IMAGE_VERSION: ${READABOOK_TAG:-local}
|
||||||
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-local}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
build:
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
@ -16,7 +14,7 @@ services:
|
|||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
|
- ${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@ -25,9 +23,7 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: apps/web/Dockerfile
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
41
scripts/publish.sh
Executable file
41
scripts/publish.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}"
|
||||||
|
TAG="${READABOOK_TAG:-latest}"
|
||||||
|
REVISION="${OCI_IMAGE_REVISION:-$(git rev-parse HEAD)}"
|
||||||
|
CREATED="${OCI_IMAGE_CREATED:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||||
|
PLATFORM="${DOCKER_BUILD_PLATFORM:-}"
|
||||||
|
|
||||||
|
build_args=(
|
||||||
|
--build-arg "OCI_IMAGE_CREATED=${CREATED}"
|
||||||
|
--build-arg "OCI_IMAGE_REVISION=${REVISION}"
|
||||||
|
--build-arg "OCI_IMAGE_VERSION=${TAG}"
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_args=()
|
||||||
|
if [[ -n "${PLATFORM}" ]]; then
|
||||||
|
platform_args=(--platform "${PLATFORM}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Publishing ReadaBook images to ${REGISTRY} with tag ${TAG}"
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
"${platform_args[@]}" \
|
||||||
|
"${build_args[@]}" \
|
||||||
|
--tag "${REGISTRY}/api:${TAG}" \
|
||||||
|
--push \
|
||||||
|
--file Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
"${platform_args[@]}" \
|
||||||
|
"${build_args[@]}" \
|
||||||
|
--tag "${REGISTRY}/web:${TAG}" \
|
||||||
|
--push \
|
||||||
|
--file apps/web/Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "Published:"
|
||||||
|
echo " ${REGISTRY}/api:${TAG}"
|
||||||
|
echo " ${REGISTRY}/web:${TAG}"
|
||||||
Reference in New Issue
Block a user