From 26d1e6ebc83403bf1a33787909e26eaa9b179c13 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:34:38 -0600 Subject: [PATCH] feat: improve header UI and optimize docker storage configuration --- .env.example | 15 +++ Dockerfile | 6 +- README.md | 50 +++++++-- client/src/components/AppHeader.vue | 50 ++++++--- client/src/components/AppSidebar.vue | 11 +- .../sidebar/SidebarSectionHeader.vue | 9 +- .../src/features/auth/composables/useAuth.ts | 39 ++++++- .../book/components/BookCoverCard.vue | 9 +- .../features/book/components/BookListRow.vue | 9 +- .../book/components/VirtualBookGrid.vue | 8 +- client/src/lib/api.ts | 22 ++-- docker-compose.yml | 13 ++- server/.env.example | 6 +- server/entrypoint.sh | 106 +++++++++++++++++- server/src/common/book-cover-storage.ts | 8 +- server/src/config/config.ts | 3 +- server/src/config/env.validation.ts | 4 +- server/src/main.ts | 25 ++++- server/src/modules/auth/auth.service.test.ts | 50 ++++++++- server/src/modules/auth/auth.service.ts | 8 +- .../authors/author-image-storage.service.ts | 6 +- .../book-bucket-ingest.service.test.ts | 3 +- .../book-bucket/book-bucket-ingest.service.ts | 4 +- .../book-bucket-watcher.service.ts | 4 +- server/src/modules/book/book.service.test.ts | 2 +- server/src/modules/book/book.service.ts | 10 +- server/src/modules/cover/cover.service.ts | 16 +-- .../file-write/file-write.service.test.ts | 2 +- .../modules/file-write/file-write.service.ts | 6 +- .../kobo/services/kepubify-binary.service.ts | 6 +- .../kobo/services/kobo-download.service.ts | 6 +- .../kobo/services/kobo-thumbnail.service.ts | 8 +- server/src/modules/library/library.service.ts | 6 +- server/src/modules/metadata/lib/cover.ts | 4 +- .../src/modules/metadata/metadata.service.ts | 8 +- .../migration/executor/cover.importer.ts | 12 +- .../executor/migration-executor.service.ts | 6 +- server/src/modules/opds/opds.controller.ts | 8 +- .../scanner/file-watcher.service.test.ts | 23 +++- .../modules/scanner/file-watcher.service.ts | 35 ++++-- .../user/user-avatar-storage.service.ts | 6 +- .../authorization-matrix-harness.ts | 16 +-- .../e2e/book-bucket/book-bucket-harness.ts | 16 +-- server/test/e2e/email/email-harness.ts | 16 +-- .../metadata-lock/metadata-lock-harness.ts | 10 +- .../metadata-write/metadata-write-harness.ts | 10 +- .../migration-booklore-harness.ts | 8 +- server/test/e2e/opds/opds-harness.ts | 16 +-- .../reader-state-isolation-harness.ts | 16 +-- .../users-admin-lifecycle-harness.ts | 16 +-- 50 files changed, 518 insertions(+), 238 deletions(-) diff --git a/.env.example b/.env.example index 447fc4c72..852cc1e88 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ APP_IMAGE=ghcr.io/your-org/projectx:sha- # App publish port on host APP_PORT=3000 +# Host folder that appears as /books in the container. +# Point this at your library root; use an absolute path on NAS/Portainer installs. +BOOKS_HOST_PATH=./books + +# Container runtime UID/GID for files written to app-managed data folders. +# Most users can leave these defaults. +PUID=1000 +PGID=1000 + +# Node.js JavaScript heap limit in MB. Raise this for very large libraries. +NODE_MAX_OLD_SPACE_SIZE=2048 + # Database credentials POSTGRES_USER=projectx POSTGRES_PASSWORD=change-this-password @@ -28,6 +40,9 @@ APP_URL=https://projectx.example.com # CORS origin for the frontend when served from a different domain than APP_URL # CLIENT_URL=https://app.example.com +# Set to false only if your platform manages bind mount ownership externally. +# PROJECTX_FIX_PERMISSIONS=true + # Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32) # EMAIL_ENCRYPTION_KEY= diff --git a/Dockerfile b/Dockerfile index e405f43e0..7e0ed62a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,7 @@ FROM ${NODE_IMAGE} AS runtime WORKDIR /app RUN apk upgrade --no-cache && \ - apk add --no-cache poppler-utils && \ + apk add --no-cache poppler-utils su-exec && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx ENV NODE_ENV=production @@ -51,9 +51,7 @@ COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=client-builder --chown=node:node /app/client/dist ./public COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh -RUN chmod +x /app/entrypoint.sh && mkdir -p /books /book-bucket /data /tmp && chown -R node:node /books /book-bucket /data /tmp - -USER node +RUN chmod +x /app/entrypoint.sh && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp EXPOSE 3000 diff --git a/README.md b/README.md index 8583a0346..533b5f507 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to 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 `/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. +> **Where does data go?** `APP_DATA_PATH` in `server/.env` defaults to `../local/data` (resolves to `/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. --- @@ -211,15 +211,16 @@ The fastest way to learn the patterns is to follow an existing module: Server environment is configured in `server/.env` for local development. Production compose uses the repo-root `.env` created from `.env.example`. -| 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 | -| `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) | -| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) | +| 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 | +| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` | +| `APP_DATA_PATH` | `../local/data` | App data directory for cover images (not where books live) | +| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) | +| `NODE_MAX_OLD_SPACE_SIZE` | `2048` | Docker Node.js heap limit in MB | ## Production Deploy @@ -233,10 +234,31 @@ Quick start: ```bash cp .env.example .env -# edit .env and set APP_IMAGE + secrets +# edit .env and set APP_IMAGE, secrets, APP_URL, and BOOKS_HOST_PATH pnpm prod:up ``` +For production compose, `BOOKS_HOST_PATH` is the only host folder most users need to choose. It is mounted as `/books` in the container, and library paths created in the UI should point under `/books`. + +Generated app data and PostgreSQL live in explicit folders beside the compose file: + +```text +./data/app generated covers, author images, staging files, and Book Bucket uploads +./data/postgres PostgreSQL data +``` + +The container starts as root only long enough to create/chown app-managed data paths, then runs the Node process as `PUID:PGID`. + +On NAS installs, set `PUID` and `PGID` to the user/group that should own app-generated files: + +```bash +id your-media-user +``` + +Do not add a compose `user:` override unless your platform requires it; that bypasses the startup permission repair. + +The app container defaults Node's JavaScript heap limit to 2048 MB. Set `NODE_MAX_OLD_SPACE_SIZE` in `.env` if the host needs a lower or higher limit. + --- ## Database Workflow @@ -331,6 +353,12 @@ docker compose up -d --wait cd server && pnpm db:migrate ``` +### `EACCES: permission denied, mkdir '/data/covers'` + +The default compose uses `./data/app` for generated app data. The container repairs `/data` ownership during startup, then runs as `PUID:PGID`. + +If this still happens, make sure the folder mounted at `/data` is writable by `PUID:PGID`. Avoid adding `user:` to the app service, because startup cannot repair ownership when it is forced to start as a non-root user. + ### `Cannot find module '@projectx/types'` The shared types package needs to be built or the workspace link is broken: diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index d9122dcfd..16b94b891 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -30,6 +30,7 @@ import { useLibraryUploadEvents } from '@/features/library/composables/useLibrar import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary' import UserAvatar from '@/components/UserAvatar.vue' import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types' +import { useThemeStore } from '@/stores/theme' const router = useRouter() const route = useRoute() @@ -38,10 +39,13 @@ const { open: openChangePassword } = useChangePasswordDialog() const { hasPermission } = usePermissions() const { onLibraryUploadCompleted } = useLibraryUploadEvents() const { summary: bookBucketSummary, fetchSummary: fetchBookBucketSummary, subscribe: subscribeBookBucketSummary } = useBookBucketSummary() +const themeStore = useThemeStore() const isBookBucketActive = computed(() => route.name === 'book-bucket') const isStatisticsActive = computed(() => route.name === 'statistics') +const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) + function navigateToBookBucket() { router.push({ name: 'book-bucket' }) } @@ -147,7 +151,7 @@ onMounted(() => { } }) -const stopUploadCompletedListener = onLibraryUploadCompleted((event) => { +const stopLibraryUploadListener = onLibraryUploadCompleted((event) => { if (event.uploadedCount === 0 && event.failedCount === 0) return const uploadedLabel = `${event.uploadedCount} book${event.uploadedCount === 1 ? '' : 's'}` @@ -167,7 +171,7 @@ const stopUploadCompletedListener = onLibraryUploadCompleted((event) => { onUnmounted(() => { window.removeEventListener('keydown', handleGlobalKeydown) - stopUploadCompletedListener() + stopLibraryUploadListener() }) function highlightSegments(text: string | null, query: string) { @@ -300,7 +304,7 @@ function formatBadgeClass(fmt: string): string {