Compare commits

...

13 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
17 changed files with 491 additions and 97 deletions

View File

@ -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

View File

@ -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

View File

@ -47,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
``` ```
@ -72,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 :
@ -85,6 +86,24 @@ Ports :
- Hôte `3000` -> conteneur `web:80`. - Hôte `3000` -> conteneur `web:80`.
- LAPI est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`. - 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 ## 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 :
@ -108,6 +127,8 @@ Volumes :
Variables principales : 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. - `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement distant. - `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement 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`.

View File

@ -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

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 { 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);
}
} catch {
setBook(null);
setProgress(null); setProgress(null);
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible."); 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 (
<Panel> <section className="book-error-page" role="alert">
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} /> <Panel>
<button className="ghost-button" onClick={() => void loadBook()}> <EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<RotateCcw size={17} /> <div className="book-card-actions">
Reessayer <button className="ghost-button" onClick={() => navigate("/home")}>
</button> Retour à l'accueil
</Panel> </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 { 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>
); );

View File

@ -2,17 +2,61 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("home progress list layout", () => { describe("home progress list layout", () => {
it("keeps the continue reading list scrollable after six visible books", () => { it("keeps the continue reading list scrollable after three visible books", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8"); const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const continueGrid = styles.match(/\.continue-grid\s*\{[^}]+\}/)?.[0] ?? "";
const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? ""; const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? "";
expect(continueGrid).toContain("--continue-visible-rows: 6"); expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(continueGrid).toContain("--continue-tile-block-size: 118px"); expect(styles).toContain("--continue-tile-block-size: 112px");
expect(continueGrid).toContain("max-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(continueGrid).toContain("overflow-y: auto"); expect(styles).toContain("overflow-y: auto");
expect(continueGrid).toContain("scrollbar-gutter: stable"); expect(styles).toContain("scrollbar-gutter: stable");
expect(continueTile).toContain("block-size: var(--continue-tile-block-size)"); expect(continueTile).toContain("block-size: var(--continue-tile-block-size)");
expect(continueTile).toContain("overflow: hidden"); 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); 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;

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 ( return (
<div className="page-grid"> <div className="page-grid">
<Panel className="profile-panel"> <Panel className="profile-panel">
<UserRound size={28} /> <div className="profile-identity">
<h1>{session.user?.name ?? "Lecteur invite"}</h1> <span className="profile-avatar" aria-hidden="true">
<p>{session.user?.email ?? "Session non connectee"}</p> <UserRound size={28} />
<span>{session.user?.role ?? "vitrine"}</span> </span>
<button className="ghost-button" onClick={logout}> <h1>{session.user?.name ?? "Lecteur invite"}</h1>
<LogOut size={17} /> <p>{session.user?.email ?? "Session non connectee"}</p>
Sortir <span>{session.user?.role ?? "vitrine"}</span>
</button> </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>
<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 />

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,6 +1,6 @@
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";
@ -11,6 +11,7 @@ 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);
} }
@ -206,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"}
@ -251,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}

View File

@ -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,9 +393,11 @@ h2 {
gap: 10px; gap: 10px;
} }
.continue-grid { .continue-grid,
--continue-visible-rows: 6; .library-list {
--continue-tile-block-size: 118px; --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))); max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
overflow-y: auto; overflow-y: auto;
padding-right: 4px; padding-right: 4px;
@ -416,12 +448,97 @@ h2 {
} }
.continue-tile { .continue-tile {
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
block-size: var(--continue-tile-block-size); block-size: var(--continue-tile-block-size);
overflow: hidden; 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 {
display: grid; display: grid;
place-items: center; place-items: center;
@ -868,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 {

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: 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
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}"