mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor: rename book-bucket to book-dock and lens to smart-scope
This commit is contained in:
+6
-6
@@ -1,5 +1,5 @@
|
||||
# Immutable image reference built by CI (use a sha-* tag or @sha256 digest)
|
||||
APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
|
||||
APP_IMAGE=ghcr.io/your-org/bookorbit:sha-<git-sha>
|
||||
|
||||
# App publish port on host
|
||||
APP_PORT=3000
|
||||
@@ -17,9 +17,9 @@ PGID=1000
|
||||
NODE_MAX_OLD_SPACE_SIZE=2048
|
||||
|
||||
# Database credentials
|
||||
POSTGRES_USER=projectx
|
||||
POSTGRES_USER=bookorbit
|
||||
POSTGRES_PASSWORD=change-this-password
|
||||
POSTGRES_DB=projectx
|
||||
POSTGRES_DB=bookorbit
|
||||
|
||||
# App secrets
|
||||
JWT_SECRET=change-this-to-a-long-random-secret
|
||||
@@ -27,21 +27,21 @@ JWT_SECRET=change-this-to-a-long-random-secret
|
||||
SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
|
||||
|
||||
# External/public URL used by emails and Kobo endpoints
|
||||
APP_URL=https://projectx.example.com
|
||||
APP_URL=https://bookorbit.example.com
|
||||
|
||||
# --- Optional overrides (uncomment to set) ---
|
||||
|
||||
# If you already run your own PostgreSQL, set DATABASE_URL and remove the
|
||||
# postgres service from docker-compose.yml. Your instance must have the
|
||||
# uuid-ossp, pg_trgm, and vector (pgvector) extensions available.
|
||||
# DATABASE_URL=postgres://user:password@yourhost:5432/projectx
|
||||
# DATABASE_URL=postgres://user:password@yourhost:5432/bookorbit
|
||||
# Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB)
|
||||
|
||||
# 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
|
||||
# BOOKORBIT_FIX_PERMISSIONS=true
|
||||
|
||||
# Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32)
|
||||
# EMAIL_ENCRYPTION_KEY=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in ProjectX
|
||||
description: Report a bug in BookOrbit
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
@@ -35,7 +35,7 @@ body:
|
||||
attributes:
|
||||
label: Your setup
|
||||
placeholder: |
|
||||
- ProjectX version/image tag: latest
|
||||
- BookOrbit version/image tag: latest
|
||||
- Deployment: Docker / manual
|
||||
- OS: Ubuntu 22.04
|
||||
- Browser: Firefox 124
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement for ProjectX
|
||||
description: Suggest a new feature or improvement for BookOrbit
|
||||
title: "[Feature] "
|
||||
labels: ["feature"]
|
||||
body:
|
||||
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
if [[ "$PACKAGE" == "server" ]]; then
|
||||
pnpm --filter server type-check
|
||||
else
|
||||
pnpm --filter @projectx/types build
|
||||
pnpm --filter @bookorbit/types build
|
||||
pnpm --filter client type-check
|
||||
fi
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "10"
|
||||
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
|
||||
E2E_DATABASE_URL: postgres://bookorbit:bookorbit@localhost:5432/bookorbit_e2e
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: scanner-e2e-secret-1234
|
||||
JWT_EXPIRES_IN: 15m
|
||||
@@ -47,13 +47,13 @@ jobs:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: projectx
|
||||
POSTGRES_PASSWORD: projectx
|
||||
POSTGRES_DB: projectx
|
||||
POSTGRES_USER: bookorbit
|
||||
POSTGRES_PASSWORD: bookorbit
|
||||
POSTGRES_DB: bookorbit
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U projectx"
|
||||
--health-cmd="pg_isready -U bookorbit"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# AI Usage Policy
|
||||
|
||||
ProjectX is maintained by real people. Every pull request, issue, and discussion is read and reviewed by humans who give their time to this project. Submitting unreviewed, untested AI output shifts the burden of that review onto maintainers - that is the problem this policy addresses. It is not a problem with AI tools themselves.
|
||||
BookOrbit is maintained by real people. Every pull request, issue, and discussion is read and reviewed by humans who give their time to this project. Submitting unreviewed, untested AI output shifts the burden of that review onto maintainers - that is the problem this policy addresses. It is not a problem with AI tools themselves.
|
||||
|
||||
## The Rules
|
||||
|
||||
@@ -39,9 +39,9 @@ AI tools make specific, recurring mistakes on this codebase. Be especially caref
|
||||
|
||||
**NestJS decorator and module mistakes.** AI frequently invents decorator options, misuses pipes, omits required module imports, or generates module wiring that looks plausible but is wrong. Check every decorator and every module registration.
|
||||
|
||||
**Ownership and multi-tenancy logic.** ProjectX is a multi-user app. Every query that touches user-owned data must filter by `userId`. AI-generated service methods routinely omit this. A missed ownership check is a data exposure bug, not a style issue. This applies to every module that manages libraries, books, collections, reading sessions, annotations, and preferences.
|
||||
**Ownership and multi-tenancy logic.** BookOrbit is a multi-user app. Every query that touches user-owned data must filter by `userId`. AI-generated service methods routinely omit this. A missed ownership check is a data exposure bug, not a style issue. This applies to every module that manages libraries, books, collections, reading sessions, annotations, and preferences.
|
||||
|
||||
**Authorization patterns.** We have specific conventions for permission gates: `@RequirePermission(...)` on the backend, `usePermissions()` on the frontend, and the `LensService` superuser-bypass pattern. AI does not know these patterns and will generate code that bypasses them or reimplements them incorrectly. Write auth and permission logic yourself.
|
||||
**Authorization patterns.** We have specific conventions for permission gates: `@RequirePermission(...)` on the backend, `usePermissions()` on the frontend, and the `SmartScopeService` superuser-bypass pattern. AI does not know these patterns and will generate code that bypasses them or reimplements them incorrectly. Write auth and permission logic yourself.
|
||||
|
||||
**Tests that prove nothing.** AI-generated tests often mock the exact thing being tested, assert on shallow output without triggering the real path, or simply confirm that a function returns without throwing. Before submitting a test, ask: would this test fail if the implementation were broken? If the answer is no, the test is not worth having.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ The **footer** is optional. Use it to reference issues, close PRs, or document b
|
||||
│ └─ Scope (optional): auth|books|library|metadata|kobo|opds|reader|
|
||||
│ collections|annotations|authors|series|cover|
|
||||
│ users|stats|notifications|settings|scanner|
|
||||
│ email|audit|lens|types|docker|deps|server|client
|
||||
│ email|audit|smart-scope|types|docker|deps|server|client
|
||||
│
|
||||
└─ Type: feat|fix|db|perf|refactor|style|docs|test|build|ci|chore|security|revert
|
||||
```
|
||||
@@ -86,7 +86,7 @@ Use one scope from the table below. If a change affects multiple areas, use the
|
||||
| `scanner` | File scanner and upload pipeline |
|
||||
| `email` | Email providers, templates, and send preferences |
|
||||
| `audit` | Audit logging and event tracking |
|
||||
| `lens` | Custom lenses and collection views |
|
||||
| `smart-scope` | Custom Smart Scopes and collection views |
|
||||
| `types` | Shared types in `packages/types/` |
|
||||
| `docker` | Dockerfile and Docker Compose files |
|
||||
| `deps` | Dependency upgrades (pair with `build` or `chore`) |
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
# Contributing to ProjectX
|
||||
# Contributing to BookOrbit
|
||||
|
||||
Thanks for your interest in contributing to ProjectX. Whether you are fixing a typo or building a major feature, this guide walks you through the process from start to finish.
|
||||
Thanks for your interest in contributing to BookOrbit. Whether you are fixing a typo or building a major feature, this guide walks you through the process from start to finish.
|
||||
|
||||
**Companion docs:**
|
||||
|
||||
@@ -63,9 +63,9 @@ This step prevents wasted effort on changes that conflict with the project direc
|
||||
[Fork the repository](../../fork), then clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/projectx.git
|
||||
cd projectx
|
||||
git remote add upstream https://github.com/neonsolstice/projectx.git
|
||||
git clone https://github.com/<your-username>/bookorbit.git
|
||||
cd bookorbit
|
||||
git remote add upstream https://github.com/neonsolstice/bookorbit.git
|
||||
```
|
||||
|
||||
> **Trusted contributors** with direct push access can skip the fork and work on branches in the main repository. The rest of this guide assumes the fork workflow.
|
||||
@@ -228,7 +228,7 @@ Do not add dependencies without prior discussion in the linked issue. If proposi
|
||||
|
||||
### Breaking Changes and Self-Hosted Upgrades
|
||||
|
||||
ProjectX is self-hosted. Any change that can break existing deployments must include a clear upgrade path before merge.
|
||||
BookOrbit is self-hosted. Any change that can break existing deployments must include a clear upgrade path before merge.
|
||||
|
||||
If your PR introduces a breaking change (schema, API contract, environment variable, config format):
|
||||
|
||||
|
||||
+21
-21
@@ -8,7 +8,7 @@ For the contribution process (issues, PRs, review), see [CONTRIBUTING.md](CONTRI
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
ProjectX is a pnpm monorepo with three workspaces: a Vue 3 single-page app, a NestJS API server, and a shared types package.
|
||||
BookOrbit is a pnpm monorepo with three workspaces: a Vue 3 single-page app, a NestJS API server, and a shared types package.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
@@ -16,7 +16,7 @@ graph LR
|
||||
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:3000/api</small>"]
|
||||
DB["PostgreSQL<br/><small>Drizzle ORM</small>"]
|
||||
WS["WebSocket<br/><small>Socket.IO</small>"]
|
||||
Types["Shared Types<br/><small>@projectx/types</small>"]
|
||||
Types["Shared Types<br/><small>@bookorbit/types</small>"]
|
||||
|
||||
Client -- "HTTP (fetch)" --> Server
|
||||
Client <-. "real-time events" .-> WS
|
||||
@@ -39,7 +39,7 @@ graph LR
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
projectx/
|
||||
bookorbit/
|
||||
├── client/ # Vue 3 frontend
|
||||
│ └── src/
|
||||
│ ├── features/ # Feature modules (composables + components)
|
||||
@@ -60,14 +60,14 @@ projectx/
|
||||
│ └── *.e2e-spec.ts # E2E test suites
|
||||
├── packages/
|
||||
│ └── types/ # Shared TypeScript types
|
||||
│ └── src/ # Exported via @projectx/types
|
||||
│ └── src/ # Exported via @bookorbit/types
|
||||
├── scripts/ # Bootstrap, DB reset, E2E orchestration
|
||||
├── docker/ # Postgres init scripts (extensions)
|
||||
├── .github/ # CI workflows, PR/issue templates
|
||||
└── .husky/ # Git hooks (pre-commit, pre-push)
|
||||
```
|
||||
|
||||
> **Shared types** live in `packages/types/` and are imported as `@projectx/types`. Never duplicate types between client and server.
|
||||
> **Shared types** live in `packages/types/` and are imported as `@bookorbit/types`. Never duplicate types between client and server.
|
||||
|
||||
---
|
||||
|
||||
@@ -96,8 +96,8 @@ This runs all the steps below automatically. If it succeeds, skip to [Running th
|
||||
#### Step 1 - Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/projectx.git
|
||||
cd projectx
|
||||
git clone https://github.com/<your-username>/bookorbit.git
|
||||
cd bookorbit
|
||||
```
|
||||
|
||||
If you are an external contributor, fork first and clone your fork. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
@@ -160,11 +160,11 @@ If everything worked, you will see the server, client, and types watcher start u
|
||||
|
||||
### Verifying Your Setup
|
||||
|
||||
| Check | Expected |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
| Open http://localhost:5173 | ProjectX login page loads |
|
||||
| Open http://localhost:3000/api/v1/health | Returns `{"status":"ok"}` |
|
||||
| `docker ps` | Shows `projectx-postgres` container running |
|
||||
| Check | Expected |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| Open http://localhost:5173 | BookOrbit login page loads |
|
||||
| Open http://localhost:3000/api/v1/health | Returns `{"status":"ok"}` |
|
||||
| `docker ps` | Shows `bookorbit-dev-db` container running |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +198,7 @@ If you are editing shared types (`packages/types/`), also start the types watche
|
||||
|
||||
```bash
|
||||
# Terminal 3: Types (only needed when editing packages/types/)
|
||||
pnpm --filter @projectx/types dev
|
||||
pnpm --filter @bookorbit/types dev
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -209,14 +209,14 @@ pnpm --filter @projectx/types dev
|
||||
|
||||
These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments.
|
||||
|
||||
| Variable | Purpose | Default (dev) |
|
||||
| --------------- | ---------------------------------- | ------------------------------------------------------ |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://projectx:projectx@localhost:5432/projectx` |
|
||||
| `PORT` | Server listen port | `3000` |
|
||||
| `NODE_ENV` | Runtime mode | `development` |
|
||||
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
|
||||
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
|
||||
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
|
||||
| Variable | Purpose | Default (dev) |
|
||||
| --------------- | ---------------------------------- | --------------------------------------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` |
|
||||
| `PORT` | Server listen port | `3000` |
|
||||
| `NODE_ENV` | Runtime mode | `development` |
|
||||
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
|
||||
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
|
||||
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ProjectX
|
||||
# BookOrbit
|
||||
|
||||
[](https://github.com/neonsolstice/projectx/actions/workflows/ci.yml)
|
||||
[](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml)
|
||||
|
||||
A self-hostable book and library management app with Kobo device support. Organize your epub, pdf, and cbz collections, track reading progress, and sync with your Kobo.
|
||||
|
||||
@@ -19,8 +19,8 @@ A self-hostable book and library management app with Kobo device support. Organi
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone <repo-url> projectx
|
||||
cd projectx
|
||||
git clone <repo-url> bookorbit
|
||||
cd bookorbit
|
||||
pnpm setup
|
||||
```
|
||||
|
||||
@@ -62,11 +62,11 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
projectx/
|
||||
bookorbit/
|
||||
├── client/ Vue 3 frontend (Vite + Tailwind CSS v4)
|
||||
├── server/ NestJS 11 backend (Fastify + Drizzle ORM)
|
||||
├── packages/
|
||||
│ └── types/ Shared TypeScript types (@projectx/types)
|
||||
│ └── types/ Shared TypeScript types (@bookorbit/types)
|
||||
├── docker/
|
||||
│ └── postgres/ Postgres init scripts (extensions)
|
||||
├── local/ Local dev data (covers, docs) - gitignored
|
||||
@@ -82,7 +82,7 @@ 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.
|
||||
- **`packages/types/`** - Shared types imported as `@bookorbit/types` by both server and client. Add any type that crosses the API boundary here.
|
||||
|
||||
---
|
||||
|
||||
@@ -203,7 +203,7 @@ 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`.
|
||||
- **Shared types:** If the feature adds API request/response shapes, add them in `packages/types/src/` and import via `@bookorbit/types`.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,16 +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` |
|
||||
| `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 |
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` | 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
|
||||
|
||||
@@ -243,7 +243,7 @@ For production compose, `BOOKS_HOST_PATH` is the only host folder most users nee
|
||||
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/app generated covers, author images, staging files, and Book Dock uploads
|
||||
./data/postgres PostgreSQL data
|
||||
```
|
||||
|
||||
@@ -297,7 +297,7 @@ pnpm db:reset
|
||||
- **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.
|
||||
- **Shared types:** `@bookorbit/types` package ensures API contracts stay in sync.
|
||||
|
||||
---
|
||||
|
||||
@@ -359,7 +359,7 @@ The default compose uses `./data/app` for generated app data. The container repa
|
||||
|
||||
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'`
|
||||
### `Cannot find module '@bookorbit/types'`
|
||||
|
||||
The shared types package needs to be built or the workspace link is broken:
|
||||
|
||||
|
||||
+29
-29
@@ -1,6 +1,6 @@
|
||||
# Testing Guide
|
||||
|
||||
ProjectX has three layers of tests:
|
||||
BookOrbit has three layers of tests:
|
||||
|
||||
- **Server unit tests:** isolated tests for NestJS services, controllers, repositories, and utilities. Dependencies are mocked; no database required.
|
||||
- **Client unit tests:** tests for Vue composables and pure utility functions using a jsdom environment.
|
||||
@@ -56,7 +56,7 @@ scripts/e2e/
|
||||
run-suite.mjs <- orchestrator: preps DB if needed, then runs vitest
|
||||
select-matrix.mjs <- CI smart matrix: picks suites based on changed file paths
|
||||
list-matrix.mjs <- full matrix: all suites (used by manual workflow dispatch)
|
||||
prepare-db.sh <- drops, re-creates, and migrates the projectx_e2e schema
|
||||
prepare-db.sh <- drops, re-creates, and migrates the bookorbit_e2e schema
|
||||
|
||||
server/
|
||||
vitest.config.e2e.ts <- separate vitest config (forks pool, sequential files)
|
||||
@@ -73,20 +73,20 @@ server/
|
||||
- `pool: 'forks'`: each spec file runs in a separate process for full isolation
|
||||
- `fileParallelism: false`: spec files within a run execute sequentially
|
||||
- `setupFiles: ['test/e2e.setup.ts']`: runs before every suite; mocks `waitForStability`
|
||||
- `DATABASE_URL` is always overridden to `E2E_DATABASE_URL` (defaults to `projectx_e2e`), so
|
||||
- `DATABASE_URL` is always overridden to `E2E_DATABASE_URL` (defaults to `bookorbit_e2e`), so
|
||||
E2E tests never touch the dev database
|
||||
|
||||
### DB modes
|
||||
|
||||
Every suite runs in one of two modes:
|
||||
|
||||
| Mode | `prepareDedicatedDatabase` | Database used | When to use |
|
||||
| ------------ | -------------------------- | ------------------------------------------------------ | ------------------------------------------------ |
|
||||
| default-db | `false` | `E2E_DATABASE_URL` with no reset before run | Fast smoke tests that don't write data |
|
||||
| dedicated-db | `true` | `projectx_e2e` dropped and re-migrated before each run | Any test that writes data or needs a clean state |
|
||||
| Mode | `prepareDedicatedDatabase` | Database used | When to use |
|
||||
| ------------ | -------------------------- | ------------------------------------------------------- | ------------------------------------------------ |
|
||||
| default-db | `false` | `E2E_DATABASE_URL` with no reset before run | Fast smoke tests that don't write data |
|
||||
| dedicated-db | `true` | `bookorbit_e2e` dropped and re-migrated before each run | Any test that writes data or needs a clean state |
|
||||
|
||||
For dedicated-db suites, `run-suite.mjs` automatically runs `pnpm run e2e:db:prepare` before
|
||||
launching vitest. This drops and re-creates the `projectx_e2e` schema and applies all
|
||||
launching vitest. This drops and re-creates the `bookorbit_e2e` schema and applies all
|
||||
migrations fresh.
|
||||
|
||||
### Running E2E tests
|
||||
@@ -108,7 +108,7 @@ pnpm run e2e:run -- scanner-scenarios --testNamePattern=book-per-folder-flat-roo
|
||||
E2E_DATABASE_URL=postgres://... pnpm run e2e:run -- scanner-scenarios
|
||||
```
|
||||
|
||||
The default E2E database URL is `postgres://projectx:projectx@localhost:5432/projectx_e2e`.
|
||||
The default E2E database URL is `postgres://bookorbit:bookorbit@localhost:5432/bookorbit_e2e`.
|
||||
PostgreSQL must be running locally (start it with `pnpm run db:up` if needed).
|
||||
|
||||
> **Override the database:** Use `E2E_DATABASE_URL`, not `DATABASE_URL`. The vitest config
|
||||
@@ -120,26 +120,26 @@ check that your env vars match the values in `server/.env.example`.
|
||||
|
||||
### Suite reference
|
||||
|
||||
| Suite ID | Lane | DB mode | Timeout | Description |
|
||||
| ----------------------------- | ----- | ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `guard-mechanics` | smoke | default-db | 30 min | `@Public` / `@RequirePermission` guard wiring; uses its own minimal NestJS testing module, not the shared app harness |
|
||||
| `scanner-scenarios` | full | dedicated-db | 45 min | File organization scenario matrix for the library scanner |
|
||||
| `scanner-file-operations` | full | dedicated-db | 60 min | File add/remove/rename operations and scanner reconciliation |
|
||||
| `auth-session-security` | full | dedicated-db | 50 min | Cookie flags, token rotation, concurrent sessions |
|
||||
| `auth-recovery-oidc-logout` | full | dedicated-db | 50 min | Password reset flow and OIDC logout |
|
||||
| `book-bucket-ingest-finalize` | full | dedicated-db | 45 min | Book Bucket upload ingest and finalize flow |
|
||||
| `metadata-write` | full | dedicated-db | 60 min | Metadata write operations (field updates, bulk edits) |
|
||||
| `metadata-lock` | full | dedicated-db | 60 min | Metadata lock enforcement across operations |
|
||||
| `migration-booklore` | full | dedicated-db | 70 min | Booklore v1 to ProjectX data migration |
|
||||
| `authorization-matrix` | full | dedicated-db | 60 min | Permission enforcement across all protected routes |
|
||||
| `book-api-contract` | full | dedicated-db | 60 min | Book API response shape and field contract |
|
||||
| `app-settings-oidc-contract` | full | dedicated-db | 60 min | App settings and OIDC configuration contract |
|
||||
| `library-admin-workflows` | full | dedicated-db | 60 min | Library create/update/delete admin workflows |
|
||||
| `reader-format-delivery` | full | dedicated-db | 60 min | Book file serving and format delivery |
|
||||
| `opds-auth-catalog` | full | dedicated-db | 45 min | OPDS feed authentication and catalog structure |
|
||||
| `email-lifecycle` | full | dedicated-db | 45 min | Email provider, template, and send lifecycle |
|
||||
| `reader-state-isolation` | full | dedicated-db | 45 min | Reading progress, bookmarks, and annotations isolated per user |
|
||||
| `users-admin-lifecycle` | full | dedicated-db | 50 min | User create/update/delete admin lifecycle |
|
||||
| Suite ID | Lane | DB mode | Timeout | Description |
|
||||
| ---------------------------- | ----- | ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `guard-mechanics` | smoke | default-db | 30 min | `@Public` / `@RequirePermission` guard wiring; uses its own minimal NestJS testing module, not the shared app harness |
|
||||
| `scanner-scenarios` | full | dedicated-db | 45 min | File organization scenario matrix for the library scanner |
|
||||
| `scanner-file-operations` | full | dedicated-db | 60 min | File add/remove/rename operations and scanner reconciliation |
|
||||
| `auth-session-security` | full | dedicated-db | 50 min | Cookie flags, token rotation, concurrent sessions |
|
||||
| `auth-recovery-oidc-logout` | full | dedicated-db | 50 min | Password reset flow and OIDC logout |
|
||||
| `book-dock-ingest-finalize` | full | dedicated-db | 45 min | Book Dock upload ingest and finalize flow |
|
||||
| `metadata-write` | full | dedicated-db | 60 min | Metadata write operations (field updates, bulk edits) |
|
||||
| `metadata-lock` | full | dedicated-db | 60 min | Metadata lock enforcement across operations |
|
||||
| `migration-booklore` | full | dedicated-db | 70 min | Booklore v1 to BookOrbit data migration |
|
||||
| `authorization-matrix` | full | dedicated-db | 60 min | Permission enforcement across all protected routes |
|
||||
| `book-api-contract` | full | dedicated-db | 60 min | Book API response shape and field contract |
|
||||
| `app-settings-oidc-contract` | full | dedicated-db | 60 min | App settings and OIDC configuration contract |
|
||||
| `library-admin-workflows` | full | dedicated-db | 60 min | Library create/update/delete admin workflows |
|
||||
| `reader-format-delivery` | full | dedicated-db | 60 min | Book file serving and format delivery |
|
||||
| `opds-auth-catalog` | full | dedicated-db | 45 min | OPDS feed authentication and catalog structure |
|
||||
| `email-lifecycle` | full | dedicated-db | 45 min | Email provider, template, and send lifecycle |
|
||||
| `reader-state-isolation` | full | dedicated-db | 45 min | Reading progress, bookmarks, and annotations isolated per user |
|
||||
| `users-admin-lifecycle` | full | dedicated-db | 50 min | User create/update/delete admin lifecycle |
|
||||
|
||||
JUnit XML output for each suite is written to `test-results/server/<suite-id>-e2e-junit.xml`.
|
||||
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
# ProjectX - Client
|
||||
# BookOrbit - Client
|
||||
|
||||
Vue 3 SPA built with Vite and Tailwind CSS v4.
|
||||
|
||||
@@ -25,7 +25,7 @@ src/
|
||||
│ ├── book/ Book detail, covers, metadata
|
||||
│ ├── library/ Library management
|
||||
│ ├── collection/ Book collections
|
||||
│ ├── lens/ Saved search views
|
||||
│ ├── smart-scope/ Saved search views
|
||||
│ ├── scanner/ Scan progress UI
|
||||
│ ├── reader/ Book reader (epub, pdf, cbz)
|
||||
│ ├── settings/ User settings
|
||||
@@ -54,4 +54,4 @@ pnpm test:cov # run unit tests with coverage
|
||||
- HTTP calls use the native `fetch` API via `src/lib/api.ts`.
|
||||
- Styling with Tailwind CSS v4 utility classes. Theme tokens are CSS variables in `src/assets/main.css`.
|
||||
- Icons from `lucide-vue-next` only.
|
||||
- Shared types imported from `@projectx/types`.
|
||||
- Shared types imported from `@bookorbit/types`.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Projectx</title>
|
||||
<title>BookOrbit</title>
|
||||
<!-- Blocking theme init — must run before first paint to prevent FOUC -->
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
|
||||
+11
-11
@@ -25,13 +25,13 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/fraunces": "^5.2.9",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@projectx/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@bookorbit/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.2.3",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.0",
|
||||
"driver.js": "^1.4.0",
|
||||
"echarts": "^6.0.0",
|
||||
"howler": "^2.2.4",
|
||||
@@ -41,7 +41,7 @@
|
||||
"reka-ui": "^2.9.6",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.2.3",
|
||||
"vue": "^3.5.32",
|
||||
"vue-draggable-plus": "^0.6.1",
|
||||
"vue-echarts": "8.1.0-beta.2",
|
||||
@@ -56,25 +56,25 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@vitest/eslint-plugin": "^1.6.15",
|
||||
"@vitest/eslint-plugin": "^1.6.16",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.60.0",
|
||||
"eslint-plugin-oxlint": "~1.61.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"prettier": "3.8.2",
|
||||
"oxlint": "~1.61.0",
|
||||
"prettier": "3.8.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.8",
|
||||
"vite": "^8.0.9",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.4",
|
||||
"vue-tsc": "^3.2.6"
|
||||
"vue-tsc": "^3.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { computed, provide } from 'vue'
|
||||
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { getProjectxThemeName, initChartThemes } from '@/lib/echarts'
|
||||
import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts'
|
||||
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -16,7 +16,7 @@ initChartThemes()
|
||||
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
|
||||
provide(
|
||||
THEME_KEY,
|
||||
computed(() => getProjectxThemeName(themeStore.theme, themeStore.accent)),
|
||||
computed(() => getBookorbitThemeName(themeStore.theme, themeStore.accent)),
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import BookUploadModal from '@/features/library/components/BookUploadModal.vue'
|
||||
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
|
||||
import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary'
|
||||
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
|
||||
import NotificationSheet from '@/features/notifications/components/NotificationSheet.vue'
|
||||
import { useNotifications } from '@/features/notifications/composables/useNotifications'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types'
|
||||
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -43,17 +43,17 @@ const { user, logout } = useAuth()
|
||||
const { open: openChangePassword } = useChangePasswordDialog()
|
||||
const { hasPermission } = usePermissions()
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
const { summary: bookBucketSummary, fetchSummary: fetchBookBucketSummary, subscribe: subscribeBookBucketSummary } = useBookBucketSummary()
|
||||
const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
|
||||
const { subscribe: subscribeNotifications } = useNotifications()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const isBookBucketActive = computed(() => route.name === 'book-bucket')
|
||||
const isBookDockActive = computed(() => route.name === 'book-dock')
|
||||
const isStatisticsActive = computed(() => route.name === 'statistics')
|
||||
|
||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||
|
||||
function navigateToBookBucket() {
|
||||
router.push({ name: 'book-bucket' })
|
||||
function navigateToBookDock() {
|
||||
router.push({ name: 'book-dock' })
|
||||
}
|
||||
|
||||
function navigateToStatistics() {
|
||||
@@ -151,9 +151,9 @@ function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
if (hasPermission('book_bucket_access')) {
|
||||
fetchBookBucketSummary()
|
||||
subscribeBookBucketSummary()
|
||||
if (hasPermission('book_dock_access')) {
|
||||
fetchBookDockSummary()
|
||||
subscribeBookDockSummary()
|
||||
}
|
||||
if (hasPermission('notification_access')) {
|
||||
subscribeNotifications()
|
||||
@@ -459,14 +459,14 @@ function formatBadgeClass(fmt: string): string {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-44">
|
||||
<DropdownMenuItem v-if="hasPermission('book_bucket_access')" @click="navigateToBookBucket">
|
||||
<DropdownMenuItem v-if="hasPermission('book_dock_access')" @click="navigateToBookDock">
|
||||
<PackageOpen :size="15" class="mr-2 text-muted-foreground" />
|
||||
Book Bucket
|
||||
Book Dock
|
||||
<span
|
||||
v-if="bookBucketSummary.total > 0"
|
||||
v-if="bookDockSummary.total > 0"
|
||||
class="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground tabular-nums leading-none"
|
||||
>
|
||||
{{ bookBucketSummary.total }}
|
||||
{{ bookDockSummary.total }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="navigateToStatistics">
|
||||
@@ -514,34 +514,34 @@ function formatBadgeClass(fmt: string): string {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Group 1: Content (Book Bucket, Statistics, Upload) -->
|
||||
<!-- Group 1: Content (Book Dock, Statistics, Upload) -->
|
||||
<div class="hidden md:flex items-center gap-2.5">
|
||||
<!-- Book Bucket button -->
|
||||
<Tooltip v-if="hasPermission('book_bucket_access')">
|
||||
<!-- Book Dock button -->
|
||||
<Tooltip v-if="hasPermission('book_dock_access')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
data-tour="book-bucket-btn"
|
||||
data-tour="book-dock-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8 border transition-colors"
|
||||
:class="[
|
||||
isBookBucketActive
|
||||
isBookDockActive
|
||||
? 'border-primary/80 bg-primary/8 text-primary'
|
||||
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground',
|
||||
iconRadiusClass,
|
||||
]"
|
||||
@click="navigateToBookBucket"
|
||||
@click="navigateToBookDock"
|
||||
>
|
||||
<PackageOpen :size="15" />
|
||||
<span
|
||||
v-if="bookBucketSummary.total > 0"
|
||||
v-if="bookDockSummary.total > 0"
|
||||
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground tabular-nums leading-none"
|
||||
>
|
||||
{{ bookBucketSummary.total }}
|
||||
{{ bookDockSummary.total }}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Book Bucket</TooltipContent>
|
||||
<TooltipContent>Book Dock</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Notifications button -->
|
||||
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
import SidebarNavItem from '@/components/sidebar/SidebarNavItem.vue'
|
||||
import SidebarSectionHeader from '@/components/sidebar/SidebarSectionHeader.vue'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
|
||||
import { useCollections } from '@/features/collection/composables/useCollections'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
|
||||
import { useDraggableOrder } from '@/composables/useDraggableOrder'
|
||||
import type { Library } from '@projectx/types'
|
||||
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
|
||||
import type { Library } from '@bookorbit/types'
|
||||
import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmartScopeDialog.vue'
|
||||
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
||||
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
||||
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
|
||||
@@ -36,10 +36,10 @@ function resolveIcon(name: string | null | undefined, fallback: Component): Comp
|
||||
}
|
||||
|
||||
function useSidebarSection(key: string) {
|
||||
const isOpen = ref(localStorage.getItem(`projectx:sidebar:${key}`) !== 'false')
|
||||
const isOpen = ref(localStorage.getItem(`bookorbit:sidebar:${key}`) !== 'false')
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
localStorage.setItem(`projectx:sidebar:${key}`, String(isOpen.value))
|
||||
localStorage.setItem(`bookorbit:sidebar:${key}`, String(isOpen.value))
|
||||
}
|
||||
return { isOpen, toggle }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
const { libraries, fetchLibraries, refreshLibraries, reorderLibraries } = useLibraries()
|
||||
const { lenses, fetchLenses, reorderLenses } = useLenses()
|
||||
const { smartScopes, fetchSmartScopes, reorderSmartScopes } = useSmartScopes()
|
||||
const { collections, fetchCollections, reorderCollections } = useCollections()
|
||||
const { hasPermission } = usePermissions()
|
||||
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
||||
@@ -69,16 +69,16 @@ watch(progressMap, (map) => {
|
||||
})
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
|
||||
const createLensOpen = ref(false)
|
||||
const createSmartScopeOpen = ref(false)
|
||||
const createCollectionOpen = ref(false)
|
||||
const createLibraryOpen = ref(false)
|
||||
|
||||
const { isOpen: librariesOpen, toggle: toggleLibraries } = useSidebarSection('libraries')
|
||||
const { isOpen: lensesOpen, toggle: toggleLenses } = useSidebarSection('lenses')
|
||||
const { isOpen: smartScopesOpen, toggle: toggleSmartScopes } = useSidebarSection('smart-scopes')
|
||||
const { isOpen: collectionsOpen, toggle: toggleCollections } = useSidebarSection('collections')
|
||||
|
||||
const isReorderingLibraries = ref(false)
|
||||
const isReorderingLenses = ref(false)
|
||||
const isReorderingSmartScopes = ref(false)
|
||||
const isReorderingCollections = ref(false)
|
||||
|
||||
const {
|
||||
@@ -88,10 +88,10 @@ const {
|
||||
} = useDraggableOrder({ source: libraries, persist: reorderLibraries })
|
||||
|
||||
const {
|
||||
localItems: localLenses,
|
||||
onDragStart: onLensDragStart,
|
||||
onDragEnd: onLensDragEnd,
|
||||
} = useDraggableOrder({ source: lenses, persist: reorderLenses })
|
||||
localItems: localSmartScopes,
|
||||
onDragStart: onSmartScopeDragStart,
|
||||
onDragEnd: onSmartScopeDragEnd,
|
||||
} = useDraggableOrder({ source: smartScopes, persist: reorderSmartScopes })
|
||||
|
||||
const {
|
||||
localItems: localCollections,
|
||||
@@ -109,9 +109,9 @@ const activeLibraryId = computed(() => {
|
||||
return route.name === 'library' && id ? Number(id) : null
|
||||
})
|
||||
|
||||
const activeLensId = computed(() => {
|
||||
const activeSmartScopeId = computed(() => {
|
||||
const id = route.params.id
|
||||
return route.name === 'lens' && id ? Number(id) : null
|
||||
return route.name === 'smartScope' && id ? Number(id) : null
|
||||
})
|
||||
|
||||
const activeCollectionId = computed(() => {
|
||||
@@ -142,7 +142,7 @@ onMounted(async () => {
|
||||
for (const lib of libraries.value) {
|
||||
subscribeLibrary(lib.id)
|
||||
}
|
||||
fetchLenses()
|
||||
fetchSmartScopes()
|
||||
fetchCollections()
|
||||
})
|
||||
|
||||
@@ -156,7 +156,7 @@ onUnmounted(() => stopLibraryUploadListener())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateLensDialog :open="createLensOpen" @close="createLensOpen = false" />
|
||||
<CreateSmartScopeDialog :open="createSmartScopeOpen" @close="createSmartScopeOpen = false" />
|
||||
<CreateCollectionDialog :open="createCollectionOpen" @close="createCollectionOpen = false" />
|
||||
<LibraryCreatorModal v-if="createLibraryOpen" @close="createLibraryOpen = false" @saved="onLibrarySaved" />
|
||||
|
||||
@@ -171,30 +171,20 @@ onUnmounted(() => stopLibraryUploadListener())
|
||||
:class="iconRadiusClass"
|
||||
style="background: linear-gradient(135deg, var(--primary) 0%, color-mix(in oklch, var(--primary) 75%, transparent) 100%)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" class="h-4 w-4" aria-hidden="true">
|
||||
<path
|
||||
d="M5 2.5A1.5 1.5 0 016.5 1h9A1.5 1.5 0 0117 2.5v15a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 015 17.5V2.5z"
|
||||
fill="var(--primary-foreground)"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path d="M3 4a1 1 0 011-1h8.5A1.5 1.5 0 0114 4.5V17a1.5 1.5 0 01-1.5 1.5H4a1 1 0 01-1-1V4z" fill="var(--primary-foreground)" />
|
||||
<line x1="6" y1="7.5" x2="11.5" y2="7.5" stroke="var(--primary)" stroke-width="1.3" stroke-linecap="round" />
|
||||
<line x1="6" y1="10.5" x2="11.5" y2="10.5" stroke="var(--primary)" stroke-width="1.3" stroke-linecap="round" opacity="0.7" />
|
||||
<line x1="6" y1="13.5" x2="9.5" y2="13.5" stroke="var(--primary)" stroke-width="1.3" stroke-linecap="round" opacity="0.4" />
|
||||
</svg>
|
||||
<Icons.Orbit class="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<!-- Brand name -->
|
||||
<div class="flex flex-col leading-none group-data-[collapsible=icon]:hidden">
|
||||
<span class="text-lg font-serif font-semibold text-sidebar-foreground leading-tight tracking-tight">
|
||||
Project<span class="text-primary"> Q</span>
|
||||
Book<span class="text-primary"> Orbit</span>
|
||||
</span>
|
||||
<span class="mt-0.5 text-[10px] uppercase tracking-[0.16em] text-sidebar-foreground/65">Library Control</span>
|
||||
<span class="mt-0.5 text-[10px] uppercase tracking-[0.16em] text-sidebar-foreground/65">Your Reading Space</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<!-- Dashboard / Book Bucket / Authors -->
|
||||
<!-- Dashboard / Book Dock / Authors -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
@@ -317,60 +307,60 @@ onUnmounted(() => stopLibraryUploadListener())
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<!-- Lenses -->
|
||||
<!-- SmartScopes -->
|
||||
<SidebarGroup>
|
||||
<SidebarSectionHeader
|
||||
data-tour="sidebar-lenses"
|
||||
label="Lenses"
|
||||
:is-open="lensesOpen"
|
||||
:collapsed-count="lenses.length"
|
||||
data-tour="sidebar-smartScopes"
|
||||
label="Smart Scopes"
|
||||
:is-open="smartScopesOpen"
|
||||
:collapsed-count="smartScopes.length"
|
||||
:can-add="true"
|
||||
add-title="New Lens"
|
||||
:can-reorder="lenses.length > 1"
|
||||
:is-reordering="isReorderingLenses"
|
||||
@toggle="toggleLenses"
|
||||
@add="createLensOpen = true"
|
||||
@toggle-reorder="isReorderingLenses = !isReorderingLenses"
|
||||
add-title="New Smart Scope"
|
||||
:can-reorder="smartScopes.length > 1"
|
||||
:is-reordering="isReorderingSmartScopes"
|
||||
@toggle="toggleSmartScopes"
|
||||
@add="createSmartScopeOpen = true"
|
||||
@toggle-reorder="isReorderingSmartScopes = !isReorderingSmartScopes"
|
||||
/>
|
||||
<Transition name="section">
|
||||
<div v-if="lensesOpen">
|
||||
<div v-if="smartScopesOpen">
|
||||
<SidebarGroupContent>
|
||||
<VueDraggable
|
||||
v-model="localLenses"
|
||||
v-model="localSmartScopes"
|
||||
tag="ul"
|
||||
:animation="150"
|
||||
handle=".drag-handle"
|
||||
:disabled="!isReorderingLenses"
|
||||
:disabled="!isReorderingSmartScopes"
|
||||
class="contents"
|
||||
@start="onLensDragStart"
|
||||
@end="onLensDragEnd"
|
||||
@start="onSmartScopeDragStart"
|
||||
@end="onSmartScopeDragEnd"
|
||||
>
|
||||
<SidebarNavItem
|
||||
v-for="lens in localLenses"
|
||||
:key="lens.id"
|
||||
:is-active="activeLensId === lens.id"
|
||||
:tooltip="lens.name"
|
||||
:icon="resolveIcon(lens.icon, Icons.Aperture)"
|
||||
:label="lens.name"
|
||||
@click="navigateFromSidebar({ name: 'lens', params: { id: lens.id } })"
|
||||
v-for="smartScope in localSmartScopes"
|
||||
:key="smartScope.id"
|
||||
:is-active="activeSmartScopeId === smartScope.id"
|
||||
:tooltip="smartScope.name"
|
||||
:icon="resolveIcon(smartScope.icon, Icons.Aperture)"
|
||||
:label="smartScope.name"
|
||||
@click="navigateFromSidebar({ name: 'smartScope', params: { id: smartScope.id } })"
|
||||
>
|
||||
<template #badge>
|
||||
<span
|
||||
v-if="lens.bookCount != null && lens.bookCount > 0"
|
||||
v-if="smartScope.bookCount != null && smartScope.bookCount > 0"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sidebar-foreground/80 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ lens.bookCount.toLocaleString() }}
|
||||
{{ smartScope.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
<Icons.GripVertical
|
||||
v-if="isReorderingLenses"
|
||||
v-if="isReorderingSmartScopes"
|
||||
class="drag-handle ml-1 h-3.5 w-3.5 shrink-0 cursor-grab text-primary/60 group-data-[collapsible=icon]:hidden"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</SidebarNavItem>
|
||||
</VueDraggable>
|
||||
<div v-if="localLenses.length === 0">
|
||||
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/40 group-data-[collapsible=icon]:hidden">No lenses yet</span>
|
||||
<div v-if="localSmartScopes.length === 0">
|
||||
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/40 group-data-[collapsible=icon]:hidden">No Smart Scopes yet</span>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { STATUS_ICONS, STATUS_OPTIONS } from '@/features/book/composables/useBookStatus'
|
||||
import type { ReadStatus } from '@projectx/types'
|
||||
import type { ReadStatus } from '@bookorbit/types'
|
||||
import type { InFlightOp } from '@/features/book/composables/useBookBulkActions'
|
||||
|
||||
export type ExportScope = 'primary' | 'all' | 'audio'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { storage } from '@/services/storage'
|
||||
|
||||
const STORAGE_KEY = 'projectx:sidebar:width'
|
||||
const STORAGE_KEY = 'bookorbit:sidebar:width'
|
||||
|
||||
export const SIDEBAR_WIDTH_DEFAULT_PX = 256
|
||||
export const SIDEBAR_WIDTH_MIN_PX = 224
|
||||
|
||||
@@ -31,7 +31,7 @@ const portraitGridGap = ref(storage.get('portraitGridGap', gridGap.value))
|
||||
const squareGridGap = ref(storage.get('squareGridGap', gridGap.value))
|
||||
const viewMode = ref<'grid' | 'list'>(storage.get('viewMode', 'grid'))
|
||||
const cardOverlays = ref<CardOverlayKey[]>(normalizeCardOverlays(storage.get('cardOverlays', DEFAULT_CARD_OVERLAYS)))
|
||||
const lensFilterExpanded = ref(storage.get('lensFilterExpanded', true))
|
||||
const smartScopeFilterExpanded = ref(storage.get('smartScopeFilterExpanded', true))
|
||||
const authorCoverSize = ref(Math.max(storage.get('authorCoverSize', 120), 100))
|
||||
const authorCoverShape = ref<AuthorCoverShape>(storage.get('authorCoverShape', 'circle'))
|
||||
|
||||
@@ -43,7 +43,7 @@ watch(portraitGridGap, (v) => storage.set('portraitGridGap', v))
|
||||
watch(squareGridGap, (v) => storage.set('squareGridGap', v))
|
||||
watch(viewMode, (v) => storage.set('viewMode', v))
|
||||
watch(cardOverlays, (v) => storage.set('cardOverlays', normalizeCardOverlays(v)), { deep: true })
|
||||
watch(lensFilterExpanded, (v) => storage.set('lensFilterExpanded', v))
|
||||
watch(smartScopeFilterExpanded, (v) => storage.set('smartScopeFilterExpanded', v))
|
||||
watch(authorCoverSize, (v) => storage.set('authorCoverSize', v))
|
||||
watch(authorCoverShape, (v) => storage.set('authorCoverShape', v))
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useDisplaySettings() {
|
||||
squareGridGap,
|
||||
viewMode,
|
||||
cardOverlays,
|
||||
lensFilterExpanded,
|
||||
smartScopeFilterExpanded,
|
||||
authorCoverSize,
|
||||
authorCoverShape,
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { CoverAspectRatio } from '@projectx/types'
|
||||
import type { CoverAspectRatio } from '@bookorbit/types'
|
||||
import { storage } from '@/services/storage'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||
|
||||
type ViewType = 'library' | 'lens' | 'collection'
|
||||
type ViewType = 'library' | 'smartScope' | 'collection'
|
||||
type CoverShapeGroup = 'portrait' | 'square'
|
||||
|
||||
function coverSizeKey(type: ViewType, id: number, group: CoverShapeGroup) {
|
||||
return `projectx:coverSize:${type}:${id}:${group}`
|
||||
return `bookorbit:coverSize:${type}:${id}:${group}`
|
||||
}
|
||||
|
||||
function gridGapKey(type: ViewType, id: number, group: CoverShapeGroup) {
|
||||
return `projectx:gridGap:${type}:${id}:${group}`
|
||||
return `bookorbit:gridGap:${type}:${id}:${group}`
|
||||
}
|
||||
|
||||
function legacyCoverSizeKey(type: ViewType, id: number) {
|
||||
return `projectx:coverSize:${type}:${id}`
|
||||
return `bookorbit:coverSize:${type}:${id}`
|
||||
}
|
||||
|
||||
function legacyGridGapKey(type: ViewType, id: number) {
|
||||
return `projectx:gridGap:${type}:${id}`
|
||||
return `bookorbit:gridGap:${type}:${id}`
|
||||
}
|
||||
|
||||
export function useViewDisplaySettings(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
import { Permission, PERMISSION_LABELS } from '@projectx/types'
|
||||
import type { AuthUser } from '@projectx/types'
|
||||
import { Permission, PERMISSION_LABELS } from '@bookorbit/types'
|
||||
import type { AuthUser } from '@bookorbit/types'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
interface Library {
|
||||
@@ -28,7 +28,7 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
|
||||
},
|
||||
{
|
||||
label: 'Devices & Access',
|
||||
permissions: [Permission.KoboSync, Permission.OpdsAccess, Permission.BookBucketAccess],
|
||||
permissions: [Permission.KoboSync, Permission.OpdsAccess, Permission.BookDockAccess],
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { UserPlus, Pencil, KeyRound, Trash2, ShieldCheck, MoreVertical } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthUser } from '@projectx/types'
|
||||
import type { AuthUser } from '@bookorbit/types'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import UserFormDrawer from './UserFormDrawer.vue'
|
||||
import ResetLinkModal from './ResetLinkModal.vue'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed, ref, watch } from 'vue'
|
||||
import { RefreshCw, Search, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { AuditAction } from '@projectx/types'
|
||||
import { AuditAction } from '@bookorbit/types'
|
||||
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
|
||||
import { useAuditLog } from './useAuditLog'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import type { AuditLogEntry, AuditLogPage } from '@projectx/types'
|
||||
import type { AuditLogEntry, AuditLogPage } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
interface AuditFilters {
|
||||
|
||||
@@ -209,7 +209,7 @@ async function handleSubmit() {
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground">project<span class="text-primary">x</span></h1>
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">Reset your password</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { OidcProviderPublic } from '@projectx/types'
|
||||
import type { OidcProviderPublic } from '@bookorbit/types'
|
||||
import { Moon, Sun, Wallpaper } from 'lucide-vue-next'
|
||||
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
@@ -227,7 +227,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
||||
<div class="text-center mb-8 animate-fade-up">
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground">project<span class="text-primary">x</span></h1>
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { OidcCallbackResponse } from '@projectx/types'
|
||||
import { OidcErrorCode } from '@projectx/types'
|
||||
import type { OidcCallbackResponse } from '@bookorbit/types'
|
||||
import { OidcErrorCode } from '@bookorbit/types'
|
||||
import { setAccessToken } from '@/lib/api'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
import { useOidc, OidcLoginError } from './composables/useOidc'
|
||||
@@ -79,7 +79,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-sm text-center">
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground mb-6">project<span class="text-primary">x</span></h1>
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground mb-6">Book<span class="text-primary"> Orbit</span></h1>
|
||||
|
||||
<div v-if="!error" class="space-y-3">
|
||||
<div class="flex justify-center">
|
||||
|
||||
@@ -229,7 +229,7 @@ async function handleSubmit() {
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground">project<span class="text-primary">x</span></h1>
|
||||
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">Set a new password</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { AuthUser, AuthResponse } from '@projectx/types'
|
||||
import type { AuthUser, AuthResponse } from '@bookorbit/types'
|
||||
import { api, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
|
||||
import router from '@/router'
|
||||
import { useSetupStatus } from './useSetupStatus'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OidcCallbackResponse, OidcProviderPublic } from '@projectx/types'
|
||||
import type { OidcCallbackResponse, OidcProviderPublic } from '@bookorbit/types'
|
||||
|
||||
export async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
||||
const array = new Uint8Array(32)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthorsPage, AuthorDetail, AuthorMetadataCandidate, AuthorMetadataProviderKey, BooksPage, MergeAuthorsResult } from '@projectx/types'
|
||||
import type { AuthorsPage, AuthorDetail, AuthorMetadataCandidate, AuthorMetadataProviderKey, BooksPage, MergeAuthorsResult } from '@bookorbit/types'
|
||||
import type { AuthorBookSort, AuthorListSort, SortDirection } from '../types/author'
|
||||
|
||||
type ListAuthorsParams = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AuthorSummary } from '@projectx/types'
|
||||
import type { AuthorSummary } from '@bookorbit/types'
|
||||
import { BookCopy, Check, ExternalLink, Loader2, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { AuthorDetail } from '@projectx/types'
|
||||
import type { AuthorDetail } from '@bookorbit/types'
|
||||
import { MoreHorizontal, Pencil, RefreshCcw, Trash2, UsersRound, X } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AuthorSummary } from '@projectx/types'
|
||||
import type { AuthorSummary } from '@bookorbit/types'
|
||||
import { ArrowRight, BookCopy, Check, Clock3, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { fetchAuthorBooks } from '../api/author'
|
||||
import type { AuthorBookSort, SortDirection } from '../types/author'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import type { AuthorDetail } from '@projectx/types'
|
||||
import type { AuthorDetail } from '@bookorbit/types'
|
||||
import { fetchAuthor } from '../api/author'
|
||||
|
||||
export function useAuthorDetail(authorId: Ref<number>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import type { AuthorMetadataCandidate } from '@projectx/types'
|
||||
import type { AuthorMetadataCandidate } from '@bookorbit/types'
|
||||
import { streamAuthorMetadataCandidates } from '../api/author'
|
||||
|
||||
export function useAuthorMetadataPreview(authorName: Ref<string>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AuthorSummary } from '@projectx/types'
|
||||
import type { AuthorSummary } from '@bookorbit/types'
|
||||
import { fetchAuthors } from '../api/author'
|
||||
import type { AuthorListSort, SortDirection } from '../types/author'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthorDetail, AuthorSummary } from '@projectx/types'
|
||||
import type { AuthorDetail, AuthorSummary } from '@bookorbit/types'
|
||||
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
export type AuthorListSort = 'name' | 'sortName' | 'bookCount' | 'lastAddedAt' | 'lastEnrichedAt'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { AuthorSummary, BookCard } from '@projectx/types'
|
||||
import type { AuthorSummary, BookCard } from '@bookorbit/types'
|
||||
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { X, Loader2 } from 'lucide-vue-next'
|
||||
import type { BookBucketBulkEditResult, BookBucketMetadata } from '@projectx/types'
|
||||
import type { BookDockBulkEditResult, BookDockMetadata } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -16,9 +16,9 @@ const emit = defineEmits<{
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const result = ref<BookBucketBulkEditResult | null>(null)
|
||||
const result = ref<BookDockBulkEditResult | null>(null)
|
||||
|
||||
const FIELDS: { key: keyof BookBucketMetadata; label: string; type: 'text' | 'number' | 'array' }[] = [
|
||||
const FIELDS: { key: keyof BookDockMetadata; label: string; type: 'text' | 'number' | 'array' }[] = [
|
||||
{ key: 'title', label: 'Title', type: 'text' },
|
||||
{ key: 'authors', label: 'Authors', type: 'array' },
|
||||
{ key: 'publisher', label: 'Publisher', type: 'text' },
|
||||
@@ -45,7 +45,7 @@ async function submit() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const fields: Partial<BookBucketMetadata> = {}
|
||||
const fields: Partial<BookDockMetadata> = {}
|
||||
for (const f of FIELDS) {
|
||||
if (!enabledFields.has(f.key)) continue
|
||||
const raw = values[f.key] ?? ''
|
||||
@@ -63,7 +63,7 @@ async function submit() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api('/api/v1/book-bucket/files/bulk-edit', {
|
||||
const res = await api('/api/v1/book-dock/files/bulk-edit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
+22
-22
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { BookOpen, FileText, Wand2, Pencil, X, ArrowRight } from 'lucide-vue-next'
|
||||
import type { BookBucketFile, BookBucketMetadata } from '@projectx/types'
|
||||
import type { BookDockFile, BookDockMetadata } from '@bookorbit/types'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from 'reka-ui'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import BookBucketStatusBadge from './BookBucketStatusBadge.vue'
|
||||
import BookDockStatusBadge from './BookDockStatusBadge.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: BookBucketFile[]
|
||||
items: BookDockFile[]
|
||||
loading: boolean
|
||||
initialized: boolean
|
||||
isSelected: (id: number) => boolean
|
||||
@@ -32,34 +32,34 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
namePreviewByFileId: () => ({}),
|
||||
emptyMessage: 'Upload files or drop them in the Book Bucket folder',
|
||||
emptyMessage: 'Upload files or drop them in the Book Dock folder',
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
select: [number]
|
||||
selectAll: []
|
||||
open: [BookBucketFile]
|
||||
open: [BookDockFile]
|
||||
applyFetched: [number]
|
||||
}>()
|
||||
|
||||
function backendCoverUrl(file: BookBucketFile): string {
|
||||
return `/api/v1/book-bucket/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}`
|
||||
function backendCoverUrl(file: BookDockFile): string {
|
||||
return `/api/v1/book-dock/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}`
|
||||
}
|
||||
|
||||
function selectedCoverUrl(file: BookBucketFile): string | null {
|
||||
function selectedCoverUrl(file: BookDockFile): string | null {
|
||||
const url = file.selectedMetadata?.coverUrl?.trim()
|
||||
return url ? url : null
|
||||
}
|
||||
|
||||
function newCoverUrl(file: BookBucketFile): string | null {
|
||||
function newCoverUrl(file: BookDockFile): string | null {
|
||||
const selected = selectedCoverUrl(file)
|
||||
if (selected) return selected
|
||||
const fetched = file.fetchedMetadata?.coverUrl?.trim()
|
||||
return fetched ? fetched : null
|
||||
}
|
||||
|
||||
function currentCoverUrl(file: BookBucketFile): string {
|
||||
function currentCoverUrl(file: BookDockFile): string {
|
||||
return backendCoverUrl(file)
|
||||
}
|
||||
|
||||
@@ -105,31 +105,31 @@ function onLightboxCoverError(event: Event) {
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
function displayTitle(file: BookBucketFile): string {
|
||||
function displayTitle(file: BookDockFile): string {
|
||||
const meta = file.selectedMetadata ?? file.embeddedMetadata
|
||||
return meta?.title ?? file.fileName
|
||||
}
|
||||
|
||||
function displayAuthor(file: BookBucketFile): string {
|
||||
function displayAuthor(file: BookDockFile): string {
|
||||
const meta = file.selectedMetadata ?? file.embeddedMetadata
|
||||
return meta?.authors?.join(', ') ?? ''
|
||||
}
|
||||
|
||||
function hasContent(m: BookBucketMetadata | null): boolean {
|
||||
function hasContent(m: BookDockMetadata | null): boolean {
|
||||
if (!m) return false
|
||||
return Object.values(m).some((v) => v !== undefined && v !== null && v !== '')
|
||||
}
|
||||
|
||||
type MetadataState = 'edited' | 'fetched' | 'embedded' | 'none'
|
||||
|
||||
function metadataState(file: BookBucketFile): MetadataState {
|
||||
function metadataState(file: BookDockFile): MetadataState {
|
||||
if (hasContent(file.selectedMetadata)) return 'edited'
|
||||
if (hasContent(file.fetchedMetadata)) return 'fetched'
|
||||
if (hasContent(file.embeddedMetadata)) return 'embedded'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function confidenceBadgeClass(file: BookBucketFile): string {
|
||||
function confidenceBadgeClass(file: BookDockFile): string {
|
||||
const c = file.confidence
|
||||
if (c === null || c === undefined) return 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
|
||||
if (c >= 80) return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
@@ -143,7 +143,7 @@ onMounted(() => {
|
||||
void fetchLibraries()
|
||||
})
|
||||
|
||||
function statusBorderClass(file: BookBucketFile): string {
|
||||
function statusBorderClass(file: BookDockFile): string {
|
||||
switch (file.status) {
|
||||
case 'pending':
|
||||
return 'border-l-2 border-l-amber-500/50'
|
||||
@@ -159,7 +159,7 @@ function statusBorderClass(file: BookBucketFile): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isTargetUnassigned(file: BookBucketFile): boolean {
|
||||
function isTargetUnassigned(file: BookDockFile): boolean {
|
||||
return file.targetLibraryId == null || file.targetFolderId == null
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ function joinTargetPath(folderPath: string, fileName: string): string {
|
||||
return `${base}${separator}${fileName}`
|
||||
}
|
||||
|
||||
function targetSummary(file: BookBucketFile): string {
|
||||
function targetSummary(file: BookDockFile): string {
|
||||
const lib = file.targetLibraryId != null ? libraries.value.find((l) => l.id === file.targetLibraryId) : null
|
||||
const folder = file.targetFolderId != null ? lib?.folders.find((f) => f.id === file.targetFolderId) : null
|
||||
const libLabel = lib?.name ?? (file.targetLibraryId != null ? 'Unknown library' : 'Unassigned')
|
||||
@@ -189,7 +189,7 @@ function targetSummary(file: BookBucketFile): string {
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div v-if="!initialized && loading && !items.length" data-test="book-bucket-loading-skeleton" class="divide-y divide-border">
|
||||
<div v-if="!initialized && loading && !items.length" data-test="book-dock-loading-skeleton" class="divide-y divide-border">
|
||||
<div v-for="n in 5" :key="n" class="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||
<div class="size-4 rounded bg-muted" />
|
||||
<div class="size-10 rounded-lg bg-muted shrink-0" />
|
||||
@@ -201,11 +201,11 @@ function targetSummary(file: BookBucketFile): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" data-test="book-bucket-empty-state" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<div v-else-if="items.length === 0" data-test="book-dock-empty-state" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<div class="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<FileText class="size-6" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-foreground">No files in Book Bucket</p>
|
||||
<p class="text-sm font-medium text-foreground">No files in Book Dock</p>
|
||||
<p class="text-xs">{{ emptyMessage }}</p>
|
||||
</div>
|
||||
|
||||
@@ -368,7 +368,7 @@ function targetSummary(file: BookBucketFile): string {
|
||||
</div>
|
||||
|
||||
<div class="w-16 flex justify-center shrink-0">
|
||||
<BookBucketStatusBadge :status="file.status" />
|
||||
<BookDockStatusBadge :status="file.status" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
+17
-17
@@ -1,28 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { X, BookOpen, Check, Trash2, Sparkles, ArrowLeft, Wand2, AlertCircle } from 'lucide-vue-next'
|
||||
import type { BookBucketFile, BookBucketMetadata, MetadataCandidate, MetadataSource, MetadataProviderKey } from '@projectx/types'
|
||||
import BookBucketStatusBadge from './BookBucketStatusBadge.vue'
|
||||
import type { BookDockFile, BookDockMetadata, MetadataCandidate, MetadataSource, MetadataProviderKey } from '@bookorbit/types'
|
||||
import BookDockStatusBadge from './BookDockStatusBadge.vue'
|
||||
import MetadataSearchPanel from '@/features/book/components/detail/tabs/MetadataSearchPanel.vue'
|
||||
import MetadataDiffPanel from '@/features/book/components/detail/tabs/MetadataDiffPanel.vue'
|
||||
import { useBookBucketDetail } from '../composables/useBookBucketDetail'
|
||||
import { useBookDockDetail } from '../composables/useBookDockDetail'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useMetadataSearch } from '@/features/book/composables/useMetadataSearch'
|
||||
import type { MetadataPatch } from '@/features/book/composables/useMetadataDiff'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
|
||||
const props = defineProps<{ file: BookBucketFile }>()
|
||||
const props = defineProps<{ file: BookDockFile }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
discarded: []
|
||||
updated: [BookBucketFile]
|
||||
updated: [BookDockFile]
|
||||
}>()
|
||||
|
||||
const { saved, saveError, saveMetadata, setTarget, discardFile, coverUrl } = useBookBucketDetail()
|
||||
const { saved, saveError, saveMetadata, setTarget, discardFile, coverUrl } = useBookDockDetail()
|
||||
const { libraries, fetchLibraries: fetchLibs } = useLibraries()
|
||||
|
||||
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as BookBucketMetadata))
|
||||
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as BookDockMetadata))
|
||||
|
||||
const metaView = ref<'editor' | 'search' | 'diff'>('editor')
|
||||
const selectedCandidate = ref<MetadataCandidate | null>(null)
|
||||
@@ -98,7 +98,7 @@ onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
function buildMetadataPatchFromForm(): Partial<BookBucketMetadata> {
|
||||
function buildMetadataPatchFromForm(): Partial<BookDockMetadata> {
|
||||
return {
|
||||
title: form.title || undefined,
|
||||
subtitle: form.subtitle || undefined,
|
||||
@@ -268,14 +268,14 @@ const hasFetchedMetadata = computed(() => {
|
||||
return Object.values(f).some((v) => v !== undefined && v !== null && v !== '')
|
||||
})
|
||||
|
||||
const backendBookBucketCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`)
|
||||
const backendBookDockCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`)
|
||||
const normalizedSelectedCoverUrl = computed(() => selectedCoverUrl.value.trim())
|
||||
const currentBookBucketCoverUrl = computed(() => normalizedSelectedCoverUrl.value || backendBookBucketCoverUrl.value)
|
||||
const currentBookBucketCoverFallbackUrl = computed(() => (normalizedSelectedCoverUrl.value ? backendBookBucketCoverUrl.value : null))
|
||||
const currentBookDockCoverUrl = computed(() => normalizedSelectedCoverUrl.value || backendBookDockCoverUrl.value)
|
||||
const currentBookDockCoverFallbackUrl = computed(() => (normalizedSelectedCoverUrl.value ? backendBookDockCoverUrl.value : null))
|
||||
|
||||
function onCurrentBookBucketCoverError(event: Event) {
|
||||
function onCurrentBookDockCoverError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
const fallback = currentBookBucketCoverFallbackUrl.value
|
||||
const fallback = currentBookDockCoverFallbackUrl.value
|
||||
if (!fallback) {
|
||||
img.style.display = 'none'
|
||||
return
|
||||
@@ -342,18 +342,18 @@ onMounted(() => {
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0 pr-12">
|
||||
<div class="relative size-12 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
:src="currentBookBucketCoverUrl"
|
||||
:src="currentBookDockCoverUrl"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
@load="($event.target as HTMLImageElement).style.display = ''"
|
||||
@error="onCurrentBookBucketCoverError"
|
||||
@error="onCurrentBookDockCoverError"
|
||||
/>
|
||||
<BookOpen class="size-5 text-muted-foreground absolute" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold truncate">{{ file.fileName }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<BookBucketStatusBadge :status="file.status" />
|
||||
<BookDockStatusBadge :status="file.status" />
|
||||
<span class="text-xs text-muted-foreground uppercase">{{ file.format }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ formatBytes(file.fileSize) }}</span>
|
||||
</div>
|
||||
@@ -585,7 +585,7 @@ onMounted(() => {
|
||||
:filtered-results="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
|
||||
:providers="providers"
|
||||
:back-label="diffSource === 'fetched' ? 'Back' : 'Results'"
|
||||
:current-cover-url="currentBookBucketCoverUrl"
|
||||
:current-cover-url="currentBookDockCoverUrl"
|
||||
:provider-ids="(file.fetchedMetadataSources as any) ?? undefined"
|
||||
@back="backFromDiff"
|
||||
@apply="handleApply"
|
||||
+4
-4
@@ -5,7 +5,7 @@ import { X, Check, AlertCircle, Copy, Loader2, ExternalLink, ChevronDown, FileTe
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useBookBucketFinalize } from '../composables/useBookBucketFinalize'
|
||||
import { useBookDockFinalize } from '../composables/useBookDockFinalize'
|
||||
|
||||
const props = defineProps<{
|
||||
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
|
||||
@@ -19,7 +19,7 @@ const emit = defineEmits<{
|
||||
|
||||
const router = useRouter()
|
||||
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
|
||||
const { result, loading, error, finalize, reset } = useBookBucketFinalize()
|
||||
const { result, loading, error, finalize, reset } = useBookDockFinalize()
|
||||
|
||||
const defaultLibraryId = ref<number | null>(null)
|
||||
const defaultFolderId = ref<number | null>(null)
|
||||
@@ -84,7 +84,7 @@ async function fetchNamePreview(libId: number | null) {
|
||||
...props.selectionPayload,
|
||||
...(requiresDefaultDestination.value && libId ? { defaultLibraryId: libId } : {}),
|
||||
}
|
||||
const res = await api('/api/v1/book-bucket/files/preview-names', {
|
||||
const res = await api('/api/v1/book-dock/files/preview-names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -103,7 +103,7 @@ async function fetchNamePreview(libId: number | null) {
|
||||
|
||||
async function fetchSelectionSummary() {
|
||||
try {
|
||||
const res = await api('/api/v1/book-bucket/files/selection-summary', {
|
||||
const res = await api('/api/v1/book-dock/files/selection-summary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(props.selectionPayload),
|
||||
+1
-1
@@ -57,7 +57,7 @@ async function applyDestination() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await api('/api/v1/book-bucket/files/set-target', {
|
||||
const res = await api('/api/v1/book-dock/files/set-target', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import type { BookBucketFileStatus } from '@projectx/types'
|
||||
import type { BookDockFileStatus } from '@bookorbit/types'
|
||||
|
||||
defineProps<{ status: BookBucketFileStatus }>()
|
||||
defineProps<{ status: BookDockFileStatus }>()
|
||||
|
||||
const styles: Record<BookBucketFileStatus, string> = {
|
||||
const styles: Record<BookDockFileStatus, string> = {
|
||||
pending: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
extracting: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
|
||||
fetching: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
|
||||
@@ -12,7 +12,7 @@ const styles: Record<BookBucketFileStatus, string> = {
|
||||
error: 'bg-red-500/15 text-red-600 dark:text-red-400',
|
||||
}
|
||||
|
||||
const labels: Record<BookBucketFileStatus, string> = {
|
||||
const labels: Record<BookDockFileStatus, string> = {
|
||||
pending: 'Pending',
|
||||
extracting: 'Extracting',
|
||||
fetching: 'Fetching',
|
||||
+7
-7
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus, Loader2 } from 'lucide-vue-next'
|
||||
import type { BookBucketFileStatus } from '@projectx/types'
|
||||
import type { BookDockFileStatus } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { SUPPORTED_FORMATS_ACCEPT, useBookBucketUpload } from '../composables/useBookBucketUpload'
|
||||
import { SUPPORTED_FORMATS_ACCEPT, useBookDockUpload } from '../composables/useBookDockUpload'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps<{
|
||||
activeStatus: BookBucketFileStatus | undefined
|
||||
activeStatus: BookDockFileStatus | undefined
|
||||
selectionCount: number
|
||||
hasSelection: boolean
|
||||
fetchedCount: number
|
||||
@@ -15,7 +15,7 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
statusFilter: [BookBucketFileStatus | undefined]
|
||||
statusFilter: [BookDockFileStatus | undefined]
|
||||
rescan: []
|
||||
rescanError: []
|
||||
retryFetch: []
|
||||
@@ -28,7 +28,7 @@ const emit = defineEmits<{
|
||||
applyFetched: []
|
||||
}>()
|
||||
|
||||
const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useBookBucketUpload()
|
||||
const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useBookDockUpload()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const rescanning = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -58,7 +58,7 @@ onUnmounted(() => {
|
||||
if (popoverTimer) clearTimeout(popoverTimer)
|
||||
})
|
||||
|
||||
const tabs: { label: string; value: BookBucketFileStatus | undefined }[] = [
|
||||
const tabs: { label: string; value: BookDockFileStatus | undefined }[] = [
|
||||
{ label: 'All', value: undefined },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Ready', value: 'ready' },
|
||||
@@ -81,7 +81,7 @@ function onFilesSelected(event: Event) {
|
||||
async function rescan() {
|
||||
rescanning.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/book-bucket/rescan', { method: 'POST' })
|
||||
const res = await api('/api/v1/book-dock/rescan', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
emit('rescan')
|
||||
} else {
|
||||
+12
-12
@@ -1,8 +1,8 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BookBucketFile } from '@projectx/types'
|
||||
import type { BookDockFile } from '@bookorbit/types'
|
||||
import { ref } from 'vue'
|
||||
import BookBucketFileList from '../BookBucketFileList.vue'
|
||||
import BookDockFileList from '../BookDockFileList.vue'
|
||||
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
useLibraries: () => ({
|
||||
@@ -11,7 +11,7 @@ vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeFile(overrides: Partial<BookBucketFile> = {}): BookBucketFile {
|
||||
function makeFile(overrides: Partial<BookDockFile> = {}): BookDockFile {
|
||||
return {
|
||||
id: 1,
|
||||
fileName: 'example.epub',
|
||||
@@ -33,8 +33,8 @@ function makeFile(overrides: Partial<BookBucketFile> = {}): BookBucketFile {
|
||||
}
|
||||
}
|
||||
|
||||
function mountList(props: Partial<InstanceType<typeof BookBucketFileList>['$props']> = {}) {
|
||||
return shallowMount(BookBucketFileList, {
|
||||
function mountList(props: Partial<InstanceType<typeof BookDockFileList>['$props']> = {}) {
|
||||
return shallowMount(BookDockFileList, {
|
||||
props: {
|
||||
items: [],
|
||||
loading: false,
|
||||
@@ -46,7 +46,7 @@ function mountList(props: Partial<InstanceType<typeof BookBucketFileList>['$prop
|
||||
})
|
||||
}
|
||||
|
||||
describe('BookBucketFileList', () => {
|
||||
describe('BookDockFileList', () => {
|
||||
it('shows the skeleton only during the initial load', () => {
|
||||
const wrapper = mountList({
|
||||
loading: true,
|
||||
@@ -54,8 +54,8 @@ describe('BookBucketFileList', () => {
|
||||
items: [],
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="book-bucket-loading-skeleton"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="book-bucket-empty-state"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="book-dock-loading-skeleton"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="book-dock-empty-state"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the empty state visible while a later tab refresh is loading', () => {
|
||||
@@ -66,8 +66,8 @@ describe('BookBucketFileList', () => {
|
||||
emptyMessage: 'No ready files',
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="book-bucket-loading-skeleton"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="book-bucket-empty-state"]').text()).toContain('No ready files')
|
||||
expect(wrapper.find('[data-test="book-dock-loading-skeleton"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="book-dock-empty-state"]').text()).toContain('No ready files')
|
||||
})
|
||||
|
||||
it('renders file rows after loading completes', () => {
|
||||
@@ -75,8 +75,8 @@ describe('BookBucketFileList', () => {
|
||||
items: [makeFile()],
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="book-bucket-loading-skeleton"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="book-bucket-empty-state"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="book-dock-loading-skeleton"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="book-dock-empty-state"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('example.epub')
|
||||
})
|
||||
})
|
||||
+13
-13
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookBucketFile, BookBucketMetadata } from '@projectx/types'
|
||||
import type { BookDockFile, BookDockMetadata } from '@bookorbit/types'
|
||||
|
||||
export function useBookBucketDetail() {
|
||||
const file = ref<BookBucketFile | null>(null)
|
||||
export function useBookDockDetail() {
|
||||
const file = ref<BookDockFile | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
@@ -14,23 +14,23 @@ export function useBookBucketDetail() {
|
||||
async function loadFile(id: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/book-bucket/files/${id}`)
|
||||
const res = await api(`/api/v1/book-dock/files/${id}`)
|
||||
if (res.ok) file.value = await res.json()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMetadata(id: number, metadata: Partial<BookBucketMetadata>): Promise<BookBucketFile | null> {
|
||||
async function saveMetadata(id: number, metadata: Partial<BookDockMetadata>): Promise<BookDockFile | null> {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/book-bucket/files/${id}`, {
|
||||
const res = await api(`/api/v1/book-dock/files/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selectedMetadata: metadata }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated: BookBucketFile = await res.json()
|
||||
const updated: BookDockFile = await res.json()
|
||||
file.value = updated
|
||||
saveError.value = null
|
||||
saved.value = true
|
||||
@@ -49,19 +49,19 @@ export function useBookBucketDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSave(id: number, metadata: Partial<BookBucketMetadata>) {
|
||||
function debouncedSave(id: number, metadata: Partial<BookDockMetadata>) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => saveMetadata(id, metadata), 1000)
|
||||
}
|
||||
|
||||
async function setTarget(id: number, libraryId: number | null, folderId: number | null): Promise<BookBucketFile | null> {
|
||||
const res = await api(`/api/v1/book-bucket/files/${id}`, {
|
||||
async function setTarget(id: number, libraryId: number | null, folderId: number | null): Promise<BookDockFile | null> {
|
||||
const res = await api(`/api/v1/book-dock/files/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetLibraryId: libraryId, targetFolderId: folderId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated: BookBucketFile = await res.json()
|
||||
const updated: BookDockFile = await res.json()
|
||||
file.value = updated
|
||||
return updated
|
||||
}
|
||||
@@ -69,12 +69,12 @@ export function useBookBucketDetail() {
|
||||
}
|
||||
|
||||
async function discardFile(id: number) {
|
||||
await api(`/api/v1/book-bucket/files/${id}`, { method: 'DELETE' })
|
||||
await api(`/api/v1/book-dock/files/${id}`, { method: 'DELETE' })
|
||||
file.value = null
|
||||
}
|
||||
|
||||
function coverUrl(id: number): string {
|
||||
return `/api/v1/book-bucket/files/${id}/cover`
|
||||
return `/api/v1/book-dock/files/${id}/cover`
|
||||
}
|
||||
|
||||
return { file, loading, saving, saved, saveError, loadFile, saveMetadata, debouncedSave, setTarget, discardFile, coverUrl }
|
||||
+8
-8
@@ -1,18 +1,18 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookBucketFile, BookBucketFilesPage, BookBucketFileStatus } from '@projectx/types'
|
||||
import type { BookDockFile, BookDockFilesPage, BookDockFileStatus } from '@bookorbit/types'
|
||||
|
||||
export type SortField = 'createdAt' | 'fileName' | 'format' | 'status' | 'fileSize'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export function useBookBucketFiles() {
|
||||
const items = ref<BookBucketFile[]>([])
|
||||
export function useBookDockFiles() {
|
||||
const items = ref<BookDockFile[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
status: undefined as BookBucketFileStatus | undefined,
|
||||
status: undefined as BookDockFileStatus | undefined,
|
||||
search: '',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
@@ -39,10 +39,10 @@ export function useBookBucketFiles() {
|
||||
params.set('sort', filters.sort)
|
||||
params.set('order', filters.order)
|
||||
|
||||
const res = await api(`/api/v1/book-bucket/files?${params}`)
|
||||
const res = await api(`/api/v1/book-dock/files?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const data: BookBucketFilesPage = await res.json()
|
||||
const data: BookDockFilesPage = await res.json()
|
||||
if (reqId !== fetchReqSeq) return
|
||||
items.value = data.items
|
||||
total.value = data.total
|
||||
@@ -56,7 +56,7 @@ export function useBookBucketFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(status: BookBucketFileStatus | undefined) {
|
||||
function setStatus(status: BookDockFileStatus | undefined) {
|
||||
clearSelection()
|
||||
filters.status = status
|
||||
filters.page = 1
|
||||
@@ -129,7 +129,7 @@ export function useBookBucketFiles() {
|
||||
fileIds?: number[]
|
||||
selectAll?: boolean
|
||||
excludedIds?: number[]
|
||||
status?: BookBucketFileStatus
|
||||
status?: BookDockFileStatus
|
||||
search?: string
|
||||
} {
|
||||
if (selectAll.value) {
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookBucketFinalizeResult, BookBucketFinalizeOverride } from '@projectx/types'
|
||||
import type { BookDockFinalizeResult, BookDockFinalizeOverride } from '@bookorbit/types'
|
||||
|
||||
export function useBookBucketFinalize() {
|
||||
const result = ref<BookBucketFinalizeResult | null>(null)
|
||||
export function useBookDockFinalize() {
|
||||
const result = ref<BookDockFinalizeResult | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -15,14 +15,14 @@ export function useBookBucketFinalize() {
|
||||
search?: string
|
||||
defaultLibraryId?: number
|
||||
defaultFolderId?: number
|
||||
overrides?: BookBucketFinalizeOverride[]
|
||||
overrides?: BookDockFinalizeOverride[]
|
||||
}) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
result.value = null
|
||||
|
||||
try {
|
||||
const res = await api('/api/v1/book-bucket/finalize', {
|
||||
const res = await api('/api/v1/book-dock/finalize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import type { BookBucketStatistics } from '@projectx/types'
|
||||
import type { BookDockStatistics } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useBookBucketStatistics() {
|
||||
const statistics = ref<BookBucketStatistics | null>(null)
|
||||
export function useBookDockStatistics() {
|
||||
const statistics = ref<BookDockStatistics | null>(null)
|
||||
|
||||
async function fetchStatistics() {
|
||||
try {
|
||||
const res = await api('/api/v1/book-bucket/statistics')
|
||||
const res = await api('/api/v1/book-dock/statistics')
|
||||
if (res.ok) statistics.value = await res.json()
|
||||
} catch {
|
||||
statistics.value = null
|
||||
+8
-8
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { api, getAccessToken } from '@/lib/api'
|
||||
import type { BookBucketSummary } from '@projectx/types'
|
||||
import type { BookDockSummary } from '@bookorbit/types'
|
||||
|
||||
const summary = ref<BookBucketSummary>({ pending: 0, ready: 0, error: 0, total: 0 })
|
||||
const summary = ref<BookDockSummary>({ pending: 0, ready: 0, error: 0, total: 0 })
|
||||
const loading = ref(false)
|
||||
const socketConnected = ref(true)
|
||||
let socket: Socket | null = null
|
||||
@@ -14,7 +14,7 @@ let loaded = false
|
||||
function requestSummary(markLoading: boolean): Promise<void> {
|
||||
if (fetchPromise) return fetchPromise
|
||||
if (markLoading) loading.value = true
|
||||
fetchPromise = api('/api/v1/book-bucket/summary')
|
||||
fetchPromise = api('/api/v1/book-dock/summary')
|
||||
.then(async (res) => {
|
||||
if (res.ok) {
|
||||
summary.value = await res.json()
|
||||
@@ -37,7 +37,7 @@ async function restFetchSummary() {
|
||||
|
||||
function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io('/book-bucket', {
|
||||
socket = io('/book-dock', {
|
||||
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
@@ -45,7 +45,7 @@ function getSocket(): Socket {
|
||||
reconnectionDelayMax: 10000,
|
||||
})
|
||||
|
||||
socket.on('book-bucket:summary', (data: BookBucketSummary) => {
|
||||
socket.on('book-dock:summary', (data: BookDockSummary) => {
|
||||
summary.value = data
|
||||
for (const fn of changeListeners) fn()
|
||||
})
|
||||
@@ -62,7 +62,7 @@ function getSocket(): Socket {
|
||||
return socket
|
||||
}
|
||||
|
||||
export function useBookBucketSummary() {
|
||||
export function useBookDockSummary() {
|
||||
async function fetchSummary(force = false): Promise<void> {
|
||||
if (!force && loaded) return
|
||||
return requestSummary(true)
|
||||
@@ -72,10 +72,10 @@ export function useBookBucketSummary() {
|
||||
getSocket()
|
||||
}
|
||||
|
||||
function onBookBucketChange(fn: () => void) {
|
||||
function onBookDockChange(fn: () => void) {
|
||||
changeListeners.add(fn)
|
||||
return () => changeListeners.delete(fn)
|
||||
}
|
||||
|
||||
return { summary, loading, socketConnected, fetchSummary, subscribe, onBookBucketChange }
|
||||
return { summary, loading, socketConnected, fetchSummary, subscribe, onBookDockChange }
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { getAccessToken } from '@/lib/api'
|
||||
import type { BookBucketFile } from '@projectx/types'
|
||||
import type { BookDockFile } from '@bookorbit/types'
|
||||
|
||||
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
|
||||
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
|
||||
@@ -14,7 +14,7 @@ export interface UploadItem {
|
||||
status: FileUploadStatus
|
||||
progress: number
|
||||
error?: string
|
||||
bookBucketFile?: BookBucketFile
|
||||
bookDockFile?: BookDockFile
|
||||
}
|
||||
|
||||
const CONCURRENCY = 3
|
||||
@@ -32,7 +32,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
|
||||
formData.append('file', item.file)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/v1/book-bucket/upload')
|
||||
xhr.open('POST', '/api/v1/book-dock/upload')
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
@@ -46,7 +46,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
|
||||
item.status = 'done'
|
||||
item.progress = 100
|
||||
try {
|
||||
item.bookBucketFile = JSON.parse(xhr.responseText) as BookBucketFile
|
||||
item.bookDockFile = JSON.parse(xhr.responseText) as BookDockFile
|
||||
} catch {
|
||||
// response parsing optional
|
||||
}
|
||||
@@ -75,7 +75,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
|
||||
const files = ref<UploadItem[]>([])
|
||||
const isUploading = ref(false)
|
||||
|
||||
export function useBookBucketUpload() {
|
||||
export function useBookDockUpload() {
|
||||
function addFiles(fileList: FileList | File[]) {
|
||||
for (const file of fileList) {
|
||||
const error = validateFile(file)
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { X, Play, Square, AlertTriangle } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { AuthorEnrichmentStatusEvent } from '@projectx/types'
|
||||
import type { AuthorEnrichmentStatusEvent } from '@bookorbit/types'
|
||||
import { useAuthorEnrichmentActions } from '@/features/settings/composables/useAuthorEnrichmentActions'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { X, RefreshCw } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { AuthorEnrichmentFailedPage } from '@projectx/types'
|
||||
import type { AuthorEnrichmentFailedPage } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthorEnrichmentActions } from '@/features/settings/composables/useAuthorEnrichmentActions'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { X, Play, Square, AlertTriangle } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookMetadataFetchStatusEvent } from '@projectx/types'
|
||||
import type { BookMetadataFetchStatusEvent } from '@bookorbit/types'
|
||||
import { useBookMetadataFetchActions } from '../composables/useBookMetadataFetchActions'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { X, RefreshCw } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookMetadataFetchFailedPage } from '@projectx/types'
|
||||
import type { BookMetadataFetchFailedPage } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useBookMetadataFetchActions } from '../composables/useBookMetadataFetchActions'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@projectx/types'
|
||||
import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const globalConfig = ref<BookMetadataFetchConfig | null>(null)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import type { BookMetadataFetchStatusEvent } from '@projectx/types'
|
||||
import type { BookMetadataFetchStatusEvent } from '@bookorbit/types'
|
||||
import { getAccessToken } from '@/lib/api'
|
||||
|
||||
const status = ref<BookMetadataFetchStatusEvent>({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import type { BookMetadataFetchConditions } from '@projectx/types'
|
||||
import type { BookMetadataFetchConditions } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useEligibleCountPreview(conditions: Ref<BookMetadataFetchConditions | null>, libraryId?: number) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { BookCard, BookFileRef } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@projectx/types'
|
||||
import type { BookCard, BookFileRef } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import { getFormatColor } from '../lib/format-colors'
|
||||
import { computed, inject, ref, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TriangleAlert,
|
||||
} from 'lucide-vue-next'
|
||||
import { useBookStatus, STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS } from '../composables/useBookStatus'
|
||||
import type { ReadStatus } from '@projectx/types'
|
||||
import type { ReadStatus } from '@bookorbit/types'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown, Download } from 'lucide-vue-next'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@projectx/types'
|
||||
import type { BookDetailFile } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import type { BookDetailFile } from '@bookorbit/types'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { FIELD_OPERATORS, RULE_FIELDS, type GroupRule, type Rule, type RuleField, type RuleOperator } from '@projectx/types'
|
||||
import { FIELD_OPERATORS, RULE_FIELDS, type GroupRule, type Rule, type RuleField, type RuleOperator } from '@bookorbit/types'
|
||||
import { FIELD_LABELS, OPERATOR_LABELS } from '@/features/book/lib/filter-labels'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import FilterChipTypeahead from './FilterChipTypeahead.vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { BookCard, BookFileRef } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import type { BookCard, BookFileRef } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP } from '@bookorbit/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import { getFormatColor } from '../lib/format-colors'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP } from '@bookorbit/types'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowDownAZ, ArrowUpAZ, ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { SortField, SortSpec } from '@projectx/types'
|
||||
import { SORT_FIELDS } from '@projectx/types'
|
||||
import type { SortField, SortSpec } from '@bookorbit/types'
|
||||
import { SORT_FIELDS } from '@bookorbit/types'
|
||||
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { BOOK_FORMATS } from '@projectx/types'
|
||||
import { BOOK_FORMATS } from '@bookorbit/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { GroupRule, Rule } from '@projectx/types'
|
||||
import type { GroupRule, Rule } from '@bookorbit/types'
|
||||
import { ruleToParts } from '@/features/book/lib/filter-labels'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import BookCoverCard from './BookCoverCard.vue'
|
||||
import CollapsedSeriesCard from './CollapsedSeriesCard.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import BookCoverCard from '../BookCoverCard.vue'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { ref } from 'vue'
|
||||
import { COVER_ASPECT_RATIO_KEY } from '@/features/book/lib/cover-aspect-ratio'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import BookListRow from '../BookListRow.vue'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import CollapsedSeriesCard from '../CollapsedSeriesCard.vue'
|
||||
|
||||
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
|
||||
|
||||
@@ -12,7 +12,7 @@ beforeEach(() => {
|
||||
)
|
||||
})
|
||||
import DetailsTab from '../detail/tabs/DetailsTab.vue'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, onUnmounted } from 'vue'
|
||||
import { Image, ImagePlus, Link, Lock, LockOpen, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP } from '@bookorbit/types'
|
||||
import { hideOnError } from '../../../lib/metadata-fetch'
|
||||
import { useCoverEditor } from '../../../composables/useCoverEditor'
|
||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { Search, Loader2, X, Image as ImageIcon, Check } from 'lucide-vue-next'
|
||||
import type { CoverSearchResult } from '@projectx/types'
|
||||
import type { CoverSearchResult } from '@bookorbit/types'
|
||||
|
||||
const props = defineProps<{
|
||||
initialTitle: string
|
||||
|
||||
@@ -8,8 +8,8 @@ import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { getProviderColor } from '@/lib/provider-colors'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@projectx/types'
|
||||
import type { BookDetail, BookKoboState, ReadStatus } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import type { BookDetail, BookKoboState, ReadStatus } from '@bookorbit/types'
|
||||
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
||||
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
|
||||
import RecommendedBooksRow from '@/features/book/components/detail/RecommendedBooksRow.vue'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Check, ChevronDown, HardDriveUpload, Loader2, Lock, LockOpen, RefreshCw, Sparkles, Star, X } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookDetail, BookMetadataLockField } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import type { BookDetail, BookMetadataLockField } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP } from '@bookorbit/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import ChipInput from '@/components/ui/ChipInput.vue'
|
||||
import CoverEditorPanel from './CoverEditorPanel.vue'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Download, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
|
||||
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@projectx/types'
|
||||
import { READER_OPENABLE_FORMATS } from '@projectx/types'
|
||||
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
|
||||
import { READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
MetadataProviderKey,
|
||||
MetadataSource,
|
||||
ProviderIds,
|
||||
} from '@projectx/types'
|
||||
} from '@bookorbit/types'
|
||||
import { useMetadataDiff, type DiffFieldKey, type MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { getProviderColor, getProviderLabel, hideOnError, providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
import MetadataDiffRow from './MetadataDiffRow.vue'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn, Layers } from 'lucide-vue-next'
|
||||
import type { MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
import type { MetadataProviderInfo, MetadataProviderKey } from '@bookorbit/types'
|
||||
import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDiff'
|
||||
import { hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2, Lock, LockOpen } from 'lucide-vue-next'
|
||||
import type { BookMetadataLockField } from '@projectx/types'
|
||||
import type { BookMetadataLockField } from '@bookorbit/types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { BookOpen } from 'lucide-vue-next'
|
||||
import type { MetadataCandidate, MetadataProviderInfo } from '@projectx/types'
|
||||
import type { MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
|
||||
import { getProviderLabel, hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { X, Sparkles } from 'lucide-vue-next'
|
||||
import { isAudioFormat } from '@projectx/types'
|
||||
import type { BookDetail, BookMetadataLockField, MetadataCandidate, MetadataSource } from '@projectx/types'
|
||||
import { isAudioFormat } from '@bookorbit/types'
|
||||
import type { BookDetail, BookMetadataLockField, MetadataCandidate, MetadataSource } from '@bookorbit/types'
|
||||
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref, watch } from 'vue'
|
||||
import { Search, BookOpen, Loader2, PencilLine } from 'lucide-vue-next'
|
||||
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@bookorbit/types'
|
||||
import MetadataResultCard from './MetadataResultCard.vue'
|
||||
import { providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { BookCard, UserBookStatus } from '@projectx/types'
|
||||
import type { BookCard, UserBookStatus } from '@bookorbit/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<{ ok: boolean }>>(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { MetadataCandidate, MetadataProviderKey, MetadataSource } from '@projectx/types'
|
||||
import type { MetadataCandidate, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
|
||||
import { useMetadataDiff } from '../useMetadataDiff'
|
||||
|
||||
describe('useMetadataDiff', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
import { useMetadataEditor } from '../useMetadataEditor'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>>())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
|
||||
import { useMetadataLocks } from '../useMetadataLocks'
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('useMetadataLocks', () => {
|
||||
})
|
||||
|
||||
it('lockAll sends every lockable field', async () => {
|
||||
const { BOOK_METADATA_LOCK_FIELDS } = await import('@projectx/types')
|
||||
const { BOOK_METADATA_LOCK_FIELDS } = await import('@bookorbit/types')
|
||||
const allLocked = makeBook({ lockedFields: [...BOOK_METADATA_LOCK_FIELDS] })
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => allLocked })
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { BookCard, ReadStatus, UserBookStatus } from '@projectx/types'
|
||||
import type { BookCard, ReadStatus, UserBookStatus } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useCoverVersions } from './useCoverVersions'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
|
||||
export function useBookDetail() {
|
||||
const detail = ref<BookDetail | null>(null)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookMissingEvent, BookMovedEvent, BookRestoredEvent } from '@projectx/types'
|
||||
import type { BookMissingEvent, BookMovedEvent, BookRestoredEvent } from '@bookorbit/types'
|
||||
import { getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||
|
||||
type BookIdsCallback = (bookIds: number[]) => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, reactive, ref, watch, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookCard, BookQuery, BooksPage, GroupRule, SortSpec } from '@projectx/types'
|
||||
import type { BookCard, BookQuery, BooksPage, GroupRule, SortSpec } from '@bookorbit/types'
|
||||
|
||||
export type { BookCard }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from '@/lib/api'
|
||||
import { Book, BookCheck, BookMarked, BookOpen, BookX, Pause, RotateCcw, ScanLine } from 'lucide-vue-next'
|
||||
import type { ReadStatus } from '@projectx/types'
|
||||
import type { ReadStatus } from '@bookorbit/types'
|
||||
|
||||
export type StatusOption = {
|
||||
value: ReadStatus
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { ComicMetadataFields } from '@projectx/types'
|
||||
import type { ComicMetadataFields } from '@bookorbit/types'
|
||||
|
||||
export interface FileMetadata {
|
||||
title?: string | null
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
MetadataProviderKey,
|
||||
MetadataSource,
|
||||
ProviderIds,
|
||||
} from '@projectx/types'
|
||||
} from '@bookorbit/types'
|
||||
import { getProviderLabel } from '../lib/metadata-fetch'
|
||||
|
||||
type ComicDiffFieldKey =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { FORMAT_TO_GROUP, type BookDetail } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP, type BookDetail } from '@bookorbit/types'
|
||||
|
||||
const ROOT_FIELDS = [
|
||||
'title',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { BOOK_METADATA_LOCK_FIELDS, type BookDetail, type BookMetadataLockField } from '@projectx/types'
|
||||
import { BOOK_METADATA_LOCK_FIELDS, type BookDetail, type BookMetadataLockField } from '@bookorbit/types'
|
||||
|
||||
function normalizeLockedFields(fields: readonly BookMetadataLockField[]): BookMetadataLockField[] {
|
||||
const unique = new Set(fields)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user