chore: implement local quality foundation

This commit is contained in:
neon
2026-03-06 09:08:15 -06:00
parent aeae0ac8ce
commit 891cd19f46
46 changed files with 891 additions and 189 deletions
+1
View File
@@ -0,0 +1 @@
_
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
HUSKY_SH="$(dirname -- "$0")/_/husky.sh"
[ -f "$HUSKY_SH" ] && . "$HUSKY_SH"
pnpm exec lint-staged
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
HUSKY_SH="$(dirname -- "$0")/_/husky.sh"
[ -f "$HUSKY_SH" ] && . "$HUSKY_SH"
pnpm run quick
+52
View File
@@ -0,0 +1,52 @@
# Contributing
## Daily Workflow
Use this command surface:
1. `pnpm setup` (once per machine)
2. `pnpm dev` (daily coding loop)
3. `pnpm verify` (before push)
Optional:
1. `pnpm quick` (faster checks while coding)
2. `pnpm verify:strict` (aspirational strict gate while we burn down baseline debt)
3. `pnpm guide` (show command reference)
## Quality Gates
Before opening or updating a PR:
1. `pnpm verify` must pass
2. `pnpm verify:strict` is currently optional until formatting and full client typecheck baseline debt is reduced.
3. If tests touched critical flows, run `pnpm test:e2e:smoke`
4. If coverage-sensitive changes are made, run `pnpm coverage`
## Check vs Fix Commands
Non-mutating:
1. `pnpm format:check`
2. `pnpm lint:check`
3. `pnpm typecheck`
4. `pnpm typecheck:full`
5. `pnpm typecheck:client:baseline:update` (update accepted client TS baseline after intentional changes)
Mutating:
1. `pnpm format`
2. `pnpm lint:fix`
## Database Commands
1. `pnpm db:up`
2. `pnpm db:migrate`
3. `pnpm db:seed`
4. `pnpm db:reset`
## Troubleshooting
1. Run `pnpm doctor` for environment checks.
2. If dependencies changed, run `pnpm install`.
3. If local DB state is inconsistent, run `pnpm db:reset`.
+48 -48
View File
@@ -11,7 +11,7 @@ A book and library management app with Kobo device support. Multi-user, self-hos
### Prerequisites
| Tool | Version | Install |
|---------|---------|--------------------------------------------------------------|
| ------- | ------- | ------------------------------------------------------------ |
| Node.js | >= 24 | [nodejs.org](https://nodejs.org) |
| pnpm | >= 9 | `corepack enable && corepack prepare pnpm@latest --activate` |
| Docker | latest | [docker.com](https://www.docker.com/products/docker-desktop) |
@@ -21,10 +21,7 @@ A book and library management app with Kobo device support. Multi-user, self-hos
```bash
git clone <repo-url> projectx
cd projectx
docker compose up -d --wait # start Postgres
cp server/.env.example server/.env # create env config (edit if needed)
pnpm install # install all dependencies
cd server && pnpm db:migrate && cd .. # apply database migrations
pnpm setup
```
Docker is only used for PostgreSQL. The server and client run directly on your machine.
@@ -55,8 +52,8 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to
### First steps after setup
1. **Log in** - a default admin account is created automatically:
- Username: `admin`
- Password: `admin`
- Username: `admin`
- Password: `admin`
2. **Change password** - you'll be prompted to set a new password on first login. Use `Admin123!` for local dev.
3. **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.
4. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
@@ -95,40 +92,58 @@ This is a **pnpm workspace**. The three packages are:
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 |
|----------------------------------------------------|---------------------------|
| `cd server && pnpm test` | Server unit tests |
| `cd server && pnpm test -- --testPathPattern=scan` | Run matching server tests |
| `cd client && pnpm test:unit` | Client unit tests |
| `cd client && pnpm test:unit -- BookCover` | Run matching client tests |
| `cd server && pnpm test:e2e` | Server E2E tests |
| 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 |
|----------------------------------------|------------------------------------------|
| `cd server && pnpm db:migrate` | Apply pending migrations |
| `cd server && pnpm db:generate <name>` | Generate a migration from schema changes |
| `cd server && pnpm db:studio` | Open Drizzle Studio (DB browser) |
| 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) |
### Linting & formatting
| Command | Description |
|---------------------------------------|--------------------|
| `cd server && npx prettier --write .` | Format server code |
| `cd client && npx prettier --write .` | Format client code |
| `cd server && pnpm lint` | Lint server |
| `cd client && pnpm lint` | Lint client |
| 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 |
---
@@ -162,7 +177,7 @@ If other people have pushed changes, you may need to sync dependencies and migra
```bash
pnpm install # pick up lockfile changes
cd server && pnpm db:migrate # apply any new migrations
pnpm db:migrate # apply any new migrations
```
### Adding a dependency
@@ -189,7 +204,7 @@ The fastest way to learn the patterns is to follow an existing module:
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 |
@@ -209,31 +224,17 @@ The database schema is defined in `server/src/db/schema/` using Drizzle ORM. Eac
**Making schema changes:**
1. Edit the relevant file in `server/src/db/schema/`
2. Generate a migration: `cd server && pnpm db:generate describe_your_change`
3. Apply it: `cd server && pnpm db:migrate`
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
```bash
# 1. Drop and recreate the public schema
docker exec $(docker ps --filter "name=postgres" --format "{{.Names}}" | head -1) \
psql -U projectx -d projectx -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
# 2. Clear Drizzle's migration tracking (it lives in a separate schema)
docker exec $(docker ps --filter "name=postgres" --format "{{.Names}}" | head -1) \
psql -U projectx -d projectx -c "DELETE FROM drizzle.__drizzle_migrations;"
# 3. Re-apply all migrations
cd server && pnpm db:migrate
# 4. (Optional) Wipe cover images
rm -rf local/data/covers
pnpm db:reset
```
Step 2 is necessary because Drizzle tracks applied migrations in `drizzle.__drizzle_migrations`, which lives outside the `public` schema and survives the drop.
---
## Architecture Overview
@@ -264,7 +265,7 @@ Another PostgreSQL instance is running. Either stop it or change the port mappin
```yaml
ports:
- "5433:5432" # use 5433 on host
- "5433:5432" # use 5433 on host
```
Then update `DATABASE_URL` in `server/.env` to use port `5433`.
@@ -325,8 +326,7 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000)
## Further Reading
| Doc | What it covers |
|----------------------------------------|--------------------------------------------------------------|
| -------------------------------------- | ------------------------------------------------------------ |
| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions |
| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions |
| [`packages/types/`](packages/types/) | Shared type definitions between server and client |
+2 -1
View File
@@ -43,7 +43,8 @@ src/
## Testing
```bash
pnpm test:unit # run Vitest
pnpm test:unit # run unit tests
pnpm test:cov # run unit tests with coverage
```
## Key conventions
+28 -21
View File
@@ -8,57 +8,64 @@
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:cov": "vitest --coverage",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
"lint": "pnpm run lint:check",
"lint:check": "run-s lint:oxlint:check lint:eslint:check",
"lint:fix": "run-s lint:oxlint:fix lint:eslint:fix",
"lint:oxlint:check": "oxlint src",
"lint:oxlint:fix": "oxlint src --fix",
"lint:eslint:check": "eslint src --cache",
"lint:eslint:fix": "eslint src --fix --cache",
"format": "prettier --write --experimental-cli src/",
"format:check": "prettier --check src/"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8",
"@projectx/types": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.2.1",
"@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.564.0",
"pdfjs-dist": "^5.4.624",
"lucide-vue-next": "^0.577.0",
"pdfjs-dist": "^5.5.207",
"pinia": "^3.0.4",
"reka-ui": "^2.8.0",
"reka-ui": "^2.9.0",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.28",
"vue-router": "^5.0.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"vue-sonner": "^2.0.9",
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
"@types/jsdom": "^28.0.0",
"@types/node": "^24.11.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vitest/eslint-plugin": "^1.6.7",
"@vue/eslint-config-typescript": "^14.6.0",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^10.0.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-oxlint": "~1.50.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.0.0",
"jsdom": "^28.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.47.0",
"oxlint": "~1.50.0",
"prettier": "3.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4"
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
+10 -3
View File
@@ -4,7 +4,6 @@ import { CheckSquare, LayoutGrid, List, MoreHorizontal, SlidersHorizontal, Squar
import * as LucideIcons from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import {
DropdownMenu,
@@ -64,7 +63,11 @@ function getIconComponent(name: string) {
variant="ghost"
size="sm"
class="hidden md:flex h-8 gap-1.5 text-[11px] font-bold uppercase tracking-tight px-2.5 rounded-lg transition-all"
:class="selectionMode ? 'text-primary bg-primary/10 hover:bg-primary/20 ring-1 ring-primary/20' : 'text-muted-foreground/60 hover:text-foreground hover:bg-primary/5'"
:class="
selectionMode
? 'text-primary bg-primary/10 hover:bg-primary/20 ring-1 ring-primary/20'
: 'text-muted-foreground/60 hover:text-foreground hover:bg-primary/5'
"
@click="emit('toggle-selection')"
>
<CheckSquare v-if="selectionMode" :size="13" />
@@ -101,7 +104,11 @@ function getIconComponent(name: string) {
<!-- Desktop: display settings popover -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8 rounded-lg text-muted-foreground/50 hover:text-foreground hover:bg-primary/5">
<Button
variant="ghost"
size="icon"
class="hidden md:flex h-8 w-8 rounded-lg text-muted-foreground/50 hover:text-foreground hover:bg-primary/5"
>
<SlidersHorizontal :size="14" />
</Button>
</PopoverTrigger>
@@ -1,5 +1,4 @@
import type { OidcPublicConfig } from '@projectx/types'
import { api } from '@/lib/api'
async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
const array = new Uint8Array(32)
@@ -104,8 +104,12 @@ function openFile(file: BookFileRef) {
<template>
<div
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors cursor-pointer hover:bg-muted/50"
:class="[selectionMode ? 'cursor-pointer select-none' : '', selected ? 'bg-primary/8 ring-1 ring-primary/30' : '']"
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors cursor-pointer"
:class="[
selectionMode ? 'cursor-pointer select-none' : '',
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
isMissing ? 'grayscale opacity-60' : 'hover:bg-muted/50',
]"
@click="selectionMode ? emit('select', $event) : emit('action', 'quick-view')"
>
<!-- Selection checkbox -->
@@ -160,11 +164,7 @@ function openFile(file: BookFileRef) {
<div class="hidden sm:flex items-center gap-0.5" @mouseleave="hoverRating = null">
<Tooltip v-for="star in 5" :key="star">
<TooltipTrigger as-child>
<button
class="p-0.5 transition-colors"
@mouseenter="hoverRating = star"
@click="setRating(star)"
>
<button class="p-0.5 transition-colors" @mouseenter="hoverRating = star" @click="setRating(star)">
<Star class="size-3" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40'" />
</button>
</TooltipTrigger>
@@ -1,9 +1,12 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeAll } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import BookCoverCard from '../BookCoverCard.vue'
import type { BookCard } from '@projectx/types'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn() }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
}))
@@ -30,6 +33,13 @@ const missingBook: BookCard = {
seriesName: null,
seriesIndex: null,
files: [],
publishedYear: null,
language: null,
genres: [],
tags: [],
rating: null,
readingProgress: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
const presentBook: BookCard = {
@@ -40,6 +50,13 @@ const presentBook: BookCard = {
seriesName: null,
seriesIndex: null,
files: [{ id: 10, format: 'epub', role: 'primary' }],
publishedYear: 2024,
language: 'en',
genres: [],
tags: [],
rating: null,
readingProgress: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
describe('BookCoverCard — missing state', () => {
@@ -66,7 +83,8 @@ describe('BookCoverCard — missing state', () => {
it('uses cursor-default on the root when book is missing', () => {
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
expect(wrapper.classes()).toContain('cursor-default')
const root = wrapper.find('div.group')
expect(root.classes()).toContain('cursor-default')
})
})
@@ -3,7 +3,10 @@ import { describe, it, expect, vi } from 'vitest'
import BookListRow from '../BookListRow.vue'
import type { BookCard } from '@projectx/types'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn() }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
}))
@@ -18,6 +21,9 @@ const globalStubs = {
DropdownMenuItem: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<div />' },
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
},
}
@@ -29,6 +35,13 @@ const missingBook: BookCard = {
seriesName: null,
seriesIndex: null,
files: [],
publishedYear: null,
language: null,
genres: [],
tags: [],
rating: null,
readingProgress: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
const presentBook: BookCard = {
@@ -39,6 +52,13 @@ const presentBook: BookCard = {
seriesName: null,
seriesIndex: null,
files: [{ id: 10, format: 'epub', role: 'primary' }],
publishedYear: 2024,
language: 'en',
genres: ['Fiction'],
tags: [],
rating: null,
readingProgress: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
describe('BookListRow — missing state', () => {
@@ -3,7 +3,10 @@ import { describe, it, expect, vi } from 'vitest'
import DetailsTab from '../detail/tabs/DetailsTab.vue'
import type { BookDetail } from '@projectx/types'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }
})
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => false }),
}))
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { Search, Loader2, X, Image as ImageIcon, ExternalLink, Check } from 'lucide-vue-next'
import { Search, Loader2, X, Image as ImageIcon, Check } from 'lucide-vue-next'
import type { CoverSearchResult } from '@projectx/types'
const props = defineProps<{
@@ -122,7 +122,9 @@ onMounted(() => {
<img :src="res.previewUrl" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
<!-- Hover Overlay -->
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
<div
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2"
>
<div class="p-2 rounded-full bg-primary text-white scale-75 group-hover:scale-100 transition-transform">
<Check class="size-5" />
</div>
@@ -130,8 +132,10 @@ onMounted(() => {
</div>
<!-- Resolution Badge -->
<div class="absolute top-2 right-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white backdrop-blur-md shadow-sm"
:class="res.width >= 1000 ? 'bg-green-500/80' : 'bg-black/50'">
<div
class="absolute top-2 right-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white backdrop-blur-md shadow-sm"
:class="res.width >= 1000 ? 'bg-green-500/80' : 'bg-black/50'"
>
{{ res.width }}x{{ res.height }}
</div>
@@ -182,10 +186,10 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.2);
background: rgba(0, 0, 0, 0.2);
}
</style>
@@ -1,4 +1,4 @@
import { computed, ref, type Ref } from 'vue'
import { computed, ref } from 'vue'
const bookIds = ref<number[]>([])
const total = ref(0)
@@ -75,9 +75,17 @@ function handleImageClick(e: MouseEvent) {
return
}
const x = e.clientX / window.innerWidth
if (x < 0.25) direction.value === 'rtl' ? nextPage() : prevPage()
else if (x > 0.75) direction.value === 'rtl' ? prevPage() : nextPage()
else handleMiddleTap()
if (x < 0.25) {
if (direction.value === 'rtl') nextPage()
else prevPage()
return
}
if (x > 0.75) {
if (direction.value === 'rtl') prevPage()
else nextPage()
return
}
handleMiddleTap()
}
// ── Touch / swipe ──────────────────────────────────────────────────────────────
@@ -90,8 +98,13 @@ function onTouchStart(e: TouchEvent) {
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchStartX
if (Math.abs(dx) < 50) return
if (dx < 0) direction.value === 'rtl' ? prevPage() : nextPage()
else direction.value === 'rtl' ? nextPage() : prevPage()
if (dx < 0) {
if (direction.value === 'rtl') prevPage()
else nextPage()
return
}
if (direction.value === 'rtl') nextPage()
else prevPage()
}
// ── Wheel (paginated mode only) ────────────────────────────────────────────────
@@ -111,16 +124,19 @@ function onKeyDown(e: KeyboardEvent) {
case 'ArrowRight':
case 'PageDown':
e.preventDefault()
isRtl ? prevPage() : nextPage()
if (isRtl) prevPage()
else nextPage()
break
case 'ArrowLeft':
case 'PageUp':
e.preventDefault()
isRtl ? nextPage() : prevPage()
if (isRtl) nextPage()
else prevPage()
break
case ' ':
e.preventDefault()
e.shiftKey ? prevPage() : nextPage()
if (e.shiftKey) prevPage()
else nextPage()
break
case 'Home':
goToPage(0)
@@ -2,7 +2,7 @@
import { nextTick, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, X } from 'lucide-vue-next'
const props = defineProps<{
defineProps<{
matchCount: number
currentIndex: number
}>()
@@ -42,10 +42,7 @@ defineExpose({
</script>
<template>
<div
class="flex items-center gap-1.5 px-3 py-1.5 shrink-0"
style="background: rgba(60, 65, 70, 1); border-bottom: 1px solid rgba(0,0,0,0.3)"
>
<div class="flex items-center gap-1.5 px-3 py-1.5 shrink-0" style="background: rgba(60, 65, 70, 1); border-bottom: 1px solid rgba(0, 0, 0, 0.3)">
<input
ref="inputRef"
v-model="query"
@@ -53,8 +50,8 @@ defineExpose({
placeholder="Find in document..."
class="flex-1 min-w-0 bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30"
style="max-width: 260px"
@keydown.enter="emit('next')"
@keydown.shift.enter.prevent="emit('prev')"
@keydown.enter.exact="emit('next')"
@keydown.enter.shift.exact.prevent="emit('prev')"
@keydown.escape="emit('close')"
/>
@@ -95,4 +92,3 @@ defineExpose({
</button>
</div>
</template>
@@ -1,4 +1,4 @@
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { api } from '@/lib/api'
export interface Bookmark {
@@ -1,4 +1,4 @@
import { ref, shallowRef } from 'vue'
import { ref } from 'vue'
import type * as pdfjsLib from 'pdfjs-dist'
import type { Ref, ShallowRef } from 'vue'
@@ -39,4 +39,3 @@ export function usePdfThumbnails(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy |
return { thumbnails, loadThumbnail, loadRange }
}
@@ -43,8 +43,8 @@ export function useReaderSettings(bookFileId: number, format: string) {
() =>
({
...(READER_GROUP_DEFAULTS[group] as ReaderSettings),
...(defaultSettings.value ?? {}),
...(bookDelta.value ?? {}),
...(defaultSettings.value ?? undefined),
...(bookDelta.value ?? undefined),
}) as ReaderSettings,
)
@@ -84,7 +84,7 @@ export function useReaderSettings(bookFileId: number, format: string) {
// Merges only the changed field(s) into the existing delta — never saves a full snapshot.
function updateBookSettings(patch: Partial<ReaderSettings>) {
const next = { ...(bookDelta.value ?? {}), ...patch } as Partial<ReaderSettings>
const next = { ...(bookDelta.value ?? undefined), ...patch } as Partial<ReaderSettings>
bookDelta.value = next
isCustomized.value = Object.keys(next).length > 0
writeLs(lsBookKey(bookFileId), next)
@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { StagingFile, StagingMetadata } from '@projectx/types'
@@ -1,4 +1,4 @@
import { computed, reactive, ref, watch } from 'vue'
import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import type { StagingFile, StagingFilesPage, StagingFileStatus } from '@projectx/types'
@@ -142,4 +142,3 @@ export function useStagingFiles() {
getSelectionPayload,
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*", "eslint.config.*"],
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "eslint.config.*"],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
+64
View File
@@ -0,0 +1,64 @@
# Client typecheck baseline
# Auto-generated by: pnpm run typecheck:client:baseline:update
# One diagnostic per line. Do not edit manually unless necessary.
src/components/IconPicker.vue(174,39): error TS18046: 'item' is of type 'unknown'.
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/library/components/LibraryCreatorDetails.vue(109,43): error TS18046: 'item' is of type 'unknown'.
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/components/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
src/features/reader/components/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
src/features/reader/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
src/features/reader/components/PdfToolbar.vue(216,93): error TS2769: No overload matches this call.
src/features/reader/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
src/features/reader/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
src/features/reader/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
src/features/reader/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
src/features/reader/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
src/features/reader/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
src/features/reader/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/composables/usePdfOutline.ts(22,72): error TS2694: Namespace '"node_modules/.pnpm/pdfjs-dist@5.5.207/node_modules/pdfjs-dist/types/src/pdf"' has no exported member 'RefProxy'.
src/features/reader/composables/usePdfOutline.ts(29,74): error TS2694: Namespace '"node_modules/.pnpm/pdfjs-dist@5.5.207/node_modules/pdfjs-dist/types/src/pdf"' has no exported member 'OutlineNode'.
src/features/reader/composables/usePdfRenderer.ts(96,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/composables/usePdfRenderer.ts(97,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/composables/usePdfRenderer.ts(98,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/composables/usePdfRenderer.ts(98,50): error TS18048: 'b' is possibly 'undefined'.
src/features/reader/composables/usePdfThumbnails.ts(23,25): error TS2345: Argument of type '{ canvasContext: CanvasRenderingContext2D; viewport: PageViewport; }' is not assignable to parameter of type 'RenderParameters'.
src/features/reader/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
src/features/reader/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue(130,25): error TS2352: Conversion of type '{ enabled: boolean; apiKey: string; } | { enabled: boolean; domain: string; cookie: string; } | { enabled: boolean; } | { enabled: boolean; apiKey: string; }' to type 'Record<string, string>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue(138,27): error TS2352: Conversion of type '{ enabled: boolean; apiKey: string; } | { enabled: boolean; domain: string; cookie: string; } | { enabled: boolean; } | { enabled: boolean; apiKey: string; }' to type 'Record<string, string>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
src/features/settings/PdfSettings.vue(45,17): error TS2367: This comparison appears to be unintentional because the types '"vertical" | "horizontal" | "page" | "wrapped"' and '"continuous"' have no overlap.
src/features/settings/PdfSettings.vue(47,32): error TS2322: Type '"continuous"' is not assignable to type '"vertical" | "horizontal" | "page" | "wrapped" | undefined'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
+11
View File
@@ -9,6 +9,17 @@ export default mergeConfig(
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
reportsDirectory: './coverage',
thresholds: {
lines: 1,
statements: 1,
functions: 1,
branches: 1,
},
},
},
}),
)
+56 -6
View File
@@ -2,24 +2,74 @@
"name": "projectx",
"private": true,
"scripts": {
"help": "node ./scripts/help.mjs",
"guide": "node ./scripts/help.mjs",
"setup": "bash ./scripts/setup.sh",
"doctor": "bash ./scripts/doctor.sh",
"dev": "concurrently --names \"server,client\" --prefix-colors \"blue,green\" \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "cd client && npm run dev -- --host",
"dev:server": "cd server && npm run start:dev",
"quick": "pnpm run verify:fast",
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke",
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke",
"build:client": "pnpm --filter client build",
"build:server": "pnpm --filter server build",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"db:reset": "bash scripts/db/reset.sh",
"db:seed": "bash scripts/postman/seed.sh"
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client:baseline",
"typecheck:full": "pnpm run typecheck:server && pnpm run typecheck:client",
"typecheck:server": "pnpm --filter server type-check",
"typecheck:client": "pnpm --filter client type-check",
"typecheck:client:baseline": "node ./scripts/client-typecheck-baseline.mjs check",
"typecheck:client:baseline:update": "node ./scripts/client-typecheck-baseline.mjs update",
"lint": "pnpm run lint:check",
"lint:check": "pnpm --filter server lint:check && pnpm --filter client lint:check",
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
"format": "pnpm --filter server format && pnpm --filter client format",
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
"test": "pnpm run test:server && pnpm run test:client",
"test:server": "pnpm --filter server test",
"test:client": "pnpm --filter client test:unit --run",
"test:e2e:smoke": "pnpm --filter server test:e2e",
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
"coverage:server": "pnpm --filter server test:cov",
"coverage:client": "pnpm --filter client test:cov --run",
"db:up": "docker compose up -d --wait",
"db:migrate": "pnpm --filter server db:migrate",
"db:reset": "bash ./scripts/db/reset.sh",
"db:seed": "bash ./scripts/db/seed.sh",
"prepare": "husky"
},
"engines": {
"node": ">=24",
"pnpm": ">=9"
},
"devDependencies": {
"concurrently": "^9.2.1"
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"prettier": "^3.6.2"
},
"lint-staged": {
"*.{md,json,yml,yaml}": [
"pnpm exec prettier --write"
],
"client/src/**/*.{ts,vue}": [
"pnpm --dir client exec eslint --fix",
"pnpm exec prettier --write"
],
"server/{src,test}/**/*.ts": [
"pnpm --dir server exec eslint --fix",
"pnpm exec prettier --write"
]
},
"pnpm": {
"onlyBuiltDependencies": ["@nestjs/core", "@parcel/watcher", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
"onlyBuiltDependencies": [
"@nestjs/core",
"@parcel/watcher",
"@swc/core",
"esbuild",
"sharp",
"unrs-resolver"
]
}
}
+120
View File
@@ -0,0 +1,120 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const mode = process.argv[2] ?? 'check'
const rootDir = process.cwd()
const baselinePath = resolve(rootDir, 'client/typecheck-baseline.txt')
function runClientTypecheck() {
const result = spawnSync('pnpm', ['--filter', 'client', 'exec', 'vue-tsc', '--build', '--pretty', 'false'], {
cwd: rootDir,
encoding: 'utf8',
})
if (result.error) {
throw result.error
}
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
const diagnostics = parseDiagnostics(combinedOutput)
return { result, combinedOutput, diagnostics }
}
function parseDiagnostics(output) {
const regex = /^(.*\.(?:ts|tsx|vue)\(\d+,\d+\): error TS\d+: .*)$/gm
const rows = []
for (const match of output.matchAll(regex)) {
const raw = String(match[1] ?? '').trim()
rows.push(normalizeDiagnostic(raw))
}
return [...new Set(rows)].sort((a, b) => a.localeCompare(b))
}
function normalizeDiagnostic(row) {
const rootPrefix = `${rootDir.replaceAll('\\', '/')}/`
let normalized = row.replaceAll('\\', '/')
normalized = normalized.split(`${rootPrefix}client/`).join('')
normalized = normalized.split(rootPrefix).join('')
if (normalized.startsWith('client/')) normalized = normalized.slice('client/'.length)
return normalized
}
function readBaseline() {
if (!existsSync(baselinePath)) return []
return readFileSync(baselinePath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
function writeBaseline(diagnostics) {
mkdirSync(dirname(baselinePath), { recursive: true })
const header = [
'# Client typecheck baseline',
'# Auto-generated by: pnpm run typecheck:client:baseline:update',
'# One diagnostic per line. Do not edit manually unless necessary.',
'',
]
writeFileSync(baselinePath, `${header.join('\n')}${diagnostics.join('\n')}\n`, 'utf8')
}
if (mode === 'update') {
const { result, combinedOutput, diagnostics } = runClientTypecheck()
if (result.status !== 0 && diagnostics.length === 0) {
console.error('Client typecheck failed without parsable diagnostics output.')
console.error(combinedOutput)
process.exit(1)
}
writeBaseline(diagnostics)
console.log(`Updated ${baselinePath} with ${diagnostics.length} diagnostics.`)
process.exit(0)
}
if (mode !== 'check') {
console.error(`Unknown mode "${mode}". Use "check" or "update".`)
process.exit(2)
}
const { result, combinedOutput, diagnostics } = runClientTypecheck()
if (result.status === 0) {
console.log('Client typecheck is clean.')
process.exit(0)
}
if (diagnostics.length === 0) {
console.error('Client typecheck failed without parsable diagnostics output.')
console.error(combinedOutput)
process.exit(1)
}
const baseline = readBaseline()
if (baseline.length === 0) {
console.error(`No baseline found at ${baselinePath}.`)
console.error('Run: pnpm run typecheck:client:baseline:update')
process.exit(1)
}
const baselineSet = new Set(baseline)
const newDiagnostics = diagnostics.filter((line) => !baselineSet.has(line))
if (newDiagnostics.length > 0) {
console.error(`Found ${newDiagnostics.length} new client type errors not present in baseline:`)
for (const line of newDiagnostics) {
console.error(`- ${line}`)
}
console.error('If these are intentional and accepted, refresh baseline:')
console.error('pnpm run typecheck:client:baseline:update')
process.exit(1)
}
const resolvedCount = baseline.filter((line) => !diagnostics.includes(line)).length
console.log(`No new client type errors. Baseline size: ${baseline.length}. Current: ${diagnostics.length}. Resolved: ${resolvedCount}.`)
if (resolvedCount > 0) {
console.log('You can refresh baseline to capture improvements:')
console.log('pnpm run typecheck:client:baseline:update')
}
process.exit(0)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
echo "Ensuring PostgreSQL is running..."
docker compose up -d --wait postgres
echo "Resetting database schema..."
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE SCHEMA IF NOT EXISTS drizzle;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM drizzle.__drizzle_migrations;"
echo "Re-applying migrations..."
pnpm run db:migrate
echo "Re-seeding baseline data..."
pnpm run db:seed
echo "Cleaning generated local covers..."
rm -rf local/data/covers local/data/staging/covers
echo "Database reset complete."
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
pnpm --filter server db:seed
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "Checking toolchain..."
node -v
pnpm -v
echo "Checking environment files..."
[[ -f server/.env.example ]] || { echo "Missing server/.env.example"; exit 1; }
[[ -f server/.env ]] || { echo "Missing server/.env (run: pnpm setup)"; exit 1; }
echo "Checking PostgreSQL container..."
docker compose ps postgres
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
echo "Checking local scripts..."
[[ -d scripts/db ]] || { echo "Missing scripts/db helpers"; exit 1; }
[[ -f scripts/help.mjs ]] || { echo "Missing scripts/help.mjs"; exit 1; }
echo "Doctor check passed."
+24
View File
@@ -0,0 +1,24 @@
const lines = [
'ProjectX local workflow',
'',
'Core commands',
' pnpm setup # one-time bootstrap on a machine',
' pnpm dev # run server + client for daily work',
' pnpm verify # default local quality gate before push',
'',
'Optional',
' pnpm quick # faster local confidence loop',
' pnpm verify:strict # aspirational strict gate (format + full typecheck)',
' pnpm guide # print this command reference',
' pnpm typecheck:client:baseline:update # refresh accepted client TS baseline',
' pnpm doctor # environment health checks',
' pnpm coverage # coverage reports for server + client',
'',
'Database helpers',
' pnpm db:up',
' pnpm db:migrate',
' pnpm db:reset',
' pnpm db:seed',
];
process.stdout.write(`${lines.join('\n')}\n`);
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ ! -f server/.env ]]; then
cp server/.env.example server/.env
echo "Created server/.env from server/.env.example"
fi
echo "Installing dependencies..."
pnpm install
echo "Starting PostgreSQL..."
docker compose up -d --wait
echo "Applying migrations..."
pnpm run db:migrate
echo "Seeding baseline data..."
pnpm run db:seed
pnpm run doctor
+10
View File
@@ -54,6 +54,16 @@ pnpm test:e2e # end-to-end tests
pnpm test:cov # unit tests with coverage
```
## Quality
```bash
pnpm type-check # TypeScript checks (no emit)
pnpm lint:check # Non-mutating lint checks
pnpm lint:fix # Auto-fix lint issues
pnpm format:check # Prettier check
pnpm format # Prettier write
```
## Key conventions
- Constructor injection with `@Inject(DB)` for the Drizzle instance.
+7 -1
View File
@@ -28,7 +28,13 @@ export default tseslint.config(
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
+23 -8
View File
@@ -8,6 +8,7 @@
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "sh -c 'export $(grep -v \"^#\" .env | xargs) 2>/dev/null; exec nest start --watch'",
"start:debug": "nest start --debug --watch",
@@ -15,14 +16,18 @@
"db:generate": "drizzle-kit generate --name",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/scripts/db-seed.ts",
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
"sim:missing": "ts-node -r tsconfig-paths/register src/scripts/missing-detection-sim.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit -p tsconfig.build.json",
"lint": "pnpm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand"
},
"dependencies": {
"7z-wasm": "^1.2.0",
@@ -38,11 +43,11 @@
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.14",
"@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/platform-fastify": "^11.1.16",
"@nestjs/platform-socket.io": "^11.1.16",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/websockets": "^11.1.14",
"@nestjs/websockets": "^11.1.16",
"@parcel/watcher": "^2.5.6",
"@projectx/types": "workspace:*",
"@types/archiver": "^7.0.0",
@@ -52,15 +57,17 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "^5.4.1",
"jose": "^6.1.3",
"fast-xml-parser": "^5.4.2",
"fastify": "^5.8.1",
"jose": "^6.2.0",
"ms": "^2.1.3",
"nestjs-pino": "^4.6.0",
"node-unrar-js": "^2.0.2",
"nodemailer": "^8.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
"pg": "^8.19.0",
"pg": "^8.20.0",
"pino-http": "^11.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -133,6 +140,14 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"coverageThreshold": {
"global": {
"lines": 8,
"statements": 8,
"functions": 3,
"branches": 5
}
},
"testEnvironment": "node"
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import type { StringValue } from 'ms';
import { CommonModule } from '../../common/common.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
@@ -25,8 +26,8 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn') },
secret: config.getOrThrow<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
}),
}),
UserModule,
+3 -13
View File
@@ -204,10 +204,7 @@ export class BookRepository {
metadataHash: koboSnapshotBooks.metadataHash,
})
.from(koboLibrarySnapshots)
.leftJoin(
koboSnapshotBooks,
and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)),
)
.leftJoin(koboSnapshotBooks, and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)))
.where(eq(koboLibrarySnapshots.userId, userId))
.limit(1);
return row ?? null;
@@ -217,10 +214,7 @@ export class BookRepository {
const rows = await this.db
.select({ name: collections.name })
.from(collectionBooks)
.innerJoin(
collections,
and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId), eq(collections.syncToKobo, true)),
)
.innerJoin(collections, and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId), eq(collections.syncToKobo, true)))
.where(eq(collectionBooks.bookId, bookId));
return rows.map((r) => r.name);
}
@@ -252,11 +246,7 @@ export class BookRepository {
.where(
and(
inArray(books.libraryId, libraryIds),
or(
sql`${bookMetadata.title} ILIKE ${pattern}`,
sql`${bookMetadata.seriesName} ILIKE ${pattern}`,
isNotNull(matchedAuthors.bookId),
),
or(sql`${bookMetadata.title} ILIKE ${pattern}`, sql`${bookMetadata.seriesName} ILIKE ${pattern}`, isNotNull(matchedAuthors.bookId)),
),
)
.orderBy(bookMetadata.title)
+10 -5
View File
@@ -129,8 +129,15 @@ export class BookService {
}
private sanitizeFilenameSegment(raw: string, fallback = 'download'): string {
const fallbackSafe = fallback.replace(/[/\\:*?"<>|\0]/g, '_').trim().slice(0, 255) || 'download';
const cleaned = raw.replace(/[/\\:*?"<>|\0]/g, '_').trim().slice(0, 255);
const fallbackSafe =
fallback
.replace(/[/\\:*?"<>|\0]/g, '_')
.trim()
.slice(0, 255) || 'download';
const cleaned = raw
.replace(/[/\\:*?"<>|\0]/g, '_')
.trim()
.slice(0, 255);
if (!cleaned || cleaned === '.' || cleaned === '..') return fallbackSafe;
return cleaned;
}
@@ -207,9 +214,7 @@ export class BookService {
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
return this.sanitizeFilenameSegment(resolvedName ?? originalFilename, originalFilename);
} catch (err) {
this.logger.warn(
`Download filename pattern resolution failed for book ${file.bookId}: ${err instanceof Error ? err.message : String(err)}`,
);
this.logger.warn(`Download filename pattern resolution failed for book ${file.bookId}: ${err instanceof Error ? err.message : String(err)}`);
return this.sanitizeFilenameSegment(originalFilename, originalFilename);
}
}
+1 -15
View File
@@ -1,18 +1,4 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, Req, Res } from '@nestjs/common';
import type { MultipartFile } from '@fastify/multipart';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
+1 -2
View File
@@ -48,8 +48,7 @@ export class CoverService {
try {
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
},
});
if (!response.ok) throw new Error(`Proxy failed: ${response.status}`);
+3 -2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { AuthModule } from '../auth/auth.module';
import { MetadataModule } from '../metadata/metadata.module';
@@ -19,8 +20,8 @@ import { ScannerService } from './scanner.service';
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn') },
secret: config.getOrThrow<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
}),
}),
],
@@ -19,6 +19,7 @@ import { createReadStream } from 'fs';
import { access } from 'fs/promises';
import { Readable } from 'stream';
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { StagingMetadata } from '@projectx/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@@ -138,7 +139,14 @@ export class StagingController {
@Post('files/bulk-edit')
bulkEdit(@Body() dto: BulkEditStagingDto) {
return this.service.bulkEdit(dto.fileIds, dto.selectAll, dto.excludedIds, dto.fields, dto.enabledFields, dto.mergeArrays);
return this.service.bulkEdit(
dto.fileIds,
dto.selectAll,
dto.excludedIds,
dto.fields as Partial<StagingMetadata & Record<string, unknown>>,
dto.enabledFields,
dto.mergeArrays,
);
}
@Post('files/preview-names')
+3 -2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AuthModule } from '../auth/auth.module';
@@ -29,8 +30,8 @@ import { StagingRepository } from './staging.repository';
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn') },
secret: config.getOrThrow<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
}),
}),
],
+30
View File
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { dbConfig } from '../config/config';
import { validateEnv } from '../config/env.validation';
import { DbModule } from '../db/db.module';
import { SeedModule } from '../modules/seed/seed.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
load: [dbConfig],
}),
DbModule,
SeedModule,
],
})
class SeedCliModule {}
async function run(): Promise<void> {
const app = await NestFactory.createApplicationContext(SeedCliModule, {
logger: ['error', 'warn', 'log'],
});
await app.close();
}
void run();
+162 -11
View File
@@ -1,22 +1,173 @@
import {
Body,
CanActivate,
Controller,
ExecutionContext,
Get,
HttpCode,
HttpStatus,
Injectable,
Module,
Post,
UnauthorizedException,
} from '@nestjs/common';
import { APP_GUARD, Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
import { IS_PUBLIC_KEY, Public } from '../src/common/decorators/public.decorator';
import { RequirePermission } from '../src/common/decorators/require-permission.decorator';
import { PermissionGuard } from '../src/common/guards/permission.guard';
import { PermissionService } from '../src/common/services/permission.service';
import type { RequestUser } from '../src/common/types/request-user';
beforeEach(async () => {
type LoginBody = {
username?: string;
password?: string;
};
const userRole = {
id: 1,
name: 'User',
description: null,
isSuperuser: false,
isSystem: false,
permissions: [] as Array<{ id: number; name: string }>,
};
function makeUser(permissionNames: string[]): RequestUser {
return {
id: 1,
username: 'smoke-user',
name: 'Smoke User',
email: null,
active: true,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
roles: [
{
...userRole,
permissions: permissionNames.map((name, index) => ({ id: index + 1, name })),
},
],
};
}
@Injectable()
class TestJwtAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined>; user?: RequestUser }>();
const auth = request.headers.authorization;
if (auth === 'Bearer admin-token') {
request.user = makeUser(['manage_users']);
return true;
}
if (auth === 'Bearer user-token') {
request.user = makeUser([]);
return true;
}
throw new UnauthorizedException();
}
}
@Controller('auth')
class SmokeAuthController {
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() body: LoginBody) {
if (body.username === 'admin' && body.password === 'admin') {
return { accessToken: 'admin-token', user: makeUser(['manage_users']) };
}
return { accessToken: 'user-token', user: makeUser([]) };
}
}
@Controller('smoke')
class SmokeProtectedController {
@Get('private')
getPrivate() {
return { ok: true };
}
@Get('admin')
@RequirePermission('manage_users')
getAdmin() {
return { ok: true };
}
}
@Module({
controllers: [SmokeAuthController, SmokeProtectedController],
providers: [PermissionService, { provide: APP_GUARD, useClass: TestJwtAuthGuard }, { provide: APP_GUARD, useClass: PermissionGuard }],
})
class SmokeAppModule {}
describe('API smoke (e2e)', () => {
let app: NestFastifyApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
imports: [SmokeAppModule],
}).compile();
app = moduleFixture.createNestApplication();
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api/v1');
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
afterAll(async () => {
await app.close();
});
it('POST /api/v1/auth/login returns an access token', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: 'admin', password: 'admin' },
});
expect(res.statusCode).toBe(200);
expect(res.json().accessToken).toBe('admin-token');
});
it('GET /api/v1/smoke/private allows authenticated access', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/smoke/private',
headers: { authorization: 'Bearer user-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
});
it('GET /api/v1/smoke/admin denies users without permission', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/smoke/admin',
headers: { authorization: 'Bearer user-token' },
});
expect(res.statusCode).toBe(403);
});
it('GET /api/v1/smoke/admin allows users with required permission', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/smoke/admin',
headers: { authorization: 'Bearer admin-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
});
});
+14 -1
View File
@@ -4,6 +4,19 @@
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"resolvePackageJsonExports": false
}
}
]
},
"moduleNameMapper": {
"^@projectx/types$": "<rootDir>/../packages/types/src/index.ts"
}
}