Compare commits

...

10 Commits

Author SHA1 Message Date
Schwitzz 020563ed85 feat(import): add universal manuscript import pipeline (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)
CI / build (push) Waiting to run
2026-10-30 16:51:33 -05:00
Schwitzz aee9c428fc feat(sync): sync Web and Desktop projects with Host Server SQLite and Main Project Directory 2026-10-30 16:35:11 -05:00
Schwitzz 668470da2b fix(auth): unify Desktop and Web SQLite DB path and fix browser autofill crash on Input/AuthGate 2026-10-30 16:18:17 -05:00
Schwitzz 09e9280da6 feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions 2026-10-30 16:06:57 -05:00
Schwitzz 65d06e1681 fix: pin Writing toolbar and Entities header, fix Entities form alignment and red hint text, and make Settings category bar horizontal 2026-10-30 15:49:24 -05:00
Schwitzz 723fafdef8 feat: add Bind Host IP and Allowed Hosts / Reverse Proxy Domain settings 2026-10-30 14:59:09 -05:00
Schwitzz 29dbc27c41 feat: add Web App Port management alongside API Server Port and fix square 512x512 app launcher icon 2026-10-30 14:23:12 -05:00
Schwitzz 1d209fedd2 feat: add tabbed Settings, 10 themes with custom accent/text color pickers, main project directory selector, and export filename/directory controls 2026-10-30 14:06:46 -05:00
Schwitzz fa22a755ee feat: add server settings (toggle on/off, port selection, start on system start) to Settings UI and backend 2026-10-30 13:56:23 -05:00
Schwitzz 42733b4799 docs: update README with release installation instructions and remove development progress status 2026-10-30 13:49:15 -05:00
39 changed files with 6235 additions and 1102 deletions
-24
View File
@@ -1,24 +0,0 @@
Features and Bugs:
1. (bug) - Whiteboard. Clicking add > Character and add > World leads to blank screen.
2. (bug) - Whiteboard. Nodes are not dragging.
3. (bug) - Whiteboard. Nodes cannot be edited after placement.
4. (bug) - connecting lines cannot be deleted.
5. (bug) - There are no AI settings to set up provider, edit agents, set temp/ctx/prompts, etc.
6. (feature) - AI.Make AI chat window a sidebar that can be toggled. Include provider, model, archived chats, agent, and load/reload button.
7. (feature) - AI. make navigation a sidebar with toggle. Untoggled will hid all but the icons.
8. (feature) - Write. Needs microsoft word-like formatting features. Line spacing, bold, italics, justification, etc. The whole formatting suite. Reference open source software like Only Office for an example. Favor icons over text buttons.
9. (feature) - All. Change the persistent "PubScript" + icon to the logo. Remove PS logo on the other persistent header.
10. (feature) - Entities. Revise the "+ New" flow. Use an add button on the individual tabs, not catch all new with drop down.
11. (bug) - Plot. Dragging beats deletes them.
12. (feature) - Plot. remove + Add Beat button. Redundant, keep the + on each Act.
13. (bug) - Projects. Clicking in this page does nothing. Cannot add new project. Cannot open existing project. Cannot delete projects.
14. (feature) - Projects. Adding new project should open up a tutorial to draft a template. Ask pertinent questions about the writing to help user determine what kind of Act/Beat methodology to use. Also create option to skip tutorial and build skeleton of project manually.
15. (bug) - Settings. Theme buttons do nothing to change theme. Some theme buttons are invisible. Remove persistent theme drop down. Use the theme dropdown in the settings.
16. (bug) - Settings. Density selections change nothing.
17. (feature) - Settings. Need many more fonts.
18. (bug) - Settings. Typography settings change UI elements. Use system Sans for UI, lock it in. Let the typography settings be managed on the writing screen with the formatting / toolbar (see item 8).
19. (bug) - Settings. Show line numbers does not work. Remove from settings, add to write toolbar.
20. (bug) - Settings. Focus and Typewrite mode do nothing. Same if toggeld from the write screen. Remove Typewrite feature, it's dumb.
21. (feature) - Persistent. Search feature should be a text box. remove hotkey reference.
22. (bug) - Admin. Admin features not working. No users. No ability to manage useres. Screen shows "Unexpected token '<', "<!doctype " .. is not valid JSON.
+14 -46
View File
@@ -2,9 +2,7 @@
**A self-hostable, multi-user writing studio for novelists — one codebase that runs as a web app and as a native desktop app, with a local-first AI co-writing engine built in.**
> 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 |
+140 -24
View File
@@ -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.
+9 -9
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 112 512 288" width="512" height="288" role="img" aria-label="PubScript">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" preserveAspectRatio="xMidYMid meet" role="img" aria-label="PubScript">
<defs>
<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
View File
@@ -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
View File
@@ -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 };
},
};
+485
View File
@@ -0,0 +1,485 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { User } from "@pubscript/core";
import { Badge, Button, toast } from "@pubscript/ui";
import { api } from "../api.js";
import { useProjectStore } from "../store/project-store.js";
import { useSettings } from "../store/settings.js";
interface AuthContextValue {
user: User | null;
logout: () => Promise<void>;
refreshAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
logout: async () => {},
refreshAuth: async () => {},
});
export function useAuth(): AuthContextValue {
return useContext(AuthContext);
}
function scorePassword(pw: string): { score: number; label: string; color: string } {
const safe = typeof pw === "string" ? pw : "";
if (!safe) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" };
let score = 0;
if (safe.length >= 8) score += 1;
if (safe.length >= 12) score += 1;
if (/[a-z]/.test(safe) && /[A-Z]/.test(safe)) score += 1;
if (/\d/.test(safe)) score += 1;
if (/[^a-zA-Z0-9]/.test(safe)) score += 1;
if (safe.length < 8) {
return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" };
}
if (score <= 2) {
return { score: 2, label: "Fair", color: "var(--ps-warning)" };
}
if (score === 3) {
return { score: 3, label: "Good", color: "var(--ps-accent)" };
}
return { score: 4, label: "Strong (Argon2id ready)", color: "var(--ps-success)" };
}
export function AuthGate({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const [user, setUser] = useState<User | null>(null);
// Setup / Login form state
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshAuth = useCallback(async () => {
try {
const status = await api.authStatus();
setInitialized(status.initialized);
setAuthenticated(status.authenticated);
setUser(status.user);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshAuth();
// Re-sync auth state when user switches from Desktop App to Web App tab
const onFocus = () => {
void refreshAuth();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [refreshAuth]);
useEffect(() => {
if (authenticated) {
void useProjectStore.getState().init();
void api.getServerConfig().then((cfg) => {
if (cfg.online && cfg.projectDirectory) {
useSettings.getState().set({
projectDirectory: cfg.projectDirectory,
...(cfg.host ? { serverHost: cfg.host } : {}),
...(cfg.allowedHosts ? { allowedHosts: cfg.allowedHosts } : {}),
...(cfg.port ? { serverPort: cfg.port } : {}),
...(cfg.webPort ? { webPort: cfg.webPort } : {}),
});
}
});
}
}, [authenticated]);
const logout = useCallback(async () => {
await api.logout();
setAuthenticated(false);
setUser(null);
setPassword("");
toast.info("Signed out of PubScript");
}, []);
const strength = useMemo(() => scorePassword(password), [password]);
const handleSetupSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const cleanName = String(displayName ?? "").trim();
const cleanEmail = String(email ?? "").trim().toLowerCase();
const safePw = String(password ?? "");
const safeConfirm = String(confirmPassword ?? "");
if (!cleanName) {
setError("Please enter an administrator display name.");
return;
}
if (!cleanEmail || !cleanEmail.includes("@")) {
setError("Please enter a valid administrator email address.");
return;
}
if (safePw.length < 8) {
setError("Password must be at least 8 characters long.");
return;
}
if (safePw !== safeConfirm) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
try {
const res = await api.setupAdmin({
email: cleanEmail,
displayName: cleanName,
password: safePw,
});
setInitialized(true);
setAuthenticated(true);
setUser(res.user);
setPassword("");
setConfirmPassword("");
toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create administrator account.";
if (msg.includes("already_initialized") || msg.includes("already been")) {
// Admin was already created in Desktop App — switch straight to Login view
setInitialized(true);
setError("Administrator account already exists. Please sign in with your credentials.");
} else {
setError(msg);
}
} finally {
setSubmitting(false);
}
};
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const cleanEmail = String(email ?? "").trim().toLowerCase();
const safePw = String(password ?? "");
if (!cleanEmail || !safePw) {
setError("Please enter your email and password.");
return;
}
setSubmitting(true);
try {
const res = await api.login(cleanEmail, safePw);
setAuthenticated(true);
setUser(res.user);
setPassword("");
toast.success(`Signed in as ${res.user.displayName}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid email or password.");
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--ps-bg)",
color: "var(--ps-text-muted)",
fontFamily: "var(--ps-font-ui)",
}}
>
<span>Verifying session security…</span>
</div>
);
}
if (!initialized || !authenticated) {
const isFirstInstall = !initialized;
return (
<div
translate="no"
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background:
"radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg)), var(--ps-bg) 70%)",
color: "var(--ps-text)",
fontFamily: "var(--ps-font-ui)",
}}
>
<div
style={{
width: "min(440px, 100%)",
background: "var(--ps-surface)",
border: "1px solid var(--ps-border)",
borderRadius: 14,
padding: "28px 28px 24px",
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.45)",
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", marginBottom: 20 }}>
<img
src="/logo.svg"
alt="PubScript"
style={{ width: 72, height: 72, objectFit: "contain", marginBottom: 10 }}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 8 }}>
<Badge variant={isFirstInstall ? "accent" : "success"}>
{isFirstInstall ? "🔒 First-Install Security Setup" : "🔒 Authenticated Access Required"}
</Badge>
</div>
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
<span>{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}</span>
</h1>
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
<span>
{isFirstInstall
? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access."
: "Enter your credentials to unlock your manuscripts and studio workspace."}
</span>
</p>
</div>
{error && (
<div
role="alert"
style={{
padding: "10px 12px",
marginBottom: 14,
borderRadius: 8,
border: "1px solid var(--ps-danger)",
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
color: "var(--ps-danger)",
fontSize: 13,
fontWeight: 500,
}}
>
<span>{error}</span>
</div>
)}
{isFirstInstall ? (
<form
key="setup-form"
onSubmit={(e) => void handleSetupSubmit(e)}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-setup-name">
Administrator Display Name
</label>
<input
id="ps-setup-name"
name="displayName"
type="text"
className="ps-input"
placeholder="e.g. Schwitzz"
autoComplete="off"
value={displayName}
onChange={(e) => setDisplayName(String(e.target?.value ?? ""))}
required
/>
</div>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-setup-email">
Administrator Email
</label>
<input
id="ps-setup-email"
name="email"
type="email"
className="ps-input"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
required
/>
</div>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-setup-pw">
Master Password (min 8 chars)
</label>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
style={{
border: "none",
background: "transparent",
color: "var(--ps-accent)",
fontSize: 11.5,
cursor: "pointer",
padding: 0,
}}
>
<span>{showPassword ? "Hide" : "Show"}</span>
</button>
</div>
<input
id="ps-setup-pw"
name="password"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Create a strong password…"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
required
/>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
<div style={{ display: "flex", gap: 4, flex: 1, marginRight: 10 }}>
{[1, 2, 3, 4].map((step) => (
<div
key={step}
style={{
flex: 1,
height: 4,
borderRadius: 2,
background:
strength.score >= step ? strength.color : "var(--ps-border)",
transition: "background 0.15s",
}}
/>
))}
</div>
<span style={{ fontSize: 11.5, color: strength.color, fontWeight: 600 }}>
{strength.label}
</span>
</div>
</div>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-setup-confirm-pw">
Confirm Master Password
</label>
<input
id="ps-setup-confirm-pw"
name="confirmPassword"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Re-enter password…"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(String(e.target?.value ?? ""))}
required
/>
</div>
<div style={{ marginTop: 8 }}>
<Button
type="submit"
variant="primary"
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
<span>{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}</span>
</Button>
</div>
</form>
) : (
<form
key="login-form"
onSubmit={(e) => void handleLoginSubmit(e)}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-login-email">
Email Address
</label>
<input
id="ps-login-email"
name="email"
type="email"
className="ps-input"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
required
/>
</div>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-login-pw">
Password
</label>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
style={{
border: "none",
background: "transparent",
color: "var(--ps-accent)",
fontSize: 11.5,
cursor: "pointer",
padding: 0,
}}
>
<span>{showPassword ? "Hide" : "Show"}</span>
</button>
</div>
<input
id="ps-login-pw"
name="password"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Enter your password…"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
required
/>
</div>
<div style={{ marginTop: 8 }}>
<Button
type="submit"
variant="primary"
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
<span>{submitting ? "Signing in…" : "Sign In"}</span>
</Button>
</div>
</form>
)}
<div
style={{
marginTop: 18,
paddingTop: 12,
borderTop: "1px solid var(--ps-border)",
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--ps-text-muted)",
}}
>
<span>Argon2id (64 MiB) · SHA-256 Session</span>
<span>HttpOnly · SameSite=Lax</span>
</div>
</div>
</div>
);
}
return (
<AuthContext.Provider value={{ user, logout, refreshAuth }}>
{children}
</AuthContext.Provider>
);
}
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { Modal, Button } from "@pubscript/ui";
import { api } from "../api.js";
import { useSettings } from "../store/settings.js";
export interface DirectoryBrowserModalProps {
open: boolean;
initialPath: string;
title?: string;
onSelect: (selectedPath: string) => void;
onClose: () => void;
}
export function DirectoryBrowserModal({
open,
initialPath,
title = "Select Directory",
onSelect,
onClose,
}: DirectoryBrowserModalProps) {
const serverPort = useSettings((s) => s.serverPort);
const [currentPath, setCurrentPath] = useState<string>(initialPath || "~/Documents/PubScript");
const [parentPath, setParentPath] = useState<string | null>(null);
const [homePath, setHomePath] = useState<string>("~");
const [directories, setDirectories] = useState<{ name: string; path: string }[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const loadPath = async (targetPath: string) => {
setLoading(true);
try {
const res = await api.listSystemDirectories(targetPath, serverPort);
setCurrentPath(res.currentPath);
setParentPath(res.parentPath);
setHomePath(res.homePath);
setDirectories(res.directories);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!open) return;
void loadPath(initialPath || "~/Documents/PubScript");
}, [open, initialPath]);
return (
<Modal
title={title}
open={open}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
onSelect(currentPath.trim() || "~/Documents/PubScript");
onClose();
}}
>
Select This Folder
</Button>
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<input
type="text"
className="ps-input"
style={{ flex: 1, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
placeholder="/path/to/directory"
/>
<Button
variant="secondary"
size="sm"
onClick={() => void loadPath(currentPath)}
disabled={loading}
>
Go
</Button>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{parentPath && (
<Button
variant="secondary"
size="sm"
onClick={() => void loadPath(parentPath)}
disabled={loading}
>
⬆ Parent Folder
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => void loadPath(homePath || "~")}
disabled={loading}
>
🏠 Home
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void loadPath("~/Documents")}
disabled={loading}
>
📄 Documents
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void loadPath("~/Documents/PubScript")}
disabled={loading}
>
📚 PubScript
</Button>
</div>
<div
style={{
maxHeight: 260,
overflowY: "auto",
border: "1px solid var(--ps-border)",
borderRadius: 8,
background: "var(--ps-bg)",
padding: 6,
}}
>
{loading ? (
<div style={{ padding: 14, fontSize: 12.5, color: "var(--ps-text-muted)" }}>
Loading folders…
</div>
) : directories.length === 0 ? (
<div style={{ padding: 14, fontSize: 12.5, color: "var(--ps-text-muted)" }}>
No subfolders in this directory (you can click &ldquo;Select This Folder&rdquo; or enter any path above).
</div>
) : (
directories.map((dir) => (
<button
key={dir.path}
type="button"
onClick={() => void loadPath(dir.path)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
textAlign: "left",
padding: "7px 10px",
border: "none",
borderRadius: 6,
background: "transparent",
color: "var(--ps-text)",
cursor: "pointer",
fontSize: 13,
}}
>
<span>📁</span>
<span style={{ fontWeight: 500 }}>{dir.name}</span>
</button>
))
)}
</div>
</div>
</Modal>
);
}
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -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}
+132 -126
View File
@@ -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")
+84 -69
View File
@@ -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>
);
}
+221 -104
View File
@@ -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 &ldquo;{currentProject.title}&rdquo;
</button>
</div>
</div>
)}
{mode === "new-project" && (
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-import-title">
Project Title
</label>
<input
id="ps-import-title"
type="text"
className="ps-input"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="Manuscript Title"
/>
</div>
)}
{parsed.format !== "epub" && parsed.format !== "json" && (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 12.5,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={splitChapters}
onChange={(e) => void handleToggleSplit(e.target.checked)}
/>
<span>
Automatically split into chapters on headings (<code>H1</code> /{" "}
<code>Chapter N</code>)
</span>
</label>
)}
<div>
<div
style={{
fontSize: 11.5,
fontWeight: 600,
color: "var(--ps-text-muted)",
marginBottom: 6,
}}
>
Converted Chapters Preview ({parsed.chapters.length})
</div>
<div
style={{
maxHeight: 160,
overflowY: "auto",
border: "1px solid var(--ps-border)",
borderRadius: 8,
background: "var(--ps-bg)",
padding: 6,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{parsed.chapters.map((ch, idx) => (
<div
key={idx}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "5px 8px",
borderRadius: 6,
fontSize: 12.5,
background: "var(--ps-surface)",
}}
>
<span style={{ fontWeight: 500 }}>
{idx + 1}. {ch.title}
</span>
<span
style={{
fontFamily: "var(--ps-font-mono)",
fontSize: 11.5,
color: "var(--ps-text-muted)",
}}
>
{ch.wordCount.toLocaleString()} words
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</Modal>
);
}
+15 -3
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui";
import { 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>
);
}
+194 -5
View File
@@ -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) {
+38 -2
View File
@@ -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) =>
+82 -34
View File
@@ -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;
+14 -2
View File
@@ -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;
+2
View File
@@ -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,
},
});
+19
View File
@@ -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);
+1 -1
View File
@@ -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" },
+39 -10
View File
@@ -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 () => {};
},
};
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
exportProjectData,
importManuscriptFile,
type ExportProject,
} from "./index.js";
const SAMPLE_PROJECT: ExportProject = {
meta: {
title: "The Harbor Ledger",
author: "Austin Breinholt",
language: "en",
},
books: [
{
id: "book-1",
projectId: "proj-import-1",
title: "Book One",
order: 0,
chapters: [
{
id: "ch-1",
bookId: "book-1",
projectId: "proj-import-1",
title: "Chapter 1: Fog Over the Pier",
order: 0,
rev: 1,
meta: { wordCount: 15, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
doc: {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: "Chapter 1: Fog Over the Pier" }],
},
{
type: "paragraph",
content: [
{ type: "text", text: "The lantern swung in the " },
{ type: "text", text: "harbor wind", marks: [{ type: "bold" }] },
{ type: "text", text: " as Mara opened the ledger." },
],
},
],
},
},
{
id: "ch-2",
bookId: "book-1",
projectId: "proj-import-1",
title: "Chapter 2: The Bell Tower",
order: 1,
rev: 1,
meta: { wordCount: 6, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
doc: {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: "Chapter 2: The Bell Tower" }],
},
{
type: "paragraph",
content: [
{ type: "text", text: "Twelve strokes echoed across the water." },
],
},
],
},
},
],
},
],
};
describe("Universal Manuscript Importer (importer.ts)", () => {
it("imports plain text (.txt) and splits chapters on Chapter headings", async () => {
const txt = [
"Chapter 1: The Arrival",
"",
"The train pulled into the station at dusk.",
"",
"* * *",
"",
"She stepped onto the wet platform.",
"",
"Chapter 2: The Departure",
"",
"By morning, the tracks were empty.",
].join("\n");
const res = await importManuscriptFile("arrival-story.txt", txt);
expect(res.format).toBe("txt");
expect(res.chapters).toHaveLength(2);
expect(res.chapters[0]?.title).toBe("Chapter 1: The Arrival");
expect(res.chapters[1]?.title).toBe("Chapter 2: The Departure");
expect(res.totalWords).toBeGreaterThan(10);
});
it("imports Markdown (.md) with frontmatter, inline marks, and chapters", async () => {
const md = [
"---",
"title: Glass Harbor",
"author: Schwitzz",
"---",
"# Chapter One",
"",
"He read the **bold entry** and *italic margin note*.",
"",
"# Chapter Two",
"",
"The second chapter begins here.",
].join("\n");
const res = await importManuscriptFile("glass-harbor.md", md);
expect(res.format).toBe("md");
expect(res.title).toBe("Glass Harbor");
expect(res.author).toBe("Schwitzz");
expect(res.chapters).toHaveLength(2);
expect(res.chapters[0]?.title).toBe("Chapter One");
expect(res.chapters[1]?.title).toBe("Chapter Two");
});
it("round-trips DOCX (Word / OnlyOffice) into PubScript chapters", async () => {
const docxBytes = (await exportProjectData(SAMPLE_PROJECT, "docx", {
generatedAt: "2026-01-01T00:00:00.000Z",
})) as Uint8Array;
const imported = await importManuscriptFile("the-harbor-ledger.docx", docxBytes);
expect(imported.format).toBe("docx");
expect(imported.chapters.length).toBeGreaterThanOrEqual(2);
expect(imported.totalWords).toBeGreaterThan(10);
});
it("round-trips ODT (OpenOffice / OnlyOffice / LibreOffice) into PubScript chapters", async () => {
const odtBytes = (await exportProjectData(SAMPLE_PROJECT, "odt", {
generatedAt: "2026-01-01T00:00:00.000Z",
})) as Uint8Array;
const imported = await importManuscriptFile("the-harbor-ledger.odt", odtBytes);
expect(imported.format).toBe("odt");
expect(imported.title).toBe("The Harbor Ledger");
expect(imported.chapters.length).toBeGreaterThanOrEqual(2);
expect(imported.totalWords).toBeGreaterThan(10);
});
it("round-trips EPUB into PubScript chapters", async () => {
const epubBytes = (await exportProjectData(SAMPLE_PROJECT, "epub", {
generatedAt: "2026-01-01T00:00:00.000Z",
})) as Uint8Array;
const imported = await importManuscriptFile("the-harbor-ledger.epub", epubBytes);
expect(imported.format).toBe("epub");
expect(imported.title).toBe("The Harbor Ledger");
expect(imported.chapters).toHaveLength(2);
expect(imported.totalWords).toBeGreaterThan(10);
});
it("imports RTF (.rtf) and HTML (.html) manuscripts", async () => {
const rtf = "{\\rtf1\\ansi{\\fonttbl\\f0 Times;}\\pard Chapter 1: Dawn\\par The sun rose over the ridge.\\par}";
const rtfRes = await importManuscriptFile("dawn.rtf", rtf);
expect(rtfRes.format).toBe("rtf");
expect(rtfRes.totalWords).toBeGreaterThan(4);
const html = "<html><head><title>Sea Wall</title></head><body><h1>Chapter 1</h1><p>Waves crashed against the <strong>stone</strong>.</p></body></html>";
const htmlRes = await importManuscriptFile("seawall.html", html);
expect(htmlRes.format).toBe("html");
expect(htmlRes.title).toBe("Sea Wall");
expect(htmlRes.chapters[0]?.title).toBe("Chapter 1");
});
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -132,3 +132,4 @@ export { exportEpub } from "./epub.js";
export { exportDocx } from "./docx.js";
export { exportOdt } from "./odt.js";
export { exportPdf } from "./pdf.js";
export * from "./importer.js";
+6 -4
View File
@@ -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); }
+3 -2
View File
@@ -1,4 +1,4 @@
import { type InputHTMLAttributes } from "react";
import { useId, type InputHTMLAttributes } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
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>}
+3 -2
View File
@@ -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>}
+14 -3
View File
@@ -2,9 +2,20 @@ import { describe, it, expect } from "vitest";
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
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
View File
@@ -1,20 +1,47 @@
/**
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
* Theme engine — CSS-variable token map.
*
* Four themes ship as token objects; `applyTheme(id)` sets
* `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;
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { FastifyInstance } from "fastify";
import { 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) };
});
+76
View File
@@ -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();
}
});
});