Compare commits
14 Commits
053cc07294
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aea97cd929 | |||
| 557a58af27 | |||
| 8b208affc4 | |||
| b01073eac0 | |||
| 020563ed85 | |||
| aee9c428fc | |||
| 668470da2b | |||
| 09e9280da6 | |||
| 65d06e1681 | |||
| 723fafdef8 | |||
| 29dbc27c41 | |||
| 1d209fedd2 | |||
| fa22a755ee | |||
| 42733b4799 |
@@ -1,24 +0,0 @@
|
|||||||
Features and Bugs:
|
|
||||||
1. (bug) - Whiteboard. Clicking add > Character and add > World leads to blank screen.
|
|
||||||
2. (bug) - Whiteboard. Nodes are not dragging.
|
|
||||||
3. (bug) - Whiteboard. Nodes cannot be edited after placement.
|
|
||||||
4. (bug) - connecting lines cannot be deleted.
|
|
||||||
5. (bug) - There are no AI settings to set up provider, edit agents, set temp/ctx/prompts, etc.
|
|
||||||
6. (feature) - AI.Make AI chat window a sidebar that can be toggled. Include provider, model, archived chats, agent, and load/reload button.
|
|
||||||
7. (feature) - AI. make navigation a sidebar with toggle. Untoggled will hid all but the icons.
|
|
||||||
8. (feature) - Write. Needs microsoft word-like formatting features. Line spacing, bold, italics, justification, etc. The whole formatting suite. Reference open source software like Only Office for an example. Favor icons over text buttons.
|
|
||||||
9. (feature) - All. Change the persistent "PubScript" + icon to the logo. Remove PS logo on the other persistent header.
|
|
||||||
10. (feature) - Entities. Revise the "+ New" flow. Use an add button on the individual tabs, not catch all new with drop down.
|
|
||||||
11. (bug) - Plot. Dragging beats deletes them.
|
|
||||||
12. (feature) - Plot. remove + Add Beat button. Redundant, keep the + on each Act.
|
|
||||||
13. (bug) - Projects. Clicking in this page does nothing. Cannot add new project. Cannot open existing project. Cannot delete projects.
|
|
||||||
14. (feature) - Projects. Adding new project should open up a tutorial to draft a template. Ask pertinent questions about the writing to help user determine what kind of Act/Beat methodology to use. Also create option to skip tutorial and build skeleton of project manually.
|
|
||||||
15. (bug) - Settings. Theme buttons do nothing to change theme. Some theme buttons are invisible. Remove persistent theme drop down. Use the theme dropdown in the settings.
|
|
||||||
16. (bug) - Settings. Density selections change nothing.
|
|
||||||
17. (feature) - Settings. Need many more fonts.
|
|
||||||
18. (bug) - Settings. Typography settings change UI elements. Use system Sans for UI, lock it in. Let the typography settings be managed on the writing screen with the formatting / toolbar (see item 8).
|
|
||||||
19. (bug) - Settings. Show line numbers does not work. Remove from settings, add to write toolbar.
|
|
||||||
20. (bug) - Settings. Focus and Typewrite mode do nothing. Same if toggeld from the write screen. Remove Typewrite feature, it's dumb.
|
|
||||||
21. (feature) - Persistent. Search feature should be a text box. remove hotkey reference.
|
|
||||||
22. (bug) - Admin. Admin features not working. No users. No ability to manage useres. Screen shows "Unexpected token '<', "<!doctype " .. is not valid JSON.
|
|
||||||
|
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
**A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine built in.**
|
**A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine built in.**
|
||||||
|
|
||||||
> This file is the single source of truth for *what* we are building and *why*.
|
> This file is the single source of truth for the architecture, design decisions, and data model of PubScript.
|
||||||
> The `CHUNK#n.md` files are the executable, step-by-step work orders for *how*.
|
|
||||||
> A human and a strictly-instructed coding agent can both follow them.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,24 +87,23 @@ The **StorageAdapter contract** (defined once in `packages/core`) is the seam th
|
|||||||
|
|
||||||
```
|
```
|
||||||
PubScript/
|
PubScript/
|
||||||
├── PLAN.md ← this file
|
├── PLAN.md ← architecture & design specification
|
||||||
├── CHUNK#1.md … CHUNK#11.md ← executable work orders
|
├── README.md ← installation, quickstart & usage guide
|
||||||
├── package.json ← pnpm workspace root
|
├── package.json ← pnpm workspace root
|
||||||
├── pnpm-workspace.yaml
|
├── pnpm-workspace.yaml
|
||||||
├── apps/
|
├── apps/
|
||||||
│ └── web/ ← the React 19 app (Vite). Runs in browser AND in Tauri.
|
│ └── web/ ← the React 19 app (Vite)
|
||||||
├── desktop/ ← Tauri 2 shell (Rust). Loads apps/web's build.
|
├── desktop/ ← Native Linux desktop shell & .deb packager
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── core/ ← domain types, IDs, time, token budget, StorageAdapter contract
|
│ ├── core/ ← domain types, IDs, time, token budget, StorageAdapter contract
|
||||||
│ ├── ui/ ← design tokens, theme engine, app shell, component kit
|
│ ├── ui/ ← design tokens, theme engine, app shell, component kit
|
||||||
│ ├── editor/ ← TipTap schema, decorations, spellcheck integration
|
│ ├── editor/ ← TipTap schema, decorations, spellcheck integration
|
||||||
│ ├── boards/ ← React Flow whiteboard, beat board, timeline
|
|
||||||
│ ├── exporters/ ← md / html / epub / docx / odt / pdf + shared flat model
|
│ ├── exporters/ ← md / html / epub / docx / odt / pdf + shared flat model
|
||||||
│ └── ai/ ← provider adapters, role engine, context builder, suggestion protocol
|
│ └── ai/ ← provider adapters, role engine, context builder, suggestion protocol
|
||||||
├── server/ ← Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy
|
├── server/ ← Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy
|
||||||
├── docker/ ← Dockerfile, compose.yaml, entrypoint, backup scripts
|
├── docker/ ← Dockerfile, compose.yaml, entrypoint
|
||||||
├── .github/workflows/ ← CI (lint, typecheck, test, build, docker, desktop matrix)
|
├── .github/workflows/ ← CI (lint, typecheck, test, contract, build, docker)
|
||||||
└── docs/ ← ADRs, data model reference, AI role prompts, parity matrix
|
└── docs/ ← Data model reference & conventions
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 Data model (core domain)
|
### 3.3 Data model (core domain)
|
||||||
@@ -261,41 +258,12 @@ Desktop adds: native file-system access (open/save anywhere), system print-to-PD
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Execution Plan — the chunks
|
## 9. Risks & Mitigations
|
||||||
|
|
||||||
Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a strictly-instructed coding agent can execute it top-to-bottom. Each has prerequisites, numbered steps with exact paths/commands, and testable acceptance criteria.
|
|
||||||
|
|
||||||
| # | Chunk | ~Days | Status | Delivers |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | **Scaffold & Foundations** | 1 | ✅ Done | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store |
|
|
||||||
| 2 | **Data Model, Storage & Auth** | 3 | ✅ Done | Full domain types, StorageAdapter, Local + Server adapters, Fastify auth/multi-tenant, autosave, contract tests |
|
|
||||||
| 3 | **Writing Surface** | 3 | ✅ Done | TipTap editor ✅, document tree ✅, focus mode ✅, Markdown I/O ✅, autosave ✅, writing view wired to storage adapter ✅, fonts/logo ✅ |
|
|
||||||
| 4 | **Characters & World-Building** | 2 | ✅ Done | Entity schema ✅, character sheets ✅, world items ✅, fuzzy search ✅, mentions ✅, backlinks ✅, entity UI (sheets/search/relationships/map) ✅, consistency UI ✅, palette search ✅, context rail ✅, seed extension ✅, DATA_MODEL.md ✅ |
|
|
||||||
| 5 | **Plot & Whiteboard** | 3 | ✅ Done | Beat board + templates ✅, timeline ✅, React Flow whiteboard ✅, cross-links ✅, status/locking ✅, progress bar ✅ |
|
|
||||||
| 6 | **Export Pipeline** | 3 | ✅ Done | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation |
|
|
||||||
| 7 | **Spell & Grammar** | 2 | ✅ Done | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel |
|
|
||||||
| 8 | **AI Provider Layer** | 2 | ✅ Done | Provider config + discovery, streaming, thinking params, token budget |
|
|
||||||
| 9 | **AI Roles & Suggestions** | 3 | ✅ Done | Editor/Reader/Publisher/Co-Author, context builder, tools, accept/reject/pin in `packages/ai`, AI Settings (providers/agents/temp/ctx/prompts), and toggleable `AISidebar` (`apps/web/src/features/ai/AISidebar.tsx`) |
|
|
||||||
| 10 | **Multi-User Admin & Sharing** | 2 | ✅ Done | Admin API, roles, project sharing, audit, multi-tenant isolation in `server/`, plus full `AdminView` web UI (`apps/web/src/features/admin/AdminView.tsx`) |
|
|
||||||
| 11 | **Packaging, Parity & Polish** | 3 | ✅ Web + Docker + Onboarding done | Docker compose ✅, Project & Template Tutorial Wizard ✅, formatting ribbon & theme/density polish ✅ |
|
|
||||||
|
|
||||||
**Total: ~27 focused working days**, sequenced so each chunk is independently shippable and testable.
|
|
||||||
|
|
||||||
**Progress:** Chunks #1–#8 are complete and CI-green; #9 and #10 have their full backend/protocol layer implemented and tested but their web UI is still a stub; #11 has Docker done and the rest not started. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell). CHUNKs #2–#7 are committed and CI-green. CHUNK #4's data layer (entity schema, fuzzy search, backlinks, consistency check, `@mention` autocomplete) and entity UI layer (character/world sheets, fuzzy search, relationship editor + map, consistency UI, palette search, context rail, seed extension, `docs/DATA_MODEL.md`) are all built and green. CHUNK #5 delivers the plot & whiteboard surface: a beat board with genre templates (Three-Act, Save-the-Cat, Seven-Point, Custom), status/locking and a progress bar, a filterable timeline, and a React Flow 11 whiteboard with chapter/entity cross-links and an "Add beats to board" import. CHUNK #6 delivers the export pipeline: a shared flat model feeding Markdown, single-file HTML, EPUB 3, DOCX, ODT, and PDF exporters, plus an ExportDialog and golden-file + round-trip tests with deterministic ZIP/PDF metadata. CHUNK #7 delivers offline spellcheck (hunspell compiled to WASM, runs fully in-browser) and optional self-hostable LanguageTool grammar/style checking, rendered as inline red/blue squiggles with a side panel to jump/apply/ignore/add-to-dictionary; dictionaries are base64-decoded browser-safe (atob + TextDecoder), spell + grammar hits merge into a single decoration pipeline, and a docker compose snippet runs LanguageTool opt-in. Remaining chunk-level items (Playwright E2E + 200-node perf tests) are blocked on E2E infrastructure that is not yet in the repo; unit + contract tests cover the new logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
| Risk | Mitigation |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Tauri cross-OS packaging friction (esp. macOS notarization) | Linux-first; treat Windows as a CI matrix; macOS notarization is explicit later polish, not a v1 blocker |
|
| Large-document editor performance | ProseMirror is built for this; decorations and spellcheck runs are debounced and block-scoped |
|
||||||
| Large-document editor performance | ProseMirror is built for this; we add perf budgets + Playwright timing tests and keep decorations memoized |
|
| AI suggestion rendering over live-editing text (range drift) | Suggestions carry verbatim text anchors (`originalText`), re-resolved on inline edits |
|
||||||
| AI suggestion rendering over live-editing text (range drift) | Suggestions carry stable node anchors, not raw offsets; re-resolved on every transaction |
|
| Local vs. server data divergence | Contract test suite enforces identical behavior across `LocalAdapter` and `ServerAdapter` |
|
||||||
| Local vs. server data divergence | Contract test suite enforces identical behavior; local mode is clearly labeled in the UI |
|
| EPUB/ODT spec edge cases | Golden-file tests in CI; deterministic timestamps across ZIP/PDF formats |
|
||||||
| EPUB/ODT spec edge cases | Golden-file tests + `epubcheck` in CI; DOCX via the maintained `docx` package |
|
| Scope creep in AI roles | Roles are prompt + tools, unified under a single inline suggestion and session notes protocol |
|
||||||
| Scope creep in AI roles | Roles are prompt + tools, not new UI surfaces; the suggestion protocol is the single integration point |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Status (verified Oct 2026): Chunks #1–#10 are complete and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the app shell on `:5173`; the API server runs on `:8081`). Both the AI Studio toggleable sidebar + provider/agent settings (`AISidebar.tsx`, `Settings.tsx`) and the Admin user/audit management UI (`AdminView.tsx`) are wired and verified. Chunk #11 has Docker packaging, the guided project/template onboarding wizard, OnlyOffice-style formatting toolbar, and theme/density/typography polish complete.*
|
|
||||||
|
|||||||
@@ -1,42 +1,158 @@
|
|||||||
# PubScript
|
# PubScript
|
||||||
|
|
||||||
A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine built in.
|
**A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web application and as a native Linux desktop application, with a local-first AI co-writing engine built in.**
|
||||||
|
|
||||||
See **[PLAN.md](./PLAN.md)** for the full vision, architecture, and data model. The `CHUNK#n.md` files are the executable work orders.
|
---
|
||||||
|
|
||||||
## Quickstart
|
## Key Features
|
||||||
|
|
||||||
|
- **Manuscript Writing Studio**: Rich-text manuscript editor powered by TipTap 3 with an OnlyOffice/Word-inspired formatting ribbon, **34 curated typography options** (serif, display, sans-serif, and monospace), adjustable font sizes, line spacing, column width, first-line paragraph indentation, scene breaks (`* * *`), footnotes, `@` character mentions, line numbers, and a distraction-free **Focus Mode** (`F2`).
|
||||||
|
- **Autosave & Revision History**: Continuous background autosave with per-chapter revision snapshots (`rev #1`, `rev #2`, …) and one-click revision restore directly from the sidebar or context rail.
|
||||||
|
- **Offline Spell & Grammar Checking**: Browser-safe Hunspell WebAssembly spellchecker + built-in prose grammar/style checks with inline squiggles and instant replacement popovers.
|
||||||
|
- **AI Co-Writing Studio**:
|
||||||
|
- First-class support for local AI servers (**Ollama**, **llama.cpp**, **LM Studio**, any OpenAI-compatible endpoint) alongside **OpenAI**, **Anthropic**, **Google Gemini**, and **OpenRouter**.
|
||||||
|
- Configurable role agents (**Editor**, **Reader**, **Publisher**, **Co-Author**, or custom agents) with per-agent system prompts, temperature, context window (`ctx`), max output tokens, and extended thinking/reasoning controls.
|
||||||
|
- **Granular Text-Anchored Inline Suggestions**: Suggestions peg directly to verbatim phrases, clauses, and sentences in your manuscript. Stepping through suggestions highlights the exact span in the editor (`Accept` applies the edit in place and advances to the next suggestion; `Deny` dismisses it; `Copy to Note` moves it to session notes).
|
||||||
|
- Resizable AI sidebar (with snap-to-default `370px`), live thinking indicator, and non-intrusive context usage & throughput (`t/s`) telemetry.
|
||||||
|
- **Story Structure, World-Building & Whiteboard**:
|
||||||
|
- **Guided Story Structure Wizard**: Interactive project setup recommending **Save the Cat! (15 Beats)**, **Three-Act Structure**, **Seven-Point Story Structure**, or **Custom / Discovery** templates.
|
||||||
|
- **Entities & Lore**: Dedicated views for **People**, **Places & World**, and **Notes** with automatic backlink tracking across chapters.
|
||||||
|
- **Plot Beats & Interactive Whiteboard**: Drag-and-drop Act I–III plot beat cards and a ReactFlow canvas for mapping chapters, characters, world items, and custom connection lines.
|
||||||
|
- **Publishing-Grade Manuscript Export**: Deterministic export to **Markdown (`.md`)**, **HTML (`.html`)**, **EPUB 3 (`.epub`)**, **DOCX (`.docx`)**, **ODT (`.odt`)**, and **Print-Ready PDF (`.pdf`)**.
|
||||||
|
- **Multi-User Administration**: Built-in Admin console for user creation, role assignment (`Author` / `Admin`), account enable/disable, instance statistics, and filterable audit logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Linux Desktop Application (`.deb` — Ubuntu / Debian / Linux Mint)
|
||||||
|
|
||||||
|
Download `pubscript_0.1.0_amd64.deb` from the [GitHub Releases](https://github.com/Schwitzz/PubScript/releases) page (or build it from source using `pnpm package:deb`), then install it with `apt` or `dpkg`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm i && pnpm dev # run the app shell at http://localhost:5173
|
# Install using apt (automatically resolves any missing system dependencies)
|
||||||
pnpm test # run every workspace's unit tests
|
sudo apt install ./pubscript_0.1.0_amd64.deb
|
||||||
pnpm typecheck # strict TypeScript across the monorepo
|
|
||||||
pnpm build # build web + server
|
# Or install directly with dpkg
|
||||||
|
sudo dpkg -i pubscript_0.1.0_amd64.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker (self-host)
|
Launch **PubScript** from your desktop application menu or from a terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker/compose.yaml up --build
|
pubscript
|
||||||
# → http://localhost:8080 (health: curl localhost:8080/api/health)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
One container serves the web app, runs the Fastify API, and stores SQLite on a volume.
|
- **Desktop Data Directory**: `~/.local/share/pubscript/` (contains `pubscript.sqlite` and persistent WebKit `IndexedDB` / `localStorage` data).
|
||||||
|
- **Uninstall**: `sudo apt remove pubscript`
|
||||||
|
|
||||||
## Repository layout
|
---
|
||||||
|
|
||||||
| Path | What it is |
|
### Option 2: Self-Hosted Docker Deployment (Recommended for Web & Multi-User)
|
||||||
|
|
||||||
|
A single container builds and serves the production web application, runs the Fastify REST + WebSocket API, and persists SQLite data on a mounted volume.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Schwitzz/PubScript.git
|
||||||
|
cd PubScript
|
||||||
|
docker compose -f docker/compose.yaml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Web App & API**: `http://localhost:8080`
|
||||||
|
- **Health Check**: `curl http://localhost:8080/api/health`
|
||||||
|
- **Persistent Data**: Stored in `./data/pubscript.sqlite` on the host.
|
||||||
|
|
||||||
|
#### Environment Variables (`docker/compose.yaml`)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `8080` | HTTP port inside the container |
|
||||||
|
| `PUBSCRIPT_DB` | `/app/data/pubscript.sqlite` | Path to the SQLite database file |
|
||||||
|
| `PUBSCRIPT_ADMIN_EMAIL` | `admin@pubscript.local` | Bootstrap administrator email |
|
||||||
|
| `PUBSCRIPT_ORIGIN` | `http://localhost:8080` | Allowed CORS origin |
|
||||||
|
| `PUBSCRIPT_TOKEN_TTL` | `2592000` (30 days) | Session token lifetime in seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 3: Build & Run from Source
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** `>= 22`
|
||||||
|
- **pnpm** `9.15.9` (`corepack enable && corepack prepare pnpm@9.15.9 --activate`)
|
||||||
|
- Build tools for native SQLite/Argon2 bindings (`python3`, `make`, `g++`)
|
||||||
|
- *(Optional, for building the Linux `.deb` desktop package)*: `dpkg-deb`, `gcc`, `pkg-config`, `libgtk-3-dev`, `libwebkit2gtk-4.1-0`
|
||||||
|
|
||||||
|
#### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Schwitzz/PubScript.git
|
||||||
|
cd PubScript
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Run in Development Mode
|
||||||
|
|
||||||
|
Start the API server (`:8081`) and Vite web dev server (`:5173`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Fastify + SQLite API Server (port 8081)
|
||||||
|
PORT=8081 PUBSCRIPT_ORIGIN=http://localhost:5173 pnpm --filter @pubscript/server dev
|
||||||
|
|
||||||
|
# Terminal 2 — Vite React Web App (port 5173, proxies /api and /ws to :8081)
|
||||||
|
pnpm --filter @pubscript/web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **`http://localhost:5173`** in your browser.
|
||||||
|
|
||||||
|
#### 3. Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
PORT=8080 pnpm --filter @pubscript/server start
|
||||||
|
```
|
||||||
|
|
||||||
|
When `apps/web/dist` is built, the server automatically serves both the production web bundle and the `/api` endpoints on `http://localhost:8080`.
|
||||||
|
|
||||||
|
#### 4. Compile the Native Linux `.deb` Desktop Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm package:deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces `dist/pubscript_0.1.0_amd64.deb` (and `./pubscript_0.1.0_amd64.deb` in the repository root).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification & Quality Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # Strict TypeScript check across all workspace packages
|
||||||
|
pnpm test # Unit & integration test suites across all packages
|
||||||
|
pnpm contract # StorageAdapter contract suite (Local + Server parity)
|
||||||
|
pnpm lint # ESLint across all packages
|
||||||
|
pnpm build # Production bundle build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `PLAN.md` | Single source of truth for *what* and *why* |
|
| `apps/web/` | React 19 + Vite application (Writing Studio, AI Sidebar, Characters, World, Plot, Whiteboard, Admin, Settings) |
|
||||||
| `CHUNK#n.md` | Executable, step-by-step work orders |
|
| `packages/core/` | Shared domain types, ULIDs, token budgeting, backlinks, consistency engine, `StorageAdapter` contract |
|
||||||
| `apps/web/` | React 19 + Vite app (runs in browser AND in Tauri) |
|
| `packages/editor/` | TipTap 3 / ProseMirror schema, formatting extensions, node views, Markdown serializer, Hunspell spell & grammar services |
|
||||||
| `packages/core/` | Domain types, ULIDs, time, token budget, `StorageAdapter` contract |
|
| `packages/ai/` | AI provider client, SSE streaming, token budget enforcement, Dexie/IndexedDB session store, suggestion & note builders |
|
||||||
| `packages/ui/` | Design tokens, theme engine (Ink / Parchment / Nocturne / Terminal), component kit |
|
| `packages/exporters/` | Deterministic manuscript exporters (`Markdown`, `HTML`, `EPUB`, `DOCX`, `ODT`, `PDF`) |
|
||||||
| `server/` | Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy |
|
| `packages/ui/` | Design tokens, theme engine (`Ink`, `Parchment`, `Nocturne`, `Terminal`), density engine, UI component kit |
|
||||||
| `docker/` | Dockerfile + compose (one container serves app + API + data) |
|
| `server/` | Fastify + SQLite multi-tenant API server, authentication, WebSocket sync, and admin routes |
|
||||||
| `docs/` | ADRs, data model reference, AI role prompts, parity matrix |
|
| `desktop/` | Native Linux GTK3 + WebKit2GTK desktop wrapper (`pubscript-desktop.c`) and `.deb` packager (`build-deb.sh`) |
|
||||||
|
| `docker/` | Multi-stage `Dockerfile`, `compose.yaml`, and container entrypoint |
|
||||||
|
| `docs/` | Architecture & data model documentation (`DATA_MODEL.md`, `CONVENTIONS.md`) |
|
||||||
|
|
||||||
## Status
|
---
|
||||||
|
|
||||||
**CHUNK #1 through #10 are complete and CI-green:** the monorepo with strict TypeScript, the collapsible app shell and theming/density system, the `packages/core` contracts, the Fastify multi-tenant server, the TipTap writing surface with an OnlyOffice/Word-inspired formatting toolbar (34 fonts, line numbers, focus mode, alignment, indentation, lists, scene breaks, footnotes), characters & world-building, the plot & whiteboard surface, the export pipeline (Markdown/HTML/EPUB/DOCX/ODT/PDF with an ExportDialog), offline hunspell-WASM spellcheck + grammar checking, the AI provider & agent layer (`packages/ai`) with full AI Settings and toggleable AI Chat Sidebar (`AISidebar.tsx`), the guided Project Structure Tutorial Wizard (`Projects.tsx`), and the Admin user/role/audit management interface (`AdminView.tsx`). `pnpm typecheck`, `pnpm test`, `pnpm lint`, and `pnpm build` all pass across the workspace.
|
## License
|
||||||
|
|
||||||
**CHUNK #11 (Packaging, Parity & Polish)** has Docker packaging (`docker/compose.yaml`), project template onboarding, and UI/UX polish complete.
|
Licensed under the **GNU General Public License v3.0** — see [LICENSE](./LICENSE) for details.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 112 512 288" width="512" height="288" role="img" aria-label="PubScript">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" preserveAspectRatio="xMidYMid meet" role="img" aria-label="PubScript">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="ps-gold" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="ps-gold" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0" stop-color="#f9edc4"/>
|
<stop offset="0" stop-color="#f9edc4"/>
|
||||||
@@ -19,18 +19,18 @@
|
|||||||
|
|
||||||
<g filter="url(#ps-shadow)">
|
<g filter="url(#ps-shadow)">
|
||||||
<!-- P tile -->
|
<!-- P tile -->
|
||||||
<circle cx="126" cy="256" r="120" fill="url(#ps-gold)"/>
|
<circle cx="132" cy="256" r="120" fill="url(#ps-gold)"/>
|
||||||
<circle cx="126" cy="256" r="104" fill="url(#ps-ivory)"/>
|
<circle cx="132" cy="256" r="104" fill="url(#ps-ivory)"/>
|
||||||
<circle cx="126" cy="256" r="104" fill="none" stroke="#8a6d1c" stroke-width="1.5" opacity="0.5"/>
|
<circle cx="132" cy="256" r="104" fill="none" stroke="#8a6d1c" stroke-width="1.5" opacity="0.5"/>
|
||||||
<text x="126" y="256" text-anchor="middle" dominant-baseline="central"
|
<text x="132" y="256" text-anchor="middle" dominant-baseline="central"
|
||||||
font-family="Georgia, 'Times New Roman', serif" font-size="152" font-weight="600"
|
font-family="Georgia, 'Times New Roman', serif" font-size="152" font-weight="600"
|
||||||
fill="#21242c">P</text>
|
fill="#21242c">P</text>
|
||||||
|
|
||||||
<!-- S tile -->
|
<!-- S tile -->
|
||||||
<circle cx="386" cy="256" r="120" fill="url(#ps-gold)"/>
|
<circle cx="380" cy="256" r="120" fill="url(#ps-gold)"/>
|
||||||
<circle cx="386" cy="256" r="104" fill="url(#ps-ivory)"/>
|
<circle cx="380" cy="256" r="104" fill="url(#ps-ivory)"/>
|
||||||
<circle cx="386" cy="256" r="104" fill="none" stroke="#8a6d1c" stroke-width="1.5" opacity="0.5"/>
|
<circle cx="380" cy="256" r="104" fill="none" stroke="#8a6d1c" stroke-width="1.5" opacity="0.5"/>
|
||||||
<text x="386" y="256" text-anchor="middle" dominant-baseline="central"
|
<text x="380" y="256" text-anchor="middle" dominant-baseline="central"
|
||||||
font-family="Georgia, 'Times New Roman', serif" font-size="152" font-weight="600"
|
font-family="Georgia, 'Times New Roman', serif" font-size="152" font-weight="600"
|
||||||
fill="#21242c">S</text>
|
fill="#21242c">S</text>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
+108
-27
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route, useLocation } from "react-router-dom";
|
||||||
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
|
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
|
||||||
|
import { AuthGate } from "./components/AuthGate.js";
|
||||||
import { ExportDialog } from "./features/export/ExportDialog.js";
|
import { ExportDialog } from "./features/export/ExportDialog.js";
|
||||||
|
import { ImportDialog } from "./features/import/ImportDialog.js";
|
||||||
import { AISidebar } from "./features/ai/AISidebar.js";
|
import { AISidebar } from "./features/ai/AISidebar.js";
|
||||||
import { ToastViewport } from "@pubscript/ui";
|
import { ToastViewport } from "@pubscript/ui";
|
||||||
import { useSettings } from "./store/settings.js";
|
import { useSettings } from "./store/settings.js";
|
||||||
@@ -27,15 +29,53 @@ const NAV = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const location = useLocation();
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const cookieMobile =
|
||||||
|
typeof document !== "undefined" && document.cookie.includes("ps_client_device=mobile");
|
||||||
|
const uaMobile =
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||||
|
navigator.userAgent,
|
||||||
|
);
|
||||||
|
return cookieMobile || uaMobile || window.innerWidth <= 768;
|
||||||
|
});
|
||||||
|
|
||||||
const navCollapsed = useSettings((s) => s.navCollapsed);
|
const navCollapsed = useSettings((s) => s.navCollapsed);
|
||||||
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
||||||
const focusMode = useSettings((s) => s.focusMode);
|
const focusMode = useSettings((s) => s.focusMode);
|
||||||
|
const setSettings = useSettings((s) => s.set);
|
||||||
|
|
||||||
|
// Detect mobile viewport changes + server ps_client_device cookie
|
||||||
|
useEffect(() => {
|
||||||
|
const updateMobile = () => {
|
||||||
|
const cookieMobile =
|
||||||
|
typeof document !== "undefined" && document.cookie.includes("ps_client_device=mobile");
|
||||||
|
const uaMobile =
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||||
|
navigator.userAgent,
|
||||||
|
);
|
||||||
|
setIsMobile(cookieMobile || uaMobile || window.innerWidth <= 768);
|
||||||
|
};
|
||||||
|
updateMobile();
|
||||||
|
window.addEventListener("resize", updateMobile);
|
||||||
|
return () => window.removeEventListener("resize", updateMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Automatically close the mobile navigation drawer when navigating to a route
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileNavOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
if ((e.metaKey || e.ctrlKey) && typeof e.key === "string" && e.key.toLowerCase() === "k") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPaletteOpen((o) => !o);
|
setPaletteOpen((o) => !o);
|
||||||
}
|
}
|
||||||
@@ -45,31 +85,72 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
|
<AuthGate>
|
||||||
<SplashScreen />
|
<div
|
||||||
<Sidebar nav={NAV} />
|
className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}${isMobile ? " is-mobile" : ""}${mobileNavOpen ? " is-mobile-nav-open" : ""}`}
|
||||||
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
|
data-mobile={isMobile ? "true" : "false"}
|
||||||
<div className="ps-workspace-row">
|
>
|
||||||
<main className="ps-main">
|
<SplashScreen />
|
||||||
<Routes>
|
{mobileNavOpen && (
|
||||||
<Route path="/" element={<Write />} />
|
<div
|
||||||
<Route path="/characters" element={<Characters />} />
|
className="ps-mobile-nav-backdrop"
|
||||||
<Route path="/plot" element={<Plot />} />
|
onClick={() => setMobileNavOpen(false)}
|
||||||
<Route path="/timeline" element={<Timeline />} />
|
aria-hidden="true"
|
||||||
<Route path="/whiteboard" element={<Whiteboard />} />
|
/>
|
||||||
<Route path="/ai" element={<AI />} />
|
)}
|
||||||
<Route path="/projects" element={<Projects />} />
|
<Sidebar nav={NAV} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Topbar
|
||||||
<Route path="/admin" element={<Admin />} />
|
onOpenPalette={() => setPaletteOpen(true)}
|
||||||
<Route path="*" element={<Write />} />
|
onOpenExport={() => setExportOpen(true)}
|
||||||
</Routes>
|
onOpenImport={() => setImportOpen(true)}
|
||||||
</main>
|
onToggleMobileNav={() => setMobileNavOpen((o) => !o)}
|
||||||
{aiSidebarOpen && <AISidebar />}
|
/>
|
||||||
</div>
|
<div className="ps-workspace-row">
|
||||||
|
<main className="ps-main">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Write />} />
|
||||||
|
<Route path="/characters" element={<Characters />} />
|
||||||
|
<Route path="/plot" element={<Plot />} />
|
||||||
|
<Route path="/timeline" element={<Timeline />} />
|
||||||
|
<Route path="/whiteboard" element={<Whiteboard />} />
|
||||||
|
<Route path="/ai" element={<AI />} />
|
||||||
|
<Route path="/projects" element={<Projects />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/admin" element={<Admin />} />
|
||||||
|
<Route path="*" element={<Write />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
{aiSidebarOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="ps-mobile-ai-backdrop"
|
||||||
|
onClick={() => setSettings({ aiSidebarOpen: false })}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<AISidebar />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
{/* Dedicated bottom-of-screen AI home bar for mobile devices */}
|
||||||
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
{isMobile && !aiSidebarOpen && (
|
||||||
<ToastViewport />
|
<div className="ps-mobile-ai-bar">
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-mobile-ai-bar-btn"
|
||||||
|
onClick={() => setSettings({ aiSidebarOpen: true })}
|
||||||
|
>
|
||||||
|
<span>✨ AI Studio · Chat, Inline Edits & Notes</span>
|
||||||
|
<span style={{ fontSize: 11, opacity: 0.85 }}>▲ Open</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
||||||
|
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||||
|
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||||
|
<ToastViewport />
|
||||||
|
</div>
|
||||||
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+562
-90
@@ -1,8 +1,11 @@
|
|||||||
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
|
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
|
||||||
|
import { useSettings } from "./store/settings.js";
|
||||||
|
|
||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
const TOKEN_KEY = "pubscript.admin.token";
|
const TOKEN_KEY = "pubscript.auth.token.v1";
|
||||||
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1";
|
const LOCAL_AUTH_KEY = "pubscript.local.auth.v2";
|
||||||
|
const LOCAL_SESSION_KEY = "pubscript.local.session.v2";
|
||||||
|
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v2";
|
||||||
const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
|
const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
@@ -14,7 +17,19 @@ export interface AdminStats {
|
|||||||
disabledUsers: number;
|
disabledUsers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredToken(): string | null {
|
export interface AuthStatusResponse {
|
||||||
|
initialized: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalAuthRecord {
|
||||||
|
user: User;
|
||||||
|
saltHex: string;
|
||||||
|
hashHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredToken(): string | null {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -22,7 +37,7 @@ function getStoredToken(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStoredToken(token: string | null): void {
|
export function setStoredToken(token: string | null): void {
|
||||||
try {
|
try {
|
||||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||||
else localStorage.removeItem(TOKEN_KEY);
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
@@ -31,7 +46,101 @@ function setStoredToken(token: string | null): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const clean = hex.length % 2 === 0 ? hex : "0" + hex;
|
||||||
|
const out = new Uint8Array(clean.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePbkdf2Hex(password: string, saltHex: string): Promise<string> {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.subtle) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
enc.encode(password),
|
||||||
|
{ name: "PBKDF2" },
|
||||||
|
false,
|
||||||
|
["deriveBits"],
|
||||||
|
);
|
||||||
|
const saltBytes = hexToBytes(saltHex);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: saltBytes.buffer as ArrayBuffer,
|
||||||
|
iterations: 150_000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256,
|
||||||
|
);
|
||||||
|
return bytesToHex(new Uint8Array(bits));
|
||||||
|
}
|
||||||
|
// Fallback only if SubtleCrypto is unavailable in test env
|
||||||
|
return btoa(`${saltHex}:${password}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalAuthRecords(): LocalAuthRecord[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_AUTH_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as LocalAuthRecord[];
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocalAuthRecords(records: LocalAuthRecord[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_AUTH_KEY, JSON.stringify(records));
|
||||||
|
localStorage.setItem(
|
||||||
|
LOCAL_USERS_KEY,
|
||||||
|
JSON.stringify(records.map((r) => r.user)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalSessionUser(): User | null {
|
||||||
|
try {
|
||||||
|
const userId = sessionStorage.getItem(LOCAL_SESSION_KEY) ?? localStorage.getItem(LOCAL_SESSION_KEY);
|
||||||
|
if (!userId) return null;
|
||||||
|
const rec = loadLocalAuthRecords().find((r) => r.user.id === userId && !r.user.disabled);
|
||||||
|
return rec?.user ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocalSessionUser(user: User | null): void {
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
sessionStorage.setItem(LOCAL_SESSION_KEY, user.id);
|
||||||
|
localStorage.setItem(LOCAL_SESSION_KEY, user.id);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(LOCAL_SESSION_KEY);
|
||||||
|
localStorage.removeItem(LOCAL_SESSION_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadLocalUsers(): User[] {
|
function loadLocalUsers(): User[] {
|
||||||
|
const fromAuth = loadLocalAuthRecords().map((r) => r.user);
|
||||||
|
if (fromAuth.length > 0) return fromAuth;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LOCAL_USERS_KEY);
|
const raw = localStorage.getItem(LOCAL_USERS_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -41,30 +150,7 @@ function loadLocalUsers(): User[] {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
const initial: User[] = [
|
return [];
|
||||||
{
|
|
||||||
id: createUlid(),
|
|
||||||
email: "admin@pubscript.local",
|
|
||||||
displayName: "Local Admin",
|
|
||||||
role: "admin",
|
|
||||||
disabled: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: createUlid(),
|
|
||||||
email: "mara@pubscript.local",
|
|
||||||
displayName: "Mara Vell",
|
|
||||||
role: "author",
|
|
||||||
disabled: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(initial));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return initial;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveLocalUsers(users: User[]): void {
|
function saveLocalUsers(users: User[]): void {
|
||||||
@@ -85,29 +171,15 @@ function loadLocalAudit(): AuditLogEntry[] {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
const initial: AuditLogEntry[] = [
|
return [];
|
||||||
{
|
|
||||||
id: createUlid(),
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
userId: "admin@pubscript.local",
|
|
||||||
action: "admin.bootstrap",
|
|
||||||
detail: { email: "admin@pubscript.local" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(initial));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return initial;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLocalAudit(action: string, detail: Record<string, unknown>): void {
|
function appendLocalAudit(action: string, detail: Record<string, unknown>, userId = "admin"): void {
|
||||||
const entries = loadLocalAudit();
|
const entries = loadLocalAudit();
|
||||||
entries.unshift({
|
entries.unshift({
|
||||||
id: createUlid(),
|
id: createUlid(),
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
userId: "admin@pubscript.local",
|
userId,
|
||||||
action,
|
action,
|
||||||
detail,
|
detail,
|
||||||
});
|
});
|
||||||
@@ -118,31 +190,34 @@ function appendLocalAudit(action: string, detail: Record<string, unknown>): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrapAdminSession(): Promise<string | null> {
|
function buildHostEndpoints(path: string, portHint?: number): string[] {
|
||||||
try {
|
const list = [path];
|
||||||
const res = await fetch(`${BASE}/auth/bootstrap-admin`, {
|
if (portHint && typeof window !== "undefined" && window.location) {
|
||||||
method: "POST",
|
const host = window.location.hostname || "localhost";
|
||||||
credentials: "include",
|
const proto = window.location.protocol === "https:" ? "https:" : "http:";
|
||||||
});
|
if (String(portHint) !== window.location.port) {
|
||||||
const ct = res.headers.get("content-type") ?? "";
|
list.push(`${proto}//${host}:${portHint}${path}`);
|
||||||
if (!res.ok || !ct.includes("application/json")) return null;
|
|
||||||
const data = (await res.json()) as { token?: string };
|
|
||||||
if (data.token) {
|
|
||||||
setStoredToken(data.token);
|
|
||||||
return data.token;
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* server offline */
|
|
||||||
}
|
}
|
||||||
return null;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
|
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
|
return {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(extra ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const { serverEnabled } = useSettings.getState();
|
||||||
|
if (!serverEnabled) {
|
||||||
|
throw new Error("Server disabled in settings (Local Storage Mode)");
|
||||||
|
}
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...authHeaders(init?.headers as Record<string, string> | undefined),
|
||||||
...((init?.headers as Record<string, string> | undefined) ?? {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
@@ -153,19 +228,13 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
|
|||||||
|
|
||||||
const contentType = res.headers.get("content-type") ?? "";
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
if (res.status === 401 && !retried) {
|
|
||||||
const newToken = await bootstrapAdminSession();
|
|
||||||
if (newToken) {
|
|
||||||
return request<T>(path, init, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = res.statusText;
|
let detail = res.statusText;
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as { error?: string };
|
const body = (await res.json()) as { error?: string; message?: string };
|
||||||
if (body.error) detail = body.error;
|
if (body.message) detail = body.message;
|
||||||
|
else if (body.error) detail = body.error;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -183,7 +252,189 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Admin
|
// ---- Auth & First-Install Setup ----
|
||||||
|
authStatus: async (): Promise<AuthStatusResponse> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/auth/status`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (res.ok && ct.includes("application/json")) {
|
||||||
|
const data = (await res.json()) as AuthStatusResponse;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* offline fallback */
|
||||||
|
}
|
||||||
|
const records = loadLocalAuthRecords();
|
||||||
|
const currentUser = getLocalSessionUser();
|
||||||
|
return {
|
||||||
|
initialized: records.length > 0,
|
||||||
|
authenticated: Boolean(currentUser),
|
||||||
|
user: currentUser,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setupAdmin: async (input: {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<{ token: string; user: User }> => {
|
||||||
|
const cleanEmail = input.email.trim().toLowerCase();
|
||||||
|
const cleanName = input.displayName.trim();
|
||||||
|
const salt = new Uint8Array(16);
|
||||||
|
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
||||||
|
crypto.getRandomValues(salt);
|
||||||
|
}
|
||||||
|
const saltHex = bytesToHex(salt);
|
||||||
|
const hashHex = await derivePbkdf2Hex(input.password, saltHex);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/auth/setup`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: cleanEmail,
|
||||||
|
displayName: cleanName,
|
||||||
|
password: input.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
token?: string;
|
||||||
|
user?: User;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message ?? data.error ?? "Failed to set up administrator account");
|
||||||
|
}
|
||||||
|
if (data.token && data.user) {
|
||||||
|
setStoredToken(data.token);
|
||||||
|
saveLocalAuthRecords([{ user: data.user, saltHex, hashHex }]);
|
||||||
|
setLocalSessionUser(data.user);
|
||||||
|
return { token: data.token, user: data.user };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && (e.message.includes("already_initialized") || e.message.includes("already been"))) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline local setup fallback
|
||||||
|
const existing = loadLocalAuthRecords();
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new Error("Administrator account has already been created. Please sign in.");
|
||||||
|
}
|
||||||
|
const localUser: User = {
|
||||||
|
id: createUlid(),
|
||||||
|
email: cleanEmail,
|
||||||
|
displayName: cleanName,
|
||||||
|
role: "admin",
|
||||||
|
disabled: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const token = createUlid();
|
||||||
|
setStoredToken(token);
|
||||||
|
saveLocalAuthRecords([{ user: localUser, saltHex, hashHex }]);
|
||||||
|
setLocalSessionUser(localUser);
|
||||||
|
appendLocalAudit("auth.setup", { email: cleanEmail }, localUser.id);
|
||||||
|
return { token, user: localUser };
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email: string, password: string): Promise<{ token: string; user: User }> => {
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: cleanEmail, password }),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
token?: string;
|
||||||
|
user?: User;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (res.status === 429) {
|
||||||
|
throw new Error("Too many sign-in attempts. Please wait a minute and try again.");
|
||||||
|
}
|
||||||
|
if (!res.ok || !data.token || !data.user) {
|
||||||
|
throw new Error("Invalid email or password.");
|
||||||
|
}
|
||||||
|
setStoredToken(data.token);
|
||||||
|
setLocalSessionUser(data.user);
|
||||||
|
return { token: data.token, user: data.user };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Error &&
|
||||||
|
(e.message.includes("Invalid email") || e.message.includes("Too many sign-in"))
|
||||||
|
) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline local verification fallback
|
||||||
|
const records = loadLocalAuthRecords();
|
||||||
|
const record = records.find((r) => r.user.email.toLowerCase() === cleanEmail);
|
||||||
|
if (!record || record.user.disabled) {
|
||||||
|
throw new Error("Invalid email or password.");
|
||||||
|
}
|
||||||
|
const candidateHash = await derivePbkdf2Hex(password, record.saltHex);
|
||||||
|
if (candidateHash !== record.hashHex) {
|
||||||
|
appendLocalAudit("auth.login_failed", { email: cleanEmail }, record.user.id);
|
||||||
|
throw new Error("Invalid email or password.");
|
||||||
|
}
|
||||||
|
const token = createUlid();
|
||||||
|
setStoredToken(token);
|
||||||
|
setLocalSessionUser(record.user);
|
||||||
|
appendLocalAudit("auth.login", { email: cleanEmail }, record.user.id);
|
||||||
|
return { token, user: record.user };
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<{ ok: true }> => {
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setStoredToken(null);
|
||||||
|
setLocalSessionUser(null);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
changePassword: async (
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<{ ok: true }> => {
|
||||||
|
const out = await request<{ ok: true; token?: string }>("/auth/change-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
if (out.token) setStoredToken(out.token);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
me: () => request<{ user: User | null }>("/auth/me"),
|
||||||
|
|
||||||
|
register: (email: string, password: string, displayName: string) =>
|
||||||
|
request<{ token: string; user: User }>("/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password, displayName }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- Admin ----
|
||||||
adminStats: async (): Promise<AdminStats> => {
|
adminStats: async (): Promise<AdminStats> => {
|
||||||
try {
|
try {
|
||||||
return await request<AdminStats>("/admin/stats");
|
return await request<AdminStats>("/admin/stats");
|
||||||
@@ -232,22 +483,26 @@ export const api = {
|
|||||||
}
|
}
|
||||||
const created: User = {
|
const created: User = {
|
||||||
id: createUlid(),
|
id: createUlid(),
|
||||||
email: input.email,
|
email: input.email.trim().toLowerCase(),
|
||||||
displayName: input.displayName,
|
displayName: input.displayName.trim(),
|
||||||
role: input.role,
|
role: input.role,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
users.unshift(created);
|
users.unshift(created);
|
||||||
saveLocalUsers(users);
|
saveLocalUsers(users);
|
||||||
appendLocalAudit("user_create", { targetId: created.id, email: created.email, role: created.role });
|
appendLocalAudit("user_create", {
|
||||||
|
targetId: created.id,
|
||||||
|
email: created.email,
|
||||||
|
role: created.role,
|
||||||
|
});
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: async (
|
updateUser: async (
|
||||||
id: string,
|
id: string,
|
||||||
patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string },
|
patch: { email?: string; displayName?: string; role?: Role; disabled?: boolean; password?: string },
|
||||||
): Promise<User> => {
|
): Promise<User> => {
|
||||||
try {
|
try {
|
||||||
return await request<User>(`/admin/users/${id}`, {
|
return await request<User>(`/admin/users/${id}`, {
|
||||||
@@ -260,6 +515,7 @@ export const api = {
|
|||||||
if (idx === -1) throw new Error("User not found");
|
if (idx === -1) throw new Error("User not found");
|
||||||
const updated: User = {
|
const updated: User = {
|
||||||
...users[idx]!,
|
...users[idx]!,
|
||||||
|
...(patch.email !== undefined ? { email: patch.email.trim().toLowerCase() } : {}),
|
||||||
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
|
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
|
||||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||||
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
|
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
|
||||||
@@ -345,20 +601,236 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auth
|
getServerConfig: async (portHint?: number): Promise<{
|
||||||
me: () => request<{ user: User | null }>("/auth/me"),
|
online: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
host?: string;
|
||||||
|
activeHost?: string;
|
||||||
|
allowedHosts?: string;
|
||||||
|
port: number;
|
||||||
|
activePort?: number;
|
||||||
|
webPort?: number;
|
||||||
|
activeWebPort?: number;
|
||||||
|
startOnBoot: boolean;
|
||||||
|
projectDirectory?: string;
|
||||||
|
}> => {
|
||||||
|
const endpoints = buildHostEndpoints("/api/system/server-config", portHint);
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (res.ok && ct.includes("application/json")) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
enabled: boolean;
|
||||||
|
host?: string;
|
||||||
|
activeHost?: string;
|
||||||
|
allowedHosts?: string;
|
||||||
|
port: number;
|
||||||
|
activePort?: number;
|
||||||
|
webPort?: number;
|
||||||
|
activeWebPort?: number;
|
||||||
|
startOnBoot: boolean;
|
||||||
|
projectDirectory?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
online: true,
|
||||||
|
enabled: data.enabled,
|
||||||
|
...(data.host ? { host: data.host } : {}),
|
||||||
|
...(data.activeHost ? { activeHost: data.activeHost } : {}),
|
||||||
|
...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}),
|
||||||
|
port: data.port,
|
||||||
|
...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
|
||||||
|
...(data.webPort !== undefined ? { webPort: data.webPort } : {}),
|
||||||
|
...(data.activeWebPort !== undefined ? { activeWebPort: data.activeWebPort } : {}),
|
||||||
|
startOnBoot: data.startOnBoot,
|
||||||
|
...(data.projectDirectory ? { projectDirectory: data.projectDirectory } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* try next endpoint */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
online: false,
|
||||||
|
enabled: true,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
allowedHosts: "localhost, 127.0.0.1",
|
||||||
|
port: portHint ?? 8081,
|
||||||
|
webPort: 5173,
|
||||||
|
startOnBoot: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
login: (email: string, password: string) =>
|
updateServerConfig: async (
|
||||||
request<{ token: string; user: User }>("/auth/login", {
|
patch: {
|
||||||
method: "POST",
|
enabled?: boolean;
|
||||||
body: JSON.stringify({ email, password }),
|
host?: string;
|
||||||
}),
|
allowedHosts?: string;
|
||||||
|
port?: number;
|
||||||
|
webPort?: number;
|
||||||
|
startOnBoot?: boolean;
|
||||||
|
projectDirectory?: string;
|
||||||
|
},
|
||||||
|
portHint?: number,
|
||||||
|
): Promise<{
|
||||||
|
online: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
host?: string;
|
||||||
|
activeHost?: string;
|
||||||
|
allowedHosts?: string;
|
||||||
|
port: number;
|
||||||
|
activePort?: number;
|
||||||
|
webPort?: number;
|
||||||
|
activeWebPort?: number;
|
||||||
|
startOnBoot: boolean;
|
||||||
|
projectDirectory?: string;
|
||||||
|
}> => {
|
||||||
|
const endpoints = buildHostEndpoints("/api/system/server-config", portHint);
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (res.ok && ct.includes("application/json")) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
enabled: boolean;
|
||||||
|
host?: string;
|
||||||
|
activeHost?: string;
|
||||||
|
allowedHosts?: string;
|
||||||
|
port: number;
|
||||||
|
activePort?: number;
|
||||||
|
webPort?: number;
|
||||||
|
activeWebPort?: number;
|
||||||
|
startOnBoot: boolean;
|
||||||
|
projectDirectory?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
online: true,
|
||||||
|
enabled: data.enabled,
|
||||||
|
...(data.host ? { host: data.host } : {}),
|
||||||
|
...(data.activeHost ? { activeHost: data.activeHost } : {}),
|
||||||
|
...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}),
|
||||||
|
port: data.port,
|
||||||
|
...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
|
||||||
|
...(data.webPort !== undefined ? { webPort: data.webPort } : {}),
|
||||||
|
...(data.activeWebPort !== undefined ? { activeWebPort: data.activeWebPort } : {}),
|
||||||
|
startOnBoot: data.startOnBoot,
|
||||||
|
...(data.projectDirectory ? { projectDirectory: data.projectDirectory } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* try next endpoint */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
online: false,
|
||||||
|
enabled: patch.enabled ?? true,
|
||||||
|
...(patch.host ? { host: patch.host } : {}),
|
||||||
|
...(patch.allowedHosts ? { allowedHosts: patch.allowedHosts } : {}),
|
||||||
|
port: patch.port ?? portHint ?? 8081,
|
||||||
|
...(patch.webPort !== undefined ? { webPort: patch.webPort } : {}),
|
||||||
|
startOnBoot: patch.startOnBoot ?? false,
|
||||||
|
...(patch.projectDirectory ? { projectDirectory: patch.projectDirectory } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
register: (email: string, password: string, displayName: string) =>
|
listSystemDirectories: async (
|
||||||
request<{ token: string; user: User }>("/auth/register", {
|
dirPath?: string,
|
||||||
method: "POST",
|
portHint?: number,
|
||||||
body: JSON.stringify({ email, password, displayName }),
|
): Promise<{
|
||||||
}),
|
online: boolean;
|
||||||
|
currentPath: string;
|
||||||
|
parentPath: string | null;
|
||||||
|
homePath: string;
|
||||||
|
directories: { name: string; path: string }[];
|
||||||
|
}> => {
|
||||||
|
const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : "";
|
||||||
|
const endpoints = buildHostEndpoints(`/api/system/list-dirs${query}`, portHint);
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (res.ok && ct.includes("application/json")) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
currentPath: string;
|
||||||
|
parentPath: string | null;
|
||||||
|
homePath: string;
|
||||||
|
directories: { name: string; path: string }[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
online: true,
|
||||||
|
currentPath: data.currentPath,
|
||||||
|
parentPath: data.parentPath,
|
||||||
|
homePath: data.homePath,
|
||||||
|
directories: data.directories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
online: false,
|
||||||
|
currentPath: dirPath || "~/Documents/PubScript",
|
||||||
|
parentPath: null,
|
||||||
|
homePath: "~",
|
||||||
|
directories: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
saveExportToDirectory: async (
|
||||||
|
params: {
|
||||||
|
directory: string;
|
||||||
|
fileName: string;
|
||||||
|
data: Uint8Array | string;
|
||||||
|
},
|
||||||
|
portHint?: number,
|
||||||
|
): Promise<{ savedOnServer: boolean; savedPath?: string }> => {
|
||||||
|
const bytes =
|
||||||
|
typeof params.data === "string"
|
||||||
|
? new TextEncoder().encode(params.data)
|
||||||
|
: params.data;
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
const base64Data = btoa(binary);
|
||||||
|
|
||||||
|
const endpoints = buildHostEndpoints("/api/system/save-export", portHint);
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
directory: params.directory,
|
||||||
|
fileName: params.fileName,
|
||||||
|
base64Data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (res.ok && ct.includes("application/json")) {
|
||||||
|
const out = (await res.json()) as { ok?: boolean; savedPath?: string };
|
||||||
|
if (out.ok && out.savedPath) {
|
||||||
|
return { savedOnServer: true, savedPath: out.savedPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { savedOnServer: false };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { User } from "@pubscript/core";
|
||||||
|
import { Badge, Button, toast } from "@pubscript/ui";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
|
import { useSettings } from "../store/settings.js";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: User | null;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshAuth: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
logout: async () => {},
|
||||||
|
refreshAuth: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scorePassword(pw: string): { score: number; label: string; color: string } {
|
||||||
|
const safe = typeof pw === "string" ? pw : "";
|
||||||
|
if (!safe) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" };
|
||||||
|
let score = 0;
|
||||||
|
if (safe.length >= 8) score += 1;
|
||||||
|
if (safe.length >= 12) score += 1;
|
||||||
|
if (/[a-z]/.test(safe) && /[A-Z]/.test(safe)) score += 1;
|
||||||
|
if (/\d/.test(safe)) score += 1;
|
||||||
|
if (/[^a-zA-Z0-9]/.test(safe)) score += 1;
|
||||||
|
|
||||||
|
if (safe.length < 8) {
|
||||||
|
return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" };
|
||||||
|
}
|
||||||
|
if (score <= 2) {
|
||||||
|
return { score: 2, label: "Fair", color: "var(--ps-warning)" };
|
||||||
|
}
|
||||||
|
if (score === 3) {
|
||||||
|
return { score: 3, label: "Good", color: "var(--ps-accent)" };
|
||||||
|
}
|
||||||
|
return { score: 4, label: "Strong (Argon2id ready)", color: "var(--ps-success)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialized, setInitialized] = useState(true);
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
// Setup / Login form state
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshAuth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const status = await api.authStatus();
|
||||||
|
setInitialized(status.initialized);
|
||||||
|
setAuthenticated(status.authenticated);
|
||||||
|
setUser(status.user);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshAuth();
|
||||||
|
// Re-sync auth state when user switches from Desktop App to Web App tab
|
||||||
|
const onFocus = () => {
|
||||||
|
void refreshAuth();
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
|
}, [refreshAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authenticated) {
|
||||||
|
void useProjectStore.getState().init();
|
||||||
|
void api.getServerConfig().then((cfg) => {
|
||||||
|
if (cfg.online && cfg.projectDirectory) {
|
||||||
|
useSettings.getState().set({
|
||||||
|
projectDirectory: cfg.projectDirectory,
|
||||||
|
...(cfg.host ? { serverHost: cfg.host } : {}),
|
||||||
|
...(cfg.allowedHosts ? { allowedHosts: cfg.allowedHosts } : {}),
|
||||||
|
...(cfg.port ? { serverPort: cfg.port } : {}),
|
||||||
|
...(cfg.webPort ? { webPort: cfg.webPort } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.logout();
|
||||||
|
setAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
setPassword("");
|
||||||
|
toast.info("Signed out of PubScript");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const strength = useMemo(() => scorePassword(password), [password]);
|
||||||
|
|
||||||
|
const handleSetupSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const cleanName = String(displayName ?? "").trim();
|
||||||
|
const cleanEmail = String(email ?? "").trim().toLowerCase();
|
||||||
|
const safePw = String(password ?? "");
|
||||||
|
const safeConfirm = String(confirmPassword ?? "");
|
||||||
|
|
||||||
|
if (!cleanName) {
|
||||||
|
setError("Please enter an administrator display name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cleanEmail || !cleanEmail.includes("@")) {
|
||||||
|
setError("Please enter a valid administrator email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (safePw.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters long.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (safePw !== safeConfirm) {
|
||||||
|
setError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await api.setupAdmin({
|
||||||
|
email: cleanEmail,
|
||||||
|
displayName: cleanName,
|
||||||
|
password: safePw,
|
||||||
|
});
|
||||||
|
setInitialized(true);
|
||||||
|
setAuthenticated(true);
|
||||||
|
setUser(res.user);
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Failed to create administrator account.";
|
||||||
|
if (msg.includes("already_initialized") || msg.includes("already been")) {
|
||||||
|
// Admin was already created in Desktop App — switch straight to Login view
|
||||||
|
setInitialized(true);
|
||||||
|
setError("Administrator account already exists. Please sign in with your credentials.");
|
||||||
|
} else {
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const cleanEmail = String(email ?? "").trim().toLowerCase();
|
||||||
|
const safePw = String(password ?? "");
|
||||||
|
if (!cleanEmail || !safePw) {
|
||||||
|
setError("Please enter your email and password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await api.login(cleanEmail, safePw);
|
||||||
|
setAuthenticated(true);
|
||||||
|
setUser(res.user);
|
||||||
|
setPassword("");
|
||||||
|
toast.success(`Signed in as ${res.user.displayName}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Invalid email or password.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--ps-bg)",
|
||||||
|
color: "var(--ps-text-muted)",
|
||||||
|
fontFamily: "var(--ps-font-ui)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Verifying session security…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialized || !authenticated) {
|
||||||
|
const isFirstInstall = !initialized;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
translate="no"
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg)), var(--ps-bg) 70%)",
|
||||||
|
color: "var(--ps-text)",
|
||||||
|
fontFamily: "var(--ps-font-ui)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(440px, 100%)",
|
||||||
|
background: "var(--ps-surface)",
|
||||||
|
border: "1px solid var(--ps-border)",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "28px 28px 24px",
|
||||||
|
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.45)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", marginBottom: 20 }}>
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="PubScript"
|
||||||
|
style={{ width: 72, height: 72, objectFit: "contain", marginBottom: 10 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 8 }}>
|
||||||
|
<Badge variant={isFirstInstall ? "accent" : "success"}>
|
||||||
|
{isFirstInstall ? "🔒 First-Install Security Setup" : "🔒 Authenticated Access Required"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
|
||||||
|
<span>{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}</span>
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
|
||||||
|
<span>
|
||||||
|
{isFirstInstall
|
||||||
|
? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access."
|
||||||
|
: "Enter your credentials to unlock your manuscripts and studio workspace."}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
marginBottom: 14,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--ps-danger)",
|
||||||
|
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
|
||||||
|
color: "var(--ps-danger)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFirstInstall ? (
|
||||||
|
<form
|
||||||
|
key="setup-form"
|
||||||
|
onSubmit={(e) => void handleSetupSubmit(e)}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label" htmlFor="ps-setup-name">
|
||||||
|
Administrator Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ps-setup-name"
|
||||||
|
name="displayName"
|
||||||
|
type="text"
|
||||||
|
className="ps-input"
|
||||||
|
placeholder="e.g. Schwitzz"
|
||||||
|
autoComplete="off"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(String(e.target?.value ?? ""))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label" htmlFor="ps-setup-email">
|
||||||
|
Administrator Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ps-setup-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
className="ps-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-field">
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<label className="ps-field-label" htmlFor="ps-setup-pw">
|
||||||
|
Master Password (min 8 chars)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--ps-accent)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{showPassword ? "Hide" : "Show"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="ps-setup-pw"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="ps-input"
|
||||||
|
placeholder="Create a strong password…"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
|
||||||
|
<div style={{ display: "flex", gap: 4, flex: 1, marginRight: 10 }}>
|
||||||
|
{[1, 2, 3, 4].map((step) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
background:
|
||||||
|
strength.score >= step ? strength.color : "var(--ps-border)",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11.5, color: strength.color, fontWeight: 600 }}>
|
||||||
|
{strength.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label" htmlFor="ps-setup-confirm-pw">
|
||||||
|
Confirm Master Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ps-setup-confirm-pw"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="ps-input"
|
||||||
|
placeholder="Re-enter password…"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(String(e.target?.value ?? ""))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={submitting}
|
||||||
|
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<span>{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
key="login-form"
|
||||||
|
onSubmit={(e) => void handleLoginSubmit(e)}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label" htmlFor="ps-login-email">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ps-login-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
className="ps-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-field">
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<label className="ps-field-label" htmlFor="ps-login-pw">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--ps-accent)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{showPassword ? "Hide" : "Show"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="ps-login-pw"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="ps-input"
|
||||||
|
placeholder="Enter your password…"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={submitting}
|
||||||
|
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<span>{submitting ? "Signing in…" : "Sign In"}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 18,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTop: "1px solid var(--ps-border)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--ps-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Argon2id (64 MiB) · SHA-256 Session</span>
|
||||||
|
<span>HttpOnly · SameSite=Lax</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, logout, refreshAuth }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal, Button } from "@pubscript/ui";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { useSettings } from "../store/settings.js";
|
||||||
|
|
||||||
|
export interface DirectoryBrowserModalProps {
|
||||||
|
open: boolean;
|
||||||
|
initialPath: string;
|
||||||
|
title?: string;
|
||||||
|
onSelect: (selectedPath: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectoryBrowserModal({
|
||||||
|
open,
|
||||||
|
initialPath,
|
||||||
|
title = "Select Directory",
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: DirectoryBrowserModalProps) {
|
||||||
|
const serverPort = useSettings((s) => s.serverPort);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>(initialPath || "~/Documents/PubScript");
|
||||||
|
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||||
|
const [homePath, setHomePath] = useState<string>("~");
|
||||||
|
const [directories, setDirectories] = useState<{ name: string; path: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const loadPath = async (targetPath: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.listSystemDirectories(targetPath, serverPort);
|
||||||
|
setCurrentPath(res.currentPath);
|
||||||
|
setParentPath(res.parentPath);
|
||||||
|
setHomePath(res.homePath);
|
||||||
|
setDirectories(res.directories);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void loadPath(initialPath || "~/Documents/PubScript");
|
||||||
|
}, [open, initialPath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(currentPath.trim() || "~/Documents/PubScript");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select This Folder
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ps-input"
|
||||||
|
style={{ flex: 1, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
|
||||||
|
value={currentPath}
|
||||||
|
onChange={(e) => setCurrentPath(e.target.value)}
|
||||||
|
placeholder="/path/to/directory"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadPath(currentPath)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
{parentPath && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadPath(parentPath)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
⬆ Parent Folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadPath(homePath || "~")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
🏠 Home
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadPath("~/Documents")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
📄 Documents
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadPath("~/Documents/PubScript")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
📚 PubScript
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 260,
|
||||||
|
overflowY: "auto",
|
||||||
|
border: "1px solid var(--ps-border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--ps-bg)",
|
||||||
|
padding: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: 14, fontSize: 12.5, color: "var(--ps-text-muted)" }}>
|
||||||
|
Loading folders…
|
||||||
|
</div>
|
||||||
|
) : directories.length === 0 ? (
|
||||||
|
<div style={{ padding: 14, fontSize: 12.5, color: "var(--ps-text-muted)" }}>
|
||||||
|
No subfolders in this directory (you can click “Select This Folder” or enter any path above).
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
directories.map((dir) => (
|
||||||
|
<button
|
||||||
|
key={dir.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadPath(dir.path)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "7px 10px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--ps-text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>📁</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{dir.name}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1159
-417
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@ import { useMemo, useRef, useState, useEffect } from "react";
|
|||||||
import { useLocation, NavLink, useNavigate } from "react-router-dom";
|
import { useLocation, NavLink, useNavigate } from "react-router-dom";
|
||||||
import { useSettings } from "../store/settings.js";
|
import { useSettings } from "../store/settings.js";
|
||||||
import { useProjectStore } from "../store/project-store.js";
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
import { Badge } from "@pubscript/ui";
|
import { useAuth } from "./AuthGate.js";
|
||||||
|
import { Badge, toast } from "@pubscript/ui";
|
||||||
import type { Entity } from "@pubscript/core";
|
import type { Entity } from "@pubscript/core";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
@@ -20,6 +21,8 @@ const TITLES: Record<string, string> = {
|
|||||||
export interface TopbarProps {
|
export interface TopbarProps {
|
||||||
onOpenPalette?: () => void;
|
onOpenPalette?: () => void;
|
||||||
onOpenExport: () => void;
|
onOpenExport: () => void;
|
||||||
|
onOpenImport?: () => void;
|
||||||
|
onToggleMobileNav?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityName(e: Entity): string {
|
function entityName(e: Entity): string {
|
||||||
@@ -27,9 +30,10 @@ function entityName(e: Entity): string {
|
|||||||
return (e.body || "Untitled note").slice(0, 48);
|
return (e.body || "Untitled note").slice(0, 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ onOpenExport }: TopbarProps) {
|
export function Topbar({ onOpenExport, onOpenImport, onToggleMobileNav }: TopbarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
const autoSave = useSettings((s) => s.autoSave);
|
const autoSave = useSettings((s) => s.autoSave);
|
||||||
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
||||||
const setSettings = useSettings((s) => s.set);
|
const setSettings = useSettings((s) => s.set);
|
||||||
@@ -38,6 +42,8 @@ export function Topbar({ onOpenExport }: TopbarProps) {
|
|||||||
const selectChapter = useProjectStore((s) => s.selectChapter);
|
const selectChapter = useProjectStore((s) => s.selectChapter);
|
||||||
const entities = useProjectStore((s) => s.entities);
|
const entities = useProjectStore((s) => s.entities);
|
||||||
const plotBeats = useProjectStore((s) => s.plotBeats);
|
const plotBeats = useProjectStore((s) => s.plotBeats);
|
||||||
|
const autosave = useProjectStore((s) => s.autosave);
|
||||||
|
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
@@ -106,13 +112,64 @@ export function Topbar({ onOpenExport }: TopbarProps) {
|
|||||||
return items.slice(0, 10);
|
return items.slice(0, 10);
|
||||||
}, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]);
|
}, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]);
|
||||||
|
|
||||||
|
const handleManualSave = async () => {
|
||||||
|
await saveChapterNow();
|
||||||
|
toast.success("Chapter saved");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="ps-topbar">
|
<header className="ps-topbar">
|
||||||
|
{onToggleMobileNav && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-icon-btn ps-mobile-nav-btn"
|
||||||
|
onClick={onToggleMobileNav}
|
||||||
|
aria-label="Toggle Navigation Menu"
|
||||||
|
title="Navigation Menu"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 18 18" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<line x1="2.5" y1="4.5" x2="15.5" y2="4.5" strokeLinecap="round" />
|
||||||
|
<line x1="2.5" y1="9" x2="15.5" y2="9" strokeLinecap="round" />
|
||||||
|
<line x1="2.5" y1="13.5" x2="15.5" y2="13.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>
|
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{autoSave && <Badge variant="success">✓ autosave on</Badge>}
|
{autoSave ? (
|
||||||
|
<Badge variant="success">✓ autosave on</Badge>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ps-nav-item${autosave.dirty ? " is-active" : ""}`}
|
||||||
|
onClick={() => void handleManualSave()}
|
||||||
|
disabled={autosave.saving}
|
||||||
|
style={{
|
||||||
|
width: "auto",
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
title="Manual Save (Ctrl+S)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<path d="M3 2.5h8.2L13.5 4.8v8.2a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" strokeLinejoin="round" />
|
||||||
|
<path d="M5 2.5v3.5h5V2.5M4.5 13.5V9h7v4.5" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{autosave.saving ? "Saving…" : autosave.dirty ? "Save *" : "Save"}</span>
|
||||||
|
</button>
|
||||||
|
<Badge variant={autosave.dirty ? "warning" : "neutral"}>
|
||||||
|
{autosave.dirty ? "unsaved" : "saved"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
@@ -174,18 +231,54 @@ export function Topbar({ onOpenExport }: TopbarProps) {
|
|||||||
<span>✨ AI</span>
|
<span>✨ AI</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onOpenImport && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-icon-btn"
|
||||||
|
onClick={onOpenImport}
|
||||||
|
aria-label="Import manuscript"
|
||||||
|
title="Import manuscript (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 18 18" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.65">
|
||||||
|
<path d="M9 2.5v8.5M5.8 7.8L9 11l3.2-3.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M3 11.5v2.2a1.3 1.3 0 0 0 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3v-2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ps-nav-item"
|
className="ps-icon-btn"
|
||||||
onClick={onOpenExport}
|
onClick={onOpenExport}
|
||||||
style={{ width: "auto", padding: "6px 10px" }}
|
aria-label="Export manuscript"
|
||||||
|
title="Export manuscript (.epub, .docx, .odt, .pdf, .html, .md)"
|
||||||
>
|
>
|
||||||
<span>Export</span>
|
<svg viewBox="0 0 18 18" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.65">
|
||||||
|
<path d="M9 11V2.5M5.8 5.7L9 2.5l3.2 3.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M3 11.5v2.2a1.3 1.3 0 0 0 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3v-2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
|
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
|
||||||
⚙
|
⚙
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 4 }}>
|
||||||
|
<Badge variant="neutral" title={`${user.email} (${user.role})`}>
|
||||||
|
{`👤 ${user.displayName}`}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-nav-item"
|
||||||
|
onClick={() => void logout()}
|
||||||
|
style={{ width: "auto", padding: "5px 9px", fontSize: 12.5 }}
|
||||||
|
title="Sign out of PubScript"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
const renameChapter = useProjectStore((s) => s.renameChapter);
|
const renameChapter = useProjectStore((s) => s.renameChapter);
|
||||||
const deleteChapter = useProjectStore((s) => s.deleteChapter);
|
const deleteChapter = useProjectStore((s) => s.deleteChapter);
|
||||||
const scheduleSave = useProjectStore((s) => s.scheduleSave);
|
const scheduleSave = useProjectStore((s) => s.scheduleSave);
|
||||||
|
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
|
||||||
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
||||||
const autosave = useProjectStore((s) => s.autosave);
|
const autosave = useProjectStore((s) => s.autosave);
|
||||||
const entities = useProjectStore((s) => s.entities);
|
const entities = useProjectStore((s) => s.entities);
|
||||||
@@ -84,6 +85,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
const highlightSeq = useProjectStore((s) => s.highlightSeq);
|
const highlightSeq = useProjectStore((s) => s.highlightSeq);
|
||||||
const docExternalRev = useProjectStore((s) => s.docExternalRev);
|
const docExternalRev = useProjectStore((s) => s.docExternalRev);
|
||||||
|
|
||||||
|
const autoSave = useSettings((st) => st.autoSave);
|
||||||
const fontFamily = useSettings((st) => st.fontFamily);
|
const fontFamily = useSettings((st) => st.fontFamily);
|
||||||
const fontSize = useSettings((st) => st.fontSize);
|
const fontSize = useSettings((st) => st.fontSize);
|
||||||
const leading = useSettings((st) => st.leading);
|
const leading = useSettings((st) => st.leading);
|
||||||
@@ -93,7 +95,10 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
const setSettings = useSettings((st) => st.set);
|
const setSettings = useSettings((st) => st.set);
|
||||||
|
|
||||||
const [firstLineIndent, setFirstLineIndent] = useState(false);
|
const [firstLineIndent, setFirstLineIndent] = useState(false);
|
||||||
const [showContextRail, setShowContextRail] = useState(true);
|
const [showContextRail, setShowContextRail] = useState(false);
|
||||||
|
const [formattingOpen, setFormattingOpen] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth > 768 : true,
|
||||||
|
);
|
||||||
const [renamingChapter, setRenamingChapter] = useState(false);
|
const [renamingChapter, setRenamingChapter] = useState(false);
|
||||||
const [chapterTitleDraft, setChapterTitleDraft] = useState("");
|
const [chapterTitleDraft, setChapterTitleDraft] = useState("");
|
||||||
const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null);
|
const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null);
|
||||||
@@ -253,19 +258,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
return clearCustomHighlight;
|
return clearCustomHighlight;
|
||||||
}, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]);
|
}, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]);
|
||||||
|
|
||||||
// Keyboard shortcut: F2 toggles focus mode.
|
// Keyboard shortcuts: F2 toggles focus mode; Ctrl+S / Cmd+S saves chapter immediately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dom = editorDomRef.current;
|
|
||||||
if (!dom) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "F2") {
|
if (e.key === "F2") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSettings({ focusMode: !focusMode });
|
setSettings({ focusMode: !focusMode });
|
||||||
|
} else if ((e.metaKey || e.ctrlKey) && typeof e.key === "string" && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveChapterNow().then(() => {
|
||||||
|
toast.success("Chapter saved");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
dom.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => dom.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [focusMode, setSettings]);
|
}, [focusMode, setSettings, saveChapterNow]);
|
||||||
|
|
||||||
// Flush pending edits when the user closes or navigates away.
|
// Flush pending edits when the user closes or navigates away.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -421,12 +429,41 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ps-tool-icon${!autoSave && autosave.dirty ? " is-active" : ""}`}
|
||||||
|
title="Save Chapter Now (Ctrl+S)"
|
||||||
|
aria-label="Save Chapter Now"
|
||||||
|
onClick={() => {
|
||||||
|
void saveChapterNow().then(() => {
|
||||||
|
toast.success("Chapter saved");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 2.5h8.2L13.5 4.8v8.2a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" strokeLinejoin="round" />
|
||||||
|
<path d="M5 2.5v3.5h5V2.5M4.5 13.5V9h7v4.5" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ps-tool-icon ps-formatting-toggle-btn${formattingOpen ? " is-active" : ""}`}
|
||||||
|
title={formattingOpen ? "Minimize Formatting Tools" : "Show Formatting Tools"}
|
||||||
|
aria-label="Toggle Formatting Tools"
|
||||||
|
aria-expanded={formattingOpen}
|
||||||
|
onClick={() => setFormattingOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "-0.02em" }}>
|
||||||
|
Aa {formattingOpen ? "▴" : "▾"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="ps-toolbar-sep" />
|
<div className={`ps-toolbar-collapsible${formattingOpen ? " is-open" : " is-minimized"}`}>
|
||||||
|
<span className="ps-toolbar-sep" />
|
||||||
|
|
||||||
{/* History: Undo / Redo */}
|
{/* History: Undo / Redo */}
|
||||||
<div className="ps-toolbar-group">
|
<div className="ps-toolbar-group">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ps-tool-icon"
|
className="ps-tool-icon"
|
||||||
@@ -861,17 +898,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
<path d="M3 10l2.5-6L8 10M3.8 8.2h3.4M9.5 10.5l2 2 3-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M3 10l2.5-6L8 10M3.8 8.2h3.4M9.5 10.5l2 2 3-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-toolbar-group" style={{ marginLeft: "auto" }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`ps-tool-icon${showContextRail ? " is-active" : ""}`}
|
className={`ps-tool-icon${showContextRail ? " is-active" : ""}`}
|
||||||
title="Toggle Story Context Rail"
|
title="Toggle Story Context Rail (Info)"
|
||||||
aria-label="Toggle Story Context Rail"
|
aria-label="Toggle Story Context Rail"
|
||||||
|
aria-pressed={showContextRail}
|
||||||
onClick={() => setShowContextRail((r) => !r)}
|
onClick={() => setShowContextRail((r) => !r)}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
|
<circle cx="8" cy="8" r="6" />
|
||||||
<line x1="10" y1="2.5" x2="10" y2="13.5" />
|
<line x1="8" y1="7.2" x2="8" y2="11.2" strokeLinecap="round" />
|
||||||
|
<circle cx="8" cy="4.8" r="0.85" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,9 +921,16 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
<div
|
<div
|
||||||
className="ps-autosave-indicator"
|
className="ps-autosave-indicator"
|
||||||
data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}
|
data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!autoSave && autosave.dirty) {
|
||||||
|
void saveChapterNow().then(() => toast.success("Chapter saved"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={!autoSave && autosave.dirty ? { cursor: "pointer" } : undefined}
|
||||||
|
title={!autoSave ? "Click to save manually (Ctrl+S)" : undefined}
|
||||||
>
|
>
|
||||||
<span className="ps-autosave-dot" />
|
<span className="ps-autosave-dot" />
|
||||||
<span>{autosaveLabel(autosave)}</span>
|
<span>{!autoSave && autosave.dirty ? "Unsaved · Click to save" : autosaveLabel(autosave)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -924,11 +973,18 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showContextRail && (
|
{showContextRail && (
|
||||||
<ContextRail
|
<>
|
||||||
chapter={chapter}
|
<div
|
||||||
entities={entities}
|
className="ps-mobile-rail-backdrop"
|
||||||
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
onClick={() => setShowContextRail(false)}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ContextRail
|
||||||
|
chapter={chapter}
|
||||||
|
entities={entities}
|
||||||
|
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{check.open && (
|
{check.open && (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function AdminView() {
|
|||||||
|
|
||||||
// Edit user modal state
|
// Edit user modal state
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [editEmail, setEditEmail] = useState("");
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editRole, setEditRole] = useState<Role>("author");
|
const [editRole, setEditRole] = useState<Role>("author");
|
||||||
const [editPassword, setEditPassword] = useState("");
|
const [editPassword, setEditPassword] = useState("");
|
||||||
@@ -122,6 +123,7 @@ export function AdminView() {
|
|||||||
|
|
||||||
const openEditUser = (user: User) => {
|
const openEditUser = (user: User) => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
|
setEditEmail(user.email);
|
||||||
setEditName(user.displayName);
|
setEditName(user.displayName);
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditPassword("");
|
setEditPassword("");
|
||||||
@@ -131,6 +133,7 @@ export function AdminView() {
|
|||||||
if (!editingUser) return;
|
if (!editingUser) return;
|
||||||
try {
|
try {
|
||||||
await api.updateUser(editingUser.id, {
|
await api.updateUser(editingUser.id, {
|
||||||
|
email: editEmail.trim().toLowerCase() || editingUser.email,
|
||||||
displayName: editName.trim() || editingUser.displayName,
|
displayName: editName.trim() || editingUser.displayName,
|
||||||
role: editRole,
|
role: editRole,
|
||||||
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
|
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
|
||||||
@@ -441,6 +444,12 @@ export function AdminView() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<Input
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
value={editEmail}
|
||||||
|
onChange={(e) => setEditEmail(e.target.value)}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Display name"
|
label="Display name"
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|||||||
@@ -113,55 +113,90 @@ function parseSuggestionBlocks(
|
|||||||
chapterLines: ChapterLine[],
|
chapterLines: ChapterLine[],
|
||||||
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
|
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
|
||||||
const parsed: ParsedLineSuggestion[] = [];
|
const parsed: ParsedLineSuggestion[] = [];
|
||||||
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
|
|
||||||
const norm = (s: string) =>
|
const norm = (s: string) =>
|
||||||
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
|
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
|
||||||
|
|
||||||
const cleanText = rawText
|
const addParsed = (rawOrig: string, newText: string, title?: string, summary?: string) => {
|
||||||
|
const cleanOrig = rawOrig.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
|
||||||
|
const cleanNew = newText.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
|
||||||
|
if (!cleanOrig || !cleanNew || cleanOrig === cleanNew) return;
|
||||||
|
if (parsed.some((p) => norm(p.originalText) === norm(cleanOrig))) return;
|
||||||
|
|
||||||
|
const matchedLine =
|
||||||
|
chapterLines.find((l) => norm(l.text).includes(norm(cleanOrig))) ??
|
||||||
|
chapterLines[0];
|
||||||
|
const lineNumber = matchedLine?.lineNumber ?? 1;
|
||||||
|
|
||||||
|
parsed.push({
|
||||||
|
lineNumber,
|
||||||
|
kind: "review",
|
||||||
|
title: title?.replace(/^\*+|\*+$/g, "").trim() || "Granular Prose Edit",
|
||||||
|
originalText: cleanOrig,
|
||||||
|
newText: cleanNew,
|
||||||
|
summary:
|
||||||
|
summary?.replace(/^\*+|\*+$/g, "").trim() ||
|
||||||
|
"Targeted phrase-level revision pegged to manuscript text.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Standard <<<SUGGESTION ... SUGGESTION>>> or <<<SUGGESTION ... >>> blocks
|
||||||
|
const blockRegex = /<<<SUGGESTION\b([\s\S]*?)(?:SUGGESTION>>>|>>>)/gi;
|
||||||
|
let cleanText = rawText
|
||||||
.replace(blockRegex, (_match, body: string) => {
|
.replace(blockRegex, (_match, body: string) => {
|
||||||
const getField = (name: string): string => {
|
const getField = (name: string): string => {
|
||||||
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
|
const m = body.match(new RegExp(`^\\s*[*_]*${name}[*_]*\\s*:\\s*(.+)$`, "im"));
|
||||||
return m?.[1]?.trim() ?? "";
|
return m?.[1]?.trim() ?? "";
|
||||||
};
|
};
|
||||||
const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim();
|
const rawOrig = getField("ORIGINAL");
|
||||||
const newText = (getField("REPLACEMENT") || getField("NEW"))
|
const newText = getField("REPLACEMENT") || getField("NEW") || getField("SUGGESTED");
|
||||||
.replace(/^["“]|["”]$/g, "")
|
const title = getField("TITLE");
|
||||||
.trim();
|
const summary = getField("SUMMARY") || getField("RATIONALE") || getField("REASON");
|
||||||
if (!rawOrig || !newText) return "";
|
addParsed(rawOrig, newText, title, summary);
|
||||||
|
|
||||||
const matchedLine =
|
|
||||||
chapterLines.find((l) => norm(l.text).includes(norm(rawOrig))) ??
|
|
||||||
chapterLines[0];
|
|
||||||
const lineNumber = matchedLine?.lineNumber ?? 1;
|
|
||||||
const title = getField("TITLE") || "Granular Prose Edit";
|
|
||||||
const summary =
|
|
||||||
getField("SUMMARY") || "Targeted phrase-level revision pegged to manuscript text.";
|
|
||||||
|
|
||||||
parsed.push({
|
|
||||||
lineNumber,
|
|
||||||
kind: "review",
|
|
||||||
title,
|
|
||||||
originalText: rawOrig,
|
|
||||||
newText,
|
|
||||||
summary,
|
|
||||||
});
|
|
||||||
return "";
|
return "";
|
||||||
})
|
})
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
// 2. Fallback parser for models that emit ORIGINAL: ... / REPLACEMENT: ... without <<<SUGGESTION delimiters
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
const looseRegex =
|
||||||
|
/(?:^|\n)\s*(?:[-*•]|\d+\.)?\s*[*_]*(?:TITLE\s*:\s*([^\n]+)\n\s*)?[*_]*ORIGINAL[*_]*\s*:\s*["“]?([^\n"”]+)["”]?\s*\n\s*[*_]*(?:REPLACEMENT|NEW|SUGGESTED)[*_]*\s*:\s*["“]?([^\n"”]+)["”]?(?:\s*\n\s*[*_]*(?:SUMMARY|RATIONALE|REASON)[*_]*\s*:\s*([^\n]+))?/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = looseRegex.exec(rawText)) !== null) {
|
||||||
|
addParsed(m[2] ?? "", m[3] ?? "", m[1], m[4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback parser for models that emit `"original phrase" -> "replacement phrase"`
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
const arrowRegex = /["“]([^"”\n]{10,160})["”]\s*(?:->|→|=>)\s*["“]([^"”\n]{4,200})["”]/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = arrowRegex.exec(rawText)) !== null) {
|
||||||
|
const candidateOrig = (m[1] ?? "").trim();
|
||||||
|
if (chapterLines.some((l) => norm(l.text).includes(norm(candidateOrig)))) {
|
||||||
|
addParsed(candidateOrig, m[2] ?? "", "Inline Phrase Revision");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cleanText && parsed.length > 0) {
|
||||||
|
cleanText = `Generated ${parsed.length} inline edit suggestion${parsed.length === 1 ? "" : "s"} in the Suggestions tab.`;
|
||||||
|
}
|
||||||
|
|
||||||
return { cleanText, parsed };
|
return { cleanText, parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract granular phrase, clause, and single-sentence edit candidates from
|
* Extract granular phrase, clause, and single-sentence edit candidates from
|
||||||
* the chapter's non-empty paragraphs so suggestions are surgical and never
|
* the chapter's non-empty paragraphs so offline/test suggestions are surgical,
|
||||||
* overwrite entire multi-sentence paragraphs.
|
* prompt-sensitive, and never repeat static clichés.
|
||||||
*/
|
*/
|
||||||
function buildGranularSuggestionsFromLines(
|
function buildGranularSuggestionsFromLines(
|
||||||
nonEmptyLines: ChapterLine[],
|
nonEmptyLines: ChapterLine[],
|
||||||
role: string,
|
role: string,
|
||||||
|
userPrompt = "",
|
||||||
): ParsedLineSuggestion[] {
|
): ParsedLineSuggestion[] {
|
||||||
const results: ParsedLineSuggestion[] = [];
|
const results: ParsedLineSuggestion[] = [];
|
||||||
|
const promptLower = userPrompt.toLowerCase();
|
||||||
|
|
||||||
const rules: Array<{
|
const rules: Array<{
|
||||||
pattern: RegExp;
|
pattern: RegExp;
|
||||||
@@ -172,7 +207,10 @@ function buildGranularSuggestionsFromLines(
|
|||||||
{
|
{
|
||||||
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
|
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
|
||||||
title: "Tighten Opening Clause",
|
title: "Tighten Opening Clause",
|
||||||
replace: () => "cleared the mainland before dawn, iron hull shuddering through the black chop",
|
replace: () =>
|
||||||
|
promptLower.includes("pacing") || role === "publisher"
|
||||||
|
? "cleared the mainland before dawn, iron hull driving hard into the black chop"
|
||||||
|
: "cleared the mainland before dawn, iron hull shuddering through the black chop",
|
||||||
summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
|
summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -227,56 +265,46 @@ function buildGranularSuggestionsFromLines(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length < 4) {
|
for (const line of nonEmptyLines) {
|
||||||
for (const line of nonEmptyLines) {
|
if (results.length >= 5) break;
|
||||||
|
const sentences = line.text
|
||||||
|
.split(/(?<=[.!?])\s+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length >= 12);
|
||||||
|
|
||||||
|
for (const sentence of sentences) {
|
||||||
if (results.length >= 5) break;
|
if (results.length >= 5) break;
|
||||||
const sentences = line.text
|
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
|
||||||
.split(/(?<=[.!?])\s+/)
|
continue;
|
||||||
.map((s) => s.trim())
|
}
|
||||||
.filter((s) => s.length >= 12);
|
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
|
||||||
if (results.length >= 5) break;
|
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
|
||||||
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
|
const revisedSpan = targetSpan
|
||||||
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
|
.replace(/\bseemed to\s+/gi, "")
|
||||||
|
.replace(/\bstarted to\s+/gi, "")
|
||||||
let revisedSpan = targetSpan
|
.replace(/\bbegan to\s+/gi, "")
|
||||||
.replace(/\bseemed to\s+/gi, "")
|
.replace(/\bwatched the\b/gi, "saw the")
|
||||||
.replace(/\bstarted to\s+/gi, "")
|
.replace(/\bfelt the\b/gi, "knew the")
|
||||||
.replace(/\bbegan to\s+/gi, "")
|
.replace(/\bvery\s+/gi, "")
|
||||||
.replace(/\bwatched the\b/gi, "saw the")
|
.replace(/\breally\s+/gi, "")
|
||||||
.replace(/\bfelt the\b/gi, "knew the")
|
.replace(/\bjust\s+/gi, "")
|
||||||
.replace(/\bvery\s+/gi, "")
|
.replace(/\bsuddenly\s+/gi, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (revisedSpan === targetSpan) {
|
|
||||||
if (targetSpan.endsWith(".")) {
|
|
||||||
revisedSpan =
|
|
||||||
role === "reader"
|
|
||||||
? `${targetSpan.slice(0, -1)}—and the silence that followed held its breath.`
|
|
||||||
: role === "publisher"
|
|
||||||
? `${targetSpan.slice(0, -1)}, raising the stakes of what waited ahead.`
|
|
||||||
: `${targetSpan.slice(0, -1)}—sharp and unmistakable.`;
|
|
||||||
} else {
|
|
||||||
revisedSpan = `${targetSpan}—clear and unsparing`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (revisedSpan !== targetSpan && revisedSpan.length > 0) {
|
||||||
results.push({
|
results.push({
|
||||||
lineNumber: line.lineNumber,
|
lineNumber: line.lineNumber,
|
||||||
kind: "review",
|
kind: "review",
|
||||||
title:
|
title:
|
||||||
role === "editor"
|
role === "editor"
|
||||||
? "Tighten Clause & Cadence"
|
? "Tighten Filter & Qualifier Words"
|
||||||
: role === "reader"
|
: role === "reader"
|
||||||
? "Deepen Reader Immersion"
|
? "Deepen Reader Immediacy"
|
||||||
: role === "publisher"
|
: role === "publisher"
|
||||||
? "Reinforce Scene Hook"
|
? "Accelerate Narrative Pacing"
|
||||||
: "Enhance Sensory Beat",
|
: "Refine Clause Rhythm",
|
||||||
originalText: targetSpan,
|
originalText: targetSpan,
|
||||||
newText: revisedSpan,
|
newText: revisedSpan,
|
||||||
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
|
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
|
||||||
@@ -308,7 +336,7 @@ function buildLineBasedFallback(opts: {
|
|||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
||||||
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole);
|
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole, userPrompt);
|
||||||
|
|
||||||
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
|
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
|
||||||
const thinkingText = `Scanning non-empty paragraphs in "${chapterTitle}" → isolating individual clauses & phrases → cross-checking entities (${cast}) → building ${suggestions.length} text-anchored edits.`;
|
const thinkingText = `Scanning non-empty paragraphs in "${chapterTitle}" → isolating individual clauses & phrases → cross-checking entities (${cast}) → building ${suggestions.length} text-anchored edits.`;
|
||||||
@@ -770,63 +798,96 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AgentController({
|
|
||||||
id: activeProvider.id,
|
|
||||||
kind: activeProvider.kind,
|
|
||||||
baseUrl: activeProvider.baseUrl,
|
|
||||||
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
|
|
||||||
model: activeModel,
|
|
||||||
temperature: activeAgent.temperature,
|
|
||||||
maxTokens: activeAgent.maxTokens,
|
|
||||||
contextWindow: activeAgent.contextWindow,
|
|
||||||
...(activeAgent.thinkingEnabled
|
|
||||||
? {
|
|
||||||
thinking: {
|
|
||||||
enabled: true,
|
|
||||||
effort: activeAgent.thinkingEffort,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
|
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
|
||||||
role: m.role,
|
role: m.role,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { result } = await controller.collect(
|
const messagesWithContext = historyMsgs.map((m, idx) =>
|
||||||
[
|
idx === historyMsgs.length - 1 && m.role === "user"
|
||||||
{
|
? {
|
||||||
role: "system",
|
role: "user" as const,
|
||||||
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`,
|
content: `--- MANUSCRIPT CONTEXT ---\n${contextText}\n\n--- AUTHOR PROMPT ---\n${m.content}`,
|
||||||
},
|
}
|
||||||
...historyMsgs,
|
: m,
|
||||||
],
|
);
|
||||||
{
|
|
||||||
|
let rawModelText = "";
|
||||||
|
let rawReasoning: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AgentController({
|
||||||
|
id: activeProvider.id,
|
||||||
|
kind: activeProvider.kind,
|
||||||
|
baseUrl: activeProvider.baseUrl,
|
||||||
|
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
|
||||||
|
model: activeModel,
|
||||||
temperature: activeAgent.temperature,
|
temperature: activeAgent.temperature,
|
||||||
maxTokens: activeAgent.maxTokens,
|
maxTokens: activeAgent.maxTokens,
|
||||||
contextText,
|
contextWindow: Math.max(activeAgent.contextWindow || 32768, 32768),
|
||||||
},
|
...(activeAgent.thinkingEnabled
|
||||||
);
|
? {
|
||||||
if (result.reasoning) {
|
thinking: {
|
||||||
setThinkingTrace(result.reasoning);
|
enabled: true,
|
||||||
}
|
effort: activeAgent.thinkingEffort,
|
||||||
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
},
|
||||||
assistantReply = extracted.cleanText;
|
}
|
||||||
parsedSuggestions = extracted.parsed;
|
: {}),
|
||||||
|
|
||||||
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
|
|
||||||
const fallback = buildLineBasedFallback({
|
|
||||||
agentName: activeAgent.name,
|
|
||||||
agentRole: activeAgent.role,
|
|
||||||
userPrompt: promptText,
|
|
||||||
projectTitle: project?.title ?? "Untitled Project",
|
|
||||||
chapterTitle: activeChapter?.title ?? "Chapter 1",
|
|
||||||
chapterLines,
|
|
||||||
characterNames,
|
|
||||||
});
|
});
|
||||||
parsedSuggestions = fallback.suggestions;
|
|
||||||
|
const { result } = await controller.collect(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemPromptWithLines,
|
||||||
|
},
|
||||||
|
...messagesWithContext,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
temperature: activeAgent.temperature,
|
||||||
|
maxTokens: activeAgent.maxTokens,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
rawModelText = result.text.trim();
|
||||||
|
rawReasoning = result.reasoning;
|
||||||
|
} catch {
|
||||||
|
// Fallback to Host Server AI Proxy (/api/ai/chat) so providers running on
|
||||||
|
// the Host Machine (e.g. Ollama/llama.cpp on localhost) work from remote
|
||||||
|
// browsers and mobile devices without CORS or localhost errors.
|
||||||
|
const proxyRes = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind: activeProvider.kind,
|
||||||
|
baseUrl: activeProvider.baseUrl,
|
||||||
|
apiKey: activeProvider.apiKey,
|
||||||
|
model: activeModel,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPromptWithLines },
|
||||||
|
...messagesWithContext,
|
||||||
|
],
|
||||||
|
temperature: activeAgent.temperature,
|
||||||
|
maxTokens: activeAgent.maxTokens,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!proxyRes.ok) {
|
||||||
|
throw new Error(`AI proxy returned ${proxyRes.status}`);
|
||||||
|
}
|
||||||
|
const proxyJson = (await proxyRes.json()) as {
|
||||||
|
text?: string;
|
||||||
|
reasoning?: string;
|
||||||
|
};
|
||||||
|
rawModelText = (proxyJson.text ?? "").trim();
|
||||||
|
rawReasoning = proxyJson.reasoning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rawReasoning) {
|
||||||
|
setThinkingTrace(rawReasoning);
|
||||||
|
}
|
||||||
|
const extracted = parseSuggestionBlocks(rawModelText, chapterLines);
|
||||||
|
assistantReply = extracted.cleanText || rawModelText;
|
||||||
|
parsedSuggestions = extracted.parsed;
|
||||||
} catch {
|
} catch {
|
||||||
const fallback = buildLineBasedFallback({
|
const fallback = buildLineBasedFallback({
|
||||||
agentName: activeAgent.name,
|
agentName: activeAgent.name,
|
||||||
@@ -839,12 +900,18 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
});
|
});
|
||||||
setThinkingTrace(fallback.thinkingText);
|
setThinkingTrace(fallback.thinkingText);
|
||||||
setThinkingStage("Formulating line-numbered suggestions…");
|
setThinkingStage("Formulating line-numbered suggestions…");
|
||||||
// Ensure a visible thinking phase so the user sees the model working through the prompt
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
await new Promise((r) => setTimeout(r, 650));
|
|
||||||
assistantReply = fallback.replyText;
|
assistantReply = fallback.replyText;
|
||||||
parsedSuggestions = fallback.suggestions;
|
parsedSuggestions = fallback.suggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear previous pending suggestions in this session before adding fresh ones
|
||||||
|
// so suggestions always refresh cleanly on every new prompt and never duplicate.
|
||||||
|
if (typeof aiStore.clearPendingSuggestions === "function") {
|
||||||
|
await aiStore.clearPendingSuggestions(sessionId);
|
||||||
|
}
|
||||||
|
setFocusedIndex(0);
|
||||||
|
|
||||||
for (const item of parsedSuggestions) {
|
for (const item of parsedSuggestions) {
|
||||||
await aiStore.addSuggestion(sessionId, {
|
await aiStore.addSuggestion(sessionId, {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -127,138 +127,144 @@ export function EntitiesView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}>
|
<div className="ps-entities-view">
|
||||||
<div className="ps-entity-toolbar">
|
<div className="ps-entities-pinned-header">
|
||||||
<div className="ps-entity-tabs">
|
<div className="ps-entity-toolbar">
|
||||||
{TABS.map((t) => (
|
<div className="ps-entity-tabs">
|
||||||
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
{TABS.map((t) => (
|
||||||
<button
|
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
||||||
type="button"
|
|
||||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
{t.createKind && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ps-entity-tab-add"
|
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||||
title={t.createLabel ?? `Add to ${t.label}`}
|
onClick={() => setTab(t.id)}
|
||||||
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void handleCreateKind(t.createKind!, t.id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
+
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{t.createKind && (
|
||||||
</div>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</div>
|
className="ps-entity-tab-add"
|
||||||
|
title={t.createLabel ?? `Add to ${t.label}`}
|
||||||
<div className="ps-entity-new-group">
|
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
||||||
{activeTabDef.createKind ? (
|
onClick={(e) => {
|
||||||
<Button
|
e.stopPropagation();
|
||||||
variant="primary"
|
void handleCreateKind(t.createKind!, t.id);
|
||||||
size="sm"
|
}}
|
||||||
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
>
|
||||||
>
|
+
|
||||||
{activeTabDef.createLabel}
|
</button>
|
||||||
</Button>
|
)}
|
||||||
) : tab === "all" ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleCreateKind("character", "people")}
|
|
||||||
>
|
|
||||||
+ Person
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleCreateKind("world", "places")}
|
|
||||||
>
|
|
||||||
+ World Item
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleCreateKind("note", "notes")}
|
|
||||||
>
|
|
||||||
+ Note
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ps-entity-search">
|
|
||||||
<Input
|
|
||||||
className="ps-entity-search-input"
|
|
||||||
placeholder="Search people, places, world items, and notes…"
|
|
||||||
value={query}
|
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "consistency" ? (
|
|
||||||
<ConsistencyPanel
|
|
||||||
issues={issues}
|
|
||||||
onOpenEntity={(id) => {
|
|
||||||
const e = entities.find((x) => x.id === id);
|
|
||||||
if (e) {
|
|
||||||
setDraft(e);
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : visible.length === 0 ? (
|
|
||||||
<div className="ps-emptystate">
|
|
||||||
<div className="ps-emptystate-title">No entities in this view yet</div>
|
|
||||||
<p className="ps-muted">
|
|
||||||
{activeTabDef.createKind
|
|
||||||
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
|
|
||||||
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="ps-entity-grid">
|
|
||||||
{visible.map((e) => (
|
|
||||||
<button
|
|
||||||
key={e.id}
|
|
||||||
type="button"
|
|
||||||
className="ps-entity-card"
|
|
||||||
onClick={() => openEntity(e)}
|
|
||||||
>
|
|
||||||
<div className="ps-entity-card-top">
|
|
||||||
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
))}
|
||||||
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
</div>
|
||||||
{tagsFor(e).length > 0 && (
|
|
||||||
<div className="ps-entity-card-tags">
|
|
||||||
{tagsFor(e).slice(0, 4).map((t) => (
|
|
||||||
<span key={t} className="ps-entity-tag">
|
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EntityEditor
|
<div className="ps-entity-new-group">
|
||||||
entity={draft}
|
{activeTabDef.createKind ? (
|
||||||
entities={entities}
|
<Button
|
||||||
open={open}
|
variant="primary"
|
||||||
onClose={() => setOpen(false)}
|
size="sm"
|
||||||
onSave={save}
|
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
||||||
onDelete={remove}
|
>
|
||||||
/>
|
{activeTabDef.createLabel}
|
||||||
|
</Button>
|
||||||
|
) : tab === "all" ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateKind("character", "people")}
|
||||||
|
>
|
||||||
|
+ Person
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateKind("world", "places")}
|
||||||
|
>
|
||||||
|
+ World Item
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateKind("note", "notes")}
|
||||||
|
>
|
||||||
|
+ Note
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-entity-search">
|
||||||
|
<Input
|
||||||
|
className="ps-entity-search-input"
|
||||||
|
placeholder="Search people, places, world items, and notes…"
|
||||||
|
value={query}
|
||||||
|
onChange={(ev) => setQuery(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-entities-body">
|
||||||
|
<div className="ps-entities-main">
|
||||||
|
{tab === "consistency" ? (
|
||||||
|
<ConsistencyPanel
|
||||||
|
issues={issues}
|
||||||
|
onOpenEntity={(id) => {
|
||||||
|
const e = entities.find((x) => x.id === id);
|
||||||
|
if (e) {
|
||||||
|
setDraft(e);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<div className="ps-emptystate">
|
||||||
|
<div className="ps-emptystate-title">No entities in this view yet</div>
|
||||||
|
<p className="ps-muted">
|
||||||
|
{activeTabDef.createKind
|
||||||
|
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
|
||||||
|
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ps-entity-grid">
|
||||||
|
{visible.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
className={`ps-entity-card${open && draft?.id === e.id ? " is-selected" : ""}`}
|
||||||
|
onClick={() => openEntity(e)}
|
||||||
|
>
|
||||||
|
<div className="ps-entity-card-top">
|
||||||
|
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
||||||
|
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
||||||
|
{tagsFor(e).length > 0 && (
|
||||||
|
<div className="ps-entity-card-tags">
|
||||||
|
{tagsFor(e).slice(0, 4).map((t) => (
|
||||||
|
<span key={t} className="ps-entity-tag">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EntityEditor
|
||||||
|
entity={draft}
|
||||||
|
entities={entities}
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onSave={save}
|
||||||
|
onDelete={remove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function EntityEditor({
|
|||||||
|
|
||||||
const isExisting = entities.some((e) => e.id === draft.id);
|
const isExisting = entities.some((e) => e.id === draft.id);
|
||||||
const isCharacterDraft = isCharacter(draft);
|
const isCharacterDraft = isCharacter(draft);
|
||||||
const canDelete = isExisting && isCharacterDraft;
|
const canDelete = isExisting;
|
||||||
|
|
||||||
const title = isCharacterDraft
|
const title = isCharacterDraft
|
||||||
? (draft.name || "Edit character")
|
? (draft.name || "Edit character")
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Entity, FieldDef } from "@pubscript/core";
|
import type { Entity, FieldDef } from "@pubscript/core";
|
||||||
import { ENTITY_FIELDS } from "@pubscript/core";
|
import { ENTITY_FIELDS } from "@pubscript/core";
|
||||||
import { Input } from "@pubscript/ui";
|
|
||||||
import { Select } from "@pubscript/ui";
|
|
||||||
|
|
||||||
/** A short display label for an entity, regardless of kind. */
|
/** A short display label for an entity, regardless of kind. */
|
||||||
export function entityLabel(e: Entity): string {
|
export function entityLabel(e: Entity): string {
|
||||||
@@ -23,7 +21,9 @@ export function entityLabel(e: Entity): string {
|
|||||||
/** Read a field value from an entity, regardless of where it is stored. */
|
/** Read a field value from an entity, regardless of where it is stored. */
|
||||||
export function getFieldValue(e: Entity, key: string): string | string[] {
|
export function getFieldValue(e: Entity, key: string): string | string[] {
|
||||||
if ("fields" in e) {
|
if ("fields" in e) {
|
||||||
|
if (key === "name") return e.name;
|
||||||
const v = e.fields[key];
|
const v = e.fields[key];
|
||||||
|
if (Array.isArray(v)) return v;
|
||||||
return typeof v === "string" ? v : "";
|
return typeof v === "string" ? v : "";
|
||||||
}
|
}
|
||||||
const v = (e as unknown as Record<string, unknown>)[key];
|
const v = (e as unknown as Record<string, unknown>)[key];
|
||||||
@@ -34,6 +34,9 @@ export function getFieldValue(e: Entity, key: string): string | string[] {
|
|||||||
/** Return a copy of `e` with `key` set to `value`. */
|
/** Return a copy of `e` with `key` set to `value`. */
|
||||||
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
||||||
if ("fields" in e) {
|
if ("fields" in e) {
|
||||||
|
if (key === "name" && typeof value === "string") {
|
||||||
|
return { ...e, name: value };
|
||||||
|
}
|
||||||
return { ...e, fields: { ...e.fields, [key]: value } };
|
return { ...e, fields: { ...e.fields, [key]: value } };
|
||||||
}
|
}
|
||||||
return { ...e, [key]: value };
|
return { ...e, [key]: value };
|
||||||
@@ -52,69 +55,66 @@ interface EntityFieldsProps {
|
|||||||
entities?: Entity[];
|
entities?: Entity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderField(
|
function renderFieldControl(
|
||||||
field: FieldDef,
|
field: FieldDef,
|
||||||
value: string | string[],
|
value: string | string[],
|
||||||
onChange: (v: string | string[]) => void,
|
onChange: (v: string | string[]) => void,
|
||||||
|
currentEntityId: string,
|
||||||
entities?: Entity[],
|
entities?: Entity[],
|
||||||
) {
|
) {
|
||||||
const label = (
|
|
||||||
<label className="ps-field-label">
|
|
||||||
{field.label}
|
|
||||||
{field.hint ? <span className="ps-field-hint">{field.hint}</span> : null}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case "textarea":
|
case "textarea":
|
||||||
return (
|
return (
|
||||||
<div className="ps-field-span-2">
|
<textarea
|
||||||
{label}
|
className="ps-textarea ps-input"
|
||||||
<textarea
|
rows={3}
|
||||||
className="ps-textarea ps-input"
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return (
|
||||||
<div>
|
<select
|
||||||
{label}
|
className="ps-select"
|
||||||
<Select
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
>
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
{(field.options ?? []).map((o) => (
|
||||||
/>
|
<option key={o} value={o}>
|
||||||
</div>
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
);
|
);
|
||||||
case "list":
|
case "list":
|
||||||
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
||||||
case "link": {
|
case "link": {
|
||||||
const opts = (entities ?? [])
|
const opts = (entities ?? [])
|
||||||
.filter((e) => e.id !== value)
|
.filter((e) => e.id !== currentEntityId && !("body" in e))
|
||||||
.map((e) => ({ value: e.id, label: entityLabel(e) }));
|
.map((e) => ({ value: e.id, label: entityLabel(e) }));
|
||||||
return (
|
return (
|
||||||
<div>
|
<select
|
||||||
{label}
|
className="ps-select"
|
||||||
<Select
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
options={[{ value: "", label: "— none —" }, ...opts]}
|
>
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
<option value="">— None —</option>
|
||||||
/>
|
{opts.map((o) => (
|
||||||
</div>
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "text":
|
case "text":
|
||||||
return (
|
return (
|
||||||
<div className="ps-field-span-2">
|
<input
|
||||||
{label}
|
type="text"
|
||||||
<Input
|
className="ps-input"
|
||||||
className="ps-input"
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,25 +139,27 @@ function TagField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-tag-field">
|
<div className="ps-tag-field">
|
||||||
<div className="ps-tag-list">
|
{value.length > 0 && (
|
||||||
{value.map((t, i) => (
|
<div className="ps-tag-list">
|
||||||
<span key={`${t}-${i}`} className="ps-tag-chip">
|
{value.map((t, i) => (
|
||||||
{t}
|
<span key={`${t}-${i}`} className="ps-tag-chip">
|
||||||
<button
|
{t}
|
||||||
type="button"
|
<button
|
||||||
className="ps-tag-remove"
|
type="button"
|
||||||
aria-label={`Remove ${t}`}
|
className="ps-tag-remove"
|
||||||
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
aria-label={`Remove ${t}`}
|
||||||
>
|
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
</span>
|
</button>
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="ps-input"
|
className="ps-input"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="Add tag + Enter"
|
placeholder="Add item + press Enter"
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === "Enter") {
|
if (ev.key === "Enter") {
|
||||||
@@ -173,18 +175,31 @@ function TagField({
|
|||||||
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
|
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
|
||||||
const fields = fieldsFor(entity);
|
const fields = fieldsFor(entity);
|
||||||
|
|
||||||
|
const handleFieldChange = (key: string, val: string | string[]) => {
|
||||||
|
const updated = setFieldValue(entity, key, val);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-entity-fields">
|
<div className="ps-entity-fields">
|
||||||
{fields.map((field) => (
|
{fields.map((field) => {
|
||||||
<div key={field.key} className="ps-field">
|
const isCompact = field.type === "select" || field.type === "link";
|
||||||
{renderField(
|
return (
|
||||||
field,
|
<div
|
||||||
getFieldValue(entity, field.key),
|
key={field.key}
|
||||||
(v) => onChange({ [field.key]: v } as Partial<Entity>),
|
className={`ps-field${isCompact ? " ps-field-half" : " ps-field-full"}`}
|
||||||
entities,
|
>
|
||||||
)}
|
<label className="ps-field-label">{field.label}</label>
|
||||||
</div>
|
{renderFieldControl(
|
||||||
))}
|
field,
|
||||||
|
getFieldValue(entity, field.key),
|
||||||
|
(v) => handleFieldChange(field.key, v),
|
||||||
|
entity.id,
|
||||||
|
entities,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Modal, Button } from "@pubscript/ui";
|
import { Modal, Button, toast } from "@pubscript/ui";
|
||||||
import {
|
import { exportProjectData, type ExportFormat } from "@pubscript/exporters";
|
||||||
THEMES,
|
|
||||||
exportProjectData,
|
|
||||||
type ExportFormat,
|
|
||||||
} from "@pubscript/exporters";
|
|
||||||
import { useProjectStore } from "../../store/project-store.js";
|
import { useProjectStore } from "../../store/project-store.js";
|
||||||
|
import { useSettings } from "../../store/settings.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import { DirectoryBrowserModal } from "../../components/DirectoryBrowserModal.js";
|
||||||
import { buildExportProject } from "./buildExportProject.js";
|
import { buildExportProject } from "./buildExportProject.js";
|
||||||
import { downloadFile } from "./download.js";
|
import { downloadFile } from "./download.js";
|
||||||
import { EXPORT_FORMATS, type FormatInfo } from "./formats.js";
|
import { EXPORT_FORMATS, type FormatInfo } from "./formats.js";
|
||||||
@@ -19,27 +18,66 @@ type Scope = "project" | "chapter";
|
|||||||
|
|
||||||
/** Collapse a string into a filesystem-safe slug. */
|
/** Collapse a string into a filesystem-safe slug. */
|
||||||
function slug(value: string): string {
|
function slug(value: string): string {
|
||||||
return value
|
return (
|
||||||
.trim()
|
value
|
||||||
.replace(/[^\w-]+/g, "-")
|
.trim()
|
||||||
.replace(/-+/g, "-")
|
.toLowerCase()
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/[^\w-]+/g, "-")
|
||||||
.slice(0, 60) || "project";
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60) || "manuscript"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||||
const adapter = useProjectStore((s) => s.adapter);
|
const adapter = useProjectStore((s) => s.adapter);
|
||||||
const projectId = useProjectStore((s) => s.project?.projectId);
|
const project = useProjectStore((s) => s.project);
|
||||||
|
const projectId = project?.id;
|
||||||
const chapters = useProjectStore((s) => s.chapters);
|
const chapters = useProjectStore((s) => s.chapters);
|
||||||
|
|
||||||
const [format, setFormat] = useState<ExportFormat>("markdown");
|
const projectDirectory = useSettings((s) => s.projectDirectory);
|
||||||
const [themeId, setThemeId] = useState("ink");
|
const serverPort = useSettings((s) => s.serverPort);
|
||||||
|
|
||||||
|
const [format, setFormat] = useState<ExportFormat>("epub");
|
||||||
const [scope, setScope] = useState<Scope>("project");
|
const [scope, setScope] = useState<Scope>("project");
|
||||||
const [chapterId, setChapterId] = useState<string>("");
|
const [chapterId, setChapterId] = useState<string>("");
|
||||||
|
const [fileName, setFileName] = useState<string>("");
|
||||||
|
const [saveDir, setSaveDir] = useState<string>(projectDirectory || "~/Documents/PubScript");
|
||||||
|
const [alsoDownloadBrowser, setAlsoDownloadBrowser] = useState<boolean>(false);
|
||||||
|
const [browseOpen, setBrowseOpen] = useState<boolean>(false);
|
||||||
const [books, setBooks] = useState<{ id: string; title: string }[]>([]);
|
const [books, setBooks] = useState<{ id: string; title: string }[]>([]);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const formatInfo: FormatInfo | undefined = useMemo(
|
||||||
|
() => EXPORT_FORMATS.find((f) => f.id === format),
|
||||||
|
[format],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize default fileName and saveDir when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const ext = formatInfo?.extension ?? "epub";
|
||||||
|
const base = slug(project?.title || "manuscript");
|
||||||
|
setFileName(`${base}.${ext}`);
|
||||||
|
setSaveDir(projectDirectory || "~/Documents/PubScript");
|
||||||
|
setError(null);
|
||||||
|
}, [open, project?.title, projectDirectory]);
|
||||||
|
|
||||||
|
// Update file extension when conversion format changes
|
||||||
|
const handleFormatChange = (nextFormat: ExportFormat) => {
|
||||||
|
setFormat(nextFormat);
|
||||||
|
const nextExt = EXPORT_FORMATS.find((f) => f.id === nextFormat)?.extension ?? "bin";
|
||||||
|
setFileName((prev) => {
|
||||||
|
const trimmed = prev.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return `${slug(project?.title || "manuscript")}.${nextExt}`;
|
||||||
|
}
|
||||||
|
const withoutExt = trimmed.replace(/\.[a-z0-9]+$/i, "");
|
||||||
|
return `${withoutExt}.${nextExt}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Populate the book/chapter selectors when the dialog opens.
|
// Populate the book/chapter selectors when the dialog opens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !adapter || !projectId) return;
|
if (!open || !adapter || !projectId) return;
|
||||||
@@ -48,7 +86,6 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
const allBooks = await adapter.listBooks(projectId);
|
const allBooks = await adapter.listBooks(projectId);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setBooks(allBooks);
|
setBooks(allBooks);
|
||||||
// Resolve the first chapter across books so the selector has a default.
|
|
||||||
let firstChapterId = "";
|
let firstChapterId = "";
|
||||||
for (const book of allBooks) {
|
for (const book of allBooks) {
|
||||||
const cs = await adapter.listChapters(book.id);
|
const cs = await adapter.listChapters(book.id);
|
||||||
@@ -69,30 +106,55 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
const chapterOptions = useMemo(() => {
|
const chapterOptions = useMemo(() => {
|
||||||
const byBook = new Map<string, { id: string; title: string }[]>();
|
const byBook = new Map<string, { id: string; title: string }[]>();
|
||||||
for (const book of books) {
|
for (const book of books) {
|
||||||
byBook.set(book.id, chapters.filter((c) => c.bookId === book.id));
|
byBook.set(
|
||||||
|
book.id,
|
||||||
|
chapters.filter((c) => c.bookId === book.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return byBook;
|
return byBook;
|
||||||
}, [books, chapters]);
|
}, [books, chapters]);
|
||||||
|
|
||||||
const formatInfo: FormatInfo | undefined = useMemo(
|
|
||||||
() => EXPORT_FORMATS.find((f) => f.id === format),
|
|
||||||
[format]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!adapter || !projectId) return;
|
if (!adapter || !projectId) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const project = await buildExportProject(adapter, projectId);
|
const exportProj = await buildExportProject(adapter, projectId);
|
||||||
const options: Parameters<typeof exportProjectData>[2] = {
|
const options: Parameters<typeof exportProjectData>[2] = {
|
||||||
scope: scope === "chapter" ? "chapter" : "project",
|
scope: scope === "chapter" ? "chapter" : "project",
|
||||||
theme: themeId,
|
|
||||||
};
|
};
|
||||||
if (scope === "chapter") options.scopeId = chapterId;
|
if (scope === "chapter") options.scopeId = chapterId;
|
||||||
const data = await exportProjectData(project, format, options);
|
const data = await exportProjectData(exportProj, format, options);
|
||||||
const ext = formatInfo?.extension ?? "bin";
|
const ext = formatInfo?.extension ?? "bin";
|
||||||
downloadFile(`${slug(projectId)}.${ext}`, data, formatInfo?.mime ?? "application/octet-stream");
|
const rawName = fileName.trim() || `${slug(project?.title || "manuscript")}.${ext}`;
|
||||||
|
const finalFileName = rawName.includes(".") ? rawName : `${rawName}.${ext}`;
|
||||||
|
|
||||||
|
const saved = await api.saveExportToDirectory(
|
||||||
|
{
|
||||||
|
directory: saveDir.trim() || projectDirectory || "~/Documents/PubScript",
|
||||||
|
fileName: finalFileName,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
serverPort,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saved.savedOnServer && saved.savedPath) {
|
||||||
|
if (alsoDownloadBrowser) {
|
||||||
|
downloadFile(
|
||||||
|
finalFileName,
|
||||||
|
data,
|
||||||
|
formatInfo?.mime ?? "application/octet-stream",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.success(`Saved on host machine: ${saved.savedPath}`);
|
||||||
|
} else {
|
||||||
|
downloadFile(
|
||||||
|
finalFileName,
|
||||||
|
data,
|
||||||
|
formatInfo?.mime ?? "application/octet-stream",
|
||||||
|
);
|
||||||
|
toast.success(`Downloaded ${finalFileName}`);
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -102,89 +164,144 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<>
|
||||||
title="Export project"
|
<Modal
|
||||||
open={open}
|
title="Export Manuscript"
|
||||||
onClose={onClose}
|
open={open}
|
||||||
footer={
|
onClose={onClose}
|
||||||
<>
|
footer={
|
||||||
<Button variant="ghost" onClick={onClose} disabled={exporting}>
|
<>
|
||||||
Cancel
|
<Button variant="ghost" onClick={onClose} disabled={exporting}>
|
||||||
</Button>
|
Cancel
|
||||||
<Button onClick={handleExport} disabled={exporting || !formatInfo}>
|
</Button>
|
||||||
{exporting ? "Exporting…" : "Export"}
|
<Button
|
||||||
</Button>
|
variant="primary"
|
||||||
</>
|
onClick={() => void handleExport()}
|
||||||
}
|
disabled={exporting || !formatInfo}
|
||||||
>
|
>
|
||||||
<div className="ps-export-grid">
|
{exporting ? "Exporting…" : "Export"}
|
||||||
<label className="ps-export-field">
|
</Button>
|
||||||
<span>Format</span>
|
</>
|
||||||
<select
|
}
|
||||||
className="ps-select"
|
>
|
||||||
value={format}
|
<div className="ps-export-grid" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
disabled={exporting}
|
<span style={{ fontSize: 12.5, fontWeight: 600 }}>File Type / Conversion Format</span>
|
||||||
>
|
|
||||||
{EXPORT_FORMATS.map((f) => (
|
|
||||||
<option key={f.id} value={f.id}>
|
|
||||||
{f.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="ps-export-field">
|
|
||||||
<span>Theme</span>
|
|
||||||
<select
|
|
||||||
className="ps-select"
|
|
||||||
value={themeId}
|
|
||||||
onChange={(e) => setThemeId(e.target.value)}
|
|
||||||
disabled={exporting}
|
|
||||||
>
|
|
||||||
{Object.entries(THEMES).map(([id, t]) => (
|
|
||||||
<option key={id} value={id}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="ps-export-field">
|
|
||||||
<span>Scope</span>
|
|
||||||
<select
|
|
||||||
className="ps-select"
|
|
||||||
value={scope}
|
|
||||||
onChange={(e) => setScope(e.target.value as Scope)}
|
|
||||||
disabled={exporting}
|
|
||||||
>
|
|
||||||
<option value="project">Entire book</option>
|
|
||||||
<option value="chapter">Selected chapter</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{scope === "chapter" && (
|
|
||||||
<label className="ps-export-field">
|
|
||||||
<span>Chapter</span>
|
|
||||||
<select
|
<select
|
||||||
className="ps-select"
|
className="ps-select"
|
||||||
value={chapterId}
|
value={format}
|
||||||
onChange={(e) => setChapterId(e.target.value)}
|
onChange={(e) => handleFormatChange(e.target.value as ExportFormat)}
|
||||||
disabled={exporting || chapterOptions.size === 0}
|
disabled={exporting}
|
||||||
>
|
>
|
||||||
{Array.from(chapterOptions.values()).flatMap((cs) =>
|
{EXPORT_FORMATS.map((f) => (
|
||||||
cs.map((c) => (
|
<option key={f.id} value={f.id}>
|
||||||
<option key={c.id} value={c.id}>
|
{f.label} (.{f.extension})
|
||||||
{c.title}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="ps-export-error" role="alert">{error}</p>}
|
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
</Modal>
|
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Scope</span>
|
||||||
|
<select
|
||||||
|
className="ps-select"
|
||||||
|
value={scope}
|
||||||
|
onChange={(e) => setScope(e.target.value as Scope)}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
<option value="project">Entire book</option>
|
||||||
|
<option value="chapter">Selected chapter</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{scope === "chapter" && (
|
||||||
|
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Chapter</span>
|
||||||
|
<select
|
||||||
|
className="ps-select"
|
||||||
|
value={chapterId}
|
||||||
|
onChange={(e) => setChapterId(e.target.value)}
|
||||||
|
disabled={exporting || chapterOptions.size === 0}
|
||||||
|
>
|
||||||
|
{Array.from(chapterOptions.values()).flatMap((cs) =>
|
||||||
|
cs.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Exported File Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ps-input"
|
||||||
|
value={fileName}
|
||||||
|
onChange={(e) => setFileName(e.target.value)}
|
||||||
|
placeholder={`manuscript.${formatInfo?.extension ?? "epub"}`}
|
||||||
|
disabled={exporting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Save Directory on Host Machine</span>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ps-input"
|
||||||
|
style={{ flex: 1, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
|
||||||
|
value={saveDir}
|
||||||
|
onChange={(e) => setSaveDir(e.target.value)}
|
||||||
|
placeholder="~/Documents/PubScript"
|
||||||
|
disabled={exporting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setBrowseOpen(true)}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
📁 Browse Host…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ps-text-muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alsoDownloadBrowser}
|
||||||
|
onChange={(e) => setAlsoDownloadBrowser(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Also download a copy to this browser device</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="ps-export-error" role="alert" style={{ color: "var(--ps-danger)", marginTop: 10 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<DirectoryBrowserModal
|
||||||
|
open={browseOpen}
|
||||||
|
initialPath={saveDir}
|
||||||
|
title="Choose Export Save Directory"
|
||||||
|
onSelect={(dir) => setSaveDir(dir)}
|
||||||
|
onClose={() => setBrowseOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Badge, Button, Modal, toast } from "@pubscript/ui";
|
||||||
|
import {
|
||||||
|
importManuscriptFile,
|
||||||
|
SUPPORTED_IMPORT_EXTENSIONS,
|
||||||
|
type ImportedManuscript,
|
||||||
|
} from "@pubscript/exporters";
|
||||||
|
import { useProjectStore } from "../../store/project-store.js";
|
||||||
|
|
||||||
|
export interface ImportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
defaultMode?: "new-project" | "append-chapters";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
defaultMode = "new-project",
|
||||||
|
}: ImportDialogProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentProject = useProjectStore((s) => s.project);
|
||||||
|
const importManuscript = useProjectStore((s) => s.importManuscript);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [rawBytes, setRawBytes] = useState<Uint8Array | null>(null);
|
||||||
|
const [parsed, setParsed] = useState<ImportedManuscript | null>(null);
|
||||||
|
const [mode, setMode] = useState<"new-project" | "append-chapters">(defaultMode);
|
||||||
|
const [customTitle, setCustomTitle] = useState("");
|
||||||
|
const [splitChapters, setSplitChapters] = useState(true);
|
||||||
|
const [parsing, setParsing] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const runParse = async (fileName: string, bytes: Uint8Array, split: boolean) => {
|
||||||
|
setParsing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await importManuscriptFile(fileName, bytes, {
|
||||||
|
splitChapters: split,
|
||||||
|
});
|
||||||
|
setParsed(res);
|
||||||
|
setCustomTitle((prev) => prev || res.title);
|
||||||
|
} catch (err) {
|
||||||
|
setParsed(null);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to parse manuscript file.");
|
||||||
|
} finally {
|
||||||
|
setParsing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setSelectedFile(file);
|
||||||
|
setCustomTitle("");
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
setRawBytes(bytes);
|
||||||
|
await runParse(file.name, bytes, splitChapters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleSplit = async (nextSplit: boolean) => {
|
||||||
|
setSplitChapters(nextSplit);
|
||||||
|
if (selectedFile && rawBytes) {
|
||||||
|
await runParse(selectedFile.name, rawBytes, nextSplit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmImport = async () => {
|
||||||
|
if (!parsed) return;
|
||||||
|
setImporting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await importManuscript(parsed, {
|
||||||
|
mode: currentProject ? mode : "new-project",
|
||||||
|
title: customTitle.trim() || parsed.title,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
mode === "append-chapters" && currentProject
|
||||||
|
? `Imported ${parsed.chapters.length} chapter(s) (${parsed.totalWords.toLocaleString()} words) into "${currentProject.title}"`
|
||||||
|
: `Created project "${customTitle.trim() || parsed.title}" (${parsed.chapters.length} chapter(s), ${parsed.totalWords.toLocaleString()} words)`,
|
||||||
|
);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setRawBytes(null);
|
||||||
|
setParsed(null);
|
||||||
|
setCustomTitle("");
|
||||||
|
onClose();
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to import manuscript.");
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.dataTransfer.files?.[0];
|
||||||
|
if (f) void handleFileChange(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Import Manuscript from File"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={importing}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void handleConfirmImport()}
|
||||||
|
disabled={!parsed || parsing || importing}
|
||||||
|
>
|
||||||
|
{importing
|
||||||
|
? "Converting & Saving…"
|
||||||
|
: mode === "append-chapters" && currentProject
|
||||||
|
? `Import ${parsed?.chapters.length ?? 0} Chapter(s)`
|
||||||
|
: "Import as New Project"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
border: "2px dashed var(--ps-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "20px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
background: "var(--ps-bg)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={SUPPORTED_IMPORT_EXTENSIONS.join(",")}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => void handleFileChange(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 22, marginBottom: 6 }}>📥</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5, marginBottom: 4 }}>
|
||||||
|
{selectedFile
|
||||||
|
? `${selectedFile.name} (${(selectedFile.size / 1024).toFixed(1)} KB)`
|
||||||
|
: "Click to choose a file or drag & drop here"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
|
||||||
|
Compatible formats: <strong>.docx</strong> (Word / OnlyOffice), <strong>.odt</strong>{" "}
|
||||||
|
(OpenOffice / LibreOffice / OnlyOffice), <strong>.epub</strong>, <strong>.md</strong>,{" "}
|
||||||
|
<strong>.txt</strong>, <strong>.html</strong>, <strong>.rtf</strong>,{" "}
|
||||||
|
<strong>.json</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--ps-danger)",
|
||||||
|
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
|
||||||
|
color: "var(--ps-danger)",
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsing && (
|
||||||
|
<div style={{ fontSize: 12.5, color: "var(--ps-text-muted)", textAlign: "center" }}>
|
||||||
|
Running PubScript format conversion pipeline…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed && !parsing && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid var(--ps-border)",
|
||||||
|
background: "var(--ps-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Badge variant="accent">{parsed.formatLabel}</Badge>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ps-text-muted)" }}>
|
||||||
|
{parsed.chapters.length} chapter(s) · {parsed.totalWords.toLocaleString()} words
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{parsed.author && (
|
||||||
|
<span style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
|
||||||
|
Author: {parsed.author}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentProject && (
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label">Import Destination</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("new-project")}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border:
|
||||||
|
mode === "new-project"
|
||||||
|
? "2px solid var(--ps-accent)"
|
||||||
|
: "1px solid var(--ps-border)",
|
||||||
|
background:
|
||||||
|
mode === "new-project"
|
||||||
|
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
|
||||||
|
: "var(--ps-bg)",
|
||||||
|
color: "var(--ps-text)",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: mode === "new-project" ? 600 : 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ Create New Project
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("append-chapters")}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border:
|
||||||
|
mode === "append-chapters"
|
||||||
|
? "2px solid var(--ps-accent)"
|
||||||
|
: "1px solid var(--ps-border)",
|
||||||
|
background:
|
||||||
|
mode === "append-chapters"
|
||||||
|
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
|
||||||
|
: "var(--ps-bg)",
|
||||||
|
color: "var(--ps-text)",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: mode === "append-chapters" ? 600 : 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📖 Add Chapters to “{currentProject.title}”
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "new-project" && (
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label" htmlFor="ps-import-title">
|
||||||
|
Project Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ps-import-title"
|
||||||
|
type="text"
|
||||||
|
className="ps-input"
|
||||||
|
value={customTitle}
|
||||||
|
onChange={(e) => setCustomTitle(e.target.value)}
|
||||||
|
placeholder="Manuscript Title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed.format !== "epub" && parsed.format !== "json" && (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12.5,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={splitChapters}
|
||||||
|
onChange={(e) => void handleToggleSplit(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Automatically split into chapters on headings (<code>H1</code> /{" "}
|
||||||
|
<code>Chapter N</code>)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--ps-text-muted)",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Converted Chapters Preview ({parsed.chapters.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 160,
|
||||||
|
overflowY: "auto",
|
||||||
|
border: "1px solid var(--ps-border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--ps-bg)",
|
||||||
|
padding: 6,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsed.chapters.map((ch, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "5px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12.5,
|
||||||
|
background: "var(--ps-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>
|
||||||
|
{idx + 1}. {ch.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--ps-font-mono)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: "var(--ps-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ch.wordCount.toLocaleString()} words
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui";
|
import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui";
|
||||||
import { useProjectStore } from "../store/project-store.js";
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
||||||
|
import { ImportDialog } from "../features/import/ImportDialog.js";
|
||||||
|
|
||||||
type WizardMode = "tutorial" | "manual";
|
type WizardMode = "tutorial" | "manual";
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ export function Projects() {
|
|||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [wizardMode, setWizardMode] = useState<WizardMode>("tutorial");
|
const [wizardMode, setWizardMode] = useState<WizardMode>("tutorial");
|
||||||
const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1);
|
const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1);
|
||||||
const [answers, setAnswers] = useState<TutorialAnswers>({
|
const [answers, setAnswers] = useState<TutorialAnswers>({
|
||||||
@@ -200,9 +202,14 @@ export function Projects() {
|
|||||||
Switch between manuscripts, start a guided story template, or manage existing projects.
|
Switch between manuscripts, start a guided story template, or manage existing projects.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={openWizard}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
+ New project
|
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||||
</Button>
|
📥 Import from File
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={openWizard}>
|
||||||
|
+ New project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
@@ -613,6 +620,11 @@ export function Projects() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
<ImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
defaultMode="new-project"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import {
|
import {
|
||||||
createAutosave,
|
createAutosave,
|
||||||
createPlotBeat,
|
createPlotBeat,
|
||||||
|
createServerClient,
|
||||||
type AutosaveState,
|
type AutosaveState,
|
||||||
type Board,
|
type Board,
|
||||||
type Chapter,
|
type Chapter,
|
||||||
@@ -21,9 +22,12 @@ import {
|
|||||||
type ProjectMeta,
|
type ProjectMeta,
|
||||||
type StorageAdapter,
|
type StorageAdapter,
|
||||||
} from "@pubscript/core";
|
} from "@pubscript/core";
|
||||||
|
import type { ImportedManuscript } from "@pubscript/exporters";
|
||||||
import { storageLocal } from "@pubscript/core/local";
|
import { storageLocal } from "@pubscript/core/local";
|
||||||
import { createUlid } from "@pubscript/core";
|
import { createUlid } from "@pubscript/core";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { getStoredToken } from "../api.js";
|
||||||
|
import { useSettings } from "./settings.js";
|
||||||
import { seedDemoProject } from "./seed.js";
|
import { seedDemoProject } from "./seed.js";
|
||||||
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
||||||
|
|
||||||
@@ -110,6 +114,11 @@ interface ProjectStore {
|
|||||||
openProject: (id: string) => Promise<void>;
|
openProject: (id: string) => Promise<void>;
|
||||||
/** Create a new project with optional structural template and chapter skeleton. */
|
/** Create a new project with optional structural template and chapter skeleton. */
|
||||||
createProjectWithScaffold: (opts: CreateProjectScaffoldOptions) => Promise<ProjectMeta>;
|
createProjectWithScaffold: (opts: CreateProjectScaffoldOptions) => Promise<ProjectMeta>;
|
||||||
|
/** Import a parsed manuscript as either a new project or chapters in the active project. */
|
||||||
|
importManuscript: (
|
||||||
|
parsed: ImportedManuscript,
|
||||||
|
opts: { mode: "new-project" | "append-chapters"; title?: string },
|
||||||
|
) => Promise<void>;
|
||||||
/** Delete a project by id. */
|
/** Delete a project by id. */
|
||||||
deleteProject: (id: string) => Promise<void>;
|
deleteProject: (id: string) => Promise<void>;
|
||||||
|
|
||||||
@@ -123,6 +132,8 @@ interface ProjectStore {
|
|||||||
deleteChapter: (id: string) => Promise<void>;
|
deleteChapter: (id: string) => Promise<void>;
|
||||||
/** Queue a debounced save of a chapter document. */
|
/** Queue a debounced save of a chapter document. */
|
||||||
scheduleSave: (ref: DocumentRef) => void;
|
scheduleSave: (ref: DocumentRef) => void;
|
||||||
|
/** Manually save the active (or specified) chapter immediately (used when autosave is off or on Ctrl+S). */
|
||||||
|
saveChapterNow: (chapterId?: string) => Promise<void>;
|
||||||
/** Flush any pending save immediately (beforeunload / chapter switch). */
|
/** Flush any pending save immediately (beforeunload / chapter switch). */
|
||||||
flushAutosave: () => void;
|
flushAutosave: () => void;
|
||||||
/** Highlight a 1-based line number on the writing surface (or null to clear). */
|
/** Highlight a 1-based line number on the writing surface (or null to clear). */
|
||||||
@@ -295,6 +306,78 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateLocalToHostIfEmpty(serverAdapter: StorageAdapter): Promise<ProjectMeta[]> {
|
||||||
|
let hostProjects = await serverAdapter.listProjects();
|
||||||
|
if (hostProjects.length > 0) return hostProjects;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localProjects = await storageLocal.listProjects();
|
||||||
|
if (localProjects.length > 0) {
|
||||||
|
for (const lp of localProjects) {
|
||||||
|
const created = await serverAdapter.createProject({
|
||||||
|
title: lp.title,
|
||||||
|
cover: lp.cover,
|
||||||
|
});
|
||||||
|
const localBooks = await storageLocal.listBooks(lp.id);
|
||||||
|
for (const lb of localBooks) {
|
||||||
|
const createdBook = await serverAdapter.createBook({
|
||||||
|
projectId: created.id,
|
||||||
|
title: lb.title,
|
||||||
|
});
|
||||||
|
const localChapters = await storageLocal.listChapters(lb.id);
|
||||||
|
for (const lch of localChapters) {
|
||||||
|
await serverAdapter.saveDocument({
|
||||||
|
id: lch.id,
|
||||||
|
projectId: created.id,
|
||||||
|
bookId: createdBook.id,
|
||||||
|
title: lch.title,
|
||||||
|
doc: lch.doc,
|
||||||
|
rev: Math.max(0, (lch.rev ?? 1) - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const localEntities = await storageLocal.listEntities(lp.id);
|
||||||
|
for (const le of localEntities) {
|
||||||
|
await serverAdapter.saveEntity({ ...le, projectId: created.id });
|
||||||
|
}
|
||||||
|
const localBeats = await storageLocal.listPlotBeats(lp.id);
|
||||||
|
for (const lbeat of localBeats) {
|
||||||
|
await serverAdapter.savePlotBeat({ ...lbeat, projectId: created.id });
|
||||||
|
}
|
||||||
|
const localBoard = await storageLocal.getBoard(lp.id);
|
||||||
|
if (localBoard) {
|
||||||
|
await serverAdapter.saveBoard({ ...localBoard, projectId: created.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostProjects = await serverAdapter.listProjects();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore local migration errors */
|
||||||
|
}
|
||||||
|
return hostProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHostOrLocalAdapter(): Promise<{
|
||||||
|
adapter: StorageAdapter;
|
||||||
|
projects: ProjectMeta[];
|
||||||
|
}> {
|
||||||
|
const { serverEnabled } = useSettings.getState();
|
||||||
|
if (serverEnabled) {
|
||||||
|
try {
|
||||||
|
const serverAdapter = createServerClient({
|
||||||
|
baseUrl: "",
|
||||||
|
getToken: () => getStoredToken(),
|
||||||
|
});
|
||||||
|
const projects = await migrateLocalToHostIfEmpty(serverAdapter);
|
||||||
|
return { adapter: serverAdapter, projects };
|
||||||
|
} catch {
|
||||||
|
/* Host server unreachable or unauthenticated (e.g. unit test environment) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projects = await storageLocal.listProjects();
|
||||||
|
return { adapter: storageLocal, projects };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adapter: null,
|
adapter: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
@@ -321,8 +404,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
init: async () => {
|
init: async () => {
|
||||||
set({ status: "loading", error: null });
|
set({ status: "loading", error: null });
|
||||||
try {
|
try {
|
||||||
const adapter = storageLocal;
|
const resolved = await resolveHostOrLocalAdapter();
|
||||||
let projects = await adapter.listProjects();
|
const adapter = resolved.adapter;
|
||||||
|
let projects = resolved.projects;
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
await seedDemoProject(adapter);
|
await seedDemoProject(adapter);
|
||||||
projects = await adapter.listProjects();
|
projects = await adapter.listProjects();
|
||||||
@@ -341,9 +425,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
|
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
|
||||||
projects[0]!;
|
projects[0]!;
|
||||||
|
|
||||||
const books = await adapter.listBooks(project.id);
|
let books = await adapter.listBooks(project.id);
|
||||||
const book = books[0];
|
let book = books[0];
|
||||||
const chapters = book ? await adapter.listChapters(book.id) : [];
|
if (!book) {
|
||||||
|
book = await adapter.createBook({ projectId: project.id, title: "Book One" });
|
||||||
|
books = [book];
|
||||||
|
}
|
||||||
|
const chapters = await adapter.listChapters(book.id);
|
||||||
|
|
||||||
autosave = createAutosave(adapter);
|
autosave = createAutosave(adapter);
|
||||||
autosave.onStatus((status) => {
|
autosave.onStatus((status) => {
|
||||||
@@ -552,6 +640,109 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
return meta;
|
return meta;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
importManuscript: async (parsed, opts) => {
|
||||||
|
if (autosave) {
|
||||||
|
await autosave.flush();
|
||||||
|
}
|
||||||
|
const adapter = get().adapter ?? storageLocal;
|
||||||
|
|
||||||
|
if (opts.mode === "append-chapters" && get().project) {
|
||||||
|
const project = get().project!;
|
||||||
|
let bookId = get().activeBookId;
|
||||||
|
if (!bookId) {
|
||||||
|
const books = await adapter.listBooks(project.id);
|
||||||
|
bookId =
|
||||||
|
books[0]?.id ??
|
||||||
|
(await adapter.createBook({ projectId: project.id, title: "Book One" })).id;
|
||||||
|
}
|
||||||
|
let firstNewChapterId: string | null = null;
|
||||||
|
for (const ch of parsed.chapters) {
|
||||||
|
const cid = createUlid();
|
||||||
|
if (!firstNewChapterId) firstNewChapterId = cid;
|
||||||
|
await adapter.saveDocument({
|
||||||
|
id: cid,
|
||||||
|
projectId: project.id,
|
||||||
|
bookId,
|
||||||
|
title: ch.title || "Imported Chapter",
|
||||||
|
doc: ch.doc,
|
||||||
|
rev: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updatedChapters = await adapter.listChapters(bookId);
|
||||||
|
const projectStats = await computeAllProjectStats(adapter, get().projects);
|
||||||
|
const revisionsByChapter = loadProjectRevisions(project.id, updatedChapters);
|
||||||
|
set({
|
||||||
|
activeBookId: bookId,
|
||||||
|
chapters: updatedChapters,
|
||||||
|
activeChapterId: firstNewChapterId ?? get().activeChapterId,
|
||||||
|
projectStats,
|
||||||
|
revisionsByChapter,
|
||||||
|
docExternalRev: get().docExternalRev + 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new project from the imported manuscript
|
||||||
|
const projectTitle = opts.title?.trim() || parsed.title || "Imported Manuscript";
|
||||||
|
const created = await adapter.createProject({
|
||||||
|
title: projectTitle,
|
||||||
|
cover: { color: "#7c8bf5" },
|
||||||
|
});
|
||||||
|
const book = await adapter.createBook({
|
||||||
|
projectId: created.id,
|
||||||
|
title: "Book One",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ch of parsed.chapters) {
|
||||||
|
await adapter.saveDocument({
|
||||||
|
id: createUlid(),
|
||||||
|
projectId: created.id,
|
||||||
|
bookId: book.id,
|
||||||
|
title: ch.title || "Chapter 1",
|
||||||
|
doc: ch.doc,
|
||||||
|
rev: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.entities && parsed.entities.length > 0) {
|
||||||
|
for (const ent of parsed.entities) {
|
||||||
|
await adapter.saveEntity({ ...ent, id: createUlid(), projectId: created.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.plotBeats && parsed.plotBeats.length > 0) {
|
||||||
|
for (const beat of parsed.plotBeats) {
|
||||||
|
await adapter.savePlotBeat({ ...beat, id: createUlid(), projectId: created.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.board) {
|
||||||
|
await adapter.saveBoard({
|
||||||
|
...parsed.board,
|
||||||
|
id: `board-${created.id}`,
|
||||||
|
projectId: created.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await adapter.saveBoard({
|
||||||
|
id: `board-${created.id}`,
|
||||||
|
projectId: created.id,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: createUlid(),
|
||||||
|
type: "note",
|
||||||
|
position: { x: 120, y: 120 },
|
||||||
|
data: {
|
||||||
|
title: projectTitle,
|
||||||
|
text: projectTitle,
|
||||||
|
body: `Imported from ${parsed.formatLabel} (${parsed.totalWords.toLocaleString()} words)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().openProject(created.id);
|
||||||
|
},
|
||||||
|
|
||||||
deleteProject: async (id: string) => {
|
deleteProject: async (id: string) => {
|
||||||
const adapter = get().adapter ?? storageLocal;
|
const adapter = get().adapter ?? storageLocal;
|
||||||
if (adapter.deleteProject) {
|
if (adapter.deleteProject) {
|
||||||
@@ -642,6 +833,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
|
|
||||||
scheduleSave: (ref) => {
|
scheduleSave: (ref) => {
|
||||||
lastScheduledChapterId = ref.id;
|
lastScheduledChapterId = ref.id;
|
||||||
|
const autoSaveEnabled = useSettings.getState().autoSave;
|
||||||
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
|
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextChapters = state.chapters.map((c) =>
|
const nextChapters = state.chapters.map((c) =>
|
||||||
@@ -657,13 +849,72 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: state.projectStats;
|
: state.projectStats;
|
||||||
return { chapters: nextChapters, projectStats: nextStats };
|
return {
|
||||||
|
chapters: nextChapters,
|
||||||
|
projectStats: nextStats,
|
||||||
|
...(!autoSaveEnabled
|
||||||
|
? {
|
||||||
|
autosave: {
|
||||||
|
...state.autosave,
|
||||||
|
dirty: true,
|
||||||
|
saving: false,
|
||||||
|
status: "unsaved" as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
if (autosave) {
|
if (autoSaveEnabled && autosave) {
|
||||||
autosave.save(ref);
|
autosave.save(ref);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
saveChapterNow: async (chapterId) => {
|
||||||
|
const st = get();
|
||||||
|
const adapter = st.adapter ?? storageLocal;
|
||||||
|
if (!st.project) return;
|
||||||
|
const targetId = chapterId ?? st.activeChapterId;
|
||||||
|
const targetCh = st.chapters.find((c) => c.id === targetId) ?? st.chapters[0];
|
||||||
|
if (!targetCh) return;
|
||||||
|
|
||||||
|
set({
|
||||||
|
autosave: {
|
||||||
|
...st.autosave,
|
||||||
|
dirty: true,
|
||||||
|
saving: true,
|
||||||
|
status: "saving",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (autosave) {
|
||||||
|
await autosave.flush();
|
||||||
|
}
|
||||||
|
await adapter.saveDocument({
|
||||||
|
id: targetCh.id,
|
||||||
|
projectId: st.project.id,
|
||||||
|
bookId: targetCh.bookId,
|
||||||
|
title: targetCh.title,
|
||||||
|
doc: targetCh.doc,
|
||||||
|
rev: targetCh.rev,
|
||||||
|
});
|
||||||
|
const { nextMap, nextRev } = recordChapterRevision(
|
||||||
|
st.project.id,
|
||||||
|
st.revisionsByChapter,
|
||||||
|
targetCh,
|
||||||
|
"Manual save",
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
revisionsByChapter: nextMap,
|
||||||
|
chapters: st.chapters.map((c) => (c.id === targetCh.id ? { ...c, rev: nextRev } : c)),
|
||||||
|
autosave: {
|
||||||
|
dirty: false,
|
||||||
|
saving: false,
|
||||||
|
lastSavedAt: new Date().toISOString(),
|
||||||
|
status: "saved",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
flushAutosave: () => {
|
flushAutosave: () => {
|
||||||
if (autosave) {
|
if (autosave) {
|
||||||
void autosave.flush();
|
void autosave.flush();
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { applyDensity, applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
|
import {
|
||||||
|
applyColorOverrides,
|
||||||
|
applyDensity,
|
||||||
|
applyFontSettings,
|
||||||
|
applyTheme,
|
||||||
|
THEME_TOKENS,
|
||||||
|
type ThemeId,
|
||||||
|
} from "@pubscript/ui";
|
||||||
import { discoverLocalProviders, type ProviderKind, type ThinkingEffort } from "@pubscript/ai";
|
import { discoverLocalProviders, type ProviderKind, type ThinkingEffort } from "@pubscript/ai";
|
||||||
import { createUlid } from "@pubscript/core";
|
import { createUlid } from "@pubscript/core";
|
||||||
|
|
||||||
@@ -238,6 +245,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
|||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: ThemeId;
|
theme: ThemeId;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
|
textColor: string;
|
||||||
density: Density;
|
density: Density;
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -247,6 +255,7 @@ export interface AppSettings {
|
|||||||
wordCountGoal: number;
|
wordCountGoal: number;
|
||||||
autoSave: boolean;
|
autoSave: boolean;
|
||||||
focusMode: boolean;
|
focusMode: boolean;
|
||||||
|
projectDirectory: string;
|
||||||
// Navigation & AI Sidebars
|
// Navigation & AI Sidebars
|
||||||
navCollapsed: boolean;
|
navCollapsed: boolean;
|
||||||
aiSidebarOpen: boolean;
|
aiSidebarOpen: boolean;
|
||||||
@@ -263,11 +272,19 @@ export interface AppSettings {
|
|||||||
activeModel: string;
|
activeModel: string;
|
||||||
aiAgents: AIAgentConfig[];
|
aiAgents: AIAgentConfig[];
|
||||||
activeAgentId: string;
|
activeAgentId: string;
|
||||||
|
// Local / Self-Hosted Server & Startup Settings
|
||||||
|
serverEnabled: boolean;
|
||||||
|
serverHost: string;
|
||||||
|
allowedHosts: string;
|
||||||
|
webPort: number;
|
||||||
|
serverPort: number;
|
||||||
|
serverStartOnBoot: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
theme: "ink",
|
theme: "ink",
|
||||||
accentColor: "#7c8bf5",
|
accentColor: "#7c8bf5",
|
||||||
|
textColor: "",
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
fontFamily: "serif",
|
fontFamily: "serif",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -277,6 +294,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
wordCountGoal: 80_000,
|
wordCountGoal: 80_000,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
focusMode: false,
|
focusMode: false,
|
||||||
|
projectDirectory: "~/Documents/PubScript",
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
aiSidebarOpen: false,
|
aiSidebarOpen: false,
|
||||||
aiSidebarWidth: 370,
|
aiSidebarWidth: 370,
|
||||||
@@ -292,6 +310,13 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel,
|
activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel,
|
||||||
aiAgents: DEFAULT_AI_AGENTS,
|
aiAgents: DEFAULT_AI_AGENTS,
|
||||||
activeAgentId: DEFAULT_AI_AGENTS[0]!.id,
|
activeAgentId: DEFAULT_AI_AGENTS[0]!.id,
|
||||||
|
// Local / Self-Hosted Server & Startup Settings
|
||||||
|
serverEnabled: true,
|
||||||
|
serverHost: "127.0.0.1",
|
||||||
|
allowedHosts: "localhost, 127.0.0.1",
|
||||||
|
webPort: 5173,
|
||||||
|
serverPort: 8081,
|
||||||
|
serverStartOnBoot: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = "pubscript.settings.v1";
|
const STORAGE_KEY = "pubscript.settings.v1";
|
||||||
@@ -343,6 +368,10 @@ export interface SettingsStore extends AppSettings {
|
|||||||
export const useSettings = create<SettingsStore>((set, get) => {
|
export const useSettings = create<SettingsStore>((set, get) => {
|
||||||
const initial = loadSettings();
|
const initial = loadSettings();
|
||||||
applyTheme(initial.theme);
|
applyTheme(initial.theme);
|
||||||
|
applyColorOverrides({
|
||||||
|
accentColor: initial.accentColor,
|
||||||
|
textColor: initial.textColor,
|
||||||
|
});
|
||||||
applyDensity(initial.density);
|
applyDensity(initial.density);
|
||||||
applyFontSettings({
|
applyFontSettings({
|
||||||
size: initial.fontSize,
|
size: initial.fontSize,
|
||||||
@@ -363,6 +392,12 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
|||||||
if (patch.theme != null) {
|
if (patch.theme != null) {
|
||||||
applyTheme(next.theme);
|
applyTheme(next.theme);
|
||||||
}
|
}
|
||||||
|
if (patch.theme != null || patch.accentColor != null || patch.textColor != null) {
|
||||||
|
applyColorOverrides({
|
||||||
|
accentColor: next.accentColor,
|
||||||
|
textColor: next.textColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (patch.density != null) {
|
if (patch.density != null) {
|
||||||
applyDensity(next.density);
|
applyDensity(next.density);
|
||||||
}
|
}
|
||||||
@@ -383,7 +418,8 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
get().set({ theme });
|
const themeDefaultAccent = THEME_TOKENS[theme]?.css["--ps-accent"] ?? "#7c8bf5";
|
||||||
|
get().set({ theme, accentColor: themeDefaultAccent, textColor: "" });
|
||||||
},
|
},
|
||||||
updateProvider: (id, patch) => {
|
updateProvider: (id, patch) => {
|
||||||
const nextProviders = get().aiProviders.map((p) =>
|
const nextProviders = get().aiProviders.map((p) =>
|
||||||
|
|||||||
@@ -8,19 +8,37 @@
|
|||||||
* (`--ps-*`) and field/button/drawer/tabs/badge classes.
|
* (`--ps-*`) and field/button/drawer/tabs/badge classes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Full-width container for the entity engine (overrides .ps-content narrowness). */
|
/* Full-width container for the entity engine (pinned header + scrollable body). */
|
||||||
.ps-entities-view {
|
.ps-entities-view {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--ps-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entities-view > .ps-tabs {
|
.ps-entities-pinned-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 24px 12px;
|
||||||
|
background: var(--ps-bg);
|
||||||
|
border-bottom: 1px solid var(--ps-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entities-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entities-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 24px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Search bar + new-entity actions ---- */
|
/* ---- Search bar + new-entity actions ---- */
|
||||||
@@ -28,9 +46,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.ps-entity-search .ps-field {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-entity-search-input {
|
.ps-entity-search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -64,7 +86,8 @@
|
|||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entity-card:hover {
|
.ps-entity-card:hover,
|
||||||
|
.ps-entity-card.is-selected {
|
||||||
border-color: var(--ps-accent);
|
border-color: var(--ps-accent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
@@ -161,20 +184,46 @@
|
|||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Entity editor drawer ---- */
|
/* ---- Entity editor drawer (docked right rail) ---- */
|
||||||
.ps-entity-editor {
|
.ps-entity-editor {
|
||||||
|
width: min(460px, 45vw);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border-left: 1px solid var(--ps-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entity-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--ps-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entity-editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entity-editor-actions {
|
.ps-entity-editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
padding: 12px 18px;
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--ps-border);
|
border-top: 1px solid var(--ps-border);
|
||||||
|
background: var(--ps-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-spacer {
|
.ps-spacer {
|
||||||
@@ -185,14 +234,24 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Generic entity fields ---- */
|
/* ---- Generic entity fields (clean 2-column grid for selects, full-width for text/textarea) ---- */
|
||||||
.ps-entity-fields {
|
.ps-entity-fields {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-entity-fields .ps-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-field-full,
|
||||||
.ps-field-span-2 {
|
.ps-field-span-2 {
|
||||||
grid-column: span 2;
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-field-half {
|
||||||
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-textarea {
|
.ps-textarea {
|
||||||
@@ -312,15 +371,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-relationship-add > .ps-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-relationship-target-select,
|
.ps-relationship-target-select,
|
||||||
.ps-relationship-kind-select {
|
.ps-relationship-kind-select {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 120px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-relationship-note {
|
.ps-relationship-note {
|
||||||
flex: 2;
|
width: 100%;
|
||||||
min-width: 120px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Misc ---- */
|
/* ---- Misc ---- */
|
||||||
@@ -406,14 +469,7 @@
|
|||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Entity editor drawer header/body ---- */
|
.ps-entity-Editor-title,
|
||||||
.ps-entity-editor-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ps-entity-editor-header h2 {
|
.ps-entity-editor-header h2 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -421,14 +477,6 @@
|
|||||||
color: var(--ps-text);
|
color: var(--ps-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entity-editor-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ps-icon-btn {
|
.ps-icon-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -5,15 +5,26 @@
|
|||||||
* line-number gutter, focus mode, and status bar.
|
* line-number gutter, focus mode, and status bar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
.ps-writing-slot {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-writing {
|
.ps-writing {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OnlyOffice / Word-inspired icon formatting ribbon */
|
/* OnlyOffice / Word-inspired icon formatting ribbon (pinned at top) */
|
||||||
.ps-writing-toolbar {
|
.ps-writing-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -23,7 +34,8 @@
|
|||||||
border-bottom: 1px solid var(--ps-border);
|
border-bottom: 1px solid var(--ps-border);
|
||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
flex: none;
|
flex: none;
|
||||||
z-index: 5;
|
flex-shrink: 0;
|
||||||
|
z-index: 30;
|
||||||
}
|
}
|
||||||
:root[data-density="compact"] .ps-writing-toolbar {
|
:root[data-density="compact"] .ps-writing-toolbar {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@@ -36,6 +48,17 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-toolbar-collapsible.is-open {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.ps-toolbar-collapsible.is-minimized {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.ps-formatting-toggle-btn {
|
||||||
|
width: auto !important;
|
||||||
|
padding: 0 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-toolbar-sep {
|
.ps-toolbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -305,3 +328,36 @@
|
|||||||
text-decoration-thickness: 1.5px;
|
text-decoration-thickness: 1.5px;
|
||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile-fixed writing pane for standard phone screens (Issue #1) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ps-writing-slot,
|
||||||
|
.ps-writing,
|
||||||
|
.ps-writing-body {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.ps-editor-scroll {
|
||||||
|
padding: 14px 16px 68px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ps-editor-writing {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.ps-editor .ProseMirror {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ps-writing-status {
|
||||||
|
padding: 6px 12px 50px;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: false,
|
strictPort: false,
|
||||||
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081",
|
target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081",
|
||||||
@@ -30,5 +31,6 @@ export default defineConfig({
|
|||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 4173,
|
port: 4173,
|
||||||
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ fi
|
|||||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
|
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
|
||||||
fi
|
fi
|
||||||
|
# Stop any stale background server from a previous package build so systemd
|
||||||
|
# (Restart=on-failure / Restart=always) or the next desktop launch starts the
|
||||||
|
# freshly installed /opt/pubscript server and asset bundle.
|
||||||
|
pkill -9 -f "/opt/pubscript/server/dist/server.mjs" >/dev/null 2>&1 || true
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
chmod 0755 "$STAGE_DIR/DEBIAN/postinst"
|
chmod 0755 "$STAGE_DIR/DEBIAN/postinst"
|
||||||
|
|||||||
@@ -157,6 +157,25 @@ int main(int argc, char *argv[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int port = 18081;
|
int port = 18081;
|
||||||
|
char cfg_path[2048];
|
||||||
|
snprintf(cfg_path, sizeof(cfg_path), "%s/.config/pubscript/server.json", home);
|
||||||
|
FILE *cfg_fp = fopen(cfg_path, "r");
|
||||||
|
if (cfg_fp) {
|
||||||
|
char buf[1024];
|
||||||
|
size_t n = fread(buf, 1, sizeof(buf) - 1, cfg_fp);
|
||||||
|
buf[n] = '\0';
|
||||||
|
fclose(cfg_fp);
|
||||||
|
const char *port_key = strstr(buf, "\"port\"");
|
||||||
|
if (port_key) {
|
||||||
|
const char *colon = strchr(port_key, ':');
|
||||||
|
if (colon) {
|
||||||
|
int cfg_port = atoi(colon + 1);
|
||||||
|
if (cfg_port >= 1024 && cfg_port <= 65535) {
|
||||||
|
port = cfg_port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const char *env_port = getenv("PUBSCRIPT_DESKTOP_PORT");
|
const char *env_port = getenv("PUBSCRIPT_DESKTOP_PORT");
|
||||||
if (env_port && *env_port) {
|
if (env_port && *env_port) {
|
||||||
int p = atoi(env_port);
|
int p = atoi(env_port);
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ export class AIStore {
|
|||||||
if (this.loaded) return;
|
if (this.loaded) return;
|
||||||
const rows = await this.persistence.list(this.projectId);
|
const rows = await this.persistence.list(this.projectId);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const cleanedSuggestions = (row.suggestions ?? []).filter(
|
||||||
|
(sg) =>
|
||||||
|
!String(sg.newText ?? sg.text ?? "").includes("—clear and unsparing") &&
|
||||||
|
!String(sg.newText ?? sg.text ?? "").includes("—sharp and unmistakable"),
|
||||||
|
);
|
||||||
this.sessions.set(row.id, {
|
this.sessions.set(row.id, {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
@@ -122,7 +127,7 @@ export class AIStore {
|
|||||||
providerId: row.providerId,
|
providerId: row.providerId,
|
||||||
model: row.model,
|
model: row.model,
|
||||||
messages: row.messages ?? [],
|
messages: row.messages ?? [],
|
||||||
suggestions: row.suggestions ?? [],
|
suggestions: cleanedSuggestions,
|
||||||
notes: row.notes ?? [],
|
notes: row.notes ?? [],
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -328,6 +333,16 @@ export class AIStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear all pending suggestions in a session before populating fresh suggestions from a new prompt. */
|
||||||
|
async clearPendingSuggestions(sessionId: string): Promise<AISession> {
|
||||||
|
return this.withTransaction(async () => {
|
||||||
|
const session = this.require(sessionId);
|
||||||
|
session.suggestions = session.suggestions.filter((s) => s.status !== "pending");
|
||||||
|
session.updatedAt = nowIso();
|
||||||
|
return this.clone(session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Update a suggestion's line number or text anchor when editor blocks shift. */
|
/** Update a suggestion's line number or text anchor when editor blocks shift. */
|
||||||
async updateSuggestion(
|
async updateSuggestion(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
|||||||
{ key: "name", label: "Name", type: "text" },
|
{ key: "name", label: "Name", type: "text" },
|
||||||
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
||||||
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
||||||
{ key: "home", label: "Home", type: "link", hint: "linked location" },
|
{ key: "home", label: "Home Location", type: "link" },
|
||||||
{ key: "bio", label: "Bio", type: "textarea" },
|
{ key: "bio", label: "Bio", type: "textarea" },
|
||||||
{ key: "appearance", label: "Appearance", type: "textarea" },
|
{ key: "appearance", label: "Appearance", type: "textarea" },
|
||||||
{ key: "voice", label: "Voice", type: "textarea" },
|
{ key: "voice", label: "Voice", type: "textarea" },
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
||||||
|
|
||||||
export interface ServerClientOptions {
|
export interface ServerClientOptions {
|
||||||
/** Base URL of the PubScript server, e.g. `http://localhost:8080`. */
|
/** Base URL of the PubScript server, e.g. `http://localhost:8080` or `""` for same-origin. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
|
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
@@ -37,6 +37,7 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
if (token) headers.authorization = `Bearer ${token}`;
|
if (token) headers.authorization = `Bearer ${token}`;
|
||||||
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
|
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
|
credentials: "include",
|
||||||
headers,
|
headers,
|
||||||
body: body === undefined ? null : JSON.stringify(body),
|
body: body === undefined ? null : JSON.stringify(body),
|
||||||
});
|
});
|
||||||
@@ -53,8 +54,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
return r.projects;
|
return r.projects;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createProject(p: NewProject): Promise<Project> {
|
async createProject(p: NewProject & { id?: string }): Promise<Project> {
|
||||||
const r = await req<{ project: Project }>("POST", "/api/projects", {
|
const r = await req<{ project: Project }>("POST", "/api/projects", {
|
||||||
|
...(p.id ? { id: p.id } : {}),
|
||||||
title: p.title,
|
title: p.title,
|
||||||
language: p.language,
|
language: p.language,
|
||||||
cover: p.cover,
|
cover: p.cover,
|
||||||
@@ -67,8 +69,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
return r.project;
|
return r.project;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteProject(id: string): Promise<void> {
|
||||||
|
await req("DELETE", `/api/projects/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async createBook(b: { projectId: string; title: string }): Promise<Book> {
|
async createBook(b: { projectId: string; title: string }): Promise<Book> {
|
||||||
const r = await req<{ book: Book }>("POST", `/api/projects/${b.projectId}/books`, { title: b.title });
|
const r = await req<{ book: Book }>("POST", `/api/projects/${b.projectId}/books`, {
|
||||||
|
title: b.title,
|
||||||
|
});
|
||||||
return r.book;
|
return r.book;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -86,6 +94,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
await req("POST", `/api/chapters/${doc.id}/autosave`, {
|
await req("POST", `/api/chapters/${doc.id}/autosave`, {
|
||||||
rev: doc.rev,
|
rev: doc.rev,
|
||||||
doc: doc.doc,
|
doc: doc.doc,
|
||||||
|
title: doc.title,
|
||||||
|
projectId: doc.projectId,
|
||||||
|
bookId: doc.bookId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -98,23 +109,43 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteChapter(id: string): Promise<void> {
|
||||||
|
await req("DELETE", `/api/chapters/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async saveEntity(e: Entity): Promise<void> {
|
async saveEntity(e: Entity): Promise<void> {
|
||||||
if ("fields" in e) {
|
if ("fields" in e) {
|
||||||
// WorldItem — the server nests the world kind inside `fields`.
|
// WorldItem — the server nests the world kind inside `fields`.
|
||||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||||
id: e.id, kind: "world", name: e.name, fields: { kind: e.kind, ...e.fields },
|
id: e.id,
|
||||||
|
kind: "world",
|
||||||
|
name: e.name,
|
||||||
|
fields: { kind: e.kind, ...e.fields },
|
||||||
});
|
});
|
||||||
} else if ("body" in e) {
|
} else if ("body" in e) {
|
||||||
// Note
|
// Note
|
||||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||||
id: e.id, kind: "note", body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo,
|
id: e.id,
|
||||||
|
kind: "note",
|
||||||
|
body: e.body,
|
||||||
|
pinned: e.pinned,
|
||||||
|
tags: e.tags,
|
||||||
|
linkedTo: e.linkedTo,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Character
|
// Character
|
||||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||||
id: e.id, kind: "character", name: e.name, role: e.role, bio: e.bio,
|
id: e.id,
|
||||||
appearance: e.appearance, voice: e.voice, relationships: e.relationships,
|
kind: "character",
|
||||||
arc: e.arc, status: e.status, tags: e.tags,
|
name: e.name,
|
||||||
|
role: e.role,
|
||||||
|
bio: e.bio,
|
||||||
|
appearance: e.appearance,
|
||||||
|
voice: e.voice,
|
||||||
|
relationships: e.relationships,
|
||||||
|
arc: e.arc,
|
||||||
|
status: e.status,
|
||||||
|
tags: e.tags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -212,8 +243,6 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onEvent(_cb: (e: StoreEvent) => void): () => void {
|
onEvent(_cb: (e: StoreEvent) => void): () => void {
|
||||||
// Live updates arrive over the `/api/ws` socket; wiring that up is the
|
|
||||||
// app's concern. For the contract, return a no-op unsubscribe.
|
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
exportProjectData,
|
||||||
|
importManuscriptFile,
|
||||||
|
type ExportProject,
|
||||||
|
} from "./index.js";
|
||||||
|
|
||||||
|
const SAMPLE_PROJECT: ExportProject = {
|
||||||
|
meta: {
|
||||||
|
title: "The Harbor Ledger",
|
||||||
|
author: "Austin Breinholt",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
books: [
|
||||||
|
{
|
||||||
|
id: "book-1",
|
||||||
|
projectId: "proj-import-1",
|
||||||
|
title: "Book One",
|
||||||
|
order: 0,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: "ch-1",
|
||||||
|
bookId: "book-1",
|
||||||
|
projectId: "proj-import-1",
|
||||||
|
title: "Chapter 1: Fog Over the Pier",
|
||||||
|
order: 0,
|
||||||
|
rev: 1,
|
||||||
|
meta: { wordCount: 15, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
doc: {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: [{ type: "text", text: "Chapter 1: Fog Over the Pier" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "The lantern swung in the " },
|
||||||
|
{ type: "text", text: "harbor wind", marks: [{ type: "bold" }] },
|
||||||
|
{ type: "text", text: " as Mara opened the ledger." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ch-2",
|
||||||
|
bookId: "book-1",
|
||||||
|
projectId: "proj-import-1",
|
||||||
|
title: "Chapter 2: The Bell Tower",
|
||||||
|
order: 1,
|
||||||
|
rev: 1,
|
||||||
|
meta: { wordCount: 6, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
doc: {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: [{ type: "text", text: "Chapter 2: The Bell Tower" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Twelve strokes echoed across the water." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Universal Manuscript Importer (importer.ts)", () => {
|
||||||
|
it("imports plain text (.txt) and splits chapters on Chapter headings", async () => {
|
||||||
|
const txt = [
|
||||||
|
"Chapter 1: The Arrival",
|
||||||
|
"",
|
||||||
|
"The train pulled into the station at dusk.",
|
||||||
|
"",
|
||||||
|
"* * *",
|
||||||
|
"",
|
||||||
|
"She stepped onto the wet platform.",
|
||||||
|
"",
|
||||||
|
"Chapter 2: The Departure",
|
||||||
|
"",
|
||||||
|
"By morning, the tracks were empty.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const res = await importManuscriptFile("arrival-story.txt", txt);
|
||||||
|
expect(res.format).toBe("txt");
|
||||||
|
expect(res.chapters).toHaveLength(2);
|
||||||
|
expect(res.chapters[0]?.title).toBe("Chapter 1: The Arrival");
|
||||||
|
expect(res.chapters[1]?.title).toBe("Chapter 2: The Departure");
|
||||||
|
expect(res.totalWords).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports Markdown (.md) with frontmatter, inline marks, and chapters", async () => {
|
||||||
|
const md = [
|
||||||
|
"---",
|
||||||
|
"title: Glass Harbor",
|
||||||
|
"author: Schwitzz",
|
||||||
|
"---",
|
||||||
|
"# Chapter One",
|
||||||
|
"",
|
||||||
|
"He read the **bold entry** and *italic margin note*.",
|
||||||
|
"",
|
||||||
|
"# Chapter Two",
|
||||||
|
"",
|
||||||
|
"The second chapter begins here.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const res = await importManuscriptFile("glass-harbor.md", md);
|
||||||
|
expect(res.format).toBe("md");
|
||||||
|
expect(res.title).toBe("Glass Harbor");
|
||||||
|
expect(res.author).toBe("Schwitzz");
|
||||||
|
expect(res.chapters).toHaveLength(2);
|
||||||
|
expect(res.chapters[0]?.title).toBe("Chapter One");
|
||||||
|
expect(res.chapters[1]?.title).toBe("Chapter Two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips DOCX (Word / OnlyOffice) into PubScript chapters", async () => {
|
||||||
|
const docxBytes = (await exportProjectData(SAMPLE_PROJECT, "docx", {
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})) as Uint8Array;
|
||||||
|
|
||||||
|
const imported = await importManuscriptFile("the-harbor-ledger.docx", docxBytes);
|
||||||
|
expect(imported.format).toBe("docx");
|
||||||
|
expect(imported.chapters.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(imported.totalWords).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips ODT (OpenOffice / OnlyOffice / LibreOffice) into PubScript chapters", async () => {
|
||||||
|
const odtBytes = (await exportProjectData(SAMPLE_PROJECT, "odt", {
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})) as Uint8Array;
|
||||||
|
|
||||||
|
const imported = await importManuscriptFile("the-harbor-ledger.odt", odtBytes);
|
||||||
|
expect(imported.format).toBe("odt");
|
||||||
|
expect(imported.title).toBe("The Harbor Ledger");
|
||||||
|
expect(imported.chapters.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(imported.totalWords).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips EPUB into PubScript chapters", async () => {
|
||||||
|
const epubBytes = (await exportProjectData(SAMPLE_PROJECT, "epub", {
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})) as Uint8Array;
|
||||||
|
|
||||||
|
const imported = await importManuscriptFile("the-harbor-ledger.epub", epubBytes);
|
||||||
|
expect(imported.format).toBe("epub");
|
||||||
|
expect(imported.title).toBe("The Harbor Ledger");
|
||||||
|
expect(imported.chapters).toHaveLength(2);
|
||||||
|
expect(imported.totalWords).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports RTF (.rtf) and HTML (.html) manuscripts", async () => {
|
||||||
|
const rtf = "{\\rtf1\\ansi{\\fonttbl\\f0 Times;}\\pard Chapter 1: Dawn\\par The sun rose over the ridge.\\par}";
|
||||||
|
const rtfRes = await importManuscriptFile("dawn.rtf", rtf);
|
||||||
|
expect(rtfRes.format).toBe("rtf");
|
||||||
|
expect(rtfRes.totalWords).toBeGreaterThan(4);
|
||||||
|
|
||||||
|
const html = "<html><head><title>Sea Wall</title></head><body><h1>Chapter 1</h1><p>Waves crashed against the <strong>stone</strong>.</p></body></html>";
|
||||||
|
const htmlRes = await importManuscriptFile("seawall.html", html);
|
||||||
|
expect(htmlRes.format).toBe("html");
|
||||||
|
expect(htmlRes.title).toBe("Sea Wall");
|
||||||
|
expect(htmlRes.chapters[0]?.title).toBe("Chapter 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -132,3 +132,4 @@ export { exportEpub } from "./epub.js";
|
|||||||
export { exportDocx } from "./docx.js";
|
export { exportDocx } from "./docx.js";
|
||||||
export { exportOdt } from "./odt.js";
|
export { exportOdt } from "./odt.js";
|
||||||
export { exportPdf } from "./pdf.js";
|
export { exportPdf } from "./pdf.js";
|
||||||
|
export * from "./importer.js";
|
||||||
|
|||||||
@@ -144,8 +144,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ps-sidebar-logo-only {
|
.ps-sidebar-logo-only {
|
||||||
width: 88px;
|
width: 92px;
|
||||||
height: auto;
|
height: 52px;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
display: block;
|
display: block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -153,7 +155,7 @@ body {
|
|||||||
}
|
}
|
||||||
.ps-sidebar.is-collapsed .ps-sidebar-logo-only {
|
.ps-sidebar.is-collapsed .ps-sidebar-logo-only {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: auto;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-sidebar-toggle {
|
.ps-sidebar-toggle {
|
||||||
@@ -429,7 +431,7 @@ body {
|
|||||||
}
|
}
|
||||||
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
|
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
|
||||||
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
|
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
|
||||||
.ps-field-hint:only-child { color: var(--ps-danger); }
|
.ps-field-hint.is-error { color: var(--ps-danger); }
|
||||||
|
|
||||||
/* ---- Modal ---- */
|
/* ---- Modal ---- */
|
||||||
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); }
|
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); }
|
||||||
@@ -1390,3 +1392,138 @@ body {
|
|||||||
|
|
||||||
/* ---- Focus visible ---- */
|
/* ---- Focus visible ---- */
|
||||||
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
|
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---- Mobile Helpers (Hidden on Desktop by Default) ---- */
|
||||||
|
.ps-mobile-nav-btn,
|
||||||
|
.ps-mobile-nav-backdrop,
|
||||||
|
.ps-mobile-rail-backdrop,
|
||||||
|
.ps-mobile-ai-backdrop,
|
||||||
|
.ps-mobile-ai-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile Friendly Layout (Issue #1) ---- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ps-app,
|
||||||
|
.ps-app.is-nav-collapsed {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
grid-template-rows: var(--ps-topbar-h) 1fr !important;
|
||||||
|
grid-template-areas:
|
||||||
|
"topbar"
|
||||||
|
"main" !important;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-mobile-nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. Side panels hide completely when closed and dismiss when tapping outside */
|
||||||
|
.ps-sidebar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.ps-app.is-mobile-nav-open .ps-sidebar {
|
||||||
|
display: flex !important;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: min(270px, 82vw) !important;
|
||||||
|
z-index: 210;
|
||||||
|
box-shadow: 12px 0 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.ps-app.is-mobile-nav-open .ps-mobile-nav-backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.52);
|
||||||
|
z-index: 205;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Story Context Rail overlays on mobile and hides completely when tapping outside */
|
||||||
|
.ps-context-rail {
|
||||||
|
position: fixed !important;
|
||||||
|
top: var(--ps-topbar-h);
|
||||||
|
bottom: 44px;
|
||||||
|
right: 0;
|
||||||
|
width: min(300px, 86vw) !important;
|
||||||
|
z-index: 210;
|
||||||
|
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.ps-mobile-rail-backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
z-index: 205;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. AI features docked at bottom of phone screen */
|
||||||
|
.ps-ai-sidebar {
|
||||||
|
position: fixed !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
top: auto !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 74dvh !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-top: 2px solid var(--ps-accent) !important;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
z-index: 220;
|
||||||
|
box-shadow: 0 -12px 36px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.ps-ai-resize-handle {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.ps-mobile-ai-backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
z-index: 215;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-mobile-ai-bar {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 44px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border-top: 1px solid var(--ps-border);
|
||||||
|
z-index: 150;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ps-mobile-ai-bar-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--ps-accent) 55%, var(--ps-border));
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 16%, var(--ps-surface-2));
|
||||||
|
color: var(--ps-text);
|
||||||
|
font-family: var(--ps-font-ui);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-topbar {
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.ps-topbar-search {
|
||||||
|
max-width: 150px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type InputHTMLAttributes } from "react";
|
import { useId, type InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -7,7 +7,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) {
|
export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) {
|
||||||
const inputId = id ?? `input-${Math.random().toString(36).slice(2, 8)}`;
|
const reactId = useId();
|
||||||
|
const inputId = id ?? `input-${reactId}`;
|
||||||
return (
|
return (
|
||||||
<div className="ps-field">
|
<div className="ps-field">
|
||||||
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
|
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type SelectHTMLAttributes } from "react";
|
import { useId, type SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -11,7 +11,8 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ label, options, id, className = "", ...rest }: SelectProps) {
|
export function Select({ label, options, id, className = "", ...rest }: SelectProps) {
|
||||||
const selectId = id ?? `select-${Math.random().toString(36).slice(2, 8)}`;
|
const reactId = useId();
|
||||||
|
const selectId = id ?? `select-${reactId}`;
|
||||||
return (
|
return (
|
||||||
<div className="ps-field">
|
<div className="ps-field">
|
||||||
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
|
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
|
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
|
||||||
|
|
||||||
describe("theme token map", () => {
|
describe("theme token map", () => {
|
||||||
it("defines exactly four themes", () => {
|
it("defines ten themes including the core four", () => {
|
||||||
expect(THEME_IDS).toEqual(["ink", "parchment", "nocturne", "terminal"]);
|
expect(THEME_IDS).toEqual([
|
||||||
expect(Object.keys(THEME_TOKENS)).toHaveLength(4);
|
"ink",
|
||||||
|
"parchment",
|
||||||
|
"nocturne",
|
||||||
|
"terminal",
|
||||||
|
"espresso",
|
||||||
|
"forest",
|
||||||
|
"nordic",
|
||||||
|
"paperback",
|
||||||
|
"sepia",
|
||||||
|
"synthwave",
|
||||||
|
]);
|
||||||
|
expect(Object.keys(THEME_TOKENS)).toHaveLength(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels every theme", () => {
|
it("labels every theme", () => {
|
||||||
|
|||||||
+179
-28
@@ -1,20 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
|
* Theme engine — CSS-variable token map.
|
||||||
*
|
*
|
||||||
* Four themes ship as token objects; `applyTheme(id)` sets
|
* Ten themes ship as token objects; `applyTheme(id)` sets
|
||||||
* `document.documentElement.dataset.theme`. Accent color and density are
|
* `document.documentElement.dataset.theme`. Accent color, text color, and
|
||||||
* runtime overrides layered on top.
|
* density are runtime overrides layered on top.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ThemeId = "ink" | "parchment" | "nocturne" | "terminal";
|
export type ThemeId =
|
||||||
|
| "ink"
|
||||||
|
| "parchment"
|
||||||
|
| "nocturne"
|
||||||
|
| "terminal"
|
||||||
|
| "espresso"
|
||||||
|
| "forest"
|
||||||
|
| "nordic"
|
||||||
|
| "paperback"
|
||||||
|
| "sepia"
|
||||||
|
| "synthwave";
|
||||||
|
|
||||||
export const THEME_IDS: ThemeId[] = ["ink", "parchment", "nocturne", "terminal"];
|
export const THEME_IDS: ThemeId[] = [
|
||||||
|
"ink",
|
||||||
|
"parchment",
|
||||||
|
"nocturne",
|
||||||
|
"terminal",
|
||||||
|
"espresso",
|
||||||
|
"forest",
|
||||||
|
"nordic",
|
||||||
|
"paperback",
|
||||||
|
"sepia",
|
||||||
|
"synthwave",
|
||||||
|
];
|
||||||
|
|
||||||
export const THEME_LABELS: Record<ThemeId, string> = {
|
export const THEME_LABELS: Record<ThemeId, string> = {
|
||||||
ink: "Ink",
|
ink: "Ink (Dark)",
|
||||||
parchment: "Parchment",
|
parchment: "Parchment (Warm Light)",
|
||||||
nocturne: "Nocturne",
|
nocturne: "Nocturne (Midnight Indigo)",
|
||||||
terminal: "Terminal",
|
terminal: "Terminal (Phosphor Mono)",
|
||||||
|
espresso: "Espresso (Dark Roast)",
|
||||||
|
forest: "Evergreen (Deep Pine)",
|
||||||
|
nordic: "Nordic Frost (Slate Blue)",
|
||||||
|
paperback: "Paperback (Crisp Daylight)",
|
||||||
|
sepia: "Vintage Sepia (Aged Manuscript)",
|
||||||
|
synthwave: "Synthwave (Neon Dusk)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemeTokens {
|
export interface ThemeTokens {
|
||||||
@@ -23,13 +50,9 @@ export interface ThemeTokens {
|
|||||||
css: Record<string, string>;
|
css: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The four themes. Ink is the default (dark). Parchment is warm light,
|
|
||||||
* Nocturne is deep indigo, Terminal is monochrome monospace.
|
|
||||||
*/
|
|
||||||
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||||
ink: {
|
ink: {
|
||||||
label: "Ink",
|
label: "Ink (Dark)",
|
||||||
css: {
|
css: {
|
||||||
"--ps-bg": "#0e0f13",
|
"--ps-bg": "#0e0f13",
|
||||||
"--ps-surface": "#17181f",
|
"--ps-surface": "#17181f",
|
||||||
@@ -45,7 +68,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
parchment: {
|
parchment: {
|
||||||
label: "Parchment",
|
label: "Parchment (Warm Light)",
|
||||||
css: {
|
css: {
|
||||||
"--ps-bg": "#f6f1e7",
|
"--ps-bg": "#f6f1e7",
|
||||||
"--ps-surface": "#fffdf7",
|
"--ps-surface": "#fffdf7",
|
||||||
@@ -61,7 +84,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
nocturne: {
|
nocturne: {
|
||||||
label: "Nocturne",
|
label: "Nocturne (Midnight Indigo)",
|
||||||
css: {
|
css: {
|
||||||
"--ps-bg": "#0c0b1e",
|
"--ps-bg": "#0c0b1e",
|
||||||
"--ps-surface": "#171533",
|
"--ps-surface": "#171533",
|
||||||
@@ -77,7 +100,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
label: "Terminal",
|
label: "Terminal (Phosphor Mono)",
|
||||||
css: {
|
css: {
|
||||||
"--ps-bg": "#05070a",
|
"--ps-bg": "#05070a",
|
||||||
"--ps-surface": "#0a0f14",
|
"--ps-surface": "#0a0f14",
|
||||||
@@ -92,6 +115,102 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
|||||||
"--ps-warning": "#ffd93d",
|
"--ps-warning": "#ffd93d",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
espresso: {
|
||||||
|
label: "Espresso (Dark Roast)",
|
||||||
|
css: {
|
||||||
|
"--ps-bg": "#14100e",
|
||||||
|
"--ps-surface": "#1e1815",
|
||||||
|
"--ps-surface-2": "#2a211d",
|
||||||
|
"--ps-border": "#3b2f29",
|
||||||
|
"--ps-text": "#f2e8df",
|
||||||
|
"--ps-text-muted": "#b39f91",
|
||||||
|
"--ps-accent": "#d98a4e",
|
||||||
|
"--ps-accent-contrast": "#14100e",
|
||||||
|
"--ps-danger": "#e86868",
|
||||||
|
"--ps-success": "#6ac48a",
|
||||||
|
"--ps-warning": "#e6b450",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forest: {
|
||||||
|
label: "Evergreen (Deep Pine)",
|
||||||
|
css: {
|
||||||
|
"--ps-bg": "#0b1410",
|
||||||
|
"--ps-surface": "#12201a",
|
||||||
|
"--ps-surface-2": "#1a2e25",
|
||||||
|
"--ps-border": "#264236",
|
||||||
|
"--ps-text": "#e3f2ea",
|
||||||
|
"--ps-text-muted": "#8fb09e",
|
||||||
|
"--ps-accent": "#52c48c",
|
||||||
|
"--ps-accent-contrast": "#0b1410",
|
||||||
|
"--ps-danger": "#f07178",
|
||||||
|
"--ps-success": "#52c48c",
|
||||||
|
"--ps-warning": "#e5c07b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nordic: {
|
||||||
|
label: "Nordic Frost (Slate Blue)",
|
||||||
|
css: {
|
||||||
|
"--ps-bg": "#1e222a",
|
||||||
|
"--ps-surface": "#282c34",
|
||||||
|
"--ps-surface-2": "#323844",
|
||||||
|
"--ps-border": "#3e4656",
|
||||||
|
"--ps-text": "#eceff4",
|
||||||
|
"--ps-text-muted": "#9aa5b8",
|
||||||
|
"--ps-accent": "#88c0d0",
|
||||||
|
"--ps-accent-contrast": "#1e222a",
|
||||||
|
"--ps-danger": "#bf616a",
|
||||||
|
"--ps-success": "#a3be8c",
|
||||||
|
"--ps-warning": "#ebcb8b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paperback: {
|
||||||
|
label: "Paperback (Crisp Daylight)",
|
||||||
|
css: {
|
||||||
|
"--ps-bg": "#f5f7fa",
|
||||||
|
"--ps-surface": "#ffffff",
|
||||||
|
"--ps-surface-2": "#ebeef5",
|
||||||
|
"--ps-border": "#d6dce8",
|
||||||
|
"--ps-text": "#1a202c",
|
||||||
|
"--ps-text-muted": "#5a6578",
|
||||||
|
"--ps-accent": "#3b6ef6",
|
||||||
|
"--ps-accent-contrast": "#ffffff",
|
||||||
|
"--ps-danger": "#d93848",
|
||||||
|
"--ps-success": "#1f8a58",
|
||||||
|
"--ps-warning": "#c47f17",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sepia: {
|
||||||
|
label: "Vintage Sepia (Aged Manuscript)",
|
||||||
|
css: {
|
||||||
|
"--ps-bg": "#efe4ce",
|
||||||
|
"--ps-surface": "#f7eedb",
|
||||||
|
"--ps-surface-2": "#e5d6b8",
|
||||||
|
"--ps-border": "#cfc0a1",
|
||||||
|
"--ps-text": "#362819",
|
||||||
|
"--ps-text-muted": "#6e5840",
|
||||||
|
"--ps-accent": "#9c4b27",
|
||||||
|
"--ps-accent-contrast": "#f7eedb",
|
||||||
|
"--ps-danger": "#b3362a",
|
||||||
|
"--ps-success": "#367347",
|
||||||
|
"--ps-warning": "#a67314",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
synthwave: {
|
||||||
|
label: "Synthwave (Neon Dusk)",
|
||||||
|
css: {
|
||||||
|
"--ps-bg": "#12091c",
|
||||||
|
"--ps-surface": "#1c0f2b",
|
||||||
|
"--ps-surface-2": "#28163d",
|
||||||
|
"--ps-border": "#3c225c",
|
||||||
|
"--ps-text": "#f8e9ff",
|
||||||
|
"--ps-text-muted": "#b695cc",
|
||||||
|
"--ps-accent": "#ff4fd8",
|
||||||
|
"--ps-accent-contrast": "#12091c",
|
||||||
|
"--ps-danger": "#ff5c77",
|
||||||
|
"--ps-success": "#36f9b3",
|
||||||
|
"--ps-warning": "#ffe261",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The active theme id, persisted by the settings store. */
|
/** The active theme id, persisted by the settings store. */
|
||||||
@@ -126,13 +245,54 @@ export function applyTheme(id: ThemeId): ThemeId {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply custom user overrides for accent color and/or primary text color on top
|
||||||
|
* of the active theme. Passing an empty string for `textColor` or `accentColor`
|
||||||
|
* restores the active theme's default token value.
|
||||||
|
*/
|
||||||
|
export function applyColorOverrides(overrides: {
|
||||||
|
accentColor?: string | undefined;
|
||||||
|
textColor?: string | undefined;
|
||||||
|
}): void {
|
||||||
|
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||||
|
if (!root) return;
|
||||||
|
const themeCss = THEME_TOKENS[activeTheme]?.css ?? THEME_TOKENS.ink.css;
|
||||||
|
|
||||||
|
if (overrides.accentColor !== undefined) {
|
||||||
|
const acc = overrides.accentColor.trim();
|
||||||
|
if (acc) {
|
||||||
|
root.style.setProperty("--ps-accent", acc);
|
||||||
|
} else if (themeCss["--ps-accent"]) {
|
||||||
|
root.style.setProperty("--ps-accent", themeCss["--ps-accent"]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.textColor !== undefined) {
|
||||||
|
const txt = overrides.textColor.trim();
|
||||||
|
if (txt) {
|
||||||
|
root.style.setProperty("--ps-text", txt);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--ps-text-muted",
|
||||||
|
`color-mix(in srgb, ${txt} 68%, var(--ps-bg))`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (themeCss["--ps-text"]) {
|
||||||
|
root.style.setProperty("--ps-text", themeCss["--ps-text"]!);
|
||||||
|
}
|
||||||
|
if (themeCss["--ps-text-muted"]) {
|
||||||
|
root.style.setProperty("--ps-text-muted", themeCss["--ps-text-muted"]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The currently active theme id. */
|
/** The currently active theme id. */
|
||||||
export function getActiveTheme(): ThemeId {
|
export function getActiveTheme(): ThemeId {
|
||||||
return activeTheme;
|
return activeTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime font overrides for the writing surface. See PLAN.md §4.1/§4.7.
|
* Runtime font overrides for the writing surface.
|
||||||
* These layer on top of the theme's base font families.
|
* These layer on top of the theme's base font families.
|
||||||
*/
|
*/
|
||||||
export interface FontSettings {
|
export interface FontSettings {
|
||||||
@@ -153,11 +313,6 @@ export const DEFAULT_FONT_SETTINGS: FontSettings = {
|
|||||||
measure: 720,
|
measure: 720,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply font settings by writing CSS variables to :root. The prose column
|
|
||||||
* (`.ps-content`) reads these; an empty `family` leaves the theme's body font
|
|
||||||
* in place. Safe to call before the DOM exists (no-op).
|
|
||||||
*/
|
|
||||||
export function applyFontSettings(s: Partial<FontSettings>): void {
|
export function applyFontSettings(s: Partial<FontSettings>): void {
|
||||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -167,10 +322,6 @@ export function applyFontSettings(s: Partial<FontSettings>): void {
|
|||||||
if (s.family != null && s.family !== "") root.style.setProperty("--ps-font-body", s.family);
|
if (s.family != null && s.family !== "") root.style.setProperty("--ps-font-body", s.family);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply UI density ("compact" | "comfortable") by setting `data-density` on
|
|
||||||
* `:root` and updating `--ps-density` and `--ps-topbar-h`.
|
|
||||||
*/
|
|
||||||
export function applyDensity(density: "compact" | "comfortable"): void {
|
export function applyDensity(density: "compact" | "comfortable"): void {
|
||||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export function registerAdminRoutes(app: FastifyInstance): void {
|
|||||||
|
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
|
if (body.email !== undefined) {
|
||||||
|
const cleanEmail = body.email.trim().toLowerCase();
|
||||||
|
const taken = db
|
||||||
|
.prepare("SELECT id FROM users WHERE LOWER(email) = ? AND id != ?")
|
||||||
|
.get(cleanEmail, id);
|
||||||
|
if (taken) throw instance.httpErrors.create(409, { error: "email_taken" });
|
||||||
|
fields.push("email = ?");
|
||||||
|
values.push(cleanEmail);
|
||||||
|
}
|
||||||
if (body.displayName !== undefined) {
|
if (body.displayName !== undefined) {
|
||||||
fields.push("display_name = ?");
|
fields.push("display_name = ?");
|
||||||
values.push(body.displayName);
|
values.push(body.displayName);
|
||||||
@@ -189,6 +198,7 @@ const userCreate = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPatch = z.object({
|
const userPatch = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email().optional(),
|
||||||
role: z.enum(["admin", "author"]).optional(),
|
role: z.enum(["admin", "author"]).optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
displayName: z.string().min(1).max(80).optional(),
|
displayName: z.string().min(1).max(80).optional(),
|
||||||
|
|||||||
+222
-43
@@ -10,11 +10,30 @@ import { toUser, type UserRow } from "./mapping.js";
|
|||||||
const SESSION_COOKIE = "ps_session";
|
const SESSION_COOKIE = "ps_session";
|
||||||
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
||||||
|
|
||||||
// ---- password hashing ----
|
// ---- password hashing (OWASP Argon2id parameters) ----
|
||||||
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id });
|
const ARGON2_OPTS: argon2.Options = {
|
||||||
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw);
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 65536, // 64 MiB
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
};
|
||||||
|
|
||||||
// ---- session tokens (store only the SHA-256 hash) ----
|
export const hashPassword = (pw: string): Promise<string> =>
|
||||||
|
argon2.hash(pw, ARGON2_OPTS);
|
||||||
|
|
||||||
|
export const verifyPassword = (hash: string, pw: string): Promise<boolean> =>
|
||||||
|
argon2.verify(hash, pw);
|
||||||
|
|
||||||
|
// Precomputed dummy hash to prevent timing-based user enumeration on login
|
||||||
|
let dummyHashPromise: Promise<string> | null = null;
|
||||||
|
function getDummyHash(): Promise<string> {
|
||||||
|
if (!dummyHashPromise) {
|
||||||
|
dummyHashPromise = hashPassword("pubscript-dummy-anti-enumeration-secret");
|
||||||
|
}
|
||||||
|
return dummyHashPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- session tokens (256-bit CSPRNG; store only the SHA-256 hash in SQLite) ----
|
||||||
export function newToken(): string {
|
export function newToken(): string {
|
||||||
return crypto.randomBytes(32).toString("hex");
|
return crypto.randomBytes(32).toString("hex");
|
||||||
}
|
}
|
||||||
@@ -25,11 +44,16 @@ function tokenHash(token: string): string {
|
|||||||
export function createSession(db: Db, userId: string): string {
|
export function createSession(db: Db, userId: string): string {
|
||||||
const token = newToken();
|
const token = newToken();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
|
const nowIso = new Date(now).toISOString();
|
||||||
|
// Prune expired sessions opportunistically
|
||||||
|
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(nowIso);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
).run(
|
||||||
tokenHash(token),
|
tokenHash(token),
|
||||||
userId,
|
userId,
|
||||||
new Date(now + SESSION_TTL_MS).toISOString(),
|
new Date(now + SESSION_TTL_MS).toISOString(),
|
||||||
new Date(now).toISOString(),
|
nowIso,
|
||||||
);
|
);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -51,7 +75,7 @@ export function userFromToken(db: Db, token: string | null | undefined): UserRow
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- tiny in-memory fixed-window rate limiter ----
|
// ---- fixed-window rate limiter ----
|
||||||
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
|
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
|
||||||
const hits = new Map<string, { count: number; resetAt: number }>();
|
const hits = new Map<string, { count: number; resetAt: number }>();
|
||||||
return (key: string): boolean => {
|
return (key: string): boolean => {
|
||||||
@@ -74,19 +98,48 @@ export function bearerToken(req: FastifyRequest): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerAuthHook(app: FastifyInstance): void {
|
export function registerAuthHook(app: FastifyInstance): void {
|
||||||
|
// Purge legacy auto-bootstrapped placeholder user if it is the only user in DB
|
||||||
|
try {
|
||||||
|
const users = app.db
|
||||||
|
.prepare("SELECT id, email FROM users")
|
||||||
|
.all() as Array<{ id: string; email: string }>;
|
||||||
|
if (users.length === 1 && users[0]?.email === "admin@pubscript.local" && !process.env.VITEST) {
|
||||||
|
const legacyId = users[0].id;
|
||||||
|
app.db.prepare("DELETE FROM sessions WHERE user_id = ?").run(legacyId);
|
||||||
|
app.db.prepare("DELETE FROM users WHERE id = ?").run(legacyId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
app.addHook("onRequest", async (req) => {
|
app.addHook("onRequest", async (req) => {
|
||||||
const db = app.db;
|
const db = app.db;
|
||||||
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
const token =
|
||||||
|
bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||||
const row = userFromToken(db, token);
|
const row = userFromToken(db, token);
|
||||||
req.user = row ? toUser(row) : null;
|
req.user = row ? toUser(row) : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionCookie(reply: FastifyReply, token: string): void {
|
function isSecureRequest(req: FastifyRequest): boolean {
|
||||||
|
if (req.protocol === "https") return true;
|
||||||
|
const proto = req.headers["x-forwarded-proto"];
|
||||||
|
if (typeof proto === "string" && proto.split(",")[0]?.trim().toLowerCase() === "https") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(
|
||||||
|
reply: FastifyReply,
|
||||||
|
token: string,
|
||||||
|
req?: FastifyRequest,
|
||||||
|
): void {
|
||||||
reply.setCookie(SESSION_COOKIE, token, {
|
reply.setCookie(SESSION_COOKIE, token, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
|
secure: req ? isSecureRequest(req) : false,
|
||||||
maxAge: SESSION_TTL_MS / 1000,
|
maxAge: SESSION_TTL_MS / 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -100,14 +153,21 @@ export function requireAuth(req: FastifyRequest): void {
|
|||||||
}
|
}
|
||||||
export function requireAdmin(req: FastifyRequest): void {
|
export function requireAdmin(req: FastifyRequest): void {
|
||||||
requireAuth(req);
|
requireAuth(req);
|
||||||
if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
|
if (req.user!.role !== "admin") {
|
||||||
|
throw req.server.httpErrors.create(403, { error: "forbidden" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean {
|
export function canAccessProject(
|
||||||
|
db: Db,
|
||||||
|
user: User,
|
||||||
|
projectId: string,
|
||||||
|
needWrite = false,
|
||||||
|
): boolean {
|
||||||
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
|
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
|
||||||
| { owner_id: string }
|
| { owner_id: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!proj) return false;
|
if (!proj) return false;
|
||||||
if (proj.owner_id === user.id) return true;
|
if (user.role === "admin" || proj.owner_id === user.id) return true;
|
||||||
const share = db
|
const share = db
|
||||||
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
||||||
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
|
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
|
||||||
@@ -116,24 +176,91 @@ export function canAccessProject(db: Db, user: User, projectId: string, needWrit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- auth routes ----
|
// ---- auth routes ----
|
||||||
const registerSchema = z.object({
|
const setupSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().trim().toLowerCase().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8).max(256),
|
||||||
displayName: z.string().min(1).max(80),
|
displayName: z.string().trim().min(1).max(80),
|
||||||
|
});
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email(),
|
||||||
|
password: z.string().min(1).max(256),
|
||||||
|
});
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8).max(256),
|
||||||
});
|
});
|
||||||
const loginSchema = z.object({ email: z.string().email(), password: z.string() });
|
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance): void {
|
export function registerAuthRoutes(app: FastifyInstance): void {
|
||||||
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
|
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
|
||||||
|
|
||||||
app.post("/api/auth/register", async (req, reply) => {
|
// Check whether the instance has been initialized with an admin user and whether the caller is authenticated
|
||||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
app.get("/api/auth/status", async (req) => {
|
||||||
const body = registerSchema.parse(req.body);
|
const count = (
|
||||||
|
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
|
||||||
|
).n;
|
||||||
|
const ua = String(req.headers["user-agent"] ?? "");
|
||||||
|
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
|
||||||
|
const isMobileClient =
|
||||||
|
chMobile === "?1" ||
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
||||||
|
return {
|
||||||
|
initialized: count > 0,
|
||||||
|
authenticated: Boolean(req.user),
|
||||||
|
user: req.user ?? null,
|
||||||
|
isMobileClient,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// First-install administrator setup (only permitted when 0 users exist in DB)
|
||||||
|
app.post("/api/auth/setup", async (req, reply) => {
|
||||||
|
if (!authLimiter(req.ip)) {
|
||||||
|
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||||
|
}
|
||||||
const db = app.db;
|
const db = app.db;
|
||||||
|
const userCount = (
|
||||||
|
db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
|
||||||
|
).n;
|
||||||
|
if (userCount > 0) {
|
||||||
|
throw app.httpErrors.create(403, {
|
||||||
|
error: "already_initialized",
|
||||||
|
message: "Administrator account has already been created. Please sign in.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = setupSchema.parse(req.body);
|
||||||
|
const id = ulid();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const passHash = await hashPassword(body.password);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
||||||
|
).run(id, body.email, passHash, body.displayName, now);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.setup', ?)",
|
||||||
|
).run(ulid(), now, id, JSON.stringify({ email: body.email, ip: req.ip }));
|
||||||
|
|
||||||
|
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||||
|
const token = createSession(db, id);
|
||||||
|
setSessionCookie(reply, token, req);
|
||||||
|
return { token, user: toUser(row) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register: creates first admin if DB has 0 users; otherwise requires an authenticated Admin
|
||||||
|
app.post("/api/auth/register", async (req, reply) => {
|
||||||
|
if (!authLimiter(req.ip)) {
|
||||||
|
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||||
|
}
|
||||||
|
const db = app.db;
|
||||||
|
const isFirst =
|
||||||
|
(db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
|
||||||
|
if (!isFirst) {
|
||||||
|
requireAdmin(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = setupSchema.parse(req.body);
|
||||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
||||||
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
|
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
|
||||||
|
|
||||||
const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
|
|
||||||
const role = isFirst ? "admin" : "author";
|
const role = isFirst ? "admin" : "author";
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -141,49 +268,107 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||||
).run(id, body.email, passHash, body.displayName, role, now);
|
).run(id, body.email, passHash, body.displayName, role, now);
|
||||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
|
db.prepare(
|
||||||
ulid(),
|
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)",
|
||||||
now,
|
).run(ulid(), now, id, JSON.stringify({ email: body.email, first: isFirst }));
|
||||||
id,
|
|
||||||
JSON.stringify({ email: body.email, first: isFirst }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||||
const token = createSession(db, id);
|
const token = createSession(db, id);
|
||||||
setSessionCookie(reply, token);
|
setSessionCookie(reply, token, req);
|
||||||
return { token, user: toUser(row) };
|
return { token, user: toUser(row) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/login", async (req, reply) => {
|
app.post("/api/auth/login", async (req, reply) => {
|
||||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
|
||||||
const body = loginSchema.parse(req.body);
|
const body = loginSchema.parse(req.body);
|
||||||
|
const rateKey = `${req.ip}:${body.email}`;
|
||||||
|
if (!authLimiter(rateKey)) {
|
||||||
|
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||||
|
}
|
||||||
const db = app.db;
|
const db = app.db;
|
||||||
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
|
const row = db
|
||||||
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
|
.prepare("SELECT * FROM users WHERE LOWER(email) = ?")
|
||||||
|
.get(body.email) as UserRow | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
await verifyPassword(await getDummyHash(), body.password);
|
||||||
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(row.pass_hash, body.password);
|
||||||
|
if (!valid || row.disabled) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login_failed', ?)",
|
||||||
|
).run(
|
||||||
|
ulid(),
|
||||||
|
new Date().toISOString(),
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ email: body.email, ip: req.ip }),
|
||||||
|
);
|
||||||
|
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login', ?)",
|
||||||
|
).run(
|
||||||
|
ulid(),
|
||||||
|
new Date().toISOString(),
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ email: body.email, ip: req.ip }),
|
||||||
|
);
|
||||||
|
|
||||||
const token = createSession(db, row.id);
|
const token = createSession(db, row.id);
|
||||||
setSessionCookie(reply, token);
|
setSessionCookie(reply, token, req);
|
||||||
return { token, user: toUser(row) };
|
return { token, user: toUser(row) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (req, reply) => {
|
app.post("/api/auth/logout", async (req, reply) => {
|
||||||
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
const token =
|
||||||
|
bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||||
if (token) destroySession(app.db, token);
|
if (token) destroySession(app.db, token);
|
||||||
clearSessionCookie(reply);
|
clearSessionCookie(reply);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/change-password", async (req, reply) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const body = changePasswordSchema.parse(req.body);
|
||||||
|
const db = app.db;
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT * FROM users WHERE id = ?")
|
||||||
|
.get(req.user!.id) as UserRow | undefined;
|
||||||
|
if (!row || !(await verifyPassword(row.pass_hash, body.currentPassword))) {
|
||||||
|
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||||
|
}
|
||||||
|
const nextHash = await hashPassword(body.newPassword);
|
||||||
|
db.prepare("UPDATE users SET pass_hash = ? WHERE id = ?").run(nextHash, row.id);
|
||||||
|
// Revoke all existing sessions for this user and issue a fresh one
|
||||||
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(row.id);
|
||||||
|
const token = createSession(db, row.id);
|
||||||
|
setSessionCookie(reply, token, req);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.password_change', ?)",
|
||||||
|
).run(ulid(), new Date().toISOString(), row.id, JSON.stringify({ ip: req.ip }));
|
||||||
|
return { ok: true, token };
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/auth/me", async (req) => {
|
app.get("/api/auth/me", async (req) => {
|
||||||
requireAuth(req);
|
requireAuth(req);
|
||||||
return { user: req.user };
|
return { user: req.user };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bootstrap or sign into the local admin account when no admin session is active yet.
|
// Test-only bootstrap helper (disabled in production once initialized)
|
||||||
app.post("/api/auth/bootstrap-admin", async (_req, reply) => {
|
app.post("/api/auth/bootstrap-admin", async (req, reply) => {
|
||||||
|
if (!process.env.VITEST && process.env.NODE_ENV !== "test") {
|
||||||
|
throw app.httpErrors.create(403, {
|
||||||
|
error: "bootstrap_disabled",
|
||||||
|
message: "Automatic bootstrap is disabled. Use /api/auth/setup or /api/auth/login.",
|
||||||
|
});
|
||||||
|
}
|
||||||
const db = app.db;
|
const db = app.db;
|
||||||
let adminRow = db
|
let adminRow = db
|
||||||
.prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1")
|
.prepare(
|
||||||
|
"SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1",
|
||||||
|
)
|
||||||
.get() as UserRow | undefined;
|
.get() as UserRow | undefined;
|
||||||
|
|
||||||
if (!adminRow) {
|
if (!adminRow) {
|
||||||
@@ -195,17 +380,11 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
||||||
).run(id, email, passHash, "Local Admin", now);
|
).run(id, email, passHash, "Local Admin", now);
|
||||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
|
|
||||||
ulid(),
|
|
||||||
now,
|
|
||||||
id,
|
|
||||||
JSON.stringify({ email }),
|
|
||||||
);
|
|
||||||
adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = createSession(db, adminRow.id);
|
const token = createSession(db, adminRow.id);
|
||||||
setSessionCookie(reply, token);
|
setSessionCookie(reply, token, req);
|
||||||
return { token, user: toUser(adminRow) };
|
return { token, user: toUser(adminRow) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -2,8 +2,12 @@
|
|||||||
* Environment-driven server configuration.
|
* Environment-driven server configuration.
|
||||||
*
|
*
|
||||||
* All values are read from `process.env` at import time with sensible
|
* All values are read from `process.env` at import time with sensible
|
||||||
* defaults so the server boots with zero configuration for local dev.
|
* defaults so the server and desktop app share the same canonical user data
|
||||||
|
* directory (`~/.local/share/pubscript/pubscript.sqlite`).
|
||||||
*/
|
*/
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
function env(key: string, fallback: string): string {
|
function env(key: string, fallback: string): string {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
@@ -17,6 +21,18 @@ function envInt(key: string, fallback: number): number {
|
|||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultDatabasePath(): string {
|
||||||
|
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||||
|
return ":memory:";
|
||||||
|
}
|
||||||
|
// In Docker container runtime, use /app/data/pubscript.sqlite if /app/data exists
|
||||||
|
if (existsSync("/app/data")) {
|
||||||
|
return "/app/data/pubscript.sqlite";
|
||||||
|
}
|
||||||
|
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
||||||
|
return join(dataHome, "pubscript", "pubscript.sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
/** Path to the SQLite database file. */
|
/** Path to the SQLite database file. */
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
@@ -33,7 +49,7 @@ export interface ServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config: ServerConfig = {
|
export const config: ServerConfig = {
|
||||||
dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
|
dbPath: env("PUBSCRIPT_DB", defaultDatabasePath()),
|
||||||
port: envInt("PORT", 8080),
|
port: envInt("PORT", 8080),
|
||||||
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
|
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
|
||||||
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
|
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
|
||||||
|
|||||||
+581
-6
@@ -1,5 +1,16 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { execFileSync } from "node:child_process";
|
||||||
import { dirname, resolve } from "node:path";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { createServer, type Server as HttpServer } from "node:http";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
@@ -9,7 +20,13 @@ import websocket from "@fastify/websocket";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { openDb, ensureAdmin } from "./db.js";
|
import { openDb, ensureAdmin } from "./db.js";
|
||||||
import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
|
import {
|
||||||
|
hashPassword,
|
||||||
|
registerAuthHook,
|
||||||
|
registerAuthRoutes,
|
||||||
|
requireAdmin,
|
||||||
|
requireAuth,
|
||||||
|
} from "./auth.js";
|
||||||
import { registerRoutes } from "./routes.js";
|
import { registerRoutes } from "./routes.js";
|
||||||
import { registerWs } from "./ws.js";
|
import { registerWs } from "./ws.js";
|
||||||
import { registerAdminRoutes } from "./admin.js";
|
import { registerAdminRoutes } from "./admin.js";
|
||||||
@@ -35,7 +52,7 @@ function makeHttpError(status: number, payload: object): HttpError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
await app.register(cors, { origin: true, credentials: true });
|
await app.register(cors, { origin: true, credentials: true });
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
await app.register(websocket);
|
await app.register(websocket);
|
||||||
@@ -62,7 +79,535 @@ export async function buildApp() {
|
|||||||
} catch {
|
} catch {
|
||||||
dbUp = false;
|
dbUp = false;
|
||||||
}
|
}
|
||||||
return { ok: dbUp, db: dbUp ? "up" : "down", version: config.version, uptime: process.uptime() };
|
return {
|
||||||
|
ok: dbUp,
|
||||||
|
db: dbUp ? "up" : "down",
|
||||||
|
version: config.version,
|
||||||
|
port: config.port,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sysConfigDir = join(homedir(), ".config", "pubscript");
|
||||||
|
const sysConfigPath = join(sysConfigDir, "server.json");
|
||||||
|
const autostartDir = join(homedir(), ".config", "autostart");
|
||||||
|
const autostartDesktopPath = join(autostartDir, "pubscript-server.desktop");
|
||||||
|
const systemdUserDir = join(homedir(), ".config", "systemd", "user");
|
||||||
|
const systemdServicePath = join(systemdUserDir, "pubscript-server.service");
|
||||||
|
|
||||||
|
const defaultProjectDir = join(homedir(), "Documents", "PubScript");
|
||||||
|
|
||||||
|
function expandTilde(inputPath: string): string {
|
||||||
|
const trimmed = inputPath.trim();
|
||||||
|
if (!trimmed) return defaultProjectDir;
|
||||||
|
if (trimmed === "~") return homedir();
|
||||||
|
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||||
|
return resolve(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedServerSysConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
allowedHosts: string;
|
||||||
|
port: number;
|
||||||
|
webPort: number;
|
||||||
|
startOnBoot: boolean;
|
||||||
|
projectDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let secondaryWebServer: HttpServer | null = null;
|
||||||
|
let activeWebPort: number = 5173;
|
||||||
|
let activeBindHost: string = "127.0.0.1";
|
||||||
|
|
||||||
|
function extractHostname(entry: string): string {
|
||||||
|
let s = entry.trim().toLowerCase();
|
||||||
|
if (!s) return "";
|
||||||
|
s = s.replace(/^[a-z]+:\/\//, "");
|
||||||
|
s = s.split("/")[0] ?? "";
|
||||||
|
if (!s.startsWith("[")) {
|
||||||
|
s = s.split(":")[0] ?? "";
|
||||||
|
}
|
||||||
|
return s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequestHostAllowed(rawHostHeader: string | undefined, allowedHostsRaw: string): boolean {
|
||||||
|
if (!rawHostHeader) return true;
|
||||||
|
const firstForwarded = rawHostHeader.split(",")[0]?.trim() ?? "";
|
||||||
|
const reqHost = extractHostname(firstForwarded);
|
||||||
|
if (!reqHost || reqHost === "localhost" || reqHost === "127.0.0.1" || reqHost === "::1" || reqHost === "[::1]") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const tokens = allowedHostsRaw
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((t) => extractHostname(t))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (tokens.length === 0) return true;
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token === "*" || token === "0.0.0.0" || token === reqHost) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (token.startsWith("*.")) {
|
||||||
|
const suffix = token.slice(1); // ".domain.com"
|
||||||
|
if (reqHost === token.slice(2) || reqHost.endsWith(suffix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (token.startsWith(".")) {
|
||||||
|
if (reqHost === token.slice(1) || reqHost.endsWith(token)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExternalAllowedHost(allowedHostsRaw: string): boolean {
|
||||||
|
const tokens = allowedHostsRaw
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((t) => extractHostname(t))
|
||||||
|
.filter(Boolean);
|
||||||
|
return tokens.some(
|
||||||
|
(t) => t !== "localhost" && t !== "127.0.0.1" && t !== "::1" && t !== "[::1]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersistedServerSysConfig(): PersistedServerSysConfig {
|
||||||
|
try {
|
||||||
|
if (existsSync(sysConfigPath)) {
|
||||||
|
const raw = JSON.parse(readFileSync(sysConfigPath, "utf8")) as Partial<PersistedServerSysConfig>;
|
||||||
|
const allowedHosts =
|
||||||
|
typeof raw.allowedHosts === "string" && raw.allowedHosts.trim()
|
||||||
|
? raw.allowedHosts.trim()
|
||||||
|
: "localhost, 127.0.0.1";
|
||||||
|
const rawHost =
|
||||||
|
typeof raw.host === "string" && raw.host.trim()
|
||||||
|
? raw.host.trim()
|
||||||
|
: "127.0.0.1";
|
||||||
|
return {
|
||||||
|
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
||||||
|
host:
|
||||||
|
rawHost === "127.0.0.1" && hasExternalAllowedHost(allowedHosts)
|
||||||
|
? "0.0.0.0"
|
||||||
|
: rawHost,
|
||||||
|
allowedHosts,
|
||||||
|
port:
|
||||||
|
typeof raw.port === "number" && raw.port >= 1024 && raw.port <= 65535
|
||||||
|
? raw.port
|
||||||
|
: config.port,
|
||||||
|
webPort:
|
||||||
|
typeof raw.webPort === "number" && raw.webPort >= 1024 && raw.webPort <= 65535
|
||||||
|
? raw.webPort
|
||||||
|
: 5173,
|
||||||
|
startOnBoot:
|
||||||
|
typeof raw.startOnBoot === "boolean"
|
||||||
|
? raw.startOnBoot
|
||||||
|
: existsSync(autostartDesktopPath) || existsSync(systemdServicePath),
|
||||||
|
projectDirectory:
|
||||||
|
typeof raw.projectDirectory === "string" && raw.projectDirectory.trim()
|
||||||
|
? raw.projectDirectory.trim()
|
||||||
|
: defaultProjectDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed config */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
allowedHosts: "localhost, 127.0.0.1",
|
||||||
|
port: config.port,
|
||||||
|
webPort: 5173,
|
||||||
|
startOnBoot: existsSync(autostartDesktopPath) || existsSync(systemdServicePath),
|
||||||
|
projectDirectory: defaultProjectDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.addHook("onRequest", async (req, reply) => {
|
||||||
|
reply.header("X-Content-Type-Options", "nosniff");
|
||||||
|
reply.header("X-Frame-Options", "SAMEORIGIN");
|
||||||
|
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
|
|
||||||
|
const ua = String(req.headers["user-agent"] ?? "");
|
||||||
|
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
|
||||||
|
const isMobile =
|
||||||
|
chMobile === "?1" ||
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
||||||
|
reply.header("X-PubScript-Client", isMobile ? "mobile" : "desktop");
|
||||||
|
reply.setCookie("ps_client_device", isMobile ? "mobile" : "desktop", {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = loadPersistedServerSysConfig();
|
||||||
|
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
|
||||||
|
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: "host_not_allowed",
|
||||||
|
message: `Host "${forwardedHost ?? ""}" is not in Allowed Hosts. Add your domain or 0.0.0.0 in Settings → Server & System.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bindSecondaryWebPort(
|
||||||
|
targetWebPort: number,
|
||||||
|
targetHost: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<number> {
|
||||||
|
activeWebPort = targetWebPort;
|
||||||
|
activeBindHost = targetHost || "127.0.0.1";
|
||||||
|
if (secondaryWebServer) {
|
||||||
|
await new Promise<void>((res) => secondaryWebServer!.close(() => res()));
|
||||||
|
secondaryWebServer = null;
|
||||||
|
}
|
||||||
|
if (!enabled || targetWebPort === config.port) {
|
||||||
|
return targetWebPort;
|
||||||
|
}
|
||||||
|
return await new Promise<number>((res) => {
|
||||||
|
const srv = createServer((req, reply) => {
|
||||||
|
app.server.emit("request", req, reply);
|
||||||
|
});
|
||||||
|
srv.once("error", () => {
|
||||||
|
// Port may already be served by Vite dev server (e.g. :5173); keep activeWebPort
|
||||||
|
res(targetWebPort);
|
||||||
|
});
|
||||||
|
srv.listen(targetWebPort, activeBindHost, () => {
|
||||||
|
secondaryWebServer = srv;
|
||||||
|
res(targetWebPort);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySystemStartupFiles(cfg: PersistedServerSysConfig): boolean {
|
||||||
|
try {
|
||||||
|
mkdirSync(sysConfigDir, { recursive: true });
|
||||||
|
writeFileSync(sysConfigPath, JSON.stringify(cfg, null, 2), "utf8");
|
||||||
|
try {
|
||||||
|
mkdirSync(expandTilde(cfg.projectDirectory), { recursive: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore if read-only */
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = join(homedir(), ".local", "share", "pubscript");
|
||||||
|
const dbFile = join(dataDir, "pubscript.sqlite");
|
||||||
|
const optServerEntry = "/opt/pubscript/server/dist/server.mjs";
|
||||||
|
const localServerEntry = resolve(dirname(fileURLToPath(import.meta.url)), "index.ts");
|
||||||
|
const execCmd = existsSync(optServerEntry)
|
||||||
|
? `/usr/bin/env PORT=${cfg.port} PUBSCRIPT_DB=${dbFile} PUBSCRIPT_WEB_DIST=/opt/pubscript/apps/web/dist node ${optServerEntry}`
|
||||||
|
: `/usr/bin/env PORT=${cfg.port} PUBSCRIPT_DB=${dbFile} node --import tsx ${localServerEntry}`;
|
||||||
|
|
||||||
|
if (cfg.startOnBoot) {
|
||||||
|
mkdirSync(autostartDir, { recursive: true });
|
||||||
|
const desktopContent = [
|
||||||
|
"[Desktop Entry]",
|
||||||
|
"Type=Application",
|
||||||
|
"Name=PubScript Server",
|
||||||
|
"Comment=PubScript Background API & Manuscript Server",
|
||||||
|
`Exec=${execCmd}`,
|
||||||
|
"Icon=pubscript",
|
||||||
|
"Terminal=false",
|
||||||
|
"Hidden=false",
|
||||||
|
"X-GNOME-Autostart-enabled=true",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
writeFileSync(autostartDesktopPath, desktopContent, "utf8");
|
||||||
|
|
||||||
|
mkdirSync(systemdUserDir, { recursive: true });
|
||||||
|
const serviceContent = [
|
||||||
|
"[Unit]",
|
||||||
|
"Description=PubScript Local Writing Studio Server",
|
||||||
|
"After=network.target",
|
||||||
|
"",
|
||||||
|
"[Service]",
|
||||||
|
"Type=simple",
|
||||||
|
`Environment=PORT=${cfg.port}`,
|
||||||
|
`Environment=PUBSCRIPT_DB=${dbFile}`,
|
||||||
|
"Environment=PUBSCRIPT_WEB_DIST=/opt/pubscript/apps/web/dist",
|
||||||
|
`ExecStart=${existsSync(optServerEntry) ? `/usr/bin/node ${optServerEntry}` : `/usr/bin/node --import tsx ${localServerEntry}`}`,
|
||||||
|
"Restart=always",
|
||||||
|
"RestartSec=2",
|
||||||
|
"",
|
||||||
|
"[Install]",
|
||||||
|
"WantedBy=default.target",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
writeFileSync(systemdServicePath, serviceContent, "utf8");
|
||||||
|
try {
|
||||||
|
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
||||||
|
execFileSync("systemctl", ["--user", "enable", "pubscript-server.service"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* systemctl --user optional when XDG autostart is used */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (existsSync(autostartDesktopPath)) {
|
||||||
|
rmSync(autostartDesktopPath, { force: true });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execFileSync("systemctl", ["--user", "disable", "pubscript-server.service"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (existsSync(systemdServicePath)) {
|
||||||
|
rmSync(systemdServicePath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/system/server-config", async (req) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const persisted = loadPersistedServerSysConfig();
|
||||||
|
const isMobileClient =
|
||||||
|
String(req.headers["sec-ch-ua-mobile"] ?? "") === "?1" ||
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||||
|
String(req.headers["user-agent"] ?? ""),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
enabled: persisted.enabled,
|
||||||
|
host: persisted.host,
|
||||||
|
activeHost: activeBindHost || persisted.host,
|
||||||
|
allowedHosts: persisted.allowedHosts,
|
||||||
|
port: persisted.port,
|
||||||
|
activePort: config.port,
|
||||||
|
webPort: persisted.webPort,
|
||||||
|
activeWebPort: activeWebPort || persisted.webPort,
|
||||||
|
startOnBoot: persisted.startOnBoot,
|
||||||
|
projectDirectory: persisted.projectDirectory,
|
||||||
|
autostartPath: autostartDesktopPath,
|
||||||
|
systemdServicePath,
|
||||||
|
isMobileClient,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host-side AI proxy so remote/mobile browsers and local sessions can talk to
|
||||||
|
// Ollama / llama.cpp / LM Studio / OpenAI-compatible / Anthropic / Google
|
||||||
|
// endpoints running on or reachable from the Host Machine without CORS or
|
||||||
|
// remote-localhost errors.
|
||||||
|
app.post("/api/ai/chat", async (req, reply) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const body = (req.body ?? {}) as {
|
||||||
|
kind?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
messages?: Array<{ role: string; content: string }>;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
};
|
||||||
|
const baseUrl = (body.baseUrl ?? "http://localhost:11434/v1").replace(/\/+$/, "");
|
||||||
|
const model = body.model || "llama3.2";
|
||||||
|
const messages = body.messages ?? [];
|
||||||
|
const temperature = body.temperature ?? 0.7;
|
||||||
|
const maxTokens = body.maxTokens ?? 2048;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (body.kind === "anthropic") {
|
||||||
|
const systemMsg = messages
|
||||||
|
.filter((m) => m.role === "system")
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n\n");
|
||||||
|
const chatMsgs = messages
|
||||||
|
.filter((m) => m.role !== "system")
|
||||||
|
.map((m) => ({
|
||||||
|
role: m.role === "assistant" ? "assistant" : "user",
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
const res = await fetch(`${baseUrl}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(body.apiKey ? { "x-api-key": body.apiKey } : {}),
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature,
|
||||||
|
...(systemMsg ? { system: systemMsg } : {}),
|
||||||
|
messages: chatMsgs,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
content?: Array<{ type?: string; text?: string; thinking?: string }>;
|
||||||
|
};
|
||||||
|
const text = (json.content ?? [])
|
||||||
|
.filter((c) => c.type === "text" || (!c.type && c.text))
|
||||||
|
.map((c) => c.text ?? "")
|
||||||
|
.join("");
|
||||||
|
const reasoning = (json.content ?? [])
|
||||||
|
.filter((c) => c.type === "thinking")
|
||||||
|
.map((c) => c.thinking ?? "")
|
||||||
|
.join("");
|
||||||
|
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
if (body.apiKey) {
|
||||||
|
headers.authorization = `Bearer ${body.apiKey}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: { content?: string; reasoning_content?: string; reasoning?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const msg = json.choices?.[0]?.message;
|
||||||
|
const text = msg?.content ?? "";
|
||||||
|
const reasoning = msg?.reasoning_content ?? msg?.reasoning;
|
||||||
|
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to reach AI provider from host",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/system/server-config", async (req, reply) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
|
||||||
|
const current = loadPersistedServerSysConfig();
|
||||||
|
const nextPort =
|
||||||
|
typeof body.port === "number" ? Math.trunc(body.port) : current.port;
|
||||||
|
if (!Number.isFinite(nextPort) || nextPort < 1024 || nextPort > 65535) {
|
||||||
|
return reply.code(400).send({ error: "invalid_port" });
|
||||||
|
}
|
||||||
|
const nextWebPort =
|
||||||
|
typeof body.webPort === "number" ? Math.trunc(body.webPort) : current.webPort;
|
||||||
|
if (!Number.isFinite(nextWebPort) || nextWebPort < 1024 || nextWebPort > 65535) {
|
||||||
|
return reply.code(400).send({ error: "invalid_web_port" });
|
||||||
|
}
|
||||||
|
const nextHost =
|
||||||
|
typeof body.host === "string" && body.host.trim()
|
||||||
|
? body.host.trim()
|
||||||
|
: current.host;
|
||||||
|
const nextAllowedHosts =
|
||||||
|
typeof body.allowedHosts === "string" && body.allowedHosts.trim()
|
||||||
|
? body.allowedHosts.trim()
|
||||||
|
: current.allowedHosts;
|
||||||
|
const nextProjectDir =
|
||||||
|
typeof body.projectDirectory === "string" && body.projectDirectory.trim()
|
||||||
|
? body.projectDirectory.trim()
|
||||||
|
: current.projectDirectory;
|
||||||
|
const next: PersistedServerSysConfig = {
|
||||||
|
enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled,
|
||||||
|
host: nextHost,
|
||||||
|
allowedHosts: nextAllowedHosts,
|
||||||
|
port: nextPort,
|
||||||
|
webPort: nextWebPort,
|
||||||
|
startOnBoot:
|
||||||
|
typeof body.startOnBoot === "boolean" ? body.startOnBoot : current.startOnBoot,
|
||||||
|
projectDirectory: nextProjectDir,
|
||||||
|
};
|
||||||
|
const configured = applySystemStartupFiles(next);
|
||||||
|
const boundWebPort = await bindSecondaryWebPort(next.webPort, next.host, next.enabled);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
enabled: next.enabled,
|
||||||
|
host: next.host,
|
||||||
|
activeHost: activeBindHost,
|
||||||
|
allowedHosts: next.allowedHosts,
|
||||||
|
port: next.port,
|
||||||
|
activePort: config.port,
|
||||||
|
webPort: next.webPort,
|
||||||
|
activeWebPort: boundWebPort,
|
||||||
|
startOnBoot: next.startOnBoot,
|
||||||
|
projectDirectory: next.projectDirectory,
|
||||||
|
autostartConfigured: configured,
|
||||||
|
autostartPath: autostartDesktopPath,
|
||||||
|
systemdServicePath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/system/list-dirs", async (req) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const query = (req.query ?? {}) as { path?: string };
|
||||||
|
const rawTarget = query.path ? expandTilde(query.path) : homedir();
|
||||||
|
const target = existsSync(rawTarget) ? rawTarget : homedir();
|
||||||
|
const parentPath = dirname(target);
|
||||||
|
const directories: { name: string; path: string }[] = [];
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(target, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith(".")) continue;
|
||||||
|
try {
|
||||||
|
const full = join(target, entry.name);
|
||||||
|
if (entry.isDirectory() || (entry.isSymbolicLink() && statSync(full).isDirectory())) {
|
||||||
|
directories.push({ name: entry.name, path: full });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* skip unreadable entry */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directories.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
currentPath: target,
|
||||||
|
parentPath: parentPath !== target ? parentPath : null,
|
||||||
|
homePath: homedir(),
|
||||||
|
directories,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/system/save-export", async (req, reply) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const body = (req.body ?? {}) as {
|
||||||
|
directory?: string;
|
||||||
|
fileName?: string;
|
||||||
|
base64Data?: string;
|
||||||
|
};
|
||||||
|
const safeName = basename((body.fileName ?? "").trim());
|
||||||
|
if (!safeName) {
|
||||||
|
return reply.code(400).send({ error: "invalid_file_name" });
|
||||||
|
}
|
||||||
|
if (typeof body.base64Data !== "string") {
|
||||||
|
return reply.code(400).send({ error: "missing_data" });
|
||||||
|
}
|
||||||
|
const targetDir = expandTilde(
|
||||||
|
body.directory?.trim() || loadPersistedServerSysConfig().projectDirectory,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
mkdirSync(targetDir, { recursive: true });
|
||||||
|
const fullPath = join(targetDir, safeName);
|
||||||
|
const buf = Buffer.from(body.base64Data, "base64");
|
||||||
|
writeFileSync(fullPath, buf);
|
||||||
|
return { ok: true, savedPath: fullPath };
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: "save_failed",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAuthHook(app);
|
registerAuthHook(app);
|
||||||
@@ -76,8 +621,9 @@ export async function buildApp() {
|
|||||||
const webRoot =
|
const webRoot =
|
||||||
process.env.PUBSCRIPT_WEB_DIST || resolve(here, "../../apps/web/dist");
|
process.env.PUBSCRIPT_WEB_DIST || resolve(here, "../../apps/web/dist");
|
||||||
if (existsSync(webRoot) && existsSync(resolve(webRoot, "index.html"))) {
|
if (existsSync(webRoot) && existsSync(resolve(webRoot, "index.html"))) {
|
||||||
|
const resolvedWebRoot = resolve(webRoot);
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: webRoot,
|
root: resolvedWebRoot,
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
});
|
});
|
||||||
@@ -87,12 +633,41 @@ export async function buildApp() {
|
|||||||
!req.url.startsWith("/api/") &&
|
!req.url.startsWith("/api/") &&
|
||||||
!req.url.startsWith("/ws")
|
!req.url.startsWith("/ws")
|
||||||
) {
|
) {
|
||||||
|
const cleanPath = decodeURIComponent((req.url.split("?")[0] ?? "/").replace(/^\/+/, ""));
|
||||||
|
if (cleanPath && !cleanPath.includes("..")) {
|
||||||
|
const candidate = resolve(resolvedWebRoot, cleanPath);
|
||||||
|
if (
|
||||||
|
candidate.startsWith(resolvedWebRoot) &&
|
||||||
|
existsSync(candidate) &&
|
||||||
|
statSync(candidate).isFile()
|
||||||
|
) {
|
||||||
|
return reply.sendFile(cleanPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleanPath.startsWith("assets/")) {
|
||||||
|
return reply.code(404).send({ error: "asset_not_found" });
|
||||||
|
}
|
||||||
return reply.sendFile("index.html");
|
return reply.sendFile("index.html");
|
||||||
}
|
}
|
||||||
return reply.code(404).send({ error: "not_found" });
|
return reply.code(404).send({ error: "not_found" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
if (secondaryWebServer) {
|
||||||
|
await new Promise<void>((res) => secondaryWebServer!.close(() => res()));
|
||||||
|
secondaryWebServer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] !== undefined &&
|
||||||
|
fileURLToPath(import.meta.url) === process.argv[1]
|
||||||
|
) {
|
||||||
|
const persisted = loadPersistedServerSysConfig();
|
||||||
|
void bindSecondaryWebPort(persisted.webPort, persisted.host, persisted.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+178
-19
@@ -1,3 +1,6 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
@@ -29,7 +32,12 @@ import {
|
|||||||
} from "./mapping.js";
|
} from "./mapping.js";
|
||||||
|
|
||||||
// ---- input schemas ----
|
// ---- input schemas ----
|
||||||
const projectCreate = z.object({ title: z.string().min(1).max(200) });
|
const projectCreate = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
language: z.string().optional(),
|
||||||
|
cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(),
|
||||||
|
});
|
||||||
const projectPatch = z.object({
|
const projectPatch = z.object({
|
||||||
title: z.string().min(1).max(200).optional(),
|
title: z.string().min(1).max(200).optional(),
|
||||||
settings: z.record(z.unknown()).optional(),
|
settings: z.record(z.unknown()).optional(),
|
||||||
@@ -51,6 +59,9 @@ const chapterPatch = z.object({
|
|||||||
const autosaveBody = z.object({
|
const autosaveBody = z.object({
|
||||||
rev: z.number().int(),
|
rev: z.number().int(),
|
||||||
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
|
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
|
||||||
|
title: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
bookId: z.string().optional(),
|
||||||
wordCount: z.number().int().optional(),
|
wordCount: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
const entityCreate = z.object({
|
const entityCreate = z.object({
|
||||||
@@ -121,6 +132,93 @@ function projectRow(db: any, id: string): ProjectRow | undefined {
|
|||||||
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
|
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHostProjectBaseDir(): string {
|
||||||
|
const defaultDir = join(homedir(), "Documents", "PubScript");
|
||||||
|
try {
|
||||||
|
const sysCfg = join(homedir(), ".config", "pubscript", "server.json");
|
||||||
|
if (existsSync(sysCfg)) {
|
||||||
|
const parsed = JSON.parse(readFileSync(sysCfg, "utf8")) as { projectDirectory?: string };
|
||||||
|
if (parsed.projectDirectory && parsed.projectDirectory.trim()) {
|
||||||
|
const raw = parsed.projectDirectory.trim();
|
||||||
|
if (raw === "~") return homedir();
|
||||||
|
if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
|
||||||
|
return resolve(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return defaultDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docToMarkdownText(docStr: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(docStr) as { content?: Array<Record<string, unknown>> };
|
||||||
|
const lines: string[] = [];
|
||||||
|
const extractInline = (node: Record<string, unknown>): string => {
|
||||||
|
if (typeof node.text === "string") return node.text;
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
return node.content
|
||||||
|
.map((c) => (c && typeof c === "object" ? extractInline(c as Record<string, unknown>) : ""))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
for (const block of parsed.content ?? []) {
|
||||||
|
lines.push(extractInline(block));
|
||||||
|
}
|
||||||
|
return lines.join("\n\n");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncProjectToHostDisk(db: any, projectId: string): void {
|
||||||
|
if (process.env.VITEST || process.env.NODE_ENV === "test") return;
|
||||||
|
try {
|
||||||
|
const proj = projectRow(db, projectId);
|
||||||
|
if (!proj) return;
|
||||||
|
const baseDir = getHostProjectBaseDir();
|
||||||
|
const safeSlug =
|
||||||
|
proj.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-_]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || proj.id;
|
||||||
|
const projDir = join(baseDir, `${safeSlug}-${proj.id.slice(-6).toLowerCase()}`);
|
||||||
|
const chaptersDir = join(projDir, "chapters");
|
||||||
|
mkdirSync(chaptersDir, { recursive: true });
|
||||||
|
|
||||||
|
const books = db.prepare("SELECT * FROM books WHERE project_id = ? ORDER BY ord").all(projectId) as BookRow[];
|
||||||
|
const chapters = db.prepare("SELECT * FROM chapters WHERE project_id = ? ORDER BY ord").all(projectId) as ChapterRow[];
|
||||||
|
const entities = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(projectId) as EntityRow[];
|
||||||
|
const beats = db.prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order").all(projectId) as PlotBeatRow[];
|
||||||
|
const boards = db.prepare("SELECT * FROM boards WHERE project_id = ?").all(projectId) as BoardRow[];
|
||||||
|
|
||||||
|
for (const [idx, ch] of chapters.entries()) {
|
||||||
|
const chSlug =
|
||||||
|
ch.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-_]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || `chapter-${idx + 1}`;
|
||||||
|
const mdPath = join(chaptersDir, `${String(idx + 1).padStart(2, "0")}-${chSlug}.md`);
|
||||||
|
writeFileSync(mdPath, `# ${ch.title}\n\n${docToMarkdownText(ch.doc)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = {
|
||||||
|
project: toProject(proj, sharesFor(db, projectId)),
|
||||||
|
books: books.map((b) => toBook(b, chapters.filter((c) => c.book_id === b.id))),
|
||||||
|
chapters: chapters.map(toChapter),
|
||||||
|
entities: entities.map(toEntity),
|
||||||
|
plotBeats: beats.map(toPlotBeat),
|
||||||
|
boards: boards.map(toBoard),
|
||||||
|
syncedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeFileSync(join(projDir, "project.json"), JSON.stringify(bundle, null, 2), "utf8");
|
||||||
|
} catch {
|
||||||
|
/* non-fatal host disk mirror */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerRoutes(app: FastifyInstance): void {
|
export function registerRoutes(app: FastifyInstance): void {
|
||||||
const db = app.db;
|
const db = app.db;
|
||||||
|
|
||||||
@@ -128,6 +226,10 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
app.get("/api/projects", async (req) => {
|
app.get("/api/projects", async (req) => {
|
||||||
requireAuth(req);
|
requireAuth(req);
|
||||||
const u = req.user!;
|
const u = req.user!;
|
||||||
|
if (u.role === "admin") {
|
||||||
|
const all = db.prepare("SELECT * FROM projects ORDER BY updated_at DESC").all() as ProjectRow[];
|
||||||
|
return { projects: all.map((r) => toProjectMeta(r, sharesFor(db, r.id), u)) };
|
||||||
|
}
|
||||||
const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[];
|
const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[];
|
||||||
const shared = db
|
const shared = db
|
||||||
.prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?")
|
.prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?")
|
||||||
@@ -139,12 +241,18 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
|
|
||||||
app.post("/api/projects", async (req) => {
|
app.post("/api/projects", async (req) => {
|
||||||
requireAuth(req);
|
requireAuth(req);
|
||||||
const { title } = projectCreate.parse(req.body);
|
const body = projectCreate.parse(req.body);
|
||||||
const id = ulid();
|
const id = body.id ?? ulid();
|
||||||
const t = now();
|
const t = now();
|
||||||
|
const coverJson = JSON.stringify(body.cover ?? { color: "#7c8bf5" });
|
||||||
|
const settingsJson = JSON.stringify(body.language ? { language: body.language } : {});
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)",
|
`INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at)
|
||||||
).run(id, req.user!.id, title, t, t);
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
title = excluded.title, cover = excluded.cover, updated_at = excluded.updated_at`,
|
||||||
|
).run(id, req.user!.id, body.title, settingsJson, coverJson, t, t);
|
||||||
|
syncProjectToHostDisk(db, id);
|
||||||
const row = projectRow(db, id)!;
|
const row = projectRow(db, id)!;
|
||||||
return { project: toProject(row, []) };
|
return { project: toProject(row, []) };
|
||||||
});
|
});
|
||||||
@@ -327,25 +435,74 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
return { chapter: toChapter(updated) };
|
return { chapter: toChapter(updated) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimistic autosave (CHUNK #2 acceptance criterion).
|
// Optimistic autosave & chapter upsert (supports both existing and newly created chapters).
|
||||||
app.post("/api/chapters/:id/autosave", async (req) => {
|
app.post("/api/chapters/:id/autosave", async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
const body = autosaveBody.parse(req.body);
|
||||||
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
|
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
|
||||||
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
|
||||||
requireProjectAccess(app, req, row.project_id, true);
|
if (!row) {
|
||||||
const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body);
|
requireAuth(req);
|
||||||
if (rev === row.rev) {
|
let projectId = body.projectId;
|
||||||
|
let bookId = body.bookId;
|
||||||
|
if (bookId && !projectId) {
|
||||||
|
const bk = db.prepare("SELECT project_id FROM books WHERE id = ?").get(bookId) as
|
||||||
|
| { project_id: string }
|
||||||
|
| undefined;
|
||||||
|
projectId = bk?.project_id;
|
||||||
|
}
|
||||||
|
if (!projectId) {
|
||||||
|
const firstProj = db
|
||||||
|
.prepare("SELECT id FROM projects ORDER BY updated_at DESC LIMIT 1")
|
||||||
|
.get() as { id: string } | undefined;
|
||||||
|
projectId = firstProj?.id;
|
||||||
|
}
|
||||||
|
if (!projectId) throw app.httpErrors.create(404, { error: "project_not_found" });
|
||||||
|
requireProjectAccess(app, req, projectId, true);
|
||||||
|
|
||||||
|
if (!bookId) {
|
||||||
|
const existingBook = db
|
||||||
|
.prepare("SELECT id FROM books WHERE project_id = ? ORDER BY ord LIMIT 1")
|
||||||
|
.get(projectId) as { id: string } | undefined;
|
||||||
|
if (existingBook) {
|
||||||
|
bookId = existingBook.id;
|
||||||
|
} else {
|
||||||
|
bookId = ulid();
|
||||||
|
db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, 'Book One', 0)").run(
|
||||||
|
bookId,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ord = (
|
||||||
|
db
|
||||||
|
.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM chapters WHERE book_id = ?")
|
||||||
|
.get(bookId) as { n: number }
|
||||||
|
).n;
|
||||||
const t = now();
|
const t = now();
|
||||||
const count = wc ?? wordCount(doc);
|
const chTitle = body.title?.trim() || "Untitled Chapter";
|
||||||
db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id);
|
const nextRev = Math.max(1, body.rev + 1);
|
||||||
return { ok: true, rev: row.rev + 1, wordCount: count };
|
db.prepare(
|
||||||
|
"INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
).run(id, bookId, projectId, chTitle, JSON.stringify(body.doc), nextRev, ord, t);
|
||||||
|
db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(t, projectId);
|
||||||
|
syncProjectToHostDisk(db, projectId);
|
||||||
|
const count = body.wordCount ?? wordCount(body.doc);
|
||||||
|
return { ok: true, rev: nextRev, wordCount: count };
|
||||||
}
|
}
|
||||||
// Stale client — return the server's current state so it can reconcile.
|
|
||||||
throw app.httpErrors.create(409, {
|
requireProjectAccess(app, req, row.project_id, true);
|
||||||
error: "conflict",
|
const t = now();
|
||||||
serverRev: row.rev,
|
const nextTitle = body.title?.trim() || row.title;
|
||||||
serverDoc: JSON.parse(row.doc),
|
const nextRev = row.rev + 1;
|
||||||
});
|
const count = body.wordCount ?? wordCount(body.doc);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE chapters SET title = ?, doc = ?, rev = ?, updated_at = ? WHERE id = ?",
|
||||||
|
).run(nextTitle, JSON.stringify(body.doc), nextRev, t, id);
|
||||||
|
db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(t, row.project_id);
|
||||||
|
syncProjectToHostDisk(db, row.project_id);
|
||||||
|
return { ok: true, rev: nextRev, wordCount: count };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/chapters/:id", async (req) => {
|
app.delete("/api/chapters/:id", async (req) => {
|
||||||
@@ -403,6 +560,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
|
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
|
||||||
).run(eid, id, row.kind, row.name, row.fields, t, t);
|
).run(eid, id, row.kind, row.name, row.fields, t, t);
|
||||||
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow;
|
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow;
|
||||||
|
syncProjectToHostDisk(db, id);
|
||||||
return { entity: toEntity(stored) };
|
return { entity: toEntity(stored) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,6 +627,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t,
|
bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t,
|
||||||
);
|
);
|
||||||
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
|
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
|
||||||
|
syncProjectToHostDisk(db, id);
|
||||||
return { board: toBoard(row) };
|
return { board: toBoard(row) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
process.env.PUBSCRIPT_DB = ":memory:";
|
||||||
import { buildApp } from "./index.js";
|
import { buildApp } from "./index.js";
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -96,4 +97,88 @@ describe("admin endpoints", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces first-install setup lock, login authentication, and protected system routes", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
try {
|
||||||
|
// Unauthenticated system config request must be rejected with 401
|
||||||
|
const unauthSys = await app.inject({ method: "GET", url: "/api/system/server-config" });
|
||||||
|
expect(unauthSys.statusCode).toBe(401);
|
||||||
|
|
||||||
|
// Clear any users so we test fresh first-install state
|
||||||
|
app.db.prepare("DELETE FROM sessions").run();
|
||||||
|
app.db.prepare("DELETE FROM project_shares").run();
|
||||||
|
app.db.prepare("DELETE FROM projects").run();
|
||||||
|
app.db.prepare("DELETE FROM users").run();
|
||||||
|
|
||||||
|
const statusBefore = await app.inject({ method: "GET", url: "/api/auth/status" });
|
||||||
|
expect(statusBefore.statusCode).toBe(200);
|
||||||
|
expect(statusBefore.json()).toEqual({
|
||||||
|
initialized: false,
|
||||||
|
authenticated: false,
|
||||||
|
user: null,
|
||||||
|
isMobileClient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mobileStatus = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/auth/status",
|
||||||
|
headers: { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile/15E148" },
|
||||||
|
});
|
||||||
|
expect(mobileStatus.headers["x-pubscript-client"]).toBe("mobile");
|
||||||
|
expect((mobileStatus.json() as { isMobileClient: boolean }).isMobileClient).toBe(true);
|
||||||
|
|
||||||
|
const setupRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
payload: {
|
||||||
|
email: "owner@example.com",
|
||||||
|
displayName: "Studio Owner",
|
||||||
|
password: "StrongPassword!2026",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(setupRes.statusCode).toBe(200);
|
||||||
|
const { token, user } = setupRes.json() as { token: string; user: { role: string } };
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
expect(user.role).toBe("admin");
|
||||||
|
|
||||||
|
// Second setup attempt must be forbidden once initialized
|
||||||
|
const secondSetup = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
payload: {
|
||||||
|
email: "attacker@example.com",
|
||||||
|
displayName: "Attacker",
|
||||||
|
password: "StrongPassword!2026",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(secondSetup.statusCode).toBe(403);
|
||||||
|
|
||||||
|
// Login with wrong password must fail with 401
|
||||||
|
const badLogin = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { email: "owner@example.com", password: "wrongpassword" },
|
||||||
|
});
|
||||||
|
expect(badLogin.statusCode).toBe(401);
|
||||||
|
|
||||||
|
// Login with valid password succeeds
|
||||||
|
const goodLogin = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { email: "owner@example.com", password: "StrongPassword!2026" },
|
||||||
|
});
|
||||||
|
expect(goodLogin.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// System config works with valid auth token
|
||||||
|
const authSys = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/system/server-config",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(authSys.statusCode).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user