chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)

This commit is contained in:
Git Agent
2026-08-23 09:56:53 +02:00
commit 8f1140127f
79 changed files with 6456 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
coverage
.git
.ideai
data
.env
*.sqlite
*.sqlite-*

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
node_modules
dist
.env
.env.local
*.sqlite
*.sqlite-*
*.tsbuildinfo
data/storage
coverage
.pnpm-store
.ideai/

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM node:22-bookworm-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/package.json
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY packages packages
COPY apps/api apps/api
RUN pnpm --filter @readabook/shared build
RUN pnpm --filter @readabook/api build
RUN pnpm --filter @readabook/api deploy --prod /prod
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV DATABASE_PATH=/data/readabook.sqlite
ENV STORAGE_DIR=/data/storage
WORKDIR /app
COPY --from=build /prod ./
EXPOSE 3000
VOLUME ["/data", "/library"]
CMD ["node", "dist/main.js"]

171
README.md Normal file
View File

@ -0,0 +1,171 @@
# ReadaBook
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.
## Fonctionnalités MVP présentes
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.
- Base SQLite locale avec Drizzle, WAL, migrations idempotentes au démarrage et index FTS5.
- Auth locale : bootstrap du premier admin, login/logout, cookie JWT httpOnly, rôles `admin`/`user`.
- Administration : CRUD utilisateurs, CRUD bibliothèques, déclenchement de scan, consultation des jobs.
- Scan récursif de bibliothèques EPUB/PDF.
- Extraction locale pragmatique : métadonnées EPUB, métadonnées PDF simples, jaquettes EPUB quand présentes.
- Enrichissement opportuniste Open Library, désactivable.
- Catalogue : liste, recherche FTS, fiche livre, stream fichier, stream couverture.
- Progression utilisateur : sauvegarde dun locator/percent et étagère “continuer”.
- Frontend PWA Vite/React dans `apps/web`, installable en mode standalone et servi par Nginx en compose.
- Expérience web “cabinet de curiosités numérique” : login, bootstrap admin, accueil, bibliothèques, recherche, fiches livre, lecteur, profil et administration.
- Lecteur web : rendu PDF via `pdfjs-dist`, adaptateur EPUB `foliate-js`, reprise de progression via `/progress`.
## Stack actuelle
- Monorepo `pnpm`.
- `apps/api` : NestJS 11, Fastify, SQLite `better-sqlite3`, Drizzle ORM, FTS5, Argon2, JOSE JWT.
- `apps/web` : Vite, React, TypeScript, `lucide-react`, `pdfjs-dist`, `foliate-js`, service worker et manifeste PWA, Nginx runtime.
- `packages/shared` : contrats Zod partagés.
- Docker multi-stage Node 22 pour build/runtime API, Nginx pour web.
## Structure repo
```text
apps/
api/ Backend NestJS/Fastify
web/ Frontend Vite/React
packages/
shared/ Types et schémas Zod partagés
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
pnpm-workspace.yaml Workspace monorepo
```
## Développement local
```bash
corepack enable
pnpm install
pnpm dev
```
Scripts utiles :
- `pnpm dev` : lance API + web en parallèle.
- `pnpm dev:api` : backend seul.
- `pnpm dev:web` : frontend seul.
- `pnpm build` : build récursif.
- `pnpm test` : tests récursifs.
Note sandbox constatée : sur Node 24 local, `better-sqlite3` peut nécessiter un build natif. Le chemin Docker utilise Node 22 et a été validé.
## Docker Compose
```bash
JWT_SECRET="replace-me" docker compose up --build
```
Services :
- `api` : API interne sur `api:3000`, non publiée directement sur lhôte.
- `web` : Nginx + frontend, publié sur `http://localhost:3000`.
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`.
## Frontend PWA
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
- `/login` : connexion locale.
- `/setup/*` : bootstrap du premier administrateur.
- `/home` : accueil, reprise de lecture, bibliothèques et catalogue.
- `/library/:libraryId` : ouvrages dune bibliothèque.
- `/book/:bookId` : fiche livre.
- `/reader/:bookId` : lecture EPUB/PDF avec sauvegarde de progression.
- `/search` : recherche catalogue.
- `/me` : profil/session.
- `/admin/*` : bibliothèques, scans, jobs et comptes.
La PWA fournit `manifest.webmanifest`, `sw.js`, une icône maskable SVG et `display: standalone`. Les appels API passent par le même origin en compose via Nginx, ce qui conserve les cookies httpOnly de session.
Volumes :
- `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`.
- `./data/library:/library:ro` : bibliothèque locale scannée en lecture seule.
Variables principales :
- `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement distant.
- `DATABASE_PATH=/data/readabook.sqlite`
- `STORAGE_DIR=/data/storage`
## Bootstrap admin
Après démarrage compose :
```bash
curl -c cookies.txt \
-H "content-type: application/json" \
-d '{"email":"admin@example.com","password":"password123","name":"Admin"}' \
http://localhost:3000/auth/bootstrap
```
Connexion :
```bash
curl -c cookies.txt -b cookies.txt \
-H "content-type: application/json" \
-d '{"email":"admin@example.com","password":"password123"}' \
http://localhost:3000/auth/login
```
## Ajouter une bibliothèque `/library`
Place les fichiers EPUB/PDF dans `data/library` côté hôte, puis déclare le volume monté dans le conteneur :
```bash
curl -b cookies.txt \
-H "content-type: application/json" \
-d '{"name":"Bibliothèque locale","path":"/library","enabled":true}' \
http://localhost:3000/admin/libraries
```
## Lancer un scan
```bash
curl -b cookies.txt -X POST http://localhost:3000/admin/libraries/1/scan
curl -b cookies.txt http://localhost:3000/admin/jobs
curl -b cookies.txt http://localhost:3000/books
```
## Endpoints principaux
- `GET /healthz`
- `POST /auth/bootstrap`
- `POST /auth/login`
- `POST /auth/logout`
- `GET /auth/me`
- `GET|POST /admin/users`
- `GET|POST|PATCH|DELETE /admin/libraries`
- `POST /admin/libraries/:id/scan`
- `GET /admin/jobs`
- `GET /books`
- `GET /books/search?q=term`
- `GET /books/:id`
- `GET /books/:id/file`
- `GET /books/:id/cover`
- `GET|PUT /progress/:bookId`
- `GET /progress/continue`
## Limites connues
- Extraction PDF limitée aux champs metadata simples, sans OCR ni parsing complet.
- Extraction EPUB correcte pour les cas standards, pas exhaustive sur tous les EPUB malformés.
- Open Library est opportuniste : échec silencieux en cas derreur réseau ou de rate limit.
- Jobs in-process : pas de worker externe, pas de reprise fine dun scan interrompu.
- Pas encore de migrations versionnées Drizzle Kit ; migrations SQL idempotentes exécutées au démarrage.
- LAPI nest pas exposée directement en compose ; elle passe par le reverse proxy Nginx du service web.

40
apps/api/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "@readabook/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@nestjs/common": "^11.0.7",
"@nestjs/core": "^11.0.7",
"@nestjs/platform-fastify": "^11.0.7",
"@readabook/shared": "workspace:*",
"adm-zip": "^0.5.16",
"argon2": "^0.41.1",
"better-sqlite3": "^11.8.1",
"drizzle-orm": "^0.39.3",
"fast-xml-parser": "^4.5.1",
"fastify": "^5.2.1",
"jose": "^5.9.6",
"mime-types": "^2.1.35",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/better-sqlite3": "^7.6.12",
"@types/mime-types": "^2.1.4",
"tsx": "^4.19.2",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,83 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import {
CreateLibraryDto,
CreateLibrarySchema,
CreateUserDto,
CreateUserSchema,
UpdateLibraryDto,
UpdateLibrarySchema,
UpdateUserDto,
UpdateUserSchema
} from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { Roles } from "../auth/roles.decorator.js";
import { RolesGuard } from "../auth/roles.guard.js";
import { AuthService } from "../auth/auth.service.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { JobsService } from "../jobs/jobs.service.js";
import { LibrariesService } from "../libraries/libraries.service.js";
import { ScannerService } from "../scanner/scanner.service.js";
@Controller("admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
export class AdminController {
constructor(
private readonly auth: AuthService,
private readonly libraries: LibrariesService,
private readonly jobs: JobsService,
private readonly scanner: ScannerService
) {}
@Get("users")
users() {
return this.auth.listUsers();
}
@Post("users")
createUser(@Body(new ZodValidationPipe(CreateUserSchema)) body: CreateUserDto) {
return this.auth.createUser(body);
}
@Patch("users/:id")
updateUser(@Param("id") id: string, @Body(new ZodValidationPipe(UpdateUserSchema)) body: UpdateUserDto) {
return this.auth.updateUser(Number(id), body);
}
@Delete("users/:id")
deleteUser(@Param("id") id: string) {
this.auth.deleteUser(Number(id));
return { ok: true };
}
@Get("libraries")
listLibraries() {
return this.libraries.list();
}
@Post("libraries")
createLibrary(@Body(new ZodValidationPipe(CreateLibrarySchema)) body: CreateLibraryDto) {
return this.libraries.create(body);
}
@Patch("libraries/:id")
updateLibrary(@Param("id") id: string, @Body(new ZodValidationPipe(UpdateLibrarySchema)) body: UpdateLibraryDto) {
return this.libraries.update(Number(id), body);
}
@Delete("libraries/:id")
deleteLibrary(@Param("id") id: string) {
this.libraries.delete(Number(id));
return { ok: true };
}
@Post("libraries/:id/scan")
scanLibrary(@Param("id") id: string) {
return this.scanner.enqueueLibraryScan(Number(id));
}
@Get("jobs")
listJobs() {
return this.jobs.list();
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js";
import { LibrariesService } from "../libraries/libraries.service.js";
import { ScannerModule } from "../scanner/scanner.module.js";
import { AdminController } from "./admin.controller.js";
@Module({
imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule],
controllers: [AdminController],
providers: [LibrariesService]
})
export class AdminModule {}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AdminModule } from "./admin/admin.module.js";
import { AuthModule } from "./auth/auth.module.js";
import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js";
import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js";
@Module({
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule],
controllers: [HealthController]
})
export class AppModule {}

View File

@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
import "@fastify/cookie";
import { FastifyReply } from "fastify";
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { CurrentUser, CurrentUserParam } from "./current-user.js";
@Controller("auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("bootstrap")
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
return this.auth.bootstrapAdmin(body);
}
@Post("login")
async login(@Body(new ZodValidationPipe(LoginSchema)) body: LoginDto, @Res({ passthrough: true }) reply: FastifyReply) {
const result = await this.auth.login(body);
reply.setCookie(this.auth.cookieName, result.token, {
httpOnly: true,
sameSite: "lax",
secure: this.auth.cookieSecure,
path: "/",
maxAge: 7 * 24 * 60 * 60
});
return { user: result.user };
}
@Post("logout")
logout(@Res({ passthrough: true }) reply: FastifyReply) {
reply.clearCookie(this.auth.cookieName, { path: "/" });
return { ok: true };
}
@Get("me")
@UseGuards(AuthGuard)
me(@CurrentUserParam() user: CurrentUser) {
return { user };
}
}

View File

@ -0,0 +1,17 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "./auth.service.js";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.cookies?.[this.auth.cookieName];
if (!token) {
throw new UnauthorizedException("Authentication required");
}
request.user = await this.auth.verifySession(token);
return true;
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { AuthController } from "./auth.controller.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { RolesGuard } from "./roles.guard.js";
@Module({
imports: [DatabaseModule],
controllers: [AuthController],
providers: [AuthService, AuthGuard, RolesGuard],
exports: [AuthService, AuthGuard, RolesGuard]
})
export class AuthModule {}

View File

@ -0,0 +1,128 @@
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { SignJWT, jwtVerify } from "jose";
import argon2 from "argon2";
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { users } from "../database/schema.js";
import { CurrentUser } from "./current-user.js";
@Injectable()
export class AuthService {
readonly cookieName: string;
private readonly secret: Uint8Array;
constructor(private readonly database: DatabaseService) {
this.cookieName = database.config.cookieName;
this.secret = new TextEncoder().encode(database.config.jwtSecret);
}
get cookieSecure(): boolean {
return this.database.config.cookieSecure;
}
async bootstrapAdmin(input: BootstrapAdminDto) {
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
if (existing) {
throw new ConflictException("Bootstrap already completed");
}
return this.createUser({ ...input, role: "admin" });
}
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
throw new UnauthorizedException("Invalid credentials");
}
const sessionUser = { id: user.id, email: user.email, role: user.role };
return { token: await this.signSession(sessionUser), user: sessionUser };
}
async verifySession(token: string): Promise<CurrentUser> {
try {
const { payload } = await jwtVerify(token, this.secret);
const id = Number(payload.sub);
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
if (!user) {
throw new UnauthorizedException("Invalid session");
}
return { id: user.id, email: user.email, role: user.role };
} catch {
throw new UnauthorizedException("Invalid session");
}
}
listUsers() {
return this.database.db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.from(users)
.all();
}
async createUser(input: CreateUserDto) {
const now = this.database.now();
const passwordHash = await argon2.hash(input.password);
try {
const user = this.database.db
.insert(users)
.values({
email: input.email.toLowerCase(),
name: input.name ?? null,
passwordHash,
role: input.role,
createdAt: now,
updatedAt: now
})
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
return user;
} catch (error) {
throw new ConflictException("Email already exists");
}
}
async updateUser(id: number, input: UpdateUserDto) {
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
if (input.email) values.email = input.email.toLowerCase();
if (input.name !== undefined) values.name = input.name;
if (input.role) values.role = input.role;
if (input.password) values.passwordHash = await argon2.hash(input.password);
return this.database.db
.update(users)
.set(values)
.where(eq(users.id, id))
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
}
deleteUser(id: number): void {
this.database.db.delete(users).where(eq(users.id, id)).run();
}
private async signSession(user: CurrentUser): Promise<string> {
return new SignJWT({ email: user.email, role: user.role })
.setProtectedHeader({ alg: "HS256" })
.setSubject(String(user.id))
.setIssuedAt()
.setExpirationTime("7d")
.sign(this.secret);
}
}

View File

@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export type CurrentUser = {
id: number;
email: string;
role: "admin" | "user";
};
export const CurrentUserParam = createParamDecorator((_data: unknown, ctx: ExecutionContext): CurrentUser => {
const request = ctx.switchToHttp().getRequest();
return request.user;
});

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: Array<"admin" | "user">) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./roles.decorator.js";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Array<"admin" | "user">>(ROLES_KEY, [
context.getHandler(),
context.getClass()
]);
if (!roles?.length) {
return true;
}
const user = context.switchToHttp().getRequest().user;
if (user && roles.includes(user.role)) {
return true;
}
throw new ForbiddenException("Insufficient role");
}
}

View File

@ -0,0 +1,43 @@
import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common";
import { FastifyReply } from "fastify";
import { lookup } from "mime-types";
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { BooksService } from "./books.service.js";
@Controller("books")
@UseGuards(AuthGuard)
export class BooksController {
constructor(private readonly books: BooksService) {}
@Get()
list(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return this.books.list(query);
}
@Get("search")
search(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return query.q ? this.books.search(query.q, query.limit, query.offset) : [];
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.get(Number(id));
}
@Get(":id/file")
file(@Param("id") id: string, @Res() reply: FastifyReply) {
const { book, stream } = this.books.streamFile(Number(id));
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream");
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`);
return reply.send(stream);
}
@Get(":id/cover")
cover(@Param("id") id: string, @Res() reply: FastifyReply) {
const { coverPath, stream } = this.books.streamCover(Number(id));
reply.header("Content-Type", lookup(coverPath) || "image/jpeg");
return reply.send(stream);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { BooksController } from "./books.controller.js";
import { BooksService } from "./books.service.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [BooksController],
providers: [BooksService],
exports: [BooksService]
})
export class BooksModule {}

View File

@ -0,0 +1,97 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync } from "node:fs";
import { and, eq, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books } from "../database/schema.js";
@Injectable()
export class BooksService {
constructor(private readonly database: DatabaseService) {}
list(query: BookQueryDto) {
const filters = [];
if (query.format) filters.push(eq(books.format, query.format));
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
if (query.q) {
return this.search(query.q, query.limit, query.offset);
}
return this.database.db
.select()
.from(books)
.where(filters.length ? and(...filters) : undefined)
.orderBy(books.title)
.limit(query.limit)
.offset(query.offset)
.all();
}
search(q: string, limit = 50, offset = 0) {
const rows = this.database.sqlite
.prepare(
`
SELECT books.*
FROM book_fts
JOIN books ON books.id = book_fts.rowid
WHERE book_fts MATCH ?
ORDER BY bm25(book_fts)
LIMIT ? OFFSET ?
`
)
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
}
get(id: number) {
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
return book;
}
streamFile(id: number) {
const book = this.get(id);
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");
}
return { book, stream: createReadStream(book.filePath) };
}
streamCover(id: number) {
const book = this.get(id);
if (!book.coverPath || !existsSync(book.coverPath)) {
throw new NotFoundException("Cover not found");
}
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
}
count() {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
}
}
function mapBookRow(row: Record<string, unknown>) {
return {
id: Number(row.id),
libraryId: Number(row.library_id),
title: String(row.title),
author: nullable(row.author),
description: nullable(row.description),
isbn: nullable(row.isbn),
language: nullable(row.language),
publisher: nullable(row.publisher),
publishedDate: nullable(row.published_date),
format: row.format,
filePath: String(row.file_path),
coverPath: nullable(row.cover_path),
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
}
function nullable(value: unknown): string | null {
return value === null || value === undefined ? null : String(value);
}

View File

@ -0,0 +1,18 @@
import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
import { ZodSchema } from "zod";
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: "Validation failed",
issues: result.error.issues
});
}
return result.data;
}
}

View File

@ -0,0 +1,34 @@
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
export type AppConfig = {
nodeEnv: string;
host: string;
port: number;
databasePath: string;
storageDir: string;
jwtSecret: string;
cookieName: string;
cookieSecure: boolean;
openLibraryEnabled: boolean;
};
export function loadConfig(): AppConfig {
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage");
mkdirSync(dirname(databasePath), { recursive: true });
mkdirSync(storageDir, { recursive: true });
return {
nodeEnv: process.env.NODE_ENV ?? "development",
host: process.env.HOST ?? "0.0.0.0",
port: Number(process.env.PORT ?? 3000),
databasePath,
storageDir,
jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
cookieSecure: process.env.COOKIE_SECURE === "true",
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false"
};
}

View File

@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DatabaseService } from "./database.service.js";
@Module({
providers: [DatabaseService],
exports: [DatabaseService]
})
export class DatabaseModule {}

View File

@ -0,0 +1,124 @@
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { AppConfig, loadConfig } from "../config/env.js";
import * as schema from "./schema.js";
@Injectable()
export class DatabaseService implements OnModuleDestroy {
readonly config: AppConfig;
readonly sqlite: Database.Database;
readonly db: BetterSQLite3Database<typeof schema>;
constructor() {
this.config = loadConfig();
this.sqlite = new Database(this.config.databasePath);
this.sqlite.pragma("journal_mode = WAL");
this.sqlite.pragma("foreign_keys = ON");
this.sqlite.pragma("busy_timeout = 5000");
this.db = drizzle(this.sqlite, { schema });
this.migrate();
}
onModuleDestroy(): void {
this.sqlite.close();
}
now(): string {
return new Date().toISOString();
}
private migrate(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')),
detail TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
title,
author,
description,
isbn,
content='books',
content_rowid='id'
);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
}

View File

@ -0,0 +1,77 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
name: text("name"),
passwordHash: text("password_hash").notNull(),
role: text("role", { enum: ["admin", "user"] }).notNull().default("user"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ emailIdx: uniqueIndex("users_email_unique").on(table.email) })
);
export const libraries = sqliteTable("libraries", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
path: text("path").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const books = sqliteTable(
"books",
{
id: integer("id").primaryKey({ autoIncrement: true }),
libraryId: integer("library_id")
.notNull()
.references(() => libraries.id, { onDelete: "cascade" }),
title: text("title").notNull(),
author: text("author"),
description: text("description"),
isbn: text("isbn"),
language: text("language"),
publisher: text("publisher"),
publishedDate: text("published_date"),
format: text("format", { enum: ["epub", "pdf"] }).notNull(),
filePath: text("file_path").notNull(),
coverPath: text("cover_path"),
fileSize: integer("file_size").notNull(),
fileMtime: text("file_mtime").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
);
export const progress = sqliteTable(
"progress",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
bookId: integer("book_id")
.notNull()
.references(() => books.id, { onDelete: "cascade" }),
locator: text("locator").notNull(),
percent: integer("percent").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ userBookIdx: uniqueIndex("progress_user_book_unique").on(table.userId, table.bookId) })
);
export const jobs = sqliteTable("jobs", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").notNull(),
status: text("status", { enum: ["queued", "running", "succeeded", "failed"] }).notNull(),
detail: text("detail"),
error: text("error"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});

View File

@ -0,0 +1,22 @@
import { Controller, Get } from "@nestjs/common";
import { BooksService } from "./books/books.service.js";
import { DatabaseService } from "./database/database.service.js";
@Controller()
export class HealthController {
constructor(
private readonly database: DatabaseService,
private readonly books: BooksService
) {}
@Get("healthz")
healthz() {
this.database.sqlite.prepare("SELECT 1").get();
return {
status: "ok",
database: "ok",
books: this.books.count(),
timestamp: this.database.now()
};
}
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { JobsService } from "./jobs.service.js";
@Module({
imports: [DatabaseModule],
providers: [JobsService],
exports: [JobsService]
})
export class JobsModule {}

View File

@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { jobs } from "../database/schema.js";
@Injectable()
export class JobsService {
constructor(private readonly database: DatabaseService) {}
create(type: string, detail?: string) {
const now = this.database.now();
return this.database.db
.insert(jobs)
.values({ type, status: "queued", detail: detail ?? null, error: null, createdAt: now, updatedAt: now })
.returning()
.get();
}
markRunning(id: number, detail?: string): void {
this.database.db
.update(jobs)
.set({ status: "running", detail: detail ?? null, updatedAt: this.database.now() })
.where(eq(jobs.id, id))
.run();
}
markSucceeded(id: number, detail?: string): void {
this.database.db
.update(jobs)
.set({ status: "succeeded", detail: detail ?? null, error: null, updatedAt: this.database.now() })
.where(eq(jobs.id, id))
.run();
}
markFailed(id: number, error: unknown): void {
this.database.db
.update(jobs)
.set({ status: "failed", error: error instanceof Error ? error.message : String(error), updatedAt: this.database.now() })
.where(eq(jobs.id, id))
.run();
}
list(limit = 50) {
return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all();
}
}

View File

@ -0,0 +1,65 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { accessSync, constants, realpathSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { libraries } from "../database/schema.js";
@Injectable()
export class LibrariesService {
constructor(private readonly database: DatabaseService) {}
list() {
return this.database.db.select().from(libraries).all();
}
get(id: number) {
const library = this.database.db.select().from(libraries).where(eq(libraries.id, id)).get();
if (!library) {
throw new NotFoundException("Library not found");
}
return library;
}
create(input: CreateLibraryDto) {
const path = this.validatePath(input.path);
const now = this.database.now();
return this.database.db
.insert(libraries)
.values({ name: input.name, path, enabled: input.enabled, createdAt: now, updatedAt: now })
.returning()
.get();
}
update(id: number, input: UpdateLibraryDto) {
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
if (input.name) values.name = input.name;
if (input.path) values.path = this.validatePath(input.path);
if (input.enabled !== undefined) values.enabled = input.enabled;
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
if (!library) {
throw new NotFoundException("Library not found");
}
return library;
}
delete(id: number): void {
this.database.db.delete(libraries).where(eq(libraries.id, id)).run();
}
private validatePath(input: string): string {
const resolved = resolve(input);
try {
accessSync(resolved, constants.R_OK);
const stats = statSync(resolved);
if (!stats.isDirectory()) {
throw new BadRequestException("Library path must be a directory");
}
return realpathSync(resolved);
} catch (error) {
if (error instanceof BadRequestException) throw error;
throw new BadRequestException("Library path is not readable");
}
}
}

20
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,20 @@
import "reflect-metadata";
import cookie from "@fastify/cookie";
import "@fastify/cookie";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import { AppModule } from "./app.module.js";
import { loadConfig } from "./config/env.js";
async function bootstrap() {
const config = loadConfig();
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter({ logger: true }));
await app.register(cookie as never, { secret: config.jwtSecret });
app.enableCors({
origin: true,
credentials: true
});
await app.listen(config.port, config.host);
}
void bootstrap();

View File

@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
import { UpdateProgressDto, UpdateProgressSchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { ProgressService } from "./progress.service.js";
@Controller("progress")
@UseGuards(AuthGuard)
export class ProgressController {
constructor(private readonly progress: ProgressService) {}
@Get("continue")
continue(@CurrentUserParam() user: CurrentUser) {
return this.progress.continueReading(user.id);
}
@Get(":bookId")
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
return this.progress.get(user.id, Number(bookId));
}
@Put(":bookId")
update(
@CurrentUserParam() user: CurrentUser,
@Param("bookId") bookId: string,
@Body(new ZodValidationPipe(UpdateProgressSchema)) body: UpdateProgressDto
) {
return this.progress.upsert(user.id, Number(bookId), body);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { ProgressController } from "./progress.controller.js";
import { ProgressService } from "./progress.service.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [ProgressController],
providers: [ProgressService]
})
export class ProgressModule {}

View File

@ -0,0 +1,67 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq, sql } from "drizzle-orm";
import { UpdateProgressDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, progress } from "../database/schema.js";
@Injectable()
export class ProgressService {
constructor(private readonly database: DatabaseService) {}
upsert(userId: number, bookId: number, input: UpdateProgressDto) {
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
const now = this.database.now();
this.database.sqlite
.prepare(
`
INSERT INTO progress(user_id, book_id, locator, percent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = excluded.locator,
percent = excluded.percent,
updated_at = excluded.updated_at
`
)
.run(userId, bookId, input.locator, Math.round(input.percent), now, now);
return this.get(userId, bookId);
}
get(userId: number, bookId: number) {
const row = this.database.db
.select({
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
updatedAt: progress.updatedAt
})
.from(progress)
.where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`)
.get();
if (!row) {
throw new NotFoundException("Progress not found");
}
return row;
}
continueReading(userId: number) {
return this.database.db
.select({
book: books,
progress: {
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
updatedAt: progress.updatedAt
}
})
.from(progress)
.innerJoin(books, eq(progress.bookId, books.id))
.where(eq(progress.userId, userId))
.orderBy(desc(progress.updatedAt))
.limit(20)
.all();
}
}

View File

@ -0,0 +1,18 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { extractMetadata } from "./metadata.js";
describe("pdf metadata extraction", () => {
it("falls back to file name and reads simple PDF info fields", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Example.pdf");
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
const metadata = extractMetadata(file, dir);
expect(metadata.title).toBe("My Book");
expect(metadata.author).toBe("Ada");
});
});

View File

@ -0,0 +1,149 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser";
export type BookMetadata = {
title: string;
author: string | null;
description: string | null;
isbn: string | null;
language: string | null;
publisher: string | null;
publishedDate: string | null;
coverPath: string | null;
};
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
textNodeName: "#text"
});
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") {
return extractEpubMetadata(filePath, storageDir);
}
return extractPdfMetadata(filePath);
}
function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata {
const zip = new AdmZip(filePath);
const containerXml = zip.readAsText("META-INF/container.xml");
const container = xmlParser.parse(containerXml);
const rootfile = container?.container?.rootfiles?.rootfile;
const opfPath = Array.isArray(rootfile) ? rootfile[0]?.["@_full-path"] : rootfile?.["@_full-path"];
if (!opfPath) {
return fallbackMetadata(filePath);
}
const opf = xmlParser.parse(zip.readAsText(opfPath));
const metadata = opf?.package?.metadata ?? {};
const manifest = opf?.package?.manifest?.item;
const opfDir = dirname(opfPath) === "." ? "" : dirname(opfPath);
const title = firstText(metadata["dc:title"]) ?? basename(filePath, extname(filePath));
const author = firstText(metadata["dc:creator"]);
const isbn = findIsbn(metadata["dc:identifier"]);
const coverHref = findCoverHref(manifest, metadata.meta);
const coverPath = coverHref ? extractCover(zip, join(opfDir, coverHref), filePath, storageDir) : null;
return {
title,
author,
description: firstText(metadata["dc:description"]),
isbn,
language: firstText(metadata["dc:language"]),
publisher: firstText(metadata["dc:publisher"]),
publishedDate: firstText(metadata["dc:date"]),
coverPath
};
}
function extractPdfMetadata(filePath: string): BookMetadata {
const buffer = readFileSync(filePath);
const head = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString("latin1");
const title = decodePdfString(matchPdfInfo(head, "Title")) ?? basename(filePath, extname(filePath));
const author = decodePdfString(matchPdfInfo(head, "Author"));
return {
title,
author,
description: decodePdfString(matchPdfInfo(head, "Subject")),
isbn: findIsbnInText(head),
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
}
function fallbackMetadata(filePath: string): BookMetadata {
return {
title: basename(filePath, extname(filePath)),
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
}
function firstText(value: unknown): string | null {
if (!value) return null;
const first = Array.isArray(value) ? value[0] : value;
if (typeof first === "string") return first.trim() || null;
if (typeof first === "object" && first !== null && "#text" in first) {
const text = String((first as Record<string, unknown>)["#text"]).trim();
return text || null;
}
return null;
}
function findIsbn(value: unknown): string | null {
const values = Array.isArray(value) ? value : value ? [value] : [];
for (const candidate of values) {
const text = firstText(candidate);
const isbn = text ? findIsbnInText(text) : null;
if (isbn) return isbn;
}
return null;
}
function findIsbnInText(text: string): string | null {
const match = text.match(/(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dX]/i);
return match ? match[0].replace(/[-\s]/g, "").toUpperCase() : null;
}
function findCoverHref(manifestValue: unknown, metaValue: unknown): string | null {
const manifest = Array.isArray(manifestValue) ? manifestValue : manifestValue ? [manifestValue] : [];
const metas = Array.isArray(metaValue) ? metaValue : metaValue ? [metaValue] : [];
const coverId = metas.find((meta) => meta?.["@_name"] === "cover")?.["@_content"];
const cover =
manifest.find((item) => coverId && item?.["@_id"] === coverId) ??
manifest.find((item) => String(item?.["@_properties"] ?? "").includes("cover-image")) ??
manifest.find((item) => String(item?.["@_media-type"] ?? "").startsWith("image/"));
return cover?.["@_href"] ?? null;
}
function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, storageDir: string): string | null {
const entry = zip.getEntry(coverPathInZip.replace(/\\/g, "/"));
if (!entry) return null;
const extension = extname(entry.entryName) || ".jpg";
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
const target = join(storageDir, "covers", `${hash}${extension}`);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, entry.getData());
return target;
}
function matchPdfInfo(text: string, key: string): string | null {
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
}
function decodePdfString(value: string | null): string | null {
if (!value) return null;
return value.replace(/\\([()\\])/g, "$1").trim() || null;
}

View File

@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { BookMetadata } from "./metadata.js";
@Injectable()
export class OpenLibraryService {
async enrich(metadata: BookMetadata): Promise<Partial<BookMetadata>> {
const query = metadata.isbn
? `isbn:${encodeURIComponent(metadata.isbn)}`
: `title:${encodeURIComponent(metadata.title)}`;
const response = await fetch(`https://openlibrary.org/search.json?q=${query}&limit=1`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
signal: AbortSignal.timeout(4000)
});
if (!response.ok) {
return {};
}
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
const doc = data.docs?.[0];
if (!doc) return {};
return {
author: metadata.author ?? firstArrayValue(doc.author_name),
language: metadata.language ?? firstArrayValue(doc.language),
publisher: metadata.publisher ?? firstArrayValue(doc.publisher),
publishedDate: metadata.publishedDate ?? (String(doc.first_publish_year ?? "") || null),
isbn: metadata.isbn ?? firstArrayValue(doc.isbn)
};
}
}
function firstArrayValue(value: unknown): string | null {
if (!Array.isArray(value) || !value.length) return null;
return String(value[0]);
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js";
import { OpenLibraryService } from "./open-library.service.js";
import { ScannerService } from "./scanner.service.js";
@Module({
imports: [DatabaseModule, JobsModule],
providers: [ScannerService, OpenLibraryService],
exports: [ScannerService]
})
export class ScannerModule {}

View File

@ -0,0 +1,91 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { readdirSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { extractMetadata } from "./metadata.js";
import { OpenLibraryService } from "./open-library.service.js";
@Injectable()
export class ScannerService {
constructor(
private readonly database: DatabaseService,
private readonly jobs: JobsService,
private readonly openLibrary: OpenLibraryService
) {}
enqueueLibraryScan(libraryId: number) {
const library = this.database.db.select().from(libraries).where(eq(libraries.id, libraryId)).get();
if (!library) {
throw new NotFoundException("Library not found");
}
const job = this.jobs.create("library-scan", `Scanning ${library.path}`);
setImmediate(() => {
void this.scanLibrary(job.id, library).catch((error) => this.jobs.markFailed(job.id, error));
});
return job;
}
private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
this.jobs.markRunning(jobId, `Scanning ${library.path}`);
let count = 0;
for (const filePath of walkBooks(library.path)) {
await this.ingestFile(library.id, filePath);
count += 1;
}
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`);
}
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
const stats = statSync(filePath);
let metadata = extractMetadata(filePath, this.database.config.storageDir);
if (this.database.config.openLibraryEnabled) {
try {
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
} catch {
// Remote enrichment is opportunistic; local ingestion must stay deterministic.
}
}
const now = this.database.now();
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
const values = {
libraryId,
title: metadata.title,
author: metadata.author,
description: metadata.description,
isbn: metadata.isbn,
language: metadata.language,
publisher: metadata.publisher,
publishedDate: metadata.publishedDate,
format,
filePath,
coverPath: metadata.coverPath,
fileSize: stats.size,
fileMtime: stats.mtime.toISOString(),
updatedAt: now
};
existing
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
}
}
function* walkBooks(root: string): Generator<string> {
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) {
yield* walkBooks(path);
continue;
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (extension === ".epub" || extension === ".pdf") {
yield path;
}
}
}

16
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": ["node"],
"paths": {
"@readabook/shared": ["../../packages/shared/src/index.ts"]
}
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*.ts"]
}

23
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM node:22-bookworm-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/package.json
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY packages/shared packages/shared
COPY apps/web apps/web
RUN pnpm --filter @readabook/shared build
RUN pnpm --filter @readabook/web build
FROM nginx:1.27-alpine AS runtime
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
CMD ["nginx", "-g", "daemon off;"]

15
apps/web/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#20150e" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/readabook.svg" type="image/svg+xml" />
<title>ReadaBook</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,40 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /auth/ {
proxy_pass http://api:3000/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /admin/ {
proxy_pass http://api:3000/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /books/ {
proxy_pass http://api:3000/books/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /progress/ {
proxy_pass http://api:3000/progress/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /healthz {
proxy_pass http://api:3000/healthz;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}

30
apps/web/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@readabook/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -p tsconfig.json && vite build",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@readabook/shared": "workspace:*",
"@vitejs/plugin-react": "^6.1.0",
"foliate-js": "^1.0.1",
"lucide-react": "^1.33.0",
"pdfjs-dist": "^6.2.108",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"vite": "^8.2.2"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ReadaBook">
<rect width="512" height="512" rx="96" fill="#20150e"/>
<path d="M96 112h128c31 0 56 25 56 56v232c0 9-10 14-17 8-15-13-34-20-54-20H96z" fill="#e8d2a6"/>
<path d="M416 112H288c-31 0-56 25-56 56v232c0 9 10 14 17 8 15-13 34-20 54-20h113z" fill="#c05a3a"/>
<circle cx="256" cy="220" r="46" fill="#29524a"/>
<path d="M256 154l14 42 45 1-36 27 13 43-36-25-36 25 13-43-36-27 45-1z" fill="#f5c84b"/>
</svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@ -0,0 +1,18 @@
{
"name": "ReadaBook",
"short_name": "ReadaBook",
"description": "Cabinet de curiosites numerique pour bibliotheques EPUB et PDF.",
"start_url": "/home",
"scope": "/",
"display": "standalone",
"background_color": "#20150e",
"theme_color": "#20150e",
"icons": [
{
"src": "/icons/readabook.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

22
apps/web/public/sw.js Normal file
View File

@ -0,0 +1,22 @@
const CACHE_NAME = "readabook-shell-v1";
const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) {
return;
}
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/"))));
});

59
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import type { Session } from "./api/types";
import { api } from "./api/client";
import { AppShell } from "./layout/AppShell";
import { AdminPage } from "./pages/AdminPage";
import { BookPage } from "./pages/BookPage";
import { HomePage } from "./pages/HomePage";
import { LibraryPage } from "./pages/LibraryPage";
import { LoginPage } from "./pages/LoginPage";
import { ProfilePage } from "./pages/ProfilePage";
import { ReaderPage } from "./pages/ReaderPage";
import { SearchPage } from "./pages/SearchPage";
import { SetupPage } from "./pages/SetupPage";
import { parseRoute, type Route } from "./router";
function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
if (route.name === "setup") return <SetupPage />;
const content =
route.name === "home" ? (
<HomePage />
) : route.name === "library" ? (
<LibraryPage libraryId={route.libraryId} />
) : route.name === "book" ? (
<BookPage bookId={route.bookId} />
) : route.name === "reader" ? (
<ReaderPage bookId={route.bookId} />
) : route.name === "search" ? (
<SearchPage />
) : route.name === "me" ? (
<ProfilePage session={session} onSessionChange={refreshSession} />
) : (
<AdminPage />
);
return <AppShell session={session}>{content}</AppShell>;
}
export function App() {
const [route, setRoute] = useState(parseRoute());
const [session, setSession] = useState<Session>({ user: null, degraded: false });
async function refreshSession() {
setSession(await api.session());
}
useEffect(() => {
refreshSession();
}, []);
useEffect(() => {
const listener = () => setRoute(parseRoute());
window.addEventListener("popstate", listener);
return () => window.removeEventListener("popstate", listener);
}, []);
return renderRoute(route, session, refreshSession);
}

140
apps/web/src/api/client.ts Normal file
View File

@ -0,0 +1,140 @@
import type {
BookDto,
BookQueryDto,
BootstrapAdminDto,
CreateLibraryDto,
JobDto,
LibraryDto,
LoginDto,
ProgressDto,
UpdateProgressDto,
UserDto
} from "@readabook/shared";
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
import type { ContinueItem, Session } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type RequestOptions = RequestInit & {
fallback?: unknown;
};
export class ApiFallbackError extends Error {
constructor(
message: string,
public readonly fallback: unknown
) {
super(message);
}
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers
}
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
} catch (error) {
if (options.fallback !== undefined) {
throw new ApiFallbackError(error instanceof Error ? error.message : String(error), options.fallback);
}
throw error;
}
}
function queryString(query: Partial<BookQueryDto>): string {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
});
const value = params.toString();
return value ? `?${value}` : "";
}
export const api = {
async session(): Promise<Session> {
try {
const result = await request<{ user: UserDto }>("/auth/me");
return { user: result.user, degraded: false };
} catch {
return { user: null, degraded: false };
}
},
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
return result;
},
async login(input: LoginDto): Promise<UserDto> {
const result = await request<{ user: UserDto }>("/auth/login", { method: "POST", body: JSON.stringify(input) });
return result.user;
},
async logout(): Promise<void> {
await request<{ ok: true }>("/auth/logout", { method: "POST" });
},
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
},
async search(query: string): Promise<BookDto[]> {
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
},
async book(id: number): Promise<BookDto> {
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
return request<BookDto>(`/books/${id}`, { fallback });
},
bookFileUrl(id: number): string {
return `${API_BASE}/books/${id}/file`;
},
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async progress(bookId: number): Promise<ProgressDto | null> {
try {
return await request<ProgressDto>(`/progress/${bookId}`, {
fallback: mockProgress.find((progress) => progress.bookId === bookId) ?? null
});
} catch (error) {
if (error instanceof ApiFallbackError) return error.fallback as ProgressDto | null;
return null;
}
},
async saveProgress(bookId: number, input: UpdateProgressDto): Promise<ProgressDto> {
return request<ProgressDto>(`/progress/${bookId}`, {
method: "PUT",
body: JSON.stringify(input),
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
});
},
async continueReading(): Promise<ContinueItem[]> {
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
},
async libraries(): Promise<LibraryDto[]> {
return request<LibraryDto[]>("/admin/libraries", { fallback: mockLibraries });
},
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
return request<LibraryDto>("/admin/libraries", {
method: "POST",
body: JSON.stringify(input),
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
});
},
async scanLibrary(id: number): Promise<JobDto> {
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
},
async jobs(): Promise<JobDto[]> {
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
},
async users(): Promise<UserDto[]> {
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
}
};

View File

@ -0,0 +1,70 @@
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
import type { ContinueItem } from "./types";
const now = new Date().toISOString();
export const mockUser: UserDto = {
id: 1,
email: "admin@readabook.local",
name: "Conservateur",
role: "admin",
createdAt: now
};
export const mockLibraries: LibraryDto[] = [
{ id: 1, name: "Reserve des EPUB", path: "/library/epub", enabled: true, createdAt: now, updatedAt: now },
{ id: 2, name: "Atlas PDF", path: "/library/pdf", enabled: true, createdAt: now, updatedAt: now }
];
export const mockBooks: BookDto[] = [
{
id: 1,
libraryId: 1,
title: "L'Herbier des machines",
author: "M. Valrose",
description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.",
isbn: null,
language: "fr",
publisher: "Cabinet ReadaBook",
publishedDate: "1908",
format: "epub",
filePath: "/library/epub/herbier.epub",
coverPath: null,
fileSize: 4300000,
fileMtime: now,
createdAt: now,
updatedAt: now
},
{
id: 2,
libraryId: 2,
title: "Cartographie des songes",
author: "I. Nadir",
description: "Un atlas annote ou chaque page devient une vitrine de lecture.",
isbn: null,
language: "fr",
publisher: "ReadaBook",
publishedDate: "1921",
format: "pdf",
filePath: "/library/pdf/cartographie.pdf",
coverPath: null,
fileSize: 9100000,
fileMtime: now,
createdAt: now,
updatedAt: now
}
];
export const mockProgress: ProgressDto[] = [
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
{ bookId: 2, locator: "mock:page-12", percent: 18, updatedAt: now }
];
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
progress,
book: mockBooks.find((book) => book.id === progress.bookId) ?? mockBooks[0]
}));
export const mockJobs: JobDto[] = [
{ id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now }
];

23
apps/web/src/api/types.ts Normal file
View File

@ -0,0 +1,23 @@
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
export type ApiState<T> =
| { status: "loading"; data?: T; error?: undefined; fallback?: false }
| { status: "ready"; data: T; error?: undefined; fallback?: boolean }
| { status: "error"; data: T; error: string; fallback: true };
export type Session = {
user: UserDto | null;
degraded: boolean;
};
export type ContinueItem = {
book: BookDto;
progress: ProgressDto;
};
export type DashboardData = {
books: BookDto[];
continueReading: ContinueItem[];
libraries: LibraryDto[];
jobs: JobDto[];
};

View File

@ -0,0 +1,34 @@
import { BookOpen, Eye } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { navigate } from "../router";
import { FormatPill } from "./ui";
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
return (
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
</button>
<div className="book-card-body">
<div className="book-card-meta">
<FormatPill format={book.format} />
<span>{book.language ?? "langue inconnue"}</span>
</div>
<h3>{book.title}</h3>
<p>{book.author ?? "Auteur inconnu"}</p>
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
<div className="book-card-actions">
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
<Eye size={16} />
Fiche
</button>
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
<BookOpen size={16} />
Lire
</button>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,41 @@
import type { ReactNode } from "react";
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
return <section className={`panel ${className}`}>{children}</section>;
}
export function EmptyState({ title, detail }: { title: string; detail: string }) {
return (
<div className="empty-state">
<span className="specimen-mark">?</span>
<h2>{title}</h2>
<p>{detail}</p>
</div>
);
}
export function LoadingState({ label = "Inventaire en cours" }: { label?: string }) {
return (
<div className="loading-state">
<span className="spinner" />
<span>{label}</span>
</div>
);
}
export function ErrorRibbon({ message }: { message?: string }) {
if (!message) return null;
return <div className="error-ribbon">{message}</div>;
}
export function FormatPill({ format }: { format: "epub" | "pdf" }) {
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
}
export function Meter({ value }: { value: number }) {
return (
<span className="meter" aria-label={`${Math.round(value)}%`}>
<span style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
</span>
);
}

View File

@ -0,0 +1,37 @@
import { Archive, Home, Search, Settings, UserRound } from "lucide-react";
import type { ReactNode } from "react";
import type { Session } from "../api/types";
import { navigate } from "../router";
const navItems = [
{ href: "/home", label: "Accueil", icon: Home },
{ href: "/search", label: "Recherche", icon: Search },
{ href: "/admin/libraries", label: "Admin", icon: Settings },
{ href: "/me", label: "Profil", icon: UserRound }
];
export function AppShell({ children, session }: { children: ReactNode; session: Session }) {
return (
<div className="app-shell">
<aside className="side-rail">
<button className="brand-button" onClick={() => navigate("/home")} aria-label="ReadaBook">
<Archive size={22} />
<span>ReadaBook</span>
</button>
<nav>
{navItems.map((item) => {
const Icon = item.icon;
return (
<button key={item.href} onClick={() => navigate(item.href)} title={item.label}>
<Icon size={19} />
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
</aside>
<main>{children}</main>
</div>
);
}

17
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/tokens.css";
import "./styles/app.css";
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => undefined);
});
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@ -0,0 +1,107 @@
import { FormEvent, useEffect, useState } from "react";
import { Play, Plus } from "lucide-react";
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
import { api } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
export function AdminPage() {
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null);
const [jobs, setJobs] = useState<JobDto[]>([]);
const [users, setUsers] = useState<UserDto[]>([]);
const [name, setName] = useState("Bibliotheque locale");
const [path, setPath] = useState("/library");
const [error, setError] = useState<string>();
async function refresh() {
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]);
setLibraries(nextLibraries);
setJobs(nextJobs);
setUsers(nextUsers);
}
useEffect(() => {
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible"));
}, []);
async function createLibrary(event: FormEvent) {
event.preventDefault();
setError(undefined);
try {
await api.createLibrary({ name, path, enabled: true });
await refresh();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "Creation impossible");
}
}
async function scan(id: number) {
setError(undefined);
try {
await api.scanLibrary(id);
await refresh();
} catch (scanError) {
setError(scanError instanceof Error ? scanError.message : "Scan impossible");
}
}
if (!libraries) return <LoadingState />;
return (
<div className="page-grid">
<Panel className="span-2">
<div className="section-heading">
<h1>Administration</h1>
<span>{users.length} comptes</span>
</div>
<ErrorRibbon message={error} />
<form className="admin-form" onSubmit={createLibrary}>
<label>
Nom du rayon
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Chemin serveur
<input value={path} onChange={(event) => setPath(event.target.value)} required />
</label>
<button className="primary-button" type="submit">
<Plus size={17} />
Ajouter
</button>
</form>
</Panel>
<Panel>
<div className="section-heading">
<h2>Travaux</h2>
<span>{jobs.length}</span>
</div>
<div className="job-list">
{jobs.map((job) => (
<div key={job.id}>
<strong>{job.type}</strong>
<span>{job.status}</span>
</div>
))}
</div>
</Panel>
<Panel className="span-3">
<div className="library-table">
{libraries.map((library) => (
<div key={library.id}>
<div>
<strong>{library.name}</strong>
<span>{library.path}</span>
</div>
<span>{library.enabled ? "actif" : "pause"}</span>
<button className="ghost-button" onClick={() => scan(library.id)}>
<Play size={16} />
Scanner
</button>
</div>
))}
</div>
</Panel>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared";
import { api } from "../api/client";
import { FormatPill, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [progress, setProgress] = useState<ProgressDto | null>(null);
useEffect(() => {
let alive = true;
Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => {
if (!alive) return;
setBook(nextBook);
setProgress(nextProgress);
});
return () => {
alive = false;
};
}, [bookId]);
if (!book) return <LoadingState />;
return (
<div className="book-detail">
<section className="book-portrait">
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
</section>
<Panel className="book-facts">
<div className="book-card-meta">
<FormatPill format={book.format} />
<span>{book.language ?? "langue inconnue"}</span>
</div>
<h1>{book.title}</h1>
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
<p>{book.description ?? "Notice absente du catalogue."}</p>
{progress && <Meter value={progress.percent} />}
<div className="book-card-actions">
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
<BookOpen size={18} />
Lire
</button>
<button className="ghost-button" onClick={() => navigate(`/library/${book.libraryId}`)}>
<LibraryBig size={18} />
Rayon
</button>
</div>
</Panel>
</div>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client";
import type { DashboardData } from "../api/types";
import { BookCard } from "../components/BookCard";
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(() => {
if (alive) setState({ books: [], continueReading: [], libraries: [], jobs: [] });
});
return () => {
alive = false;
};
}, []);
if (!state) return <LoadingState />;
return (
<div className="page-grid">
<section className="hero-band">
<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>
</div>
<button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} />
Explorer
</button>
</section>
<Panel className="span-2">
<div className="section-heading">
<h2>Reprise de lecture</h2>
<span>{state.continueReading.length} traces</span>
</div>
{state.continueReading.length ? (
<div className="continue-grid">
{state.continueReading.map((item) => (
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
<strong>{item.book.title}</strong>
<span>{item.book.author ?? "Auteur inconnu"}</span>
<Meter value={item.progress.percent} />
</button>
))}
</div>
) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
)}
</Panel>
<Panel>
<div className="section-heading">
<h2>Bibliotheques</h2>
<LibraryBig size={20} />
</div>
<div className="library-list">
{state.libraries.map((library) => (
<button key={library.id} onClick={() => navigate(`/library/${library.id}`)}>
<strong>{library.name}</strong>
<span>{library.path}</span>
</button>
))}
</div>
</Panel>
<section className="book-grid span-3">
{state.books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import type { BookDto, LibraryDto } from "@readabook/shared";
import { api } from "../api/client";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function LibraryPage({ libraryId }: { libraryId: number }) {
const [books, setBooks] = useState<BookDto[] | null>(null);
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
useEffect(() => {
let alive = true;
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
if (!alive) return;
setBooks(nextBooks);
setLibraries(nextLibraries);
});
return () => {
alive = false;
};
}, [libraryId]);
if (!books) return <LoadingState />;
const library = libraries.find((item) => item.id === libraryId);
return (
<div className="page-grid">
<Panel className="span-3">
<div className="section-heading">
<div>
<h1>{library?.name ?? "Bibliotheque"}</h1>
<p>{library?.path ?? "Rayonnage non identifie"}</p>
</div>
<span>{books.length} ouvrages</span>
</div>
</Panel>
{books.length ? (
<section className="book-grid span-3">
{books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
) : (
<Panel className="span-3">
<EmptyState title="Rayon vide" detail="Lance un scan depuis l'administration." />
</Panel>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { FormEvent, useState } from "react";
import { KeyRound, LogIn } from "lucide-react";
import { api } from "../api/client";
import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui";
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) {
const [email, setEmail] = useState("admin@readabook.local");
const [password, setPassword] = useState("");
const [error, setError] = useState<string>();
async function submit(event: FormEvent) {
event.preventDefault();
setError(undefined);
try {
await api.login({ email, password });
await onSessionChange();
navigate("/home");
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : "Connexion impossible");
}
}
return (
<div className="auth-surface">
<section className="auth-hero">
<p>Cabinet de curiosites numerique</p>
<h1>ReadaBook</h1>
<span>Bibliotheques EPUB et PDF, rangees comme des specimens vivants.</span>
</section>
<Panel className="auth-panel">
<KeyRound size={24} />
<h2>Entrer dans le cabinet</h2>
<ErrorRibbon message={error} />
<form onSubmit={submit} className="stack-form">
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
</label>
<label>
Mot de passe
<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" required />
</label>
<button className="primary-button" type="submit">
<LogIn size={17} />
Se connecter
</button>
</form>
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
Initialiser le premier admin
</button>
</Panel>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { LogOut, UserRound } from "lucide-react";
import type { Session } from "../api/types";
import { api } from "../api/client";
import { Panel } from "../components/ui";
import { navigate } from "../router";
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) {
async function logout() {
await api.logout();
await onSessionChange();
navigate("/login");
}
return (
<div className="page-grid">
<Panel className="span-2 profile-panel">
<UserRound size={28} />
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span>
<button className="ghost-button" onClick={logout}>
<LogOut size={17} />
Sortir
</button>
</Panel>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Save } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { LoadingState, Meter } from "../components/ui";
import { navigate } from "../router";
import { EpubReader } from "../reader/EpubReader";
import { PdfReader } from "../reader/PdfReader";
import { useReaderProgress } from "../reader/useReaderProgress";
export function ReaderPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [page, setPage] = useState(1);
const { progress, saving, save } = useReaderProgress(bookId);
useEffect(() => {
api.book(bookId).then(setBook);
}, [bookId]);
useEffect(() => {
if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1);
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const savePdfPage = useCallback(
(nextPage: number, pages: number) => {
setPage(nextPage);
void save(`pdf:page:${nextPage}`, Math.round((nextPage / pages) * 100));
},
[save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
if (!book) return <LoadingState label="Ouverture du lecteur" />;
return (
<div className="reader-page">
<header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
<ArrowLeft size={17} />
Fiche
</button>
<div>
<strong>{book.title}</strong>
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span>
</div>
<Save size={18} />
</header>
<Meter value={progress?.percent ?? 0} />
{book.format === "pdf" ? (
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} />
) : (
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
import { FormEvent, useEffect, useState } from "react";
import { Search } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function SearchPage() {
const [query, setQuery] = useState("");
const [books, setBooks] = useState<BookDto[] | null>(null);
useEffect(() => {
api.books().then(setBooks);
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
setBooks(null);
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
}
return (
<div className="page-grid">
<Panel className="span-3">
<form className="search-form" onSubmit={submit}>
<Search size={20} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Titre, auteur, ISBN" />
<button className="primary-button" type="submit">
Chercher
</button>
</form>
</Panel>
{!books ? (
<LoadingState />
) : books.length ? (
<section className="book-grid span-3">
{books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
) : (
<Panel className="span-3">
<EmptyState title="Aucun specimen" detail="Essaie un autre terme ou relance l'indexation." />
</Panel>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { FormEvent, useState } from "react";
import { Sparkles } from "lucide-react";
import { api } from "../api/client";
import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui";
export function SetupPage() {
const [email, setEmail] = useState("admin@readabook.local");
const [name, setName] = useState("Conservateur");
const [password, setPassword] = useState("");
const [error, setError] = useState<string>();
async function submit(event: FormEvent) {
event.preventDefault();
setError(undefined);
try {
await api.bootstrap({ email, name, password });
navigate("/login");
} catch (setupError) {
setError(setupError instanceof Error ? setupError.message : "Initialisation impossible");
}
}
return (
<div className="auth-surface">
<section className="auth-hero">
<p>Premiere cle</p>
<h1>Installer le cabinet</h1>
<span>Un administrateur, puis les rayonnages.</span>
</section>
<Panel className="auth-panel">
<Sparkles size={24} />
<h2>Premier administrateur</h2>
<ErrorRibbon message={error} />
<form onSubmit={submit} className="stack-form">
<label>
Nom
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
</label>
<label>
Mot de passe
<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" minLength={8} required />
</label>
<button className="primary-button" type="submit">
Creer la cle
</button>
</form>
</Panel>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from "react";
type FoliateModule = {
EPUB?: unknown;
default?: unknown;
};
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState("Ouverture EPUB");
useEffect(() => {
let cancelled = false;
async function mount() {
try {
const module = (await import("foliate-js/epub.js")) as FoliateModule;
if (cancelled || !hostRef.current) return;
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
setStatus("EPUB pret");
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1);
} catch {
setStatus("Apercu EPUB indisponible dans ce navigateur");
}
}
mount();
return () => {
cancelled = true;
};
}, [locator, onLocatorChange, url]);
return (
<div className="epub-reader" ref={hostRef}>
<iframe title="EPUB" src={url} />
<div className="reader-fallback">{status}</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export function PdfReader({ url, page, onPageChange }: { url: string; page: number; onPageChange: (page: number, pages: number) => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pages, setPages] = useState(1);
const [error, setError] = useState<string>();
useEffect(() => {
let cancelled = false;
async function render() {
try {
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise;
if (cancelled) return;
setPages(document.numPages);
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
const canvas = canvasRef.current;
if (!canvas) return;
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) });
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) return;
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages);
} catch (renderError) {
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
}
}
render();
return () => {
cancelled = true;
};
}, [url, page, onPageChange]);
return (
<div className="pdf-reader">
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />}
<div className="reader-stepper">
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}>
Precedent
</button>
<span>
{page} / {pages}
</span>
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}>
Suivant
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { useCallback, useEffect, useState } from "react";
import type { ProgressDto } from "@readabook/shared";
import { api } from "../api/client";
export function useReaderProgress(bookId: number) {
const [progress, setProgress] = useState<ProgressDto | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.progress(bookId).then(setProgress);
}, [bookId]);
const save = useCallback(
async (locator: string, percent: number) => {
setSaving(true);
try {
const next = await api.saveProgress(bookId, { locator, percent });
setProgress(next);
} finally {
setSaving(false);
}
},
[bookId]
);
return { progress, saving, save };
}

28
apps/web/src/router.ts Normal file
View File

@ -0,0 +1,28 @@
export type Route =
| { name: "login" }
| { name: "setup"; step: string }
| { name: "home" }
| { name: "library"; libraryId: number }
| { name: "book"; bookId: number }
| { name: "reader"; bookId: number }
| { name: "search" }
| { name: "me" }
| { name: "admin"; section: string };
export function parseRoute(pathname = window.location.pathname): Route {
const parts = pathname.split("/").filter(Boolean);
if (parts[0] === "login") return { name: "login" };
if (parts[0] === "setup") return { name: "setup", step: parts[1] ?? "admin" };
if (parts[0] === "library") return { name: "library", libraryId: Number(parts[1] ?? 0) };
if (parts[0] === "book") return { name: "book", bookId: Number(parts[1] ?? 0) };
if (parts[0] === "reader") return { name: "reader", bookId: Number(parts[1] ?? 0) };
if (parts[0] === "search") return { name: "search" };
if (parts[0] === "me") return { name: "me" };
if (parts[0] === "admin") return { name: "admin", section: parts[1] ?? "libraries" };
return { name: "home" };
}
export function navigate(to: string): void {
window.history.pushState({}, "", to);
window.dispatchEvent(new PopStateEvent("popstate"));
}

512
apps/web/src/styles/app.css Normal file
View File

@ -0,0 +1,512 @@
#root {
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
min-height: 100vh;
}
.side-rail {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 18px;
height: 100vh;
padding: 18px;
border-right: 1px solid var(--line);
background: rgba(23, 17, 13, 0.78);
backdrop-filter: blur(18px);
}
.brand-button,
.side-rail nav button,
.ghost-button,
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
border-radius: var(--radius);
border: 1px solid var(--line);
color: var(--ink);
background: rgba(255, 255, 255, 0.04);
}
.brand-button {
justify-content: flex-start;
width: 100%;
color: var(--brass);
font-weight: 800;
}
.side-rail nav {
display: grid;
gap: 8px;
}
.side-rail nav button {
justify-content: flex-start;
width: 100%;
}
.session-chip {
margin-top: auto;
overflow: hidden;
color: var(--ink-muted);
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
main {
min-width: 0;
padding: 28px;
}
.page-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.span-2 {
grid-column: span 2;
}
.span-3 {
grid-column: 1 / -1;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px;
background: rgba(38, 26, 18, 0.82);
box-shadow: var(--shadow);
}
.hero-band {
grid-column: 1 / -1;
display: flex;
align-items: end;
justify-content: space-between;
min-height: 270px;
padding: 28px;
border: 1px solid var(--line);
border-radius: var(--radius);
background:
linear-gradient(120deg, rgba(32, 21, 14, 0.48), rgba(32, 21, 14, 0.92)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='900' height='420' viewBox='0 0 900 420'%3E%3Crect width='900' height='420' fill='%2320150e'/%3E%3Cg fill='none' stroke='%23d5a84d' stroke-opacity='.28'%3E%3Cpath d='M68 326h764M82 286h736M116 120h668M134 84h632'/%3E%3Cpath d='M138 84v242M274 84v242M418 84v242M572 84v242M724 84v242'/%3E%3C/g%3E%3Cg fill='%23a94834' fill-opacity='.72'%3E%3Crect x='166' y='126' width='48' height='156'/%3E%3Crect x='304' y='102' width='34' height='184'/%3E%3Crect x='614' y='134' width='58' height='150'/%3E%3C/g%3E%3Cg fill='%232d6f63' fill-opacity='.72'%3E%3Ccircle cx='492' cy='194' r='48'/%3E%3Cpath d='M742 124l34 92h-68z'/%3E%3C/g%3E%3C/svg%3E") center / cover;
}
.hero-band p,
.auth-hero p {
margin: 0 0 8px;
color: var(--brass);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(2.1rem, 6vw, 5rem);
line-height: 0.95;
}
h2 {
margin-bottom: 8px;
}
.primary-button {
border-color: rgba(213, 168, 77, 0.62);
background: linear-gradient(180deg, #d5a84d, #a94834);
color: #17110d;
font-weight: 800;
}
.ghost-button:hover,
.side-rail nav button:hover,
.brand-button:hover {
border-color: rgba(213, 168, 77, 0.55);
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.book-card {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 14px;
min-height: 220px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(49, 34, 24, 0.94), rgba(23, 17, 13, 0.94));
}
.cover-button,
.book-portrait {
display: grid;
place-items: center;
min-height: 156px;
border: 1px solid rgba(213, 168, 77, 0.35);
border-radius: 6px;
background:
linear-gradient(135deg, rgba(213, 168, 77, 0.2), rgba(45, 111, 99, 0.22)),
var(--paper-soft);
color: var(--brass);
}
.cover-button img,
.book-portrait img {
width: 100%;
height: 100%;
object-fit: cover;
}
.book-card h3 {
margin-bottom: 5px;
line-height: 1.1;
}
.book-card p,
.book-facts p,
.section-heading p {
color: var(--ink-muted);
}
.book-card-description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.book-card-meta,
.book-card-actions,
.section-heading,
.reader-topbar {
display: flex;
align-items: center;
gap: 10px;
}
.book-card-actions,
.section-heading {
justify-content: space-between;
}
.format-pill {
padding: 4px 7px;
border-radius: 999px;
color: #17110d;
font-size: 0.72rem;
font-weight: 900;
background: var(--brass);
}
.format-pdf {
background: var(--lacquer);
color: var(--ink);
}
.continue-grid,
.library-list,
.job-list {
display: grid;
gap: 10px;
}
.continue-tile,
.library-list button,
.job-list div,
.library-table > div {
display: grid;
gap: 5px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
color: var(--ink);
text-align: left;
}
.library-table > div {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
.meter {
display: block;
width: 100%;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.09);
}
.meter span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--verdigris), var(--brass));
}
.auth-surface {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
gap: 24px;
min-height: 100vh;
padding: 28px;
}
.auth-hero {
display: flex;
flex-direction: column;
justify-content: end;
min-height: calc(100vh - 56px);
padding: 28px;
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(23, 17, 13, 0.12), rgba(23, 17, 13, 0.9)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='960' viewBox='0 0 720 960'%3E%3Crect width='720' height='960' fill='%23261a12'/%3E%3Cpath d='M80 190h560v590H80z' fill='none' stroke='%23d5a84d' stroke-opacity='.38' stroke-width='6'/%3E%3Ccircle cx='360' cy='426' r='112' fill='%232d6f63' fill-opacity='.64'/%3E%3Cpath d='M210 676h300M244 728h232M180 250h360' stroke='%23e8d2a6' stroke-opacity='.36' stroke-width='10'/%3E%3C/svg%3E") center / cover;
}
.auth-panel {
align-self: center;
}
.stack-form,
.admin-form {
display: grid;
gap: 12px;
}
label {
display: grid;
gap: 6px;
color: var(--ink-muted);
font-size: 0.9rem;
}
input {
min-height: 42px;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 12px;
color: var(--ink);
background: rgba(0, 0, 0, 0.22);
}
.full-width {
width: 100%;
margin-top: 12px;
}
.error-ribbon {
margin: 10px 0;
padding: 10px 12px;
border: 1px solid rgba(169, 72, 52, 0.72);
border-radius: var(--radius);
color: #ffd8cf;
background: rgba(169, 72, 52, 0.18);
}
.empty-state,
.loading-state {
display: grid;
place-items: center;
gap: 8px;
min-height: 160px;
color: var(--ink-muted);
text-align: center;
}
.specimen-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--brass);
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(255, 255, 255, 0.13);
border-top-color: var(--brass);
border-radius: 999px;
animation: spin 0.9s linear infinite;
}
.search-form {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.book-detail {
display: grid;
grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
gap: 18px;
}
.book-portrait {
min-height: 520px;
}
.lead {
font-size: 1.15rem;
}
.profile-panel {
display: grid;
gap: 10px;
justify-items: start;
}
.reader-page {
display: grid;
gap: 12px;
min-height: 100vh;
padding: 14px;
background: #120e0b;
}
.reader-topbar {
justify-content: space-between;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(38, 26, 18, 0.84);
}
.reader-topbar div {
display: grid;
justify-items: center;
}
.reader-topbar span {
color: var(--ink-muted);
font-size: 0.82rem;
}
.pdf-reader,
.epub-reader {
display: grid;
place-items: center;
gap: 10px;
min-height: calc(100vh - 120px);
}
.pdf-reader canvas,
.epub-reader iframe {
max-width: min(100%, 980px);
max-height: calc(100vh - 170px);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #f7f0df;
}
.epub-reader iframe {
width: min(100%, 980px);
height: calc(100vh - 170px);
}
.reader-fallback {
padding: 12px;
color: var(--ink-muted);
}
.reader-stepper {
display: flex;
align-items: center;
gap: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
.app-shell,
.auth-surface,
.book-detail {
grid-template-columns: 1fr;
}
.side-rail {
position: fixed;
inset: auto 0 0;
z-index: 10;
flex-direction: row;
height: auto;
padding: 8px;
}
.side-rail nav {
grid-template-columns: repeat(4, 1fr);
flex: 1;
}
.side-rail nav button span,
.brand-button span,
.session-chip {
display: none;
}
main {
padding: 16px 16px 86px;
}
.page-grid {
grid-template-columns: 1fr;
}
.span-2,
.span-3 {
grid-column: 1;
}
.hero-band {
min-height: 220px;
align-items: start;
flex-direction: column;
}
.auth-surface {
padding: 16px;
}
.auth-hero {
min-height: 360px;
}
.book-card {
grid-template-columns: 86px minmax(0, 1fr);
}
.library-table > div,
.search-form {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,42 @@
:root {
color-scheme: dark;
--ink: #f3ead9;
--ink-muted: #bfae94;
--paper: #261a12;
--paper-soft: #312218;
--cabinet: #17110d;
--brass: #d5a84d;
--verdigris: #2d6f63;
--lacquer: #a94834;
--violet-glass: #514069;
--line: rgba(243, 234, 217, 0.14);
--shadow: 0 22px 70px rgba(0, 0, 0, 0.36);
--radius: 8px;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--cabinet);
color: var(--ink);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(213, 168, 77, 0.07) 1px, transparent 1px) 0 0 / 44px 44px,
radial-gradient(circle at 20% 10%, rgba(45, 111, 99, 0.28), transparent 34%),
linear-gradient(135deg, #17110d 0%, #251910 52%, #1b1518 100%);
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}

7
apps/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "foliate-js/epub.js" {
const module: unknown;
export default module;
export const EPUB: unknown;
}

12
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

16
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/auth": "http://127.0.0.1:3000",
"/admin": "http://127.0.0.1:3000",
"/books": "http://127.0.0.1:3000",
"/progress": "http://127.0.0.1:3000",
"/healthz": "http://127.0.0.1:3000"
}
}
});

38
docker-compose.yaml Normal file
View File

@ -0,0 +1,38 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
environment:
NODE_ENV: production
PORT: "3000"
HOST: 0.0.0.0
DATABASE_PATH: /data/readabook.sqlite
STORAGE_DIR: /data/storage
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true}
volumes:
- ./data:/data
- ./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
timeout: 5s
retries: 5
start_period: 10s
web:
build:
context: .
dockerfile: apps/web/Dockerfile
depends_on:
api:
condition: service_healthy
ports:
- "3000:80"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "readabook",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "pnpm --parallel --filter @readabook/api --filter @readabook/web dev",
"dev:api": "pnpm --filter @readabook/api dev",
"dev:web": "pnpm --filter @readabook/web dev",
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"start": "pnpm --filter @readabook/api start"
},
"devDependencies": {
"@types/node": "^22.13.1",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
}

View File

@ -0,0 +1,27 @@
{
"name": "@readabook/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -w -p tsconfig.json",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "^3.24.2"
},
"devDependencies": {
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { BootstrapAdminSchema, BookQuerySchema } from "./index.js";
describe("shared contracts", () => {
it("validates bootstrap admin payloads", () => {
expect(BootstrapAdminSchema.parse({ email: "admin@example.com", password: "password123" }).email).toBe(
"admin@example.com"
);
});
it("normalizes catalogue query defaults", () => {
expect(BookQuerySchema.parse({}).limit).toBe(50);
});
});

View File

@ -0,0 +1,120 @@
import { z } from "zod";
export const RoleSchema = z.enum(["admin", "user"]);
export type Role = z.infer<typeof RoleSchema>;
export const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
name: z.string().nullable(),
role: RoleSchema,
createdAt: z.string()
});
export type UserDto = z.infer<typeof UserSchema>;
export const BootstrapAdminSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(120).optional()
});
export type BootstrapAdminDto = z.infer<typeof BootstrapAdminSchema>;
export const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
});
export type LoginDto = z.infer<typeof LoginSchema>;
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(120).optional(),
role: RoleSchema.default("user")
});
export type CreateUserDto = z.infer<typeof CreateUserSchema>;
export const UpdateUserSchema = z.object({
email: z.string().email().optional(),
password: z.string().min(8).optional(),
name: z.string().min(1).max(120).nullable().optional(),
role: RoleSchema.optional()
});
export type UpdateUserDto = z.infer<typeof UpdateUserSchema>;
export const LibrarySchema = z.object({
id: z.number().int().positive(),
name: z.string(),
path: z.string(),
enabled: z.boolean(),
createdAt: z.string(),
updatedAt: z.string()
});
export type LibraryDto = z.infer<typeof LibrarySchema>;
export const CreateLibrarySchema = z.object({
name: z.string().min(1).max(160),
path: z.string().min(1),
enabled: z.boolean().default(true)
});
export type CreateLibraryDto = z.infer<typeof CreateLibrarySchema>;
export const UpdateLibrarySchema = z.object({
name: z.string().min(1).max(160).optional(),
path: z.string().min(1).optional(),
enabled: z.boolean().optional()
});
export type UpdateLibraryDto = z.infer<typeof UpdateLibrarySchema>;
export const BookSchema = z.object({
id: z.number().int().positive(),
libraryId: z.number().int().positive(),
title: z.string(),
author: z.string().nullable(),
description: z.string().nullable(),
isbn: z.string().nullable(),
language: z.string().nullable(),
publisher: z.string().nullable(),
publishedDate: z.string().nullable(),
format: z.enum(["epub", "pdf"]),
filePath: z.string(),
coverPath: z.string().nullable(),
fileSize: z.number().int().nonnegative(),
fileMtime: z.string(),
createdAt: z.string(),
updatedAt: z.string()
});
export type BookDto = z.infer<typeof BookSchema>;
export const BookQuerySchema = z.object({
q: z.string().optional(),
format: z.enum(["epub", "pdf"]).optional(),
libraryId: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0)
});
export type BookQueryDto = z.infer<typeof BookQuerySchema>;
export const ProgressSchema = z.object({
bookId: z.number().int().positive(),
locator: z.string().min(1),
percent: z.number().min(0).max(100),
updatedAt: z.string()
});
export type ProgressDto = z.infer<typeof ProgressSchema>;
export const UpdateProgressSchema = z.object({
locator: z.string().min(1),
percent: z.number().min(0).max(100)
});
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
export const JobSchema = z.object({
id: z.number().int().positive(),
type: z.string(),
status: z.enum(["queued", "running", "succeeded", "failed"]),
detail: z.string().nullable(),
error: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string()
});
export type JobDto = z.infer<typeof JobSchema>;

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"]
}

2736
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

14
tsconfig.base.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
}
}