commit 8f1140127f59c581b46a2251f2cc835a8c1396e2 Author: Git Agent Date: Sun Aug 23 09:56:53 2026 +0200 chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6490157 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +coverage +.git +.ideai +data +.env +*.sqlite +*.sqlite-* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4216187 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +.env +.env.local +*.sqlite +*.sqlite-* +*.tsbuildinfo +data/storage +coverage +.pnpm-store +.ideai/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7eacdb2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdfc20f --- /dev/null +++ b/README.md @@ -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 d’un 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 l’hôte. +- `web` : Nginx + frontend, publié sur `http://localhost:3000`. + +Ports : + +- Hôte `3000` -> conteneur `web:80`. +- L’API est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`. + +## 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 d’une 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 l’enrichissement 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 d’erreur réseau ou de rate limit. +- Jobs in-process : pas de worker externe, pas de reprise fine d’un scan interrompu. +- Pas encore de migrations versionnées Drizzle Kit ; migrations SQL idempotentes exécutées au démarrage. +- L’API n’est pas exposée directement en compose ; elle passe par le reverse proxy Nginx du service web. diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..1e3a4d3 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts new file mode 100644 index 0000000..401d0d7 --- /dev/null +++ b/apps/api/src/admin/admin.controller.ts @@ -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(); + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..71e6d2e --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -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 {} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..df14680 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..9e6c92f --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts new file mode 100644 index 0000000..179c184 --- /dev/null +++ b/apps/api/src/auth/auth.guard.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..a3185c8 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..40174ee --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 = { 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 { + return new SignJWT({ email: user.email, role: user.role }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(String(user.id)) + .setIssuedAt() + .setExpirationTime("7d") + .sign(this.secret); + } +} diff --git a/apps/api/src/auth/current-user.ts b/apps/api/src/auth/current-user.ts new file mode 100644 index 0000000..8399d4f --- /dev/null +++ b/apps/api/src/auth/current-user.ts @@ -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; +}); diff --git a/apps/api/src/auth/roles.decorator.ts b/apps/api/src/auth/roles.decorator.ts new file mode 100644 index 0000000..091adb9 --- /dev/null +++ b/apps/api/src/auth/roles.decorator.ts @@ -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); diff --git a/apps/api/src/auth/roles.guard.ts b/apps/api/src/auth/roles.guard.ts new file mode 100644 index 0000000..8d0f297 --- /dev/null +++ b/apps/api/src/auth/roles.guard.ts @@ -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>(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"); + } +} diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts new file mode 100644 index 0000000..f73a961 --- /dev/null +++ b/apps/api/src/books/books.controller.ts @@ -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); + } +} diff --git a/apps/api/src/books/books.module.ts b/apps/api/src/books/books.module.ts new file mode 100644 index 0000000..c183835 --- /dev/null +++ b/apps/api/src/books/books.module.ts @@ -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 {} diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts new file mode 100644 index 0000000..d024e19 --- /dev/null +++ b/apps/api/src/books/books.service.ts @@ -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>).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`count(*)` }).from(books).get()?.count ?? 0; + } +} + +function mapBookRow(row: Record) { + 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); +} diff --git a/apps/api/src/common/zod-validation.pipe.ts b/apps/api/src/common/zod-validation.pipe.ts new file mode 100644 index 0000000..7e139c0 --- /dev/null +++ b/apps/api/src/common/zod-validation.pipe.ts @@ -0,0 +1,18 @@ +import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common"; +import { ZodSchema } from "zod"; + +@Injectable() +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + 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; + } +} diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..4aba511 --- /dev/null +++ b/apps/api/src/config/env.ts @@ -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" + }; +} diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts new file mode 100644 index 0000000..b2b132b --- /dev/null +++ b/apps/api/src/database/database.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { DatabaseService } from "./database.service.js"; + +@Module({ + providers: [DatabaseService], + exports: [DatabaseService] +}) +export class DatabaseModule {} diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts new file mode 100644 index 0000000..21b9ec0 --- /dev/null +++ b/apps/api/src/database/database.service.ts @@ -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; + + 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')"); + } +} diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts new file mode 100644 index 0000000..7a551e6 --- /dev/null +++ b/apps/api/src/database/schema.ts @@ -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() +}); diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts new file mode 100644 index 0000000..c61795f --- /dev/null +++ b/apps/api/src/health.controller.ts @@ -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() + }; + } +} diff --git a/apps/api/src/jobs/jobs.module.ts b/apps/api/src/jobs/jobs.module.ts new file mode 100644 index 0000000..3a3a4f7 --- /dev/null +++ b/apps/api/src/jobs/jobs.module.ts @@ -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 {} diff --git a/apps/api/src/jobs/jobs.service.ts b/apps/api/src/jobs/jobs.service.ts new file mode 100644 index 0000000..f219825 --- /dev/null +++ b/apps/api/src/jobs/jobs.service.ts @@ -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(); + } +} diff --git a/apps/api/src/libraries/libraries.service.ts b/apps/api/src/libraries/libraries.service.ts new file mode 100644 index 0000000..e8d40df --- /dev/null +++ b/apps/api/src/libraries/libraries.service.ts @@ -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 = { 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"); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..b6113fc --- /dev/null +++ b/apps/api/src/main.ts @@ -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(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(); diff --git a/apps/api/src/progress/progress.controller.ts b/apps/api/src/progress/progress.controller.ts new file mode 100644 index 0000000..808f877 --- /dev/null +++ b/apps/api/src/progress/progress.controller.ts @@ -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); + } +} diff --git a/apps/api/src/progress/progress.module.ts b/apps/api/src/progress/progress.module.ts new file mode 100644 index 0000000..84e6c98 --- /dev/null +++ b/apps/api/src/progress/progress.module.ts @@ -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 {} diff --git a/apps/api/src/progress/progress.service.ts b/apps/api/src/progress/progress.service.ts new file mode 100644 index 0000000..6e40277 --- /dev/null +++ b/apps/api/src/progress/progress.service.ts @@ -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(); + } +} diff --git a/apps/api/src/scanner/metadata.test.ts b/apps/api/src/scanner/metadata.test.ts new file mode 100644 index 0000000..c20554d --- /dev/null +++ b/apps/api/src/scanner/metadata.test.ts @@ -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"); + }); +}); diff --git a/apps/api/src/scanner/metadata.ts b/apps/api/src/scanner/metadata.ts new file mode 100644 index 0000000..c954722 --- /dev/null +++ b/apps/api/src/scanner/metadata.ts @@ -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)["#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; +} diff --git a/apps/api/src/scanner/open-library.service.ts b/apps/api/src/scanner/open-library.service.ts new file mode 100644 index 0000000..437c2e3 --- /dev/null +++ b/apps/api/src/scanner/open-library.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { BookMetadata } from "./metadata.js"; + +@Injectable() +export class OpenLibraryService { + async enrich(metadata: BookMetadata): Promise> { + 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> }; + 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]); +} diff --git a/apps/api/src/scanner/scanner.module.ts b/apps/api/src/scanner/scanner.module.ts new file mode 100644 index 0000000..5c63116 --- /dev/null +++ b/apps/api/src/scanner/scanner.module.ts @@ -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 {} diff --git a/apps/api/src/scanner/scanner.service.ts b/apps/api/src/scanner/scanner.service.ts new file mode 100644 index 0000000..98af707 --- /dev/null +++ b/apps/api/src/scanner/scanner.service.ts @@ -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 { + 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 { + 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 { + 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; + } + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..20156fe --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..2421817 --- /dev/null +++ b/apps/web/Dockerfile @@ -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;"] diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..2392835 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + ReadaBook + + +
+ + + diff --git a/apps/web/nginx/default.conf b/apps/web/nginx/default.conf new file mode 100644 index 0000000..ef87ba5 --- /dev/null +++ b/apps/web/nginx/default.conf @@ -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; + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a8b62e3 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/public/icons/readabook.svg b/apps/web/public/icons/readabook.svg new file mode 100644 index 0000000..05bc011 --- /dev/null +++ b/apps/web/public/icons/readabook.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000..1207280 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..6d7910e --- /dev/null +++ b/apps/web/public/sw.js @@ -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("/")))); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..70be6aa --- /dev/null +++ b/apps/web/src/App.tsx @@ -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) { + if (route.name === "login") return ; + if (route.name === "setup") return ; + + const content = + route.name === "home" ? ( + + ) : route.name === "library" ? ( + + ) : route.name === "book" ? ( + + ) : route.name === "reader" ? ( + + ) : route.name === "search" ? ( + + ) : route.name === "me" ? ( + + ) : ( + + ); + + return {content}; +} + +export function App() { + const [route, setRoute] = useState(parseRoute()); + const [session, setSession] = useState({ 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); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..25830e2 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + 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): 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 { + 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 { + const result = await request("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) }); + return result; + }, + async login(input: LoginDto): Promise { + const result = await request<{ user: UserDto }>("/auth/login", { method: "POST", body: JSON.stringify(input) }); + return result.user; + }, + async logout(): Promise { + await request<{ ok: true }>("/auth/logout", { method: "POST" }); + }, + async books(query: Partial = {}): Promise { + return request(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks }); + }, + async search(query: string): Promise { + return request(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks }); + }, + async book(id: number): Promise { + const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0]; + return request(`/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 { + try { + return await request(`/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 { + return request(`/progress/${bookId}`, { + method: "PUT", + body: JSON.stringify(input), + fallback: { bookId, ...input, updatedAt: new Date().toISOString() } + }); + }, + async continueReading(): Promise { + return request("/progress/continue", { fallback: mockContinue }); + }, + async libraries(): Promise { + return request("/admin/libraries", { fallback: mockLibraries }); + }, + async createLibrary(input: CreateLibraryDto): Promise { + return request("/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 { + return request(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] }); + }, + async jobs(): Promise { + return request("/admin/jobs", { fallback: mockJobs }); + }, + async users(): Promise { + return request("/admin/users", { fallback: [mockUser] }); + } +}; diff --git a/apps/web/src/api/mockData.ts b/apps/web/src/api/mockData.ts new file mode 100644 index 0000000..0f70fc7 --- /dev/null +++ b/apps/web/src/api/mockData.ts @@ -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 } +]; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts new file mode 100644 index 0000000..cf08628 --- /dev/null +++ b/apps/web/src/api/types.ts @@ -0,0 +1,23 @@ +import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared"; + +export type ApiState = + | { 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[]; +}; diff --git a/apps/web/src/components/BookCard.tsx b/apps/web/src/components/BookCard.tsx new file mode 100644 index 0000000..91b6476 --- /dev/null +++ b/apps/web/src/components/BookCard.tsx @@ -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 ( +
+ +
+
+ + {book.language ?? "langue inconnue"} +
+

{book.title}

+

{book.author ?? "Auteur inconnu"}

+ {!compact &&

{book.description ?? "Notice absente du catalogue."}

} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..00154cc --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function EmptyState({ title, detail }: { title: string; detail: string }) { + return ( +
+ ? +

{title}

+

{detail}

+
+ ); +} + +export function LoadingState({ label = "Inventaire en cours" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function ErrorRibbon({ message }: { message?: string }) { + if (!message) return null; + return
{message}
; +} + +export function FormatPill({ format }: { format: "epub" | "pdf" }) { + return {format.toUpperCase()}; +} + +export function Meter({ value }: { value: number }) { + return ( + + + + ); +} diff --git a/apps/web/src/layout/AppShell.tsx b/apps/web/src/layout/AppShell.tsx new file mode 100644 index 0000000..0b84200 --- /dev/null +++ b/apps/web/src/layout/AppShell.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..7a55645 --- /dev/null +++ b/apps/web/src/main.tsx @@ -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( + + + +); diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx new file mode 100644 index 0000000..bfc3106 --- /dev/null +++ b/apps/web/src/pages/AdminPage.tsx @@ -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(null); + const [jobs, setJobs] = useState([]); + const [users, setUsers] = useState([]); + const [name, setName] = useState("Bibliotheque locale"); + const [path, setPath] = useState("/library"); + const [error, setError] = useState(); + + 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 ; + + return ( +
+ +
+

Administration

+ {users.length} comptes +
+ +
+ + + +
+
+ + +
+

Travaux

+ {jobs.length} +
+
+ {jobs.map((job) => ( +
+ {job.type} + {job.status} +
+ ))} +
+
+ + +
+ {libraries.map((library) => ( +
+
+ {library.name} + {library.path} +
+ {library.enabled ? "actif" : "pause"} + +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/pages/BookPage.tsx b/apps/web/src/pages/BookPage.tsx new file mode 100644 index 0000000..440adb1 --- /dev/null +++ b/apps/web/src/pages/BookPage.tsx @@ -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(null); + const [progress, setProgress] = useState(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 ; + + return ( +
+
+ {book.coverPath ? : } +
+ +
+ + {book.language ?? "langue inconnue"} +
+

{book.title}

+

{book.author ?? "Auteur inconnu"}

+

{book.description ?? "Notice absente du catalogue."}

+ {progress && } +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..f0daec1 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -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(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 ; + + return ( +
+
+
+

Cabinet de curiosites numerique

+

Ouvrir, classer, reprendre.

+ {fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."} +
+ +
+ + +
+

Reprise de lecture

+ {state.continueReading.length} traces +
+ {state.continueReading.length ? ( +
+ {state.continueReading.map((item) => ( + + ))} +
+ ) : ( + + )} +
+ + +
+

Bibliotheques

+ +
+
+ {state.libraries.map((library) => ( + + ))} +
+
+ +
+ {state.books.map((book) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/pages/LibraryPage.tsx b/apps/web/src/pages/LibraryPage.tsx new file mode 100644 index 0000000..c236922 --- /dev/null +++ b/apps/web/src/pages/LibraryPage.tsx @@ -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(null); + const [libraries, setLibraries] = useState([]); + + 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 ; + const library = libraries.find((item) => item.id === libraryId); + + return ( +
+ +
+
+

{library?.name ?? "Bibliotheque"}

+

{library?.path ?? "Rayonnage non identifie"}

+
+ {books.length} ouvrages +
+
+ {books.length ? ( +
+ {books.map((book) => ( + + ))} +
+ ) : ( + + + + )} +
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..9195481 --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -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 }) { + const [email, setEmail] = useState("admin@readabook.local"); + const [password, setPassword] = useState(""); + const [error, setError] = useState(); + + 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 ( +
+
+

Cabinet de curiosites numerique

+

ReadaBook

+ Bibliotheques EPUB et PDF, rangees comme des specimens vivants. +
+ + +

Entrer dans le cabinet

+ +
+ + + +
+ +
+
+ ); +} diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..e45f2a1 --- /dev/null +++ b/apps/web/src/pages/ProfilePage.tsx @@ -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 }) { + async function logout() { + await api.logout(); + await onSessionChange(); + navigate("/login"); + } + + return ( +
+ + +

{session.user?.name ?? "Lecteur invite"}

+

{session.user?.email ?? "Session non connectee"}

+ {session.user?.role ?? "vitrine"} + +
+
+ ); +} diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx new file mode 100644 index 0000000..21763fc --- /dev/null +++ b/apps/web/src/pages/ReaderPage.tsx @@ -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(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 ; + + return ( +
+
+ +
+ {book.title} + {saving ? "Sauvegarde" : "Progression synchronisee"} +
+ +
+ + {book.format === "pdf" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx new file mode 100644 index 0000000..e9a2f32 --- /dev/null +++ b/apps/web/src/pages/SearchPage.tsx @@ -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(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 ( +
+ +
+ + setQuery(event.target.value)} placeholder="Titre, auteur, ISBN" /> + + +
+ {!books ? ( + + ) : books.length ? ( +
+ {books.map((book) => ( + + ))} +
+ ) : ( + + + + )} +
+ ); +} diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx new file mode 100644 index 0000000..fa9eee2 --- /dev/null +++ b/apps/web/src/pages/SetupPage.tsx @@ -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(); + + 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 ( +
+
+

Premiere cle

+

Installer le cabinet

+ Un administrateur, puis les rayonnages. +
+ + +

Premier administrateur

+ +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx new file mode 100644 index 0000000..8fba5d0 --- /dev/null +++ b/apps/web/src/reader/EpubReader.tsx @@ -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(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 ( +
+