Compare commits

..

24 Commits

Author SHA1 Message Date
3faa7ca44b merge: fix/51-auth-status-unavailable dans develop (première connexion utilisable quand le statut d'authentification est indisponible — correctif #51) 2026-08-30 09:41:43 +02:00
d2557c14fa fix(web): login — première connexion utilisable quand le statut d'authentification est indisponible : l'appel de statut n'enrichit que l'écran déconnecté, l'erreur bloquante est retirée du catch, avec test de non-régression LoginPageAuthStatusUnavailable (bug #51) 2026-08-30 09:41:43 +02:00
7e6c0bdfab merge: feature/50-gitea-docker-packages dans develop (images Docker publiées sur le registry Gitea et consommées par compose — évolution #50) 2026-08-30 09:13:58 +02:00
2484fd1e09 feat(build): publier les images API+web sur le registry Gitea et faire consommer compose aux packages — compose tire ${READABOOK_REGISTRY}/api|web au lieu de builder, scripts/publish.sh (buildx --push, labels OCI), override docker-compose.build.yaml pour le build local, chemin bibliothèque re-paramétrable via READABOOK_LIBRARY_HOST_PATH — évolution #50 2026-08-30 09:13:58 +02:00
c17ea105a9 merge: develop dans main — release v1.0.0 (lecteur PDF/EPUB/CBZ avec zoom/pinch/modes, accueil refondu, pages d'erreur, profil clarifié — tickets #34 à #48) 2026-08-28 23:56:17 +02:00
af8ebef275 merge: feature/48-home-profile-polish dans develop (accueil allégé, grilles pleine largeur à 5 colonnes, bibliothèques scrollables, profil clarifié — évolution #48) 2026-08-28 23:53:15 +02:00
1bbf03d875 feat(web): accueil et profil — allègement du hero (textes descriptifs retirés), grilles de livres pleine largeur à 5 colonnes (auto-fit sans max-width), liste des bibliothèques scrollable (3 lignes visibles, partagée avec .continue-grid), et écran de profil clarifié (avatar, bloc identité, actions Modifier/Sortir — évolution #48) 2026-08-28 23:53:11 +02:00
8d5d1bc2ff merge: fix/47-home-continue-meter dans develop (barre d'avancement rétablie dans les livres en cours de l'accueil — correctif #47) 2026-08-28 23:36:15 +02:00
72f43c629d fix(web): accueil — rétablir la barre d'avancement dans les livres en cours (Meter avec progressPercent dans renderHomeBook, tuiles « en cours » pointant de nouveau vers le lecteur — correctif bug #47, régression de l'évolution #45) 2026-08-28 23:36:13 +02:00
28ee33bd0b merge: feature/46-home-grid-error-pages dans develop (grilles limitées à 5 colonnes, bandeau API retiré, pages d'erreur pour livres inexistants — évolution #46) 2026-08-28 23:29:08 +02:00
c2cdb07f9a feat(web): bibliothèque et lecteur — grilles de livres bornées à 5 colonnes (max-width calc sur .book-grid et .home-book-grid), retrait du bandeau « API absente ou incomplete » de l'accueil, et véritables pages d'erreur « Livre introuvable » sur fiche et lecteur (suppression de getApiFallback, retour à l'accueil + réessayer — évolution #46) 2026-08-28 23:29:03 +02:00
644a4b0183 merge: feature/45-home-redesign dans develop (refonte sobre de l'accueil : cartes simplifiées et livres en cours limités à 3 visibles — évolution #45) 2026-08-28 23:07:43 +02:00
11bb529322 feat(web): accueil — refonte sobre et moderne : hero resserré, cartes de livres simplifiées (renderHomeBook : couverture + titre + auteur, sans pill de métadonnées ni Meter) et liste des livres en cours limitée à 3 éléments visibles avant scroll (tuiles 112px — évolution #45) 2026-08-28 23:07:39 +02:00
cf8779781f docs(readme): documenter la remote Gitea et le modèle de branches
Ajout de l'URL de clone (gitea.anthonybouteiller.ovh/blomios/ReadaBook) et
d'une note sur le git-flow simplifié main/develop/feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 22:47:37 +02:00
452a4b6455 merge: feature/44-home-progress-scroll dans develop (liste des livres en cours scrollable après 6 éléments — évolution #44) 2026-08-28 22:41:04 +02:00
1d0b389c1e feat(web): accueil — rendre scrollable la liste des livres en cours après 6 éléments visibles (hauteur de .continue-grid bornée à 6 tuiles de 118px, overflow-y auto, scrollbar-gutter stable, tuiles à block-size fixe — évolution #44) 2026-08-28 22:40:59 +02:00
a9996cf15f merge: fix/43-reader-mobile-pinch-jump dans develop (stabilisation du pinch-zoom mobile : ancrage du point médian, gel du suivi vertical, blocage du zoom natif — correctif #43) 2026-08-28 19:08:39 +02:00
c4fe4d9de6 fix(web): lecteur mobile — stabiliser le pinch-zoom (ancrage du point médian sous les doigts avec restauration du scroll par page, gel du commit vertical via data-reader-pinch-active pendant le pincement, preventDefault sur touchstart et touch-action: pan-x pan-y pour bloquer le zoom natif — correctif bug #43) 2026-08-28 19:08:34 +02:00
01723c6eb7 merge: fix/42-reader-mobile-slow-load dans develop (préchargeur CBZ vertical priorisé, concurrency 3 + fenêtre profonde, fin de la saturation réseau Firefox mobile — 4e correctif #42) 2026-08-27 18:06:55 +02:00
7b3b687035 fix(web): lecteur mobile — remplacer le préchargeur CBZ vertical exhaustif (24 requêtes concurrentes sur tout le livre) par un planificateur à priorités (concurrence 3, délai 100ms, pages adjacentes + fenêtre profonde à +90, annulation par génération) et passer la page courante en fetchPriority high pour un chargement rapide des pages sur Firefox mobile (4e correctif bug #42) 2026-08-27 18:06:50 +02:00
9a5e78af72 merge: fix/42-reader-vertical-deep-pages dans develop (préchargeur CBZ vertical concurrent pour les pages profondes — 3e correctif #42) 2026-08-26 17:39:31 +02:00
03c8657df9 fix(web): lecteur mobile — précharger les pages CBZ verticales en profondeur via un préchargeur concurrent (batch RAF des tailles d'images, tracking resynchronisé au scroll) pour éliminer les black screens sur les pages lointaines (3e correctif bug #42) 2026-08-26 17:39:28 +02:00
5f63faa648 merge: fix/42-reader-vertical-black-screen-persistent dans develop (fallback page-sized étendu au PDF vertical mobile — 2e correctif #42) 2026-08-26 17:14:18 +02:00
ab77d129d3 fix(web): lecteur mobile — étendre le fallback page-sized aux frames PDF verticales non rendues (même cellule grid que le canvas, fond papier stable) pour éliminer les black screens d'environ 1s au scroll vertical PDF (2e correctif bug #42) 2026-08-26 17:14:11 +02:00
21 changed files with 828 additions and 106 deletions

View File

@ -1,5 +1,9 @@
# 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.
READABOOK_LIBRARY_HOST_PATH=./data/library

View File

@ -19,6 +19,15 @@ RUN pnpm --filter @readabook/api build
RUN pnpm --filter @readabook/api deploy --prod /prod
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 HOST=0.0.0.0
ENV PORT=3000

View File

@ -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.
## 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
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.
@ -37,7 +47,8 @@ data/
library/ Dossier local à scanner, monté en lecture seule dans compose
storage/ Cache backend, notamment couvertures extraites
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
```
@ -62,7 +73,7 @@ Note sandbox constatée : sur Node 24 local, `better-sqlite3` peut nécessiter u
## Docker Compose
```bash
JWT_SECRET="replace-me" docker compose up --build
JWT_SECRET="replace-me" docker compose up
```
Services :
@ -75,6 +86,24 @@ Ports :
- Hôte `3000` -> conteneur `web:80`.
- LAPI 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
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
@ -98,6 +127,8 @@ Volumes :
Variables principales :
- `READABOOK_REGISTRY` : préfixe registry/repository des images API et web.
- `READABOOK_TAG` : tag dimage utilisé par compose et par `scripts/publish.sh`.
- `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement distant.
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.

View File

@ -17,6 +17,15 @@ RUN pnpm --filter @readabook/shared build
RUN pnpm --filter @readabook/web build
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 --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80

View 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.");
});
});

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { api } from "../api/client";
import { cleanBookDescription } from "../book/description";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
@ -17,14 +17,17 @@ export function BookPage({ bookId }: { bookId: number }) {
setLoading(true);
setError(undefined);
try {
const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]);
const nextBook = await api.book(bookId);
setBook(nextBook);
setProgress(nextProgress);
} catch (loadError) {
const fallback = getApiFallback<BookDto>(loadError);
setBook(fallback ?? null);
try {
setProgress(await api.progress(bookId));
} catch {
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 {
setLoading(false);
}
@ -43,13 +46,20 @@ export function BookPage({ bookId }: { bookId: number }) {
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="book-error-page" role="alert">
<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()}>
<RotateCcw size={17} />
Reessayer
</button>
</div>
</Panel>
</section>
);
}

View File

@ -1,22 +1,19 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
import { BookCard } from "../components/BookCard";
import type { DashboardData } from "../api/types";
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router";
export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => {
if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs });
})
.catch(() => {
@ -28,15 +25,33 @@ export function HomePage() {
}, []);
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 (
<div className="page-grid">
<section className="hero-band">
<div className="page-grid home-page">
<section className="hero-band home-hero">
<div>
<p>Cabinet de curiosites numerique</p>
<h1>Ouvrir, classer, reprendre.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
<h1>Reprendre la lecture.</h1>
</div>
<button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} />
@ -46,30 +61,17 @@ export function HomePage() {
<Panel className="span-2">
<div className="section-heading">
<h2>Reprise de lecture</h2>
<span>{state.continueReading.length} traces</span>
<h2>Livres en cours</h2>
<span>{state.continueReading.length} lectures</span>
</div>
{state.continueReading.length ? (
<div className="continue-grid">
{state.continueReading.map((item) => {
const metadataState = bookMetadataState(item.book);
const publishedDate = displayPublishedDate(item.book.publishedDate);
const volumeLabel = bookVolumeLabel(item.book);
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>
);
})}
{state.continueReading.map((item) =>
renderHomeBook(item.book, "home-book-card continue-tile", {
href: `/reader/${item.book.id}`,
progressPercent: item.progress.percent
})
)}
</div>
) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
@ -91,10 +93,8 @@ export function HomePage() {
</div>
</Panel>
<section className="book-grid span-3">
{state.books.map((book) => (
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))}
<section className="home-book-grid span-3">
{state.books.map((book) => renderHomeBook(book))}
</section>
</div>
);

View 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");
});
});

View File

@ -24,7 +24,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
setEmail((current) => current || nextStatus.initialAdminEmail);
})
.catch(() => {
if (alive) setError("Statut d'authentification indisponible.");
// Auth status only enriches the unauthenticated screen; login must remain usable without it.
});
return () => {
alive = false;

View 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");
});
});

View File

@ -42,14 +42,24 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
return (
<div className="page-grid">
<Panel className="profile-panel">
<div className="profile-identity">
<span className="profile-avatar" aria-hidden="true">
<UserRound size={28} />
</span>
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<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}>
<LogOut size={17} />
Sortir
</button>
</div>
</Panel>
<Panel className="span-2">
<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>
<ErrorRibbon message={error} />
{success && <div className="success-ribbon">{success}</div>}
<form className="stack-form" onSubmit={updateSecurity}>
<form id="profile-security-form" className="stack-form" onSubmit={updateSecurity}>
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />

View 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");
});
});

View File

@ -1,16 +1,17 @@
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { api } from "../api/client";
import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
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 { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
const idleControls: ReaderControls = {
canPrevious: false,
@ -74,10 +75,9 @@ export function ReaderPage({ bookId }: { bookId: number }) {
setError(undefined);
try {
setBook(await api.book(bookId));
} catch (loadError) {
const fallback = getApiFallback<BookDto>(loadError);
setBook(fallback ?? null);
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
} catch {
setBook(null);
setError("Ce livre n'existe pas dans le catalogue.");
} finally {
setLoading(false);
}
@ -159,7 +159,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
setMode("horizontal");
saveReaderMode("horizontal");
}, [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 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;
@ -169,6 +169,14 @@ export function ReaderPage({ bookId }: { bookId: number }) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
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.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
});
@ -179,7 +187,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
supportsZoom
? {
zoom,
onZoomChange: (nextZoom) => changeZoom(nextZoom),
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
@ -198,6 +206,26 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[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 (
<ReaderShell
title={book?.title ?? "Ouverture du lecteur"}
@ -243,14 +271,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</div>
)}
>
{!book ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
<button className="ghost-button" onClick={() => void loadBook()}>
Reessayer
</button>
</div>
) : book.format === "pdf" ? (
{book.format === "pdf" ? (
<PdfReader
url={fileUrl}
page={page}

View File

@ -6,11 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
import type { ReaderControls } from "./ReaderShell";
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_STABLE_FRAMES = 18;
const VERTICAL_ANCHOR_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({
bookId,
@ -39,6 +102,10 @@ export function CbzReader({
const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = 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 [documentError, setDocumentError] = useState<string>();
const [pageError, setPageError] = useState<string>();
@ -91,9 +158,95 @@ export function CbzReader({
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
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(
(stage: HTMLElement, allowInitialCommit = false) => {
if (!pages || !verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect();
@ -192,8 +345,19 @@ export function CbzReader({
useEffect(() => {
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]);
useEffect(() => {
if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
}, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
useEffect(() => {
const frame = frameRef.current;
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
@ -264,6 +428,7 @@ export function CbzReader({
if (!stage) return;
let frameId = 0;
const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true;
};
const markUserScrollKey = (event: KeyboardEvent) => {
@ -275,6 +440,8 @@ export function CbzReader({
commitVisiblePage(stage);
};
const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage);
};
@ -297,6 +464,12 @@ export function CbzReader({
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
useEffect(
() => () => {
if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
},
[]
);
if (documentError) {
return (
@ -348,14 +521,13 @@ export function CbzReader({
<img
src={api.cbzPageUrl(bookId, item.page)}
alt={item.name}
loading="lazy"
fetchPriority={item.page === currentPage ? "high" : "auto"}
loading={item.page === currentPage ? "eager" : "lazy"}
style={verticalImageStyle(item.page)}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
setImageSizes((current) => ({
...current,
[item.page]: { width: naturalWidth, height: naturalHeight }
}));
preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
}}
onError={() => setPageError(`Page ${item.page} indisponible.`)}
/>

View File

@ -190,6 +190,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => {
if (!verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect();
@ -330,6 +331,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
if (!stage) return;
let frameId = 0;
const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true;
};
const markUserScrollKey = (event: KeyboardEvent) => {
@ -341,6 +343,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
commitVisiblePage(stage);
};
const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage);
};

View File

@ -25,7 +25,7 @@ export type ReaderControls = {
export type ReaderZoomControls = {
zoom: number;
onZoomChange: (zoom: number) => void;
onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void;
onZoomOut: () => void;
onZoomIn: () => void;
onZoomReset: () => void;
@ -47,6 +47,64 @@ type ReaderShellProps = {
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) {
const readerRef = useRef<HTMLDivElement>(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 tapStartRef = useRef<ReaderPoint | null>(null);
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 [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const [headerVisible, setHeaderVisible] = useState(true);
@ -178,26 +236,35 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
const stage = stageRef.current;
if (!stage || !zoomControls) return;
const touchPoint = (touch: Touch): ReaderPoint => ({ x: touch.clientX, y: touch.clientY });
const startPinch = (event: TouchEvent) => {
if (!isMobileReaderViewport() || event.touches.length !== 2) {
pinchRef.current = null;
stage.removeAttribute("data-reader-pinch-active");
return;
}
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
pinchRef.current = { distance, zoom: zoomControls.zoom };
event.preventDefault();
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) => {
if (!pinchRef.current || event.touches.length !== 2) return;
event.preventDefault();
const distance = readerPointDistance(touchPoint(event.touches[0]), touchPoint(event.touches[1]));
zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance));
const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]);
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) => {
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("touchend", endPinch);
stage.addEventListener("touchcancel", endPinch);

View File

@ -222,11 +222,30 @@ describe("reader runtime helpers", () => {
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");
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 > .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("background: #f7f0df");
});
@ -259,6 +278,22 @@ describe("reader runtime helpers", () => {
expect(source).toContain("onPointerUp={handleReaderPointerUp}");
expect(source).toContain("readerPinchZoom");
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", () => {

View File

@ -161,9 +161,12 @@ h2 {
}
.book-grid {
--book-grid-column-min: 240px;
--book-grid-gap: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
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%;
}
.book-card {
@ -237,6 +240,15 @@ h2 {
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 {
display: -webkit-box;
overflow: hidden;
@ -356,6 +368,24 @@ h2 {
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,
.library-list,
.job-list {
@ -363,6 +393,17 @@ h2 {
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 {
max-height: 350px;
overflow: auto;
@ -407,8 +448,95 @@ h2 {
}
.continue-tile {
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
block-size: var(--continue-tile-block-size);
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 {
@ -857,8 +985,54 @@ select {
.profile-panel {
display: grid;
gap: 10px;
align-content: start;
gap: 18px;
}
.profile-identity {
display: grid;
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 {
@ -1023,6 +1197,7 @@ main.app-main-reader .reader-page {
min-width: 0;
padding: 0;
overflow: auto;
touch-action: pan-x pan-y;
}
.reader-content {
@ -1114,11 +1289,14 @@ main.app-main-reader .reader-page {
padding: 0 12px;
}
.pdf-page-frame-vertical > canvas,
.pdf-page-frame-vertical > .reader-fallback,
.comic-page-frame-vertical > img,
.comic-page-frame-vertical > .reader-fallback {
grid-area: 1 / 1;
}
.pdf-page-frame-vertical > .reader-fallback,
.comic-page-frame-vertical > .reader-fallback {
width: 100%;
min-height: inherit;

20
docker-compose.build.yaml Normal file
View 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}

View File

@ -1,8 +1,6 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}
environment:
NODE_ENV: production
PORT: "3000"
@ -16,7 +14,7 @@ services:
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
volumes:
- ./data:/data
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
- ${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro
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))"]
interval: 10s
@ -25,9 +23,7 @@ services:
start_period: 10s
web:
build:
context: .
dockerfile: apps/web/Dockerfile
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}
depends_on:
api:
condition: service_healthy

41
scripts/publish.sh Executable file
View 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}"