Compare commits
10 Commits
053cc07294
...
020563ed85
| Author | SHA1 | Date | |
|---|---|---|---|
| 020563ed85 | |||
| aee9c428fc | |||
| 668470da2b | |||
| 09e9280da6 | |||
| 65d06e1681 | |||
| 723fafdef8 | |||
| 29dbc27c41 | |||
| 1d209fedd2 | |||
| fa22a755ee | |||
| 42733b4799 |
@@ -1,24 +0,0 @@
|
||||
Features and Bugs:
|
||||
1. (bug) - Whiteboard. Clicking add > Character and add > World leads to blank screen.
|
||||
2. (bug) - Whiteboard. Nodes are not dragging.
|
||||
3. (bug) - Whiteboard. Nodes cannot be edited after placement.
|
||||
4. (bug) - connecting lines cannot be deleted.
|
||||
5. (bug) - There are no AI settings to set up provider, edit agents, set temp/ctx/prompts, etc.
|
||||
6. (feature) - AI.Make AI chat window a sidebar that can be toggled. Include provider, model, archived chats, agent, and load/reload button.
|
||||
7. (feature) - AI. make navigation a sidebar with toggle. Untoggled will hid all but the icons.
|
||||
8. (feature) - Write. Needs microsoft word-like formatting features. Line spacing, bold, italics, justification, etc. The whole formatting suite. Reference open source software like Only Office for an example. Favor icons over text buttons.
|
||||
9. (feature) - All. Change the persistent "PubScript" + icon to the logo. Remove PS logo on the other persistent header.
|
||||
10. (feature) - Entities. Revise the "+ New" flow. Use an add button on the individual tabs, not catch all new with drop down.
|
||||
11. (bug) - Plot. Dragging beats deletes them.
|
||||
12. (feature) - Plot. remove + Add Beat button. Redundant, keep the + on each Act.
|
||||
13. (bug) - Projects. Clicking in this page does nothing. Cannot add new project. Cannot open existing project. Cannot delete projects.
|
||||
14. (feature) - Projects. Adding new project should open up a tutorial to draft a template. Ask pertinent questions about the writing to help user determine what kind of Act/Beat methodology to use. Also create option to skip tutorial and build skeleton of project manually.
|
||||
15. (bug) - Settings. Theme buttons do nothing to change theme. Some theme buttons are invisible. Remove persistent theme drop down. Use the theme dropdown in the settings.
|
||||
16. (bug) - Settings. Density selections change nothing.
|
||||
17. (feature) - Settings. Need many more fonts.
|
||||
18. (bug) - Settings. Typography settings change UI elements. Use system Sans for UI, lock it in. Let the typography settings be managed on the writing screen with the formatting / toolbar (see item 8).
|
||||
19. (bug) - Settings. Show line numbers does not work. Remove from settings, add to write toolbar.
|
||||
20. (bug) - Settings. Focus and Typewrite mode do nothing. Same if toggeld from the write screen. Remove Typewrite feature, it's dumb.
|
||||
21. (feature) - Persistent. Search feature should be a text box. remove hotkey reference.
|
||||
22. (bug) - Admin. Admin features not working. No users. No ability to manage useres. Screen shows "Unexpected token '<', "<!doctype " .. is not valid JSON.
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
**A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine built in.**
|
||||
|
||||
> This file is the single source of truth for *what* we are building and *why*.
|
||||
> 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.
|
||||
> This file is the single source of truth for the architecture, design decisions, and data model of PubScript.
|
||||
|
||||
---
|
||||
|
||||
@@ -89,24 +87,23 @@ The **StorageAdapter contract** (defined once in `packages/core`) is the seam th
|
||||
|
||||
```
|
||||
PubScript/
|
||||
├── PLAN.md ← this file
|
||||
├── CHUNK#1.md … CHUNK#11.md ← executable work orders
|
||||
├── PLAN.md ← architecture & design specification
|
||||
├── README.md ← installation, quickstart & usage guide
|
||||
├── package.json ← pnpm workspace root
|
||||
├── pnpm-workspace.yaml
|
||||
├── apps/
|
||||
│ └── web/ ← the React 19 app (Vite). Runs in browser AND in Tauri.
|
||||
├── desktop/ ← Tauri 2 shell (Rust). Loads apps/web's build.
|
||||
│ └── web/ ← the React 19 app (Vite)
|
||||
├── desktop/ ← Native Linux desktop shell & .deb packager
|
||||
├── packages/
|
||||
│ ├── core/ ← domain types, IDs, time, token budget, StorageAdapter contract
|
||||
│ ├── ui/ ← design tokens, theme engine, app shell, component kit
|
||||
│ ├── editor/ ← TipTap schema, decorations, spellcheck integration
|
||||
│ ├── boards/ ← React Flow whiteboard, beat board, timeline
|
||||
│ ├── exporters/ ← md / html / epub / docx / odt / pdf + shared flat model
|
||||
│ └── ai/ ← provider adapters, role engine, context builder, suggestion protocol
|
||||
├── server/ ← Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy
|
||||
├── docker/ ← Dockerfile, compose.yaml, entrypoint, backup scripts
|
||||
├── .github/workflows/ ← CI (lint, typecheck, test, build, docker, desktop matrix)
|
||||
└── docs/ ← ADRs, data model reference, AI role prompts, parity matrix
|
||||
├── docker/ ← Dockerfile, compose.yaml, entrypoint
|
||||
├── .github/workflows/ ← CI (lint, typecheck, test, contract, build, docker)
|
||||
└── docs/ ← Data model reference & conventions
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
## 9. Risks & Mitigations
|
||||
|
||||
| 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; we add perf budgets + Playwright timing tests and keep decorations memoized |
|
||||
| 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; local mode is clearly labeled in the UI |
|
||||
| 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, 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.*
|
||||
| Large-document editor performance | ProseMirror is built for this; decorations and spellcheck runs are debounced and block-scoped |
|
||||
| AI suggestion rendering over live-editing text (range drift) | Suggestions carry verbatim text anchors (`originalText`), re-resolved on inline edits |
|
||||
| Local vs. server data divergence | Contract test suite enforces identical behavior across `LocalAdapter` and `ServerAdapter` |
|
||||
| EPUB/ODT spec edge cases | Golden-file tests in CI; deterministic timestamps across ZIP/PDF formats |
|
||||
| Scope creep in AI roles | Roles are prompt + tools, unified under a single inline suggestion and session notes protocol |
|
||||
|
||||
@@ -1,42 +1,158 @@
|
||||
# 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
|
||||
pnpm i && pnpm dev # run the app shell at http://localhost:5173
|
||||
pnpm test # run every workspace's unit tests
|
||||
pnpm typecheck # strict TypeScript across the monorepo
|
||||
pnpm build # build web + server
|
||||
# Install using apt (automatically resolves any missing system dependencies)
|
||||
sudo apt install ./pubscript_0.1.0_amd64.deb
|
||||
|
||||
# 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
|
||||
docker compose -f docker/compose.yaml up --build
|
||||
# → http://localhost:8080 (health: curl localhost:8080/api/health)
|
||||
pubscript
|
||||
```
|
||||
|
||||
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* |
|
||||
| `CHUNK#n.md` | Executable, step-by-step work orders |
|
||||
| `apps/web/` | React 19 + Vite app (runs in browser AND in Tauri) |
|
||||
| `packages/core/` | Domain types, ULIDs, time, token budget, `StorageAdapter` contract |
|
||||
| `packages/ui/` | Design tokens, theme engine (Ink / Parchment / Nocturne / Terminal), component kit |
|
||||
| `server/` | Fastify + SQLite: auth, multi-tenant, WS, admin, AI proxy |
|
||||
| `docker/` | Dockerfile + compose (one container serves app + API + data) |
|
||||
| `docs/` | ADRs, data model reference, AI role prompts, parity matrix |
|
||||
| `apps/web/` | React 19 + Vite application (Writing Studio, AI Sidebar, Characters, World, Plot, Whiteboard, Admin, Settings) |
|
||||
| `packages/core/` | Shared domain types, ULIDs, token budgeting, backlinks, consistency engine, `StorageAdapter` contract |
|
||||
| `packages/editor/` | TipTap 3 / ProseMirror schema, formatting extensions, node views, Markdown serializer, Hunspell spell & grammar services |
|
||||
| `packages/ai/` | AI provider client, SSE streaming, token budget enforcement, Dexie/IndexedDB session store, suggestion & note builders |
|
||||
| `packages/exporters/` | Deterministic manuscript exporters (`Markdown`, `HTML`, `EPUB`, `DOCX`, `ODT`, `PDF`) |
|
||||
| `packages/ui/` | Design tokens, theme engine (`Ink`, `Parchment`, `Nocturne`, `Terminal`), density engine, UI component kit |
|
||||
| `server/` | Fastify + SQLite multi-tenant API server, authentication, WebSocket sync, and admin routes |
|
||||
| `desktop/` | Native Linux GTK3 + WebKit2GTK desktop wrapper (`pubscript-desktop.c`) and `.deb` packager (`build-deb.sh`) |
|
||||
| `docker/` | Multi-stage `Dockerfile`, `compose.yaml`, and container entrypoint |
|
||||
| `docs/` | Architecture & data model documentation (`DATA_MODEL.md`, `CONVENTIONS.md`) |
|
||||
|
||||
## Status
|
||||
---
|
||||
|
||||
**CHUNK #1 through #10 are complete and CI-green:** the monorepo with strict TypeScript, the collapsible app shell and theming/density system, the `packages/core` contracts, the Fastify multi-tenant server, the TipTap writing surface with an OnlyOffice/Word-inspired formatting toolbar (34 fonts, line numbers, focus mode, alignment, indentation, lists, scene breaks, footnotes), characters & world-building, the plot & whiteboard surface, the export pipeline (Markdown/HTML/EPUB/DOCX/ODT/PDF with an ExportDialog), offline hunspell-WASM spellcheck + grammar checking, the AI provider & agent layer (`packages/ai`) with full AI Settings and toggleable AI Chat Sidebar (`AISidebar.tsx`), the guided Project Structure Tutorial Wizard (`Projects.tsx`), and the Admin user/role/audit management interface (`AdminView.tsx`). `pnpm typecheck`, `pnpm test`, `pnpm lint`, and `pnpm build` all pass across the workspace.
|
||||
## License
|
||||
|
||||
**CHUNK #11 (Packaging, Parity & Polish)** has Docker packaging (`docker/compose.yaml`), project template onboarding, and UI/UX polish complete.
|
||||
Licensed under the **GNU General Public License v3.0** — see [LICENSE](./LICENSE) for details.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 112 512 288" width="512" height="288" role="img" aria-label="PubScript">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" preserveAspectRatio="xMidYMid meet" role="img" aria-label="PubScript">
|
||||
<defs>
|
||||
<linearGradient id="ps-gold" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#f9edc4"/>
|
||||
@@ -19,18 +19,18 @@
|
||||
|
||||
<g filter="url(#ps-shadow)">
|
||||
<!-- P tile -->
|
||||
<circle cx="126" cy="256" r="120" fill="url(#ps-gold)"/>
|
||||
<circle cx="126" 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"/>
|
||||
<text x="126" y="256" text-anchor="middle" dominant-baseline="central"
|
||||
<circle cx="132" cy="256" r="120" fill="url(#ps-gold)"/>
|
||||
<circle cx="132" cy="256" r="104" fill="url(#ps-ivory)"/>
|
||||
<circle cx="132" cy="256" r="104" fill="none" stroke="#8a6d1c" stroke-width="1.5" opacity="0.5"/>
|
||||
<text x="132" y="256" text-anchor="middle" dominant-baseline="central"
|
||||
font-family="Georgia, 'Times New Roman', serif" font-size="152" font-weight="600"
|
||||
fill="#21242c">P</text>
|
||||
|
||||
<!-- S tile -->
|
||||
<circle cx="386" cy="256" r="120" fill="url(#ps-gold)"/>
|
||||
<circle cx="386" 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"/>
|
||||
<text x="386" y="256" text-anchor="middle" dominant-baseline="central"
|
||||
<circle cx="380" cy="256" r="120" fill="url(#ps-gold)"/>
|
||||
<circle cx="380" cy="256" r="104" fill="url(#ps-ivory)"/>
|
||||
<circle cx="380" cy="256" r="104" fill="none" stroke="#8a6d1c" stroke-width="1.5" opacity="0.5"/>
|
||||
<text x="380" y="256" text-anchor="middle" dominant-baseline="central"
|
||||
font-family="Georgia, 'Times New Roman', serif" font-size="152" font-weight="600"
|
||||
fill="#21242c">S</text>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
+36
-26
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
|
||||
import { AuthGate } from "./components/AuthGate.js";
|
||||
import { ExportDialog } from "./features/export/ExportDialog.js";
|
||||
import { ImportDialog } from "./features/import/ImportDialog.js";
|
||||
import { AISidebar } from "./features/ai/AISidebar.js";
|
||||
import { ToastViewport } from "@pubscript/ui";
|
||||
import { useSettings } from "./store/settings.js";
|
||||
@@ -29,13 +31,14 @@ const NAV = [
|
||||
export default function App() {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const navCollapsed = useSettings((s) => s.navCollapsed);
|
||||
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
||||
const focusMode = useSettings((s) => s.focusMode);
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
setPaletteOpen((o) => !o);
|
||||
}
|
||||
@@ -45,31 +48,38 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
|
||||
<SplashScreen />
|
||||
<Sidebar nav={NAV} />
|
||||
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
|
||||
<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 && <AISidebar />}
|
||||
</div>
|
||||
<AuthGate>
|
||||
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
|
||||
<SplashScreen />
|
||||
<Sidebar nav={NAV} />
|
||||
<Topbar
|
||||
onOpenPalette={() => setPaletteOpen(true)}
|
||||
onOpenExport={() => setExportOpen(true)}
|
||||
onOpenImport={() => setImportOpen(true)}
|
||||
/>
|
||||
<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 && <AISidebar />}
|
||||
</div>
|
||||
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
||||
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
||||
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
+562
-90
@@ -1,8 +1,11 @@
|
||||
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
|
||||
import { useSettings } from "./store/settings.js";
|
||||
|
||||
const BASE = "/api";
|
||||
const TOKEN_KEY = "pubscript.admin.token";
|
||||
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1";
|
||||
const TOKEN_KEY = "pubscript.auth.token.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";
|
||||
|
||||
export interface AdminStats {
|
||||
@@ -14,7 +17,19 @@ export interface AdminStats {
|
||||
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 {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
@@ -22,7 +37,7 @@ function getStoredToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function setStoredToken(token: string | null): void {
|
||||
export function setStoredToken(token: string | null): void {
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
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[] {
|
||||
const fromAuth = loadLocalAuthRecords().map((r) => r.user);
|
||||
if (fromAuth.length > 0) return fromAuth;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_USERS_KEY);
|
||||
if (raw) {
|
||||
@@ -41,30 +150,7 @@ function loadLocalUsers(): User[] {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const initial: User[] = [
|
||||
{
|
||||
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;
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveLocalUsers(users: User[]): void {
|
||||
@@ -85,29 +171,15 @@ function loadLocalAudit(): AuditLogEntry[] {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const initial: AuditLogEntry[] = [
|
||||
{
|
||||
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;
|
||||
return [];
|
||||
}
|
||||
|
||||
function appendLocalAudit(action: string, detail: Record<string, unknown>): void {
|
||||
function appendLocalAudit(action: string, detail: Record<string, unknown>, userId = "admin"): void {
|
||||
const entries = loadLocalAudit();
|
||||
entries.unshift({
|
||||
id: createUlid(),
|
||||
at: new Date().toISOString(),
|
||||
userId: "admin@pubscript.local",
|
||||
userId,
|
||||
action,
|
||||
detail,
|
||||
});
|
||||
@@ -118,31 +190,34 @@ function appendLocalAudit(action: string, detail: Record<string, unknown>): void
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapAdminSession(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/bootstrap-admin`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
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;
|
||||
function buildHostEndpoints(path: string, portHint?: number): string[] {
|
||||
const list = [path];
|
||||
if (portHint && typeof window !== "undefined" && window.location) {
|
||||
const host = window.location.hostname || "localhost";
|
||||
const proto = window.location.protocol === "https:" ? "https:" : "http:";
|
||||
if (String(portHint) !== window.location.port) {
|
||||
list.push(`${proto}//${host}:${portHint}${path}`);
|
||||
}
|
||||
} 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();
|
||||
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> = {
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...((init?.headers as Record<string, string> | undefined) ?? {}),
|
||||
...authHeaders(init?.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
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") ?? "";
|
||||
|
||||
if (res.status === 401 && !retried) {
|
||||
const newToken = await bootstrapAdminSession();
|
||||
if (newToken) {
|
||||
return request<T>(path, init, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
if (body.error) detail = body.error;
|
||||
const body = (await res.json()) as { error?: string; message?: string };
|
||||
if (body.message) detail = body.message;
|
||||
else if (body.error) detail = body.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -183,7 +252,189 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
|
||||
}
|
||||
|
||||
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> => {
|
||||
try {
|
||||
return await request<AdminStats>("/admin/stats");
|
||||
@@ -232,22 +483,26 @@ export const api = {
|
||||
}
|
||||
const created: User = {
|
||||
id: createUlid(),
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
email: input.email.trim().toLowerCase(),
|
||||
displayName: input.displayName.trim(),
|
||||
role: input.role,
|
||||
disabled: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
users.unshift(created);
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
updateUser: async (
|
||||
id: string,
|
||||
patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string },
|
||||
patch: { email?: string; displayName?: string; role?: Role; disabled?: boolean; password?: string },
|
||||
): Promise<User> => {
|
||||
try {
|
||||
return await request<User>(`/admin/users/${id}`, {
|
||||
@@ -260,6 +515,7 @@ export const api = {
|
||||
if (idx === -1) throw new Error("User not found");
|
||||
const updated: User = {
|
||||
...users[idx]!,
|
||||
...(patch.email !== undefined ? { email: patch.email.trim().toLowerCase() } : {}),
|
||||
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
|
||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
|
||||
@@ -345,20 +601,236 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
// Auth
|
||||
me: () => request<{ user: User | null }>("/auth/me"),
|
||||
getServerConfig: async (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, {
|
||||
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) =>
|
||||
request<{ token: string; user: User }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
updateServerConfig: async (
|
||||
patch: {
|
||||
enabled?: boolean;
|
||||
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) =>
|
||||
request<{ token: string; user: User }>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
}),
|
||||
listSystemDirectories: async (
|
||||
dirPath?: string,
|
||||
portHint?: number,
|
||||
): Promise<{
|
||||
online: boolean;
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
homePath: string;
|
||||
directories: { name: string; path: string }[];
|
||||
}> => {
|
||||
const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : "";
|
||||
const endpoints = buildHostEndpoints(`/api/system/list-dirs${query}`, portHint);
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
credentials: "include",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (res.ok && ct.includes("application/json")) {
|
||||
const data = (await res.json()) as {
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
homePath: string;
|
||||
directories: { name: string; path: string }[];
|
||||
};
|
||||
return {
|
||||
online: true,
|
||||
currentPath: data.currentPath,
|
||||
parentPath: data.parentPath,
|
||||
homePath: data.homePath,
|
||||
directories: data.directories,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return {
|
||||
online: false,
|
||||
currentPath: dirPath || "~/Documents/PubScript",
|
||||
parentPath: null,
|
||||
homePath: "~",
|
||||
directories: [],
|
||||
};
|
||||
},
|
||||
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
saveExportToDirectory: async (
|
||||
params: {
|
||||
directory: string;
|
||||
fileName: string;
|
||||
data: Uint8Array | string;
|
||||
},
|
||||
portHint?: number,
|
||||
): Promise<{ savedOnServer: boolean; savedPath?: string }> => {
|
||||
const bytes =
|
||||
typeof params.data === "string"
|
||||
? new TextEncoder().encode(params.data)
|
||||
: params.data;
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
const base64Data = btoa(binary);
|
||||
|
||||
const endpoints = buildHostEndpoints("/api/system/save-export", portHint);
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
directory: params.directory,
|
||||
fileName: params.fileName,
|
||||
base64Data,
|
||||
}),
|
||||
});
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (res.ok && ct.includes("application/json")) {
|
||||
const out = (await res.json()) as { ok?: boolean; savedPath?: string };
|
||||
if (out.ok && out.savedPath) {
|
||||
return { savedOnServer: true, savedPath: out.savedPath };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return { savedOnServer: false };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { User } from "@pubscript/core";
|
||||
import { Badge, Button, toast } from "@pubscript/ui";
|
||||
import { api } from "../api.js";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
logout: () => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
logout: async () => {},
|
||||
refreshAuth: async () => {},
|
||||
});
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
function scorePassword(pw: string): { score: number; label: string; color: string } {
|
||||
const safe = typeof pw === "string" ? pw : "";
|
||||
if (!safe) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" };
|
||||
let score = 0;
|
||||
if (safe.length >= 8) score += 1;
|
||||
if (safe.length >= 12) score += 1;
|
||||
if (/[a-z]/.test(safe) && /[A-Z]/.test(safe)) score += 1;
|
||||
if (/\d/.test(safe)) score += 1;
|
||||
if (/[^a-zA-Z0-9]/.test(safe)) score += 1;
|
||||
|
||||
if (safe.length < 8) {
|
||||
return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" };
|
||||
}
|
||||
if (score <= 2) {
|
||||
return { score: 2, label: "Fair", color: "var(--ps-warning)" };
|
||||
}
|
||||
if (score === 3) {
|
||||
return { score: 3, label: "Good", color: "var(--ps-accent)" };
|
||||
}
|
||||
return { score: 4, label: "Strong (Argon2id ready)", color: "var(--ps-success)" };
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialized, setInitialized] = useState(true);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
// Setup / Login form state
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
const status = await api.authStatus();
|
||||
setInitialized(status.initialized);
|
||||
setAuthenticated(status.authenticated);
|
||||
setUser(status.user);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth();
|
||||
// Re-sync auth state when user switches from Desktop App to Web App tab
|
||||
const onFocus = () => {
|
||||
void refreshAuth();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [refreshAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
void useProjectStore.getState().init();
|
||||
void api.getServerConfig().then((cfg) => {
|
||||
if (cfg.online && cfg.projectDirectory) {
|
||||
useSettings.getState().set({
|
||||
projectDirectory: cfg.projectDirectory,
|
||||
...(cfg.host ? { serverHost: cfg.host } : {}),
|
||||
...(cfg.allowedHosts ? { allowedHosts: cfg.allowedHosts } : {}),
|
||||
...(cfg.port ? { serverPort: cfg.port } : {}),
|
||||
...(cfg.webPort ? { webPort: cfg.webPort } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [authenticated]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout();
|
||||
setAuthenticated(false);
|
||||
setUser(null);
|
||||
setPassword("");
|
||||
toast.info("Signed out of PubScript");
|
||||
}, []);
|
||||
|
||||
const strength = useMemo(() => scorePassword(password), [password]);
|
||||
|
||||
const handleSetupSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const cleanName = String(displayName ?? "").trim();
|
||||
const cleanEmail = String(email ?? "").trim().toLowerCase();
|
||||
const safePw = String(password ?? "");
|
||||
const safeConfirm = String(confirmPassword ?? "");
|
||||
|
||||
if (!cleanName) {
|
||||
setError("Please enter an administrator display name.");
|
||||
return;
|
||||
}
|
||||
if (!cleanEmail || !cleanEmail.includes("@")) {
|
||||
setError("Please enter a valid administrator email address.");
|
||||
return;
|
||||
}
|
||||
if (safePw.length < 8) {
|
||||
setError("Password must be at least 8 characters long.");
|
||||
return;
|
||||
}
|
||||
if (safePw !== safeConfirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await api.setupAdmin({
|
||||
email: cleanEmail,
|
||||
displayName: cleanName,
|
||||
password: safePw,
|
||||
});
|
||||
setInitialized(true);
|
||||
setAuthenticated(true);
|
||||
setUser(res.user);
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to create administrator account.";
|
||||
if (msg.includes("already_initialized") || msg.includes("already been")) {
|
||||
// Admin was already created in Desktop App — switch straight to Login view
|
||||
setInitialized(true);
|
||||
setError("Administrator account already exists. Please sign in with your credentials.");
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const cleanEmail = String(email ?? "").trim().toLowerCase();
|
||||
const safePw = String(password ?? "");
|
||||
if (!cleanEmail || !safePw) {
|
||||
setError("Please enter your email and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await api.login(cleanEmail, safePw);
|
||||
setAuthenticated(true);
|
||||
setUser(res.user);
|
||||
setPassword("");
|
||||
toast.success(`Signed in as ${res.user.displayName}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid email or password.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--ps-bg)",
|
||||
color: "var(--ps-text-muted)",
|
||||
fontFamily: "var(--ps-font-ui)",
|
||||
}}
|
||||
>
|
||||
<span>Verifying session security…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!initialized || !authenticated) {
|
||||
const isFirstInstall = !initialized;
|
||||
return (
|
||||
<div
|
||||
translate="no"
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
background:
|
||||
"radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg)), var(--ps-bg) 70%)",
|
||||
color: "var(--ps-text)",
|
||||
fontFamily: "var(--ps-font-ui)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "min(440px, 100%)",
|
||||
background: "var(--ps-surface)",
|
||||
border: "1px solid var(--ps-border)",
|
||||
borderRadius: 14,
|
||||
padding: "28px 28px 24px",
|
||||
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.45)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", marginBottom: 20 }}>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="PubScript"
|
||||
style={{ width: 72, height: 72, objectFit: "contain", marginBottom: 10 }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 8 }}>
|
||||
<Badge variant={isFirstInstall ? "accent" : "success"}>
|
||||
{isFirstInstall ? "🔒 First-Install Security Setup" : "🔒 Authenticated Access Required"}
|
||||
</Badge>
|
||||
</div>
|
||||
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
|
||||
<span>{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}</span>
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
|
||||
<span>
|
||||
{isFirstInstall
|
||||
? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access."
|
||||
: "Enter your credentials to unlock your manuscripts and studio workspace."}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
marginBottom: 14,
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--ps-danger)",
|
||||
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
|
||||
color: "var(--ps-danger)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFirstInstall ? (
|
||||
<form
|
||||
key="setup-form"
|
||||
onSubmit={(e) => void handleSetupSubmit(e)}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<div className="ps-field">
|
||||
<label className="ps-field-label" htmlFor="ps-setup-name">
|
||||
Administrator Display Name
|
||||
</label>
|
||||
<input
|
||||
id="ps-setup-name"
|
||||
name="displayName"
|
||||
type="text"
|
||||
className="ps-input"
|
||||
placeholder="e.g. Schwitzz"
|
||||
autoComplete="off"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(String(e.target?.value ?? ""))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-field">
|
||||
<label className="ps-field-label" htmlFor="ps-setup-email">
|
||||
Administrator Email
|
||||
</label>
|
||||
<input
|
||||
id="ps-setup-email"
|
||||
name="email"
|
||||
type="email"
|
||||
className="ps-input"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-field">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<label className="ps-field-label" htmlFor="ps-setup-pw">
|
||||
Master Password (min 8 chars)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--ps-accent)",
|
||||
fontSize: 11.5,
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<span>{showPassword ? "Hide" : "Show"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="ps-setup-pw"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="ps-input"
|
||||
placeholder="Create a strong password…"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
|
||||
required
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
|
||||
<div style={{ display: "flex", gap: 4, flex: 1, marginRight: 10 }}>
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background:
|
||||
strength.score >= step ? strength.color : "var(--ps-border)",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: 11.5, color: strength.color, fontWeight: 600 }}>
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-field">
|
||||
<label className="ps-field-label" htmlFor="ps-setup-confirm-pw">
|
||||
Confirm Master Password
|
||||
</label>
|
||||
<input
|
||||
id="ps-setup-confirm-pw"
|
||||
name="confirmPassword"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="ps-input"
|
||||
placeholder="Re-enter password…"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(String(e.target?.value ?? ""))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={submitting}
|
||||
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
||||
>
|
||||
<span>{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
key="login-form"
|
||||
onSubmit={(e) => void handleLoginSubmit(e)}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<div className="ps-field">
|
||||
<label className="ps-field-label" htmlFor="ps-login-email">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="ps-login-email"
|
||||
name="email"
|
||||
type="email"
|
||||
className="ps-input"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-field">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<label className="ps-field-label" htmlFor="ps-login-pw">
|
||||
Password
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--ps-accent)",
|
||||
fontSize: 11.5,
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<span>{showPassword ? "Hide" : "Show"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="ps-login-pw"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="ps-input"
|
||||
placeholder="Enter your password…"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={submitting}
|
||||
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
||||
>
|
||||
<span>{submitting ? "Signing in…" : "Sign In"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 18,
|
||||
paddingTop: 12,
|
||||
borderTop: "1px solid var(--ps-border)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--ps-text-muted)",
|
||||
}}
|
||||
>
|
||||
<span>Argon2id (64 MiB) · SHA-256 Session</span>
|
||||
<span>HttpOnly · SameSite=Lax</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, logout, refreshAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, Button } from "@pubscript/ui";
|
||||
import { api } from "../api.js";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
export interface DirectoryBrowserModalProps {
|
||||
open: boolean;
|
||||
initialPath: string;
|
||||
title?: string;
|
||||
onSelect: (selectedPath: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DirectoryBrowserModal({
|
||||
open,
|
||||
initialPath,
|
||||
title = "Select Directory",
|
||||
onSelect,
|
||||
onClose,
|
||||
}: DirectoryBrowserModalProps) {
|
||||
const serverPort = useSettings((s) => s.serverPort);
|
||||
const [currentPath, setCurrentPath] = useState<string>(initialPath || "~/Documents/PubScript");
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [homePath, setHomePath] = useState<string>("~");
|
||||
const [directories, setDirectories] = useState<{ name: string; path: string }[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const loadPath = async (targetPath: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.listSystemDirectories(targetPath, serverPort);
|
||||
setCurrentPath(res.currentPath);
|
||||
setParentPath(res.parentPath);
|
||||
setHomePath(res.homePath);
|
||||
setDirectories(res.directories);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadPath(initialPath || "~/Documents/PubScript");
|
||||
}, [open, initialPath]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onSelect(currentPath.trim() || "~/Documents/PubScript");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Select This Folder
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
className="ps-input"
|
||||
style={{ flex: 1, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
|
||||
value={currentPath}
|
||||
onChange={(e) => setCurrentPath(e.target.value)}
|
||||
placeholder="/path/to/directory"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void loadPath(currentPath)}
|
||||
disabled={loading}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void loadPath(parentPath)}
|
||||
disabled={loading}
|
||||
>
|
||||
⬆ Parent Folder
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void loadPath(homePath || "~")}
|
||||
disabled={loading}
|
||||
>
|
||||
🏠 Home
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void loadPath("~/Documents")}
|
||||
disabled={loading}
|
||||
>
|
||||
📄 Documents
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void loadPath("~/Documents/PubScript")}
|
||||
disabled={loading}
|
||||
>
|
||||
📚 PubScript
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 260,
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--ps-border)",
|
||||
borderRadius: 8,
|
||||
background: "var(--ps-bg)",
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: 14, fontSize: 12.5, color: "var(--ps-text-muted)" }}>
|
||||
Loading folders…
|
||||
</div>
|
||||
) : directories.length === 0 ? (
|
||||
<div style={{ padding: 14, fontSize: 12.5, color: "var(--ps-text-muted)" }}>
|
||||
No subfolders in this directory (you can click “Select This Folder” or enter any path above).
|
||||
</div>
|
||||
) : (
|
||||
directories.map((dir) => (
|
||||
<button
|
||||
key={dir.path}
|
||||
type="button"
|
||||
onClick={() => void loadPath(dir.path)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "7px 10px",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--ps-text)",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span>📁</span>
|
||||
<span style={{ fontWeight: 500 }}>{dir.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+1159
-417
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useLocation, NavLink, useNavigate } from "react-router-dom";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import { useAuth } from "./AuthGate.js";
|
||||
import { Badge } from "@pubscript/ui";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
|
||||
@@ -20,6 +21,7 @@ const TITLES: Record<string, string> = {
|
||||
export interface TopbarProps {
|
||||
onOpenPalette?: () => void;
|
||||
onOpenExport: () => void;
|
||||
onOpenImport?: () => void;
|
||||
}
|
||||
|
||||
function entityName(e: Entity): string {
|
||||
@@ -27,9 +29,10 @@ function entityName(e: Entity): string {
|
||||
return (e.body || "Untitled note").slice(0, 48);
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenExport }: TopbarProps) {
|
||||
export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const autoSave = useSettings((s) => s.autoSave);
|
||||
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
||||
const setSettings = useSettings((s) => s.set);
|
||||
@@ -174,6 +177,18 @@ export function Topbar({ onOpenExport }: TopbarProps) {
|
||||
<span>✨ AI</span>
|
||||
</button>
|
||||
|
||||
{onOpenImport && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-nav-item"
|
||||
onClick={onOpenImport}
|
||||
style={{ width: "auto", padding: "6px 10px" }}
|
||||
title="Import manuscript (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)"
|
||||
>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ps-nav-item"
|
||||
@@ -186,6 +201,23 @@ export function Topbar({ onOpenExport }: TopbarProps) {
|
||||
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
|
||||
⚙
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function AdminView() {
|
||||
|
||||
// Edit user modal state
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [editEmail, setEditEmail] = useState("");
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editRole, setEditRole] = useState<Role>("author");
|
||||
const [editPassword, setEditPassword] = useState("");
|
||||
@@ -122,6 +123,7 @@ export function AdminView() {
|
||||
|
||||
const openEditUser = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setEditEmail(user.email);
|
||||
setEditName(user.displayName);
|
||||
setEditRole(user.role);
|
||||
setEditPassword("");
|
||||
@@ -131,6 +133,7 @@ export function AdminView() {
|
||||
if (!editingUser) return;
|
||||
try {
|
||||
await api.updateUser(editingUser.id, {
|
||||
email: editEmail.trim().toLowerCase() || editingUser.email,
|
||||
displayName: editName.trim() || editingUser.displayName,
|
||||
role: editRole,
|
||||
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
|
||||
@@ -441,6 +444,12 @@ export function AdminView() {
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Input
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Display name"
|
||||
value={editName}
|
||||
|
||||
@@ -127,138 +127,144 @@ export function EntitiesView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}>
|
||||
<div className="ps-entity-toolbar">
|
||||
<div className="ps-entity-tabs">
|
||||
{TABS.map((t) => (
|
||||
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
||||
<button
|
||||
type="button"
|
||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
{t.createKind && (
|
||||
<div className="ps-entities-view">
|
||||
<div className="ps-entities-pinned-header">
|
||||
<div className="ps-entity-toolbar">
|
||||
<div className="ps-entity-tabs">
|
||||
{TABS.map((t) => (
|
||||
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
||||
<button
|
||||
type="button"
|
||||
className="ps-entity-tab-add"
|
||||
title={t.createLabel ?? `Add to ${t.label}`}
|
||||
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleCreateKind(t.createKind!, t.id);
|
||||
}}
|
||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
+
|
||||
{t.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ps-entity-new-group">
|
||||
{activeTabDef.createKind ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
||||
>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
{t.createKind && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-entity-tab-add"
|
||||
title={t.createLabel ?? `Add to ${t.label}`}
|
||||
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleCreateKind(t.createKind!, t.id);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</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 className="ps-entity-new-group">
|
||||
{activeTabDef.createKind ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function EntityEditor({
|
||||
|
||||
const isExisting = entities.some((e) => e.id === draft.id);
|
||||
const isCharacterDraft = isCharacter(draft);
|
||||
const canDelete = isExisting && isCharacterDraft;
|
||||
const canDelete = isExisting;
|
||||
|
||||
const title = isCharacterDraft
|
||||
? (draft.name || "Edit character")
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
import React from "react";
|
||||
import type { Entity, FieldDef } 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. */
|
||||
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. */
|
||||
export function getFieldValue(e: Entity, key: string): string | string[] {
|
||||
if ("fields" in e) {
|
||||
if (key === "name") return e.name;
|
||||
const v = e.fields[key];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
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`. */
|
||||
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
||||
if ("fields" in e) {
|
||||
if (key === "name" && typeof value === "string") {
|
||||
return { ...e, name: value };
|
||||
}
|
||||
return { ...e, fields: { ...e.fields, [key]: value } };
|
||||
}
|
||||
return { ...e, [key]: value };
|
||||
@@ -52,69 +55,66 @@ interface EntityFieldsProps {
|
||||
entities?: Entity[];
|
||||
}
|
||||
|
||||
function renderField(
|
||||
function renderFieldControl(
|
||||
field: FieldDef,
|
||||
value: string | string[],
|
||||
onChange: (v: string | string[]) => void,
|
||||
currentEntityId: string,
|
||||
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) {
|
||||
case "textarea":
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
<textarea
|
||||
className="ps-textarea ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
className="ps-textarea ps-input"
|
||||
rows={3}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
>
|
||||
{(field.options ?? []).map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
case "list":
|
||||
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
||||
case "link": {
|
||||
const opts = (entities ?? [])
|
||||
.filter((e) => e.id !== value)
|
||||
.filter((e) => e.id !== currentEntityId && !("body" in e))
|
||||
.map((e) => ({ value: e.id, label: entityLabel(e) }));
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
options={[{ value: "", label: "— none —" }, ...opts]}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{opts.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
case "text":
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
<Input
|
||||
className="ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,25 +139,27 @@ function TagField({
|
||||
|
||||
return (
|
||||
<div className="ps-tag-field">
|
||||
<div className="ps-tag-list">
|
||||
{value.map((t, i) => (
|
||||
<span key={`${t}-${i}`} className="ps-tag-chip">
|
||||
{t}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-tag-remove"
|
||||
aria-label={`Remove ${t}`}
|
||||
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="ps-tag-list">
|
||||
{value.map((t, i) => (
|
||||
<span key={`${t}-${i}`} className="ps-tag-chip">
|
||||
{t}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-tag-remove"
|
||||
aria-label={`Remove ${t}`}
|
||||
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="ps-input"
|
||||
value={draft}
|
||||
placeholder="Add tag + Enter"
|
||||
placeholder="Add item + press Enter"
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -173,18 +175,31 @@ function TagField({
|
||||
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
|
||||
const fields = fieldsFor(entity);
|
||||
|
||||
const handleFieldChange = (key: string, val: string | string[]) => {
|
||||
const updated = setFieldValue(entity, key, val);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-entity-fields">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="ps-field">
|
||||
{renderField(
|
||||
field,
|
||||
getFieldValue(entity, field.key),
|
||||
(v) => onChange({ [field.key]: v } as Partial<Entity>),
|
||||
entities,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{fields.map((field) => {
|
||||
const isCompact = field.type === "select" || field.type === "link";
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`ps-field${isCompact ? " ps-field-half" : " ps-field-full"}`}
|
||||
>
|
||||
<label className="ps-field-label">{field.label}</label>
|
||||
{renderFieldControl(
|
||||
field,
|
||||
getFieldValue(entity, field.key),
|
||||
(v) => handleFieldChange(field.key, v),
|
||||
entity.id,
|
||||
entities,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Button } from "@pubscript/ui";
|
||||
import {
|
||||
THEMES,
|
||||
exportProjectData,
|
||||
type ExportFormat,
|
||||
} from "@pubscript/exporters";
|
||||
import { Modal, Button, toast } from "@pubscript/ui";
|
||||
import { exportProjectData, type ExportFormat } from "@pubscript/exporters";
|
||||
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 { downloadFile } from "./download.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. */
|
||||
function slug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^\w-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "project";
|
||||
return (
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "manuscript"
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
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 [format, setFormat] = useState<ExportFormat>("markdown");
|
||||
const [themeId, setThemeId] = useState("ink");
|
||||
const projectDirectory = useSettings((s) => s.projectDirectory);
|
||||
const serverPort = useSettings((s) => s.serverPort);
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>("epub");
|
||||
const [scope, setScope] = useState<Scope>("project");
|
||||
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 [exporting, setExporting] = useState(false);
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (!open || !adapter || !projectId) return;
|
||||
@@ -48,7 +86,6 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
const allBooks = await adapter.listBooks(projectId);
|
||||
if (!active) return;
|
||||
setBooks(allBooks);
|
||||
// Resolve the first chapter across books so the selector has a default.
|
||||
let firstChapterId = "";
|
||||
for (const book of allBooks) {
|
||||
const cs = await adapter.listChapters(book.id);
|
||||
@@ -69,30 +106,55 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
const chapterOptions = useMemo(() => {
|
||||
const byBook = new Map<string, { id: string; title: string }[]>();
|
||||
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;
|
||||
}, [books, chapters]);
|
||||
|
||||
const formatInfo: FormatInfo | undefined = useMemo(
|
||||
() => EXPORT_FORMATS.find((f) => f.id === format),
|
||||
[format]
|
||||
);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!adapter || !projectId) return;
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const project = await buildExportProject(adapter, projectId);
|
||||
const exportProj = await buildExportProject(adapter, projectId);
|
||||
const options: Parameters<typeof exportProjectData>[2] = {
|
||||
scope: scope === "chapter" ? "chapter" : "project",
|
||||
theme: themeId,
|
||||
};
|
||||
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";
|
||||
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();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -102,89 +164,144 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Export project"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={exporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={exporting || !formatInfo}>
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="ps-export-grid">
|
||||
<label className="ps-export-field">
|
||||
<span>Format</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{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>
|
||||
<>
|
||||
<Modal
|
||||
title="Export Manuscript"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={exporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void handleExport()}
|
||||
disabled={exporting || !formatInfo}
|
||||
>
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="ps-export-grid" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>File Type / Conversion Format</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={chapterId}
|
||||
onChange={(e) => setChapterId(e.target.value)}
|
||||
disabled={exporting || chapterOptions.size === 0}
|
||||
value={format}
|
||||
onChange={(e) => handleFormatChange(e.target.value as ExportFormat)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{Array.from(chapterOptions.values()).flatMap((cs) =>
|
||||
cs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
{EXPORT_FORMATS.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.label} (.{f.extension})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="ps-export-error" role="alert">{error}</p>}
|
||||
</Modal>
|
||||
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Scope</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as Scope)}
|
||||
disabled={exporting}
|
||||
>
|
||||
<option value="project">Entire book</option>
|
||||
<option value="chapter">Selected chapter</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{scope === "chapter" && (
|
||||
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Chapter</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={chapterId}
|
||||
onChange={(e) => setChapterId(e.target.value)}
|
||||
disabled={exporting || chapterOptions.size === 0}
|
||||
>
|
||||
{Array.from(chapterOptions.values()).flatMap((cs) =>
|
||||
cs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
)),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Exported File Name</span>
|
||||
<input
|
||||
type="text"
|
||||
className="ps-input"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder={`manuscript.${formatInfo?.extension ?? "epub"}`}
|
||||
disabled={exporting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Save Directory on Host Machine</span>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
className="ps-input"
|
||||
style={{ flex: 1, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
|
||||
value={saveDir}
|
||||
onChange={(e) => setSaveDir(e.target.value)}
|
||||
placeholder="~/Documents/PubScript"
|
||||
disabled={exporting}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setBrowseOpen(true)}
|
||||
disabled={exporting}
|
||||
>
|
||||
📁 Browse Host…
|
||||
</Button>
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--ps-text-muted)",
|
||||
marginTop: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alsoDownloadBrowser}
|
||||
onChange={(e) => setAlsoDownloadBrowser(e.target.checked)}
|
||||
/>
|
||||
<span>Also download a copy to this browser device</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="ps-export-error" role="alert" style={{ color: "var(--ps-danger)", marginTop: 10 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<DirectoryBrowserModal
|
||||
open={browseOpen}
|
||||
initialPath={saveDir}
|
||||
title="Choose Export Save Directory"
|
||||
onSelect={(dir) => setSaveDir(dir)}
|
||||
onClose={() => setBrowseOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Button, Modal, toast } from "@pubscript/ui";
|
||||
import {
|
||||
importManuscriptFile,
|
||||
SUPPORTED_IMPORT_EXTENSIONS,
|
||||
type ImportedManuscript,
|
||||
} from "@pubscript/exporters";
|
||||
import { useProjectStore } from "../../store/project-store.js";
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
defaultMode?: "new-project" | "append-chapters";
|
||||
}
|
||||
|
||||
export function ImportDialog({
|
||||
open,
|
||||
onClose,
|
||||
defaultMode = "new-project",
|
||||
}: ImportDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const currentProject = useProjectStore((s) => s.project);
|
||||
const importManuscript = useProjectStore((s) => s.importManuscript);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [rawBytes, setRawBytes] = useState<Uint8Array | null>(null);
|
||||
const [parsed, setParsed] = useState<ImportedManuscript | null>(null);
|
||||
const [mode, setMode] = useState<"new-project" | "append-chapters">(defaultMode);
|
||||
const [customTitle, setCustomTitle] = useState("");
|
||||
const [splitChapters, setSplitChapters] = useState(true);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runParse = async (fileName: string, bytes: Uint8Array, split: boolean) => {
|
||||
setParsing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await importManuscriptFile(fileName, bytes, {
|
||||
splitChapters: split,
|
||||
});
|
||||
setParsed(res);
|
||||
setCustomTitle((prev) => prev || res.title);
|
||||
} catch (err) {
|
||||
setParsed(null);
|
||||
setError(err instanceof Error ? err.message : "Failed to parse manuscript file.");
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setSelectedFile(file);
|
||||
setCustomTitle("");
|
||||
const buf = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
setRawBytes(bytes);
|
||||
await runParse(file.name, bytes, splitChapters);
|
||||
};
|
||||
|
||||
const handleToggleSplit = async (nextSplit: boolean) => {
|
||||
setSplitChapters(nextSplit);
|
||||
if (selectedFile && rawBytes) {
|
||||
await runParse(selectedFile.name, rawBytes, nextSplit);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmImport = async () => {
|
||||
if (!parsed) return;
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await importManuscript(parsed, {
|
||||
mode: currentProject ? mode : "new-project",
|
||||
title: customTitle.trim() || parsed.title,
|
||||
});
|
||||
toast.success(
|
||||
mode === "append-chapters" && currentProject
|
||||
? `Imported ${parsed.chapters.length} chapter(s) (${parsed.totalWords.toLocaleString()} words) into "${currentProject.title}"`
|
||||
: `Created project "${customTitle.trim() || parsed.title}" (${parsed.chapters.length} chapter(s), ${parsed.totalWords.toLocaleString()} words)`,
|
||||
);
|
||||
setSelectedFile(null);
|
||||
setRawBytes(null);
|
||||
setParsed(null);
|
||||
setCustomTitle("");
|
||||
onClose();
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to import manuscript.");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) void handleFileChange(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Import Manuscript from File"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={importing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void handleConfirmImport()}
|
||||
disabled={!parsed || parsing || importing}
|
||||
>
|
||||
{importing
|
||||
? "Converting & Saving…"
|
||||
: mode === "append-chapters" && currentProject
|
||||
? `Import ${parsed?.chapters.length ?? 0} Chapter(s)`
|
||||
: "Import as New Project"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: "2px dashed var(--ps-border)",
|
||||
borderRadius: 10,
|
||||
padding: "20px 16px",
|
||||
textAlign: "center",
|
||||
background: "var(--ps-bg)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_IMPORT_EXTENSIONS.join(",")}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => void handleFileChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div style={{ fontSize: 22, marginBottom: 6 }}>📥</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, marginBottom: 4 }}>
|
||||
{selectedFile
|
||||
? `${selectedFile.name} (${(selectedFile.size / 1024).toFixed(1)} KB)`
|
||||
: "Click to choose a file or drag & drop here"}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
|
||||
Compatible formats: <strong>.docx</strong> (Word / OnlyOffice), <strong>.odt</strong>{" "}
|
||||
(OpenOffice / LibreOffice / OnlyOffice), <strong>.epub</strong>, <strong>.md</strong>,{" "}
|
||||
<strong>.txt</strong>, <strong>.html</strong>, <strong>.rtf</strong>,{" "}
|
||||
<strong>.json</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--ps-danger)",
|
||||
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
|
||||
color: "var(--ps-danger)",
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsing && (
|
||||
<div style={{ fontSize: 12.5, color: "var(--ps-text-muted)", textAlign: "center" }}>
|
||||
Running PubScript format conversion pipeline…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && !parsing && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--ps-border)",
|
||||
background: "var(--ps-surface)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Badge variant="accent">{parsed.formatLabel}</Badge>
|
||||
<span style={{ fontSize: 12, color: "var(--ps-text-muted)" }}>
|
||||
{parsed.chapters.length} chapter(s) · {parsed.totalWords.toLocaleString()} words
|
||||
</span>
|
||||
</div>
|
||||
{parsed.author && (
|
||||
<span style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
|
||||
Author: {parsed.author}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentProject && (
|
||||
<div className="ps-field">
|
||||
<label className="ps-field-label">Import Destination</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("new-project")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border:
|
||||
mode === "new-project"
|
||||
? "2px solid var(--ps-accent)"
|
||||
: "1px solid var(--ps-border)",
|
||||
background:
|
||||
mode === "new-project"
|
||||
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
|
||||
: "var(--ps-bg)",
|
||||
color: "var(--ps-text)",
|
||||
fontSize: 12.5,
|
||||
fontWeight: mode === "new-project" ? 600 : 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
✨ Create New Project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("append-chapters")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border:
|
||||
mode === "append-chapters"
|
||||
? "2px solid var(--ps-accent)"
|
||||
: "1px solid var(--ps-border)",
|
||||
background:
|
||||
mode === "append-chapters"
|
||||
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
|
||||
: "var(--ps-bg)",
|
||||
color: "var(--ps-text)",
|
||||
fontSize: 12.5,
|
||||
fontWeight: mode === "append-chapters" ? 600 : 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
📖 Add Chapters to “{currentProject.title}”
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "new-project" && (
|
||||
<div className="ps-field">
|
||||
<label className="ps-field-label" htmlFor="ps-import-title">
|
||||
Project Title
|
||||
</label>
|
||||
<input
|
||||
id="ps-import-title"
|
||||
type="text"
|
||||
className="ps-input"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Manuscript Title"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.format !== "epub" && parsed.format !== "json" && (
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12.5,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={splitChapters}
|
||||
onChange={(e) => void handleToggleSplit(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Automatically split into chapters on headings (<code>H1</code> /{" "}
|
||||
<code>Chapter N</code>)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--ps-text-muted)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Converted Chapters Preview ({parsed.chapters.length})
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 160,
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--ps-border)",
|
||||
borderRadius: 8,
|
||||
background: "var(--ps-bg)",
|
||||
padding: 6,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{parsed.chapters.map((ch, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "5px 8px",
|
||||
borderRadius: 6,
|
||||
fontSize: 12.5,
|
||||
background: "var(--ps-surface)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{idx + 1}. {ch.title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--ps-font-mono)",
|
||||
fontSize: 11.5,
|
||||
color: "var(--ps-text-muted)",
|
||||
}}
|
||||
>
|
||||
{ch.wordCount.toLocaleString()} words
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
||||
import { ImportDialog } from "../features/import/ImportDialog.js";
|
||||
|
||||
type WizardMode = "tutorial" | "manual";
|
||||
|
||||
@@ -88,6 +89,7 @@ export function Projects() {
|
||||
} = useProjectStore();
|
||||
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [wizardMode, setWizardMode] = useState<WizardMode>("tutorial");
|
||||
const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1);
|
||||
const [answers, setAnswers] = useState<TutorialAnswers>({
|
||||
@@ -200,9 +202,14 @@ export function Projects() {
|
||||
Switch between manuscripts, start a guided story template, or manage existing projects.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={openWizard}>
|
||||
+ New project
|
||||
</Button>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||
📥 Import from File
|
||||
</Button>
|
||||
<Button variant="primary" onClick={openWizard}>
|
||||
+ New project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
@@ -613,6 +620,11 @@ export function Projects() {
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
<ImportDialog
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
defaultMode="new-project"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
createAutosave,
|
||||
createPlotBeat,
|
||||
createServerClient,
|
||||
type AutosaveState,
|
||||
type Board,
|
||||
type Chapter,
|
||||
@@ -21,9 +22,12 @@ import {
|
||||
type ProjectMeta,
|
||||
type StorageAdapter,
|
||||
} from "@pubscript/core";
|
||||
import type { ImportedManuscript } from "@pubscript/exporters";
|
||||
import { storageLocal } from "@pubscript/core/local";
|
||||
import { createUlid } from "@pubscript/core";
|
||||
import { create } from "zustand";
|
||||
import { getStoredToken } from "../api.js";
|
||||
import { useSettings } from "./settings.js";
|
||||
import { seedDemoProject } from "./seed.js";
|
||||
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
||||
|
||||
@@ -110,6 +114,11 @@ interface ProjectStore {
|
||||
openProject: (id: string) => Promise<void>;
|
||||
/** Create a new project with optional structural template and chapter skeleton. */
|
||||
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. */
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
|
||||
@@ -295,6 +304,78 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
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 {
|
||||
adapter: null,
|
||||
projects: [],
|
||||
@@ -321,8 +402,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
init: async () => {
|
||||
set({ status: "loading", error: null });
|
||||
try {
|
||||
const adapter = storageLocal;
|
||||
let projects = await adapter.listProjects();
|
||||
const resolved = await resolveHostOrLocalAdapter();
|
||||
const adapter = resolved.adapter;
|
||||
let projects = resolved.projects;
|
||||
if (projects.length === 0) {
|
||||
await seedDemoProject(adapter);
|
||||
projects = await adapter.listProjects();
|
||||
@@ -341,9 +423,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
|
||||
projects[0]!;
|
||||
|
||||
const books = await adapter.listBooks(project.id);
|
||||
const book = books[0];
|
||||
const chapters = book ? await adapter.listChapters(book.id) : [];
|
||||
let books = await adapter.listBooks(project.id);
|
||||
let book = books[0];
|
||||
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.onStatus((status) => {
|
||||
@@ -552,6 +638,109 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
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) => {
|
||||
const adapter = get().adapter ?? storageLocal;
|
||||
if (adapter.deleteProject) {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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 { createUlid } from "@pubscript/core";
|
||||
|
||||
@@ -238,6 +245,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
||||
export interface AppSettings {
|
||||
theme: ThemeId;
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
fontSize: number;
|
||||
@@ -247,6 +255,7 @@ export interface AppSettings {
|
||||
wordCountGoal: number;
|
||||
autoSave: boolean;
|
||||
focusMode: boolean;
|
||||
projectDirectory: string;
|
||||
// Navigation & AI Sidebars
|
||||
navCollapsed: boolean;
|
||||
aiSidebarOpen: boolean;
|
||||
@@ -263,11 +272,19 @@ export interface AppSettings {
|
||||
activeModel: string;
|
||||
aiAgents: AIAgentConfig[];
|
||||
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 = {
|
||||
theme: "ink",
|
||||
accentColor: "#7c8bf5",
|
||||
textColor: "",
|
||||
density: "comfortable",
|
||||
fontFamily: "serif",
|
||||
fontSize: 16,
|
||||
@@ -277,6 +294,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
wordCountGoal: 80_000,
|
||||
autoSave: true,
|
||||
focusMode: false,
|
||||
projectDirectory: "~/Documents/PubScript",
|
||||
navCollapsed: false,
|
||||
aiSidebarOpen: false,
|
||||
aiSidebarWidth: 370,
|
||||
@@ -292,6 +310,13 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel,
|
||||
aiAgents: DEFAULT_AI_AGENTS,
|
||||
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";
|
||||
@@ -343,6 +368,10 @@ export interface SettingsStore extends AppSettings {
|
||||
export const useSettings = create<SettingsStore>((set, get) => {
|
||||
const initial = loadSettings();
|
||||
applyTheme(initial.theme);
|
||||
applyColorOverrides({
|
||||
accentColor: initial.accentColor,
|
||||
textColor: initial.textColor,
|
||||
});
|
||||
applyDensity(initial.density);
|
||||
applyFontSettings({
|
||||
size: initial.fontSize,
|
||||
@@ -363,6 +392,12 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
||||
if (patch.theme != null) {
|
||||
applyTheme(next.theme);
|
||||
}
|
||||
if (patch.theme != null || patch.accentColor != null || patch.textColor != null) {
|
||||
applyColorOverrides({
|
||||
accentColor: next.accentColor,
|
||||
textColor: next.textColor,
|
||||
});
|
||||
}
|
||||
if (patch.density != null) {
|
||||
applyDensity(next.density);
|
||||
}
|
||||
@@ -383,7 +418,8 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
get().set({ theme });
|
||||
const themeDefaultAccent = THEME_TOKENS[theme]?.css["--ps-accent"] ?? "#7c8bf5";
|
||||
get().set({ theme, accentColor: themeDefaultAccent, textColor: "" });
|
||||
},
|
||||
updateProvider: (id, patch) => {
|
||||
const nextProviders = get().aiProviders.map((p) =>
|
||||
|
||||
@@ -8,19 +8,37 @@
|
||||
* (`--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 {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
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 ---- */
|
||||
@@ -28,9 +46,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ps-entity-search .ps-field {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ps-entity-search-input {
|
||||
flex: 1;
|
||||
@@ -64,7 +86,8 @@
|
||||
font-family: var(--ps-font-ui);
|
||||
}
|
||||
|
||||
.ps-entity-card:hover {
|
||||
.ps-entity-card:hover,
|
||||
.ps-entity-card.is-selected {
|
||||
border-color: var(--ps-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
@@ -161,20 +184,46 @@
|
||||
font-family: var(--ps-font-ui);
|
||||
}
|
||||
|
||||
/* ---- Entity editor drawer ---- */
|
||||
/* ---- Entity editor drawer (docked right rail) ---- */
|
||||
.ps-entity-editor {
|
||||
width: min(460px, 45vw);
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 16px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ps-spacer {
|
||||
@@ -185,14 +234,24 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ---- Generic entity fields ---- */
|
||||
/* ---- Generic entity fields (clean 2-column grid for selects, full-width for text/textarea) ---- */
|
||||
.ps-entity-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
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 {
|
||||
grid-column: span 2;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ps-field-half {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.ps-textarea {
|
||||
@@ -312,15 +371,19 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ps-relationship-add > .ps-field {
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.ps-relationship-target-select,
|
||||
.ps-relationship-kind-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ps-relationship-note {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Misc ---- */
|
||||
@@ -406,14 +469,7 @@
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ---- Entity editor drawer header/body ---- */
|
||||
.ps-entity-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ps-entity-Editor-title,
|
||||
.ps-entity-editor-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@@ -421,14 +477,6 @@
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-entity-editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.ps-icon-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
@@ -5,15 +5,26 @@
|
||||
* line-number gutter, focus mode, and status bar.
|
||||
*/
|
||||
|
||||
.ps-writing-slot {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ps-writing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* OnlyOffice / Word-inspired icon formatting ribbon */
|
||||
/* OnlyOffice / Word-inspired icon formatting ribbon (pinned at top) */
|
||||
.ps-writing-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@@ -23,7 +34,8 @@
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
font-family: var(--ps-font-ui);
|
||||
flex: none;
|
||||
z-index: 5;
|
||||
flex-shrink: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
:root[data-density="compact"] .ps-writing-toolbar {
|
||||
padding: 4px 10px;
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081",
|
||||
@@ -30,5 +31,6 @@ export default defineConfig({
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173,
|
||||
allowedHosts: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,6 +157,25 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
|
||||
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");
|
||||
if (env_port && *env_port) {
|
||||
int p = atoi(env_port);
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
||||
{ 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: "appearance", label: "Appearance", type: "textarea" },
|
||||
{ key: "voice", label: "Voice", type: "textarea" },
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
||||
|
||||
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;
|
||||
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
|
||||
fetch?: typeof fetch;
|
||||
@@ -37,6 +37,7 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: body === undefined ? null : JSON.stringify(body),
|
||||
});
|
||||
@@ -53,8 +54,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
||||
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", {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
title: p.title,
|
||||
language: p.language,
|
||||
cover: p.cover,
|
||||
@@ -67,8 +69,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
||||
return r.project;
|
||||
},
|
||||
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
await req("DELETE", `/api/projects/${id}`);
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -86,6 +94,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
||||
await req("POST", `/api/chapters/${doc.id}/autosave`, {
|
||||
rev: doc.rev,
|
||||
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> {
|
||||
if ("fields" in e) {
|
||||
// WorldItem — the server nests the world kind inside `fields`.
|
||||
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) {
|
||||
// Note
|
||||
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 {
|
||||
// Character
|
||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||
id: e.id, kind: "character", 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,
|
||||
id: e.id,
|
||||
kind: "character",
|
||||
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 {
|
||||
// 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 () => {};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
exportProjectData,
|
||||
importManuscriptFile,
|
||||
type ExportProject,
|
||||
} from "./index.js";
|
||||
|
||||
const SAMPLE_PROJECT: ExportProject = {
|
||||
meta: {
|
||||
title: "The Harbor Ledger",
|
||||
author: "Austin Breinholt",
|
||||
language: "en",
|
||||
},
|
||||
books: [
|
||||
{
|
||||
id: "book-1",
|
||||
projectId: "proj-import-1",
|
||||
title: "Book One",
|
||||
order: 0,
|
||||
chapters: [
|
||||
{
|
||||
id: "ch-1",
|
||||
bookId: "book-1",
|
||||
projectId: "proj-import-1",
|
||||
title: "Chapter 1: Fog Over the Pier",
|
||||
order: 0,
|
||||
rev: 1,
|
||||
meta: { wordCount: 15, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
doc: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "Chapter 1: Fog Over the Pier" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "The lantern swung in the " },
|
||||
{ type: "text", text: "harbor wind", marks: [{ type: "bold" }] },
|
||||
{ type: "text", text: " as Mara opened the ledger." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ch-2",
|
||||
bookId: "book-1",
|
||||
projectId: "proj-import-1",
|
||||
title: "Chapter 2: The Bell Tower",
|
||||
order: 1,
|
||||
rev: 1,
|
||||
meta: { wordCount: 6, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
doc: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "Chapter 2: The Bell Tower" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Twelve strokes echoed across the water." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("Universal Manuscript Importer (importer.ts)", () => {
|
||||
it("imports plain text (.txt) and splits chapters on Chapter headings", async () => {
|
||||
const txt = [
|
||||
"Chapter 1: The Arrival",
|
||||
"",
|
||||
"The train pulled into the station at dusk.",
|
||||
"",
|
||||
"* * *",
|
||||
"",
|
||||
"She stepped onto the wet platform.",
|
||||
"",
|
||||
"Chapter 2: The Departure",
|
||||
"",
|
||||
"By morning, the tracks were empty.",
|
||||
].join("\n");
|
||||
|
||||
const res = await importManuscriptFile("arrival-story.txt", txt);
|
||||
expect(res.format).toBe("txt");
|
||||
expect(res.chapters).toHaveLength(2);
|
||||
expect(res.chapters[0]?.title).toBe("Chapter 1: The Arrival");
|
||||
expect(res.chapters[1]?.title).toBe("Chapter 2: The Departure");
|
||||
expect(res.totalWords).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("imports Markdown (.md) with frontmatter, inline marks, and chapters", async () => {
|
||||
const md = [
|
||||
"---",
|
||||
"title: Glass Harbor",
|
||||
"author: Schwitzz",
|
||||
"---",
|
||||
"# Chapter One",
|
||||
"",
|
||||
"He read the **bold entry** and *italic margin note*.",
|
||||
"",
|
||||
"# Chapter Two",
|
||||
"",
|
||||
"The second chapter begins here.",
|
||||
].join("\n");
|
||||
|
||||
const res = await importManuscriptFile("glass-harbor.md", md);
|
||||
expect(res.format).toBe("md");
|
||||
expect(res.title).toBe("Glass Harbor");
|
||||
expect(res.author).toBe("Schwitzz");
|
||||
expect(res.chapters).toHaveLength(2);
|
||||
expect(res.chapters[0]?.title).toBe("Chapter One");
|
||||
expect(res.chapters[1]?.title).toBe("Chapter Two");
|
||||
});
|
||||
|
||||
it("round-trips DOCX (Word / OnlyOffice) into PubScript chapters", async () => {
|
||||
const docxBytes = (await exportProjectData(SAMPLE_PROJECT, "docx", {
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
})) as Uint8Array;
|
||||
|
||||
const imported = await importManuscriptFile("the-harbor-ledger.docx", docxBytes);
|
||||
expect(imported.format).toBe("docx");
|
||||
expect(imported.chapters.length).toBeGreaterThanOrEqual(2);
|
||||
expect(imported.totalWords).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("round-trips ODT (OpenOffice / OnlyOffice / LibreOffice) into PubScript chapters", async () => {
|
||||
const odtBytes = (await exportProjectData(SAMPLE_PROJECT, "odt", {
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
})) as Uint8Array;
|
||||
|
||||
const imported = await importManuscriptFile("the-harbor-ledger.odt", odtBytes);
|
||||
expect(imported.format).toBe("odt");
|
||||
expect(imported.title).toBe("The Harbor Ledger");
|
||||
expect(imported.chapters.length).toBeGreaterThanOrEqual(2);
|
||||
expect(imported.totalWords).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("round-trips EPUB into PubScript chapters", async () => {
|
||||
const epubBytes = (await exportProjectData(SAMPLE_PROJECT, "epub", {
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
})) as Uint8Array;
|
||||
|
||||
const imported = await importManuscriptFile("the-harbor-ledger.epub", epubBytes);
|
||||
expect(imported.format).toBe("epub");
|
||||
expect(imported.title).toBe("The Harbor Ledger");
|
||||
expect(imported.chapters).toHaveLength(2);
|
||||
expect(imported.totalWords).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("imports RTF (.rtf) and HTML (.html) manuscripts", async () => {
|
||||
const rtf = "{\\rtf1\\ansi{\\fonttbl\\f0 Times;}\\pard Chapter 1: Dawn\\par The sun rose over the ridge.\\par}";
|
||||
const rtfRes = await importManuscriptFile("dawn.rtf", rtf);
|
||||
expect(rtfRes.format).toBe("rtf");
|
||||
expect(rtfRes.totalWords).toBeGreaterThan(4);
|
||||
|
||||
const html = "<html><head><title>Sea Wall</title></head><body><h1>Chapter 1</h1><p>Waves crashed against the <strong>stone</strong>.</p></body></html>";
|
||||
const htmlRes = await importManuscriptFile("seawall.html", html);
|
||||
expect(htmlRes.format).toBe("html");
|
||||
expect(htmlRes.title).toBe("Sea Wall");
|
||||
expect(htmlRes.chapters[0]?.title).toBe("Chapter 1");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,3 +132,4 @@ export { exportEpub } from "./epub.js";
|
||||
export { exportDocx } from "./docx.js";
|
||||
export { exportOdt } from "./odt.js";
|
||||
export { exportPdf } from "./pdf.js";
|
||||
export * from "./importer.js";
|
||||
|
||||
@@ -144,8 +144,10 @@ body {
|
||||
}
|
||||
|
||||
.ps-sidebar-logo-only {
|
||||
width: 88px;
|
||||
height: auto;
|
||||
width: 92px;
|
||||
height: 52px;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
background: transparent;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
@@ -153,7 +155,7 @@ body {
|
||||
}
|
||||
.ps-sidebar.is-collapsed .ps-sidebar-logo-only {
|
||||
width: 48px;
|
||||
height: auto;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.ps-sidebar-toggle {
|
||||
@@ -429,7 +431,7 @@ body {
|
||||
}
|
||||
.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:only-child { color: var(--ps-danger); }
|
||||
.ps-field-hint.is-error { color: var(--ps-danger); }
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type InputHTMLAttributes } from "react";
|
||||
import { useId, type InputHTMLAttributes } from "react";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
@@ -7,7 +7,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="ps-field">
|
||||
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type SelectHTMLAttributes } from "react";
|
||||
import { useId, type SelectHTMLAttributes } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
@@ -11,7 +11,8 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="ps-field">
|
||||
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
|
||||
|
||||
@@ -2,9 +2,20 @@ import { describe, it, expect } from "vitest";
|
||||
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
|
||||
|
||||
describe("theme token map", () => {
|
||||
it("defines exactly four themes", () => {
|
||||
expect(THEME_IDS).toEqual(["ink", "parchment", "nocturne", "terminal"]);
|
||||
expect(Object.keys(THEME_TOKENS)).toHaveLength(4);
|
||||
it("defines ten themes including the core four", () => {
|
||||
expect(THEME_IDS).toEqual([
|
||||
"ink",
|
||||
"parchment",
|
||||
"nocturne",
|
||||
"terminal",
|
||||
"espresso",
|
||||
"forest",
|
||||
"nordic",
|
||||
"paperback",
|
||||
"sepia",
|
||||
"synthwave",
|
||||
]);
|
||||
expect(Object.keys(THEME_TOKENS)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("labels every theme", () => {
|
||||
|
||||
+179
-28
@@ -1,20 +1,47 @@
|
||||
/**
|
||||
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
|
||||
* Theme engine — CSS-variable token map.
|
||||
*
|
||||
* Four themes ship as token objects; `applyTheme(id)` sets
|
||||
* `document.documentElement.dataset.theme`. Accent color and density are
|
||||
* runtime overrides layered on top.
|
||||
* Ten themes ship as token objects; `applyTheme(id)` sets
|
||||
* `document.documentElement.dataset.theme`. Accent color, text color, and
|
||||
* 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> = {
|
||||
ink: "Ink",
|
||||
parchment: "Parchment",
|
||||
nocturne: "Nocturne",
|
||||
terminal: "Terminal",
|
||||
ink: "Ink (Dark)",
|
||||
parchment: "Parchment (Warm Light)",
|
||||
nocturne: "Nocturne (Midnight Indigo)",
|
||||
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 {
|
||||
@@ -23,13 +50,9 @@ export interface ThemeTokens {
|
||||
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> = {
|
||||
ink: {
|
||||
label: "Ink",
|
||||
label: "Ink (Dark)",
|
||||
css: {
|
||||
"--ps-bg": "#0e0f13",
|
||||
"--ps-surface": "#17181f",
|
||||
@@ -45,7 +68,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
},
|
||||
},
|
||||
parchment: {
|
||||
label: "Parchment",
|
||||
label: "Parchment (Warm Light)",
|
||||
css: {
|
||||
"--ps-bg": "#f6f1e7",
|
||||
"--ps-surface": "#fffdf7",
|
||||
@@ -61,7 +84,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
},
|
||||
},
|
||||
nocturne: {
|
||||
label: "Nocturne",
|
||||
label: "Nocturne (Midnight Indigo)",
|
||||
css: {
|
||||
"--ps-bg": "#0c0b1e",
|
||||
"--ps-surface": "#171533",
|
||||
@@ -77,7 +100,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
label: "Terminal",
|
||||
label: "Terminal (Phosphor Mono)",
|
||||
css: {
|
||||
"--ps-bg": "#05070a",
|
||||
"--ps-surface": "#0a0f14",
|
||||
@@ -92,6 +115,102 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
"--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. */
|
||||
@@ -126,13 +245,54 @@ export function applyTheme(id: ThemeId): ThemeId {
|
||||
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. */
|
||||
export function getActiveTheme(): ThemeId {
|
||||
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.
|
||||
*/
|
||||
export interface FontSettings {
|
||||
@@ -153,11 +313,6 @@ export const DEFAULT_FONT_SETTINGS: FontSettings = {
|
||||
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 {
|
||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||
if (!root) return;
|
||||
|
||||
@@ -70,6 +70,15 @@ export function registerAdminRoutes(app: FastifyInstance): void {
|
||||
|
||||
const fields: string[] = [];
|
||||
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) {
|
||||
fields.push("display_name = ?");
|
||||
values.push(body.displayName);
|
||||
@@ -189,6 +198,7 @@ const userCreate = z.object({
|
||||
});
|
||||
|
||||
const userPatch = z.object({
|
||||
email: z.string().trim().toLowerCase().email().optional(),
|
||||
role: z.enum(["admin", "author"]).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
|
||||
+216
-43
@@ -10,11 +10,30 @@ import { toUser, type UserRow } from "./mapping.js";
|
||||
const SESSION_COOKIE = "ps_session";
|
||||
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
||||
|
||||
// ---- password hashing ----
|
||||
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id });
|
||||
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw);
|
||||
// ---- password hashing (OWASP Argon2id parameters) ----
|
||||
const ARGON2_OPTS: argon2.Options = {
|
||||
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 {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -25,11 +44,16 @@ function tokenHash(token: string): string {
|
||||
export function createSession(db: Db, userId: string): string {
|
||||
const token = newToken();
|
||||
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),
|
||||
userId,
|
||||
new Date(now + SESSION_TTL_MS).toISOString(),
|
||||
new Date(now).toISOString(),
|
||||
nowIso,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
@@ -51,7 +75,7 @@ export function userFromToken(db: Db, token: string | null | undefined): UserRow
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ---- tiny in-memory fixed-window rate limiter ----
|
||||
// ---- fixed-window rate limiter ----
|
||||
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
|
||||
const hits = new Map<string, { count: number; resetAt: number }>();
|
||||
return (key: string): boolean => {
|
||||
@@ -74,19 +98,48 @@ export function bearerToken(req: FastifyRequest): string | null {
|
||||
}
|
||||
|
||||
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) => {
|
||||
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);
|
||||
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, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: req ? isSecureRequest(req) : false,
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
});
|
||||
}
|
||||
@@ -100,14 +153,21 @@ export function requireAuth(req: FastifyRequest): void {
|
||||
}
|
||||
export function requireAdmin(req: FastifyRequest): void {
|
||||
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
|
||||
| { owner_id: string }
|
||||
| undefined;
|
||||
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
|
||||
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
||||
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
|
||||
@@ -116,24 +176,85 @@ export function canAccessProject(db: Db, user: User, projectId: string, needWrit
|
||||
}
|
||||
|
||||
// ---- auth routes ----
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().min(1).max(80),
|
||||
const setupSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email(),
|
||||
password: z.string().min(8).max(256),
|
||||
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 {
|
||||
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
|
||||
|
||||
app.post("/api/auth/register", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
const body = registerSchema.parse(req.body);
|
||||
// Check whether the instance has been initialized with an admin user and whether the caller is authenticated
|
||||
app.get("/api/auth/status", async (req) => {
|
||||
const count = (
|
||||
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
|
||||
).n;
|
||||
return {
|
||||
initialized: count > 0,
|
||||
authenticated: Boolean(req.user),
|
||||
user: req.user ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// 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 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);
|
||||
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 id = ulid();
|
||||
const now = new Date().toISOString();
|
||||
@@ -141,49 +262,107 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
).run(id, body.email, passHash, body.displayName, role, now);
|
||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
|
||||
ulid(),
|
||||
now,
|
||||
id,
|
||||
JSON.stringify({ email: body.email, first: isFirst }),
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)",
|
||||
).run(ulid(), now, id, JSON.stringify({ email: body.email, first: isFirst }));
|
||||
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||
const token = createSession(db, id);
|
||||
setSessionCookie(reply, token);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
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 rateKey = `${req.ip}:${body.email}`;
|
||||
if (!authLimiter(rateKey)) {
|
||||
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
}
|
||||
const db = app.db;
|
||||
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
|
||||
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
|
||||
const row = db
|
||||
.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" });
|
||||
}
|
||||
|
||||
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);
|
||||
setSessionCookie(reply, token);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
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);
|
||||
clearSessionCookie(reply);
|
||||
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) => {
|
||||
requireAuth(req);
|
||||
return { user: req.user };
|
||||
});
|
||||
|
||||
// Bootstrap or sign into the local admin account when no admin session is active yet.
|
||||
app.post("/api/auth/bootstrap-admin", async (_req, reply) => {
|
||||
// Test-only bootstrap helper (disabled in production once initialized)
|
||||
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;
|
||||
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;
|
||||
|
||||
if (!adminRow) {
|
||||
@@ -195,17 +374,11 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
||||
).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;
|
||||
}
|
||||
|
||||
const token = createSession(db, adminRow.id);
|
||||
setSessionCookie(reply, token);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(adminRow) };
|
||||
});
|
||||
}
|
||||
|
||||
+18
-2
@@ -2,8 +2,12 @@
|
||||
* Environment-driven server configuration.
|
||||
*
|
||||
* 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 {
|
||||
const value = process.env[key];
|
||||
@@ -17,6 +21,18 @@ function envInt(key: string, fallback: number): number {
|
||||
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 {
|
||||
/** Path to the SQLite database file. */
|
||||
dbPath: string;
|
||||
@@ -33,7 +49,7 @@ export interface ServerConfig {
|
||||
}
|
||||
|
||||
export const config: ServerConfig = {
|
||||
dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
|
||||
dbPath: env("PUBSCRIPT_DB", defaultDatabasePath()),
|
||||
port: envInt("PORT", 8080),
|
||||
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
|
||||
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
|
||||
|
||||
+428
-5
@@ -1,5 +1,16 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
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 Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
@@ -9,7 +20,13 @@ import websocket from "@fastify/websocket";
|
||||
import { ZodError } from "zod";
|
||||
import { config } from "./config.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 { registerWs } from "./ws.js";
|
||||
import { registerAdminRoutes } from "./admin.js";
|
||||
@@ -35,7 +52,7 @@ function makeHttpError(status: number, payload: object): HttpError {
|
||||
}
|
||||
|
||||
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(cookie);
|
||||
await app.register(websocket);
|
||||
@@ -62,7 +79,398 @@ export async function buildApp() {
|
||||
} catch {
|
||||
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 loadPersistedServerSysConfig(): PersistedServerSysConfig {
|
||||
try {
|
||||
if (existsSync(sysConfigPath)) {
|
||||
const raw = JSON.parse(readFileSync(sysConfigPath, "utf8")) as Partial<PersistedServerSysConfig>;
|
||||
return {
|
||||
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
||||
host:
|
||||
typeof raw.host === "string" && raw.host.trim()
|
||||
? raw.host.trim()
|
||||
: "127.0.0.1",
|
||||
allowedHosts:
|
||||
typeof raw.allowedHosts === "string" && raw.allowedHosts.trim()
|
||||
? raw.allowedHosts.trim()
|
||||
: "localhost, 127.0.0.1",
|
||||
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 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}`,
|
||||
`ExecStart=${existsSync(optServerEntry) ? `/usr/bin/node ${optServerEntry}` : `/usr/bin/node --import tsx ${localServerEntry}`}`,
|
||||
"Restart=on-failure",
|
||||
"RestartSec=3",
|
||||
"",
|
||||
"[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();
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -93,6 +501,21 @@ export async function buildApp() {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+178
-19
@@ -1,3 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { ulid } from "ulid";
|
||||
@@ -29,7 +32,12 @@ import {
|
||||
} from "./mapping.js";
|
||||
|
||||
// ---- 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({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
settings: z.record(z.unknown()).optional(),
|
||||
@@ -51,6 +59,9 @@ const chapterPatch = z.object({
|
||||
const autosaveBody = z.object({
|
||||
rev: z.number().int(),
|
||||
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(),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const db = app.db;
|
||||
|
||||
@@ -128,6 +226,10 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
app.get("/api/projects", async (req) => {
|
||||
requireAuth(req);
|
||||
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 shared = db
|
||||
.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) => {
|
||||
requireAuth(req);
|
||||
const { title } = projectCreate.parse(req.body);
|
||||
const id = ulid();
|
||||
const body = projectCreate.parse(req.body);
|
||||
const id = body.id ?? ulid();
|
||||
const t = now();
|
||||
const coverJson = JSON.stringify(body.cover ?? { color: "#7c8bf5" });
|
||||
const settingsJson = JSON.stringify(body.language ? { language: body.language } : {});
|
||||
db.prepare(
|
||||
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)",
|
||||
).run(id, req.user!.id, title, t, t);
|
||||
`INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at)
|
||||
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)!;
|
||||
return { project: toProject(row, []) };
|
||||
});
|
||||
@@ -327,25 +435,74 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
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) => {
|
||||
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;
|
||||
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
||||
requireProjectAccess(app, req, row.project_id, true);
|
||||
const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body);
|
||||
if (rev === row.rev) {
|
||||
|
||||
if (!row) {
|
||||
requireAuth(req);
|
||||
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 count = wc ?? wordCount(doc);
|
||||
db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id);
|
||||
return { ok: true, rev: row.rev + 1, wordCount: count };
|
||||
const chTitle = body.title?.trim() || "Untitled Chapter";
|
||||
const nextRev = Math.max(1, body.rev + 1);
|
||||
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, {
|
||||
error: "conflict",
|
||||
serverRev: row.rev,
|
||||
serverDoc: JSON.parse(row.doc),
|
||||
});
|
||||
|
||||
requireProjectAccess(app, req, row.project_id, true);
|
||||
const t = now();
|
||||
const nextTitle = body.title?.trim() || row.title;
|
||||
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) => {
|
||||
@@ -403,6 +560,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
|
||||
).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;
|
||||
syncProjectToHostDisk(db, id);
|
||||
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,
|
||||
);
|
||||
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
|
||||
syncProjectToHostDisk(db, id);
|
||||
return { board: toBoard(row) };
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
process.env.PUBSCRIPT_DB = ":memory:";
|
||||
import { buildApp } from "./index.js";
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -96,4 +97,79 @@ describe("admin endpoints", () => {
|
||||
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,
|
||||
});
|
||||
|
||||
const setupRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/setup",
|
||||
payload: {
|
||||
email: "owner@example.com",
|
||||
displayName: "Studio Owner",
|
||||
password: "StrongPassword!2026",
|
||||
},
|
||||
});
|
||||
expect(setupRes.statusCode).toBe(200);
|
||||
const { token, user } = setupRes.json() as { token: string; user: { role: string } };
|
||||
expect(token).toBeTruthy();
|
||||
expect(user.role).toBe("admin");
|
||||
|
||||
// Second setup attempt must be forbidden once initialized
|
||||
const secondSetup = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/setup",
|
||||
payload: {
|
||||
email: "attacker@example.com",
|
||||
displayName: "Attacker",
|
||||
password: "StrongPassword!2026",
|
||||
},
|
||||
});
|
||||
expect(secondSetup.statusCode).toBe(403);
|
||||
|
||||
// Login with wrong password must fail with 401
|
||||
const badLogin = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { email: "owner@example.com", password: "wrongpassword" },
|
||||
});
|
||||
expect(badLogin.statusCode).toBe(401);
|
||||
|
||||
// Login with valid password succeeds
|
||||
const goodLogin = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { email: "owner@example.com", password: "StrongPassword!2026" },
|
||||
});
|
||||
expect(goodLogin.statusCode).toBe(200);
|
||||
|
||||
// System config works with valid auth token
|
||||
const authSys = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/server-config",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(authSys.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user