Compare commits

...

14 Commits

Author SHA1 Message Date
Schwitzz aea97cd929 fix(server): auto-bind 0.0.0.0 when allowedHosts includes a reverse-proxy domain
CI / build (push) Waiting to run
2026-11-02 14:31:03 -06:00
Schwitzz 557a58af27 fix(systemd): set Restart=always and PUBSCRIPT_WEB_DIST in user service and force-restart on .deb install
CI / build (push) Waiting to run
2026-11-02 14:11:07 -06:00
Schwitzz 8b208affc4 fix(server): serve updated static assets dynamically and restart stale server on .deb upgrade
CI / build (push) Waiting to run
2026-11-02 14:08:46 -06:00
Schwitzz b01073eac0 fix: resolve Gitea issues #1–#5 (mobile layout, suggestion refresh, import/export icons, context rail default/icon, manual save)
CI / build (push) Waiting to run
Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
2026-11-02 14:00:48 -06:00
Schwitzz 020563ed85 feat(import): add universal manuscript import pipeline (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)
CI / build (push) Waiting to run
2026-10-30 16:51:33 -05:00
Schwitzz aee9c428fc feat(sync): sync Web and Desktop projects with Host Server SQLite and Main Project Directory 2026-10-30 16:35:11 -05:00
Schwitzz 668470da2b fix(auth): unify Desktop and Web SQLite DB path and fix browser autofill crash on Input/AuthGate 2026-10-30 16:18:17 -05:00
Schwitzz 09e9280da6 feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions 2026-10-30 16:06:57 -05:00
Schwitzz 65d06e1681 fix: pin Writing toolbar and Entities header, fix Entities form alignment and red hint text, and make Settings category bar horizontal 2026-10-30 15:49:24 -05:00
Schwitzz 723fafdef8 feat: add Bind Host IP and Allowed Hosts / Reverse Proxy Domain settings 2026-10-30 14:59:09 -05:00
Schwitzz 29dbc27c41 feat: add Web App Port management alongside API Server Port and fix square 512x512 app launcher icon 2026-10-30 14:23:12 -05:00
Schwitzz 1d209fedd2 feat: add tabbed Settings, 10 themes with custom accent/text color pickers, main project directory selector, and export filename/directory controls 2026-10-30 14:06:46 -05:00
Schwitzz fa22a755ee feat: add server settings (toggle on/off, port selection, start on system start) to Settings UI and backend 2026-10-30 13:56:23 -05:00
Schwitzz 42733b4799 docs: update README with release installation instructions and remove development progress status 2026-10-30 13:49:15 -05:00
43 changed files with 7064 additions and 1249 deletions
-24
View File
@@ -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.
+14 -46
View File
@@ -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.*
+140 -24
View File
@@ -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.
+9 -9
View File
@@ -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
View File
@@ -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 &amp; 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
View File
@@ -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 };
},
}; };
+485
View File
@@ -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 &ldquo;Select This Folder&rdquo; 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>
);
}
File diff suppressed because it is too large Load Diff
+99 -6
View File
@@ -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>
); );
} }
+75 -19
View File
@@ -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}
+185 -118
View File
@@ -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,
+132 -126
View File
@@ -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")
+84 -69
View File
@@ -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>
); );
} }
+221 -104
View File
@@ -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 &ldquo;{currentProject.title}&rdquo;
</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>
);
}
+15 -3
View File
@@ -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>
); );
} }
+258 -7
View File
@@ -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();
+38 -2
View File
@@ -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) =>
+82 -34
View File
@@ -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;
+58 -2
View File
@@ -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;
}
}
+2
View File
@@ -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,
}, },
}); });
+4
View File
@@ -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"
+19
View File
@@ -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);
+16 -1
View File
@@ -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,
+1 -1
View File
@@ -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" },
+39 -10
View File
@@ -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 () => {};
}, },
}; };
+173
View File
@@ -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
+1
View File
@@ -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";
+141 -4
View File
@@ -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;
}
}
+3 -2
View File
@@ -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>}
+3 -2
View File
@@ -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>}
+14 -3
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) };
}); });
+85
View File
@@ -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();
}
});
}); });