neon 854ab783c8 feat: multi-format read and download support
- add split Read button on book detail page (primary format direct, dropdown for others)
- add useBookDownload composable with auth-aware blob download and ZIP export
- add BookDownloadButton component for detail page and files tab
- refactor useBookBulkActions to delegate export to useBookDownload
- add Open/Download submenus to book card dropdown
- group Edit/Refresh/Regenerate under Metadata submenu in book card
2026-03-16 08:33:03 -06:00
2026-03-11 14:07:42 -06:00
2026-03-11 14:07:42 -06:00

ProjectX

A book and library management app with Kobo device support. Multi-user, self-hostable.

Tech stack: NestJS 11 (Fastify) + Vue 3 + PostgreSQL + Drizzle ORM


Quick Start

Prerequisites

Tool Version Install
Node.js >= 24 nodejs.org
pnpm >= 9 corepack enable && corepack prepare pnpm@latest --activate
Docker latest docker.com

Setup

git clone <repo-url> projectx
cd projectx
pnpm setup

Docker is only used for PostgreSQL. The server and client run directly on your machine.

Start developing

pnpm dev

This starts the NestJS server and Vue client concurrently:

If you prefer separate terminals (cleaner logs):

# Terminal 1
cd server && pnpm start:dev

# Terminal 2
cd client && pnpm dev

Both must be running. The client proxies all /api and /socket.io requests to the server, so API calls will fail if only the client is running.

First steps after setup

  1. Complete initial setup - you'll be redirected to /setup to create the first administrator account (username, password, name, and email).
  2. Create a library - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing .epub, .pdf, or .cbz files.
  3. Scan - the library scans automatically after creation. You should see books appear on the home page once the scan completes.

Where does data go? BOOKS_PATH in server/.env defaults to ../local/data (resolves to <project-root>/local/data). This is the app's data directory where extracted cover images and thumbnails are stored (local/data/covers/). It is not where your book files live - those paths are configured per-library in the UI. The local/ folder is gitignored.


Project Structure

projectx/
├── client/             Vue 3 frontend (Vite + Tailwind CSS v4)
├── server/             NestJS 11 backend (Fastify + Drizzle ORM)
├── packages/
│   └── types/          Shared TypeScript types (@projectx/types)
├── docker/
│   └── postgres/       Postgres init scripts (extensions)
├── docs/
│   └── production.md   Production deploy/backup/restore runbook
├── local/              Local dev data (covers, docs) - gitignored
├── docker-compose.yml  Dev: runs Postgres only (app runs natively)
├── docker-compose.prod.yml  Prod: app + Postgres + migration job
├── .env.prod.example   Template env file for production compose
├── Dockerfile          Production multi-stage build
└── Dockerfile.dev      Runs entire stack in Docker (not used for normal dev)

Monorepo layout

This is a pnpm workspace. The three packages are:

  • server/ - NestJS API. Modules live in src/modules/, one folder per feature. Database schema is in src/db/schema/.
  • client/ - Vue 3 SPA. Feature code lives in src/features/, shared components in src/components/ui/.
  • packages/types/ - Shared types imported as @projectx/types by both server and client. Add any type that crosses the API boundary here.

Common Commands

All commands run from the repo root unless noted otherwise.

Everyday workflow

Command Description
pnpm setup One-time bootstrap
pnpm dev Daily development
pnpm verify Default local checks before push
pnpm quick Faster local checks while coding
pnpm verify:strict Aspirational strict gate (format + full typecheck)
pnpm guide Print command reference

Development

Command Description
pnpm dev Start server + client (concurrently)
cd server && pnpm start:dev Server only (watch mode)
cd client && pnpm dev Client only (Vite dev server)

Testing

Command Description
pnpm test Server + client unit tests
pnpm test:server Server unit tests
pnpm test:client Client unit tests
pnpm test:e2e:smoke Server smoke e2e
pnpm coverage Coverage for server + client
pnpm --filter server test:e2e Server e2e only

Database

Command Description
pnpm db:up Start local Postgres
pnpm db:migrate Apply pending migrations
pnpm db:seed Seed baseline app data
pnpm db:reset Reset schema, migrate, and seed
pnpm --filter server db:generate <name> Generate a migration from schema changes
pnpm --filter server db:studio Open Drizzle Studio (DB browser)

Production operations

Command Description
pnpm prod:up Start/update production compose stack
pnpm prod:down Stop production compose stack
pnpm db:backup:prod Create Postgres backup (local/backups/)
pnpm db:restore:prod -- <file.dump> Restore backup into production database
pnpm db:restore:test:prod -- <file.dump> Restore into temp DB to validate backup file

Linting & formatting

Command Description
pnpm format:check Check formatting
pnpm format Auto-format source files
pnpm lint:check Non-mutating lint checks
pnpm lint:fix Auto-fix lint issues
pnpm typecheck Server typecheck + no-new client TS errors
pnpm typecheck:full Typecheck server + client (strict)
pnpm typecheck:client:baseline:update Refresh accepted client TS baseline

Development Workflow

Once pnpm dev is running, here's how the feedback loop works for each type of change:

Frontend (client/)

Save a file → Vite HMR hot-swaps the module → browser updates instantly. No restart needed.

Backend (server/)

Save a file → NestJS watch mode (SWC) recompiles and restarts the server automatically. Takes about a second.

Shared types (packages/types/)

The types package points directly at .ts source files. Edit a type → both client and server pick it up on their next rebuild cycle. No build step needed.

Database schema

Schema changes are the one thing that isn't automatic. See Database Workflow for the generate + migrate steps.

Environment variables

The start:dev script reads server/.env once at startup. If you change a value in .env, you must restart pnpm dev for it to take effect.

After git pull

If other people have pushed changes, you may need to sync dependencies and migrations:

pnpm install                      # pick up lockfile changes
pnpm db:migrate                   # apply any new migrations

Adding a dependency

Always install into the correct workspace, not the root:

cd server && pnpm add <pkg>       # backend dependency
cd client && pnpm add <pkg>       # frontend dependency

Adding a new feature

The fastest way to learn the patterns is to follow an existing module:

  • Backend: Look at server/src/modules/bookmark/ for a minimal example (controller, service, repository, DTO, module). Copy it, rename, and adapt.
  • Frontend: Look at client/src/features/collection/ for a full feature (composables for state/API, components for UI).
  • Shared types: If the feature adds API request/response shapes, add them in packages/types/src/ and import via @projectx/types.

Environment Variables

Server environment is configured in server/.env (created from .env.example during setup).

Variable Default Description
DATABASE_URL postgres://projectx:projectx@localhost:5432/projectx PostgreSQL connection string
PORT 3000 Server port
NODE_ENV development Environment mode
JWT_SECRET change-me-in-production JWT signing secret
JWT_EXPIRES_IN 15m Access token lifetime
JWT_REFRESH_EXPIRES_IN 7d Refresh token lifetime
SETUP_BOOTSTRAP_TOKEN (empty) Required in production for initial /api/v1/auth/setup
BOOKS_PATH ../local/data App data directory for cover images (not where books live)
SMTP_* (empty) Optional SMTP config for password-reset emails
APP_URL http://localhost:5173 Client URL (used in emails)

Production Deploy

Production uses a separate compose file and env file:

  • Compose: docker-compose.prod.yml
  • Env file: .env.prod (from .env.prod.example)
  • Image source: immutable images built in CI (.github/workflows/container-image.yml)

Quick start:

cp .env.prod.example .env.prod
# edit .env.prod and set APP_IMAGE + secrets
pnpm prod:up

Detailed runbook (deploy, backup, restore, restore drills): docs/production.md


Database Workflow

The database schema is defined in server/src/db/schema/ using Drizzle ORM. Each domain has its own schema file (e.g. books.ts, libraries.ts, auth.ts).

Making schema changes:

  1. Edit the relevant file in server/src/db/schema/
  2. Generate a migration: pnpm --filter server db:generate describe_your_change
  3. Apply it: pnpm db:migrate

Never hand-write migration SQL. Always generate from schema diffs.

Resetting the database from scratch

pnpm db:reset

Architecture Overview

┌──────────────┐       ┌──────────────────────┐       ┌────────────┐
│  Vue 3 SPA   │──────>│  NestJS API (/api)   │──────>│ PostgreSQL │
│  port 5173   │<──────│  port 3000           │<──────│ port 5432  │
└──────────────┘       └──────────────────────┘       └────────────┘
       │                        │
       │  WebSocket             │  Drizzle ORM
       │  (socket.io)           │  (schema in src/db/schema/)
       └────────────────────────┘
  • Backend: NestJS 11 on Fastify. Global prefix /api. Auth via JWT (access + refresh tokens in httpOnly cookies). RBAC with permissions system.
  • Frontend: Vue 3 Composition API (<script setup>). Tailwind CSS v4 for styling. Feature-local state in composables, not global stores.
  • Database: PostgreSQL 16 via Drizzle ORM. Migrations generated by Drizzle Kit.
  • Shared types: @projectx/types package ensures API contracts stay in sync.

Troubleshooting

Port 5432 already in use

Another PostgreSQL instance is running. Either stop it or change the port mapping in docker-compose.yml:

ports:
  - "5433:5432" # use 5433 on host

Then update DATABASE_URL in server/.env to use port 5433.

Port 3000 or 5173 already in use

Kill the process occupying the port:

lsof -ti:3000 | xargs kill -9   # server port
lsof -ti:5173 | xargs kill -9   # client port

pnpm db:migrate does nothing after a schema change

You need to generate a migration first:

cd server && pnpm db:generate describe_your_change
cd server && pnpm db:migrate

pnpm db:generate fails with a connection error

Drizzle Kit auto-loads server/.env for the DATABASE_URL. If the file is missing or the URL is wrong, generation fails. Verify server/.env exists and that PostgreSQL is running (docker compose ps).

The fallback URL in drizzle.config.ts uses postgres:5432 (the Docker service hostname), which only works inside Docker, not from the host machine.

Docker container won't start

docker compose down
docker compose up -d --wait

If the volume is corrupted, wipe it (destroys all data):

docker compose down -v
docker compose up -d --wait
cd server && pnpm db:migrate

Cannot find module '@projectx/types'

The shared types package needs to be built or the workspace link is broken:

pnpm install

CORS errors in the browser

Make sure you're accessing the client at http://localhost:5173 (not port 3000). The Vite dev server proxies API requests to the backend automatically.


Further Reading

Doc What it covers
docs/production.md Production compose deployment, backups, restore testing
server/README.md Backend module map, DB commands, NestJS conventions
client/README.md Frontend project layout, IDE setup, Vue/Tailwind conventions
packages/types/ Shared type definitions between server and client
Languages
TypeScript 74.3%
Vue 16%
Lua 7.6%
JavaScript 1.5%
CSS 0.3%
Other 0.2%