Files
excalidraw-box/PLAN.md
2026-05-24 15:32:36 +00:00

832 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project: `excalidraw-box`
## Goal
Build a small self-hosted web app that provides:
```text
- Excalidraw editor
- Server-side autosave to SQLite
- No collaboration
- No accounts inside the app
- Reverse-proxy auth handled by Caddy
- Manual export to PNG/SVG/.excalidraw
- Docker Compose deployment
```
The app should be intentionally small and auditable.
---
# Non-goals
Do **not** implement:
```text
- real-time collaboration
- public sharing links
- user accounts
- teams/workspaces
- comments
- Firebase
- S3/MinIO in v1
- complex permissions
```
Authentication is handled outside the app by Caddy `basic_auth` or later Authelia/AuthentiK.
---
# Stack
Use:
```text
Next.js App Router
React
TypeScript
@excalidraw/excalidraw
better-sqlite3
Docker Compose
Caddy reverse proxy
```
Rationale:
- `@excalidraw/excalidraw` is the official embeddable package. ([npm][1])
- Excalidraw exposes `onChange(elements, appState, files)` for persistence. ([Excalidraw Docs][2])
- Excalidraw provides export utilities such as `exportToCanvas` for rendering scenes to images. ([Excalidraw Docs][3])
- Next.js officially supports standalone Docker deployment. ([Next.js][4])
---
# Version 1 feature set
## V1 must have
```text
1. Single default drawing
2. Load drawing from SQLite on page load
3. Autosave drawing to SQLite after edits
4. Debounce saves by 1.5 seconds
5. Show save status:
- Saved
- Saving…
- Save failed
6. Export PNG from the current scene
7. Export .excalidraw JSON from the current scene
8. Persist SQLite database under /data
9. Dockerfile
10. docker-compose.yml
11. Caddyfile example
```
## V1 should avoid
```text
- multiple drawings
- login UI
- sharing
- collaboration
- migrations framework
- Prisma, unless really needed
```
Use plain `better-sqlite3` to keep the code small.
---
# Directory structure
```text
excalidraw-autosave/
app/
api/
drawing/
route.ts
globals.css
layout.tsx
page.tsx
components/
ExcalidrawClient.tsx
lib/
db.ts
scene.ts
debounce.ts
public/
Dockerfile
docker-compose.yml
Caddyfile.example
next.config.ts
package.json
tsconfig.json
.dockerignore
README.md
```
---
# Data model
Use one SQLite table.
```sql
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
For v1, always use:
```text
id = "default"
```
Stored `data` should be the scene object:
```json
{
"type": "excalidraw-autosave-scene",
"version": 1,
"elements": [],
"appState": {},
"files": {}
}
```
Do not store only rendered PNG. Store the editable scene JSON as the source of truth.
---
# API design
## `GET /api/drawing`
Returns the saved drawing.
If no drawing exists, return an empty scene:
```json
{
"type": "excalidraw-autosave-scene",
"version": 1,
"elements": [],
"appState": {},
"files": {}
}
```
## `POST /api/drawing`
Accepts:
```json
{
"elements": [],
"appState": {},
"files": {}
}
```
Behavior:
```text
- Validate body is an object.
- Validate elements is an array.
- Validate appState is an object.
- Validate files is an object.
- Strip volatile or UI-only appState fields if needed.
- Save JSON to SQLite.
- Upsert row id = "default".
- Return { "ok": true, "updatedAt": "..." }.
```
## Optional `GET /api/health`
Returns:
```json
{
"ok": true
}
```
Useful for Docker health checks.
---
# Scene handling
Create `lib/scene.ts`.
Responsibilities:
```text
- define EmptyScene
- normalize incoming scene
- remove unwanted volatile appState fields
- cap payload size
```
Recommended payload size cap for v1:
```text
25 MB
```
If the scene exceeds this, return HTTP `413 Payload Too Large`.
Reason: Excalidraw embedded images are stored under `files`; they can make scene JSON large.
---
# Frontend behavior
## Page load
```text
1. Render loading state.
2. Fetch /api/drawing.
3. Pass returned scene to Excalidraw as initialData.
4. Render editor full-screen.
```
The official integration docs show that the Excalidraw component should be used client-side in Next.js, with `"use client"` and the Excalidraw CSS import. ([Excalidraw Docs][5])
## Autosave
Use Excalidraws `onChange`.
Pseudo-flow:
```tsx
onChange={(elements, appState, files) => {
setSaveStatus("dirty");
debouncedSave({ elements, appState, files });
}}
```
Debounced save:
```text
- wait 1500 ms after last change
- POST to /api/drawing
- set status Saving… while request in flight
- set status Saved after success
- set status Save failed after failure
```
Important: Do not save on every pointer movement without debounce.
## Save status UI
A small fixed indicator:
```text
top-right:
Saved
Saving…
Save failed
```
Optionally show last saved timestamp.
---
# Export behavior
## `.excalidraw` export
Implement client-side export by taking the current scene and downloading JSON.
Filename:
```text
drawing-YYYY-MM-DD-HHMM.excalidraw
```
Content:
```json
{
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.com",
"elements": [],
"appState": {},
"files": {}
}
```
This keeps compatibility with regular Excalidraw import/export. The Excalidraw repo describes the open `.excalidraw` JSON file format as a supported feature. ([GitHub][6])
## PNG export
Use Excalidraw export utilities client-side.
The docs show `exportToCanvas` exported from `@excalidraw/excalidraw`; it takes `elements`, `appState`, and export dimensions. ([Excalidraw Docs][3])
Implementation idea:
```tsx
import { exportToCanvas } from "@excalidraw/excalidraw";
const canvas = await exportToCanvas({
elements,
appState: {
...appState,
exportBackground: true,
},
files,
getDimensions: (width, height) => ({
width,
height,
scale: 2,
}),
});
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((blob) => resolve(blob!), "image/png"),
);
```
Then trigger browser download.
Do PNG export client-side first. It avoids needing headless browser rendering on the server.
---
# Important implementation detail: access to current scene
Use `excalidrawAPI`.
The Excalidraw API exposes methods such as `getFiles`. ([Excalidraw Docs][7])
Store latest values from `onChange` in React refs:
```tsx
const latestSceneRef = useRef({
elements: [],
appState: {},
files: {},
});
```
Update it inside `onChange`.
Use that ref for export buttons.
---
# Suggested files
## `package.json`
```json
{
"name": "excalidraw-autosave",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@excalidraw/excalidraw": "latest",
"better-sqlite3": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"@types/better-sqlite3": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest"
}
}
```
## `next.config.ts`
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
```
---
# Database module
## `lib/db.ts`
Requirements:
```text
- Read DATABASE_PATH from env
- Default to /data/excalidraw.sqlite
- Ensure parent directory exists
- Initialize schema
- Export getDrawing() and saveDrawing()
```
Pseudo-code:
```ts
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
const dbPath = process.env.DATABASE_PATH ?? "/data/excalidraw.sqlite";
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
export function getDrawing(id = "default") {
const row = db.prepare("SELECT data FROM drawings WHERE id = ?").get(id) as
| { data: string }
| undefined;
return row ? JSON.parse(row.data) : null;
}
export function saveDrawing(data: unknown, id = "default") {
const serialized = JSON.stringify(data);
db.prepare(
`
INSERT INTO drawings (id, data, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
data = excluded.data,
updated_at = CURRENT_TIMESTAMP
`,
).run(id, serialized);
return new Date().toISOString();
}
```
---
# API route
## `app/api/drawing/route.ts`
Requirements:
```text
- Force Node runtime, not Edge
- GET returns saved or empty scene
- POST saves normalized scene
- Handle invalid JSON
- Enforce payload size cap
```
Add:
```ts
export const runtime = "nodejs";
```
Pseudo-code:
```ts
import { NextResponse } from "next/server";
import { getDrawing, saveDrawing } from "@/lib/db";
import { emptyScene, normalizeScene } from "@/lib/scene";
export const runtime = "nodejs";
export async function GET() {
const saved = getDrawing("default");
return NextResponse.json(saved ?? emptyScene());
}
export async function POST(req: Request) {
const text = await req.text();
if (text.length > 25 * 1024 * 1024) {
return NextResponse.json(
{ ok: false, error: "Scene too large" },
{ status: 413 },
);
}
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return NextResponse.json(
{ ok: false, error: "Invalid JSON" },
{ status: 400 },
);
}
const scene = normalizeScene(raw);
const updatedAt = saveDrawing(scene, "default");
return NextResponse.json({ ok: true, updatedAt });
}
```
---
# Client component
## `components/ExcalidrawClient.tsx`
Requirements:
```text
- "use client"
- import Excalidraw and CSS
- fetch initial data
- render full viewport
- onChange debounced autosave
- export JSON button
- export PNG button
- save status
```
Important:
```tsx
import "@excalidraw/excalidraw/index.css";
```
Use a custom debounce instead of lodash to avoid another dependency.
---
# CSS/UI
Keep UI minimal.
```text
- Editor fills viewport.
- Floating toolbar at top-right.
- Buttons:
- Export PNG
- Export .excalidraw
- Save status badge:
- Saved
- Saving…
- Failed
```
Do not attempt to reproduce Excalidraws own toolbar.
---
# Docker
## `Dockerfile`
Use Next.js standalone output.
```dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV DATABASE_PATH=/data/excalidraw.sqlite
RUN mkdir -p /data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
```
## `docker-compose.yml`
```yaml
services:
excalidraw-autosave:
build: .
container_name: excalidraw-autosave
expose:
- "3000"
environment:
DATABASE_PATH: /data/excalidraw.sqlite
volumes:
- ./data:/data
restart: unless-stopped
caddy:
image: caddy:latest
container_name: excalidraw-caddy
depends_on:
- excalidraw-autosave
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile.example:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
```
## `Caddyfile.example`
```caddyfile
draw.example.com {
basic_auth {
zero REPLACE_WITH_CADDY_HASH
}
reverse_proxy excalidraw-autosave:3000
}
```
Generate password hash:
```bash
docker run --rm -it caddy:latest caddy hash-password
```
---
# Backup plan
SQLite file:
```text
./data/excalidraw.sqlite
```
Backup command:
```bash
mkdir -p ./backups
sqlite3 ./data/excalidraw.sqlite ".backup './backups/excalidraw-$(date +%F-%H%M).sqlite'"
```
Recommended cron:
```cron
0 3 * * * cd /path/to/excalidraw-autosave && sqlite3 ./data/excalidraw.sqlite ".backup './backups/excalidraw-$(date +\%F-\%H\%M).sqlite'"
```
Also back up `.excalidraw` exports manually for critical drawings.
---
# Security model
V1 app assumes:
```text
- It is not public without reverse proxy auth.
- Caddy handles TLS.
- Caddy handles basic_auth.
- The app does not implement users.
- The app should not expose port 3000 to the public internet.
```
In Compose, use:
```yaml
expose:
- "3000"
```
Do **not** use:
```yaml
ports:
- "3000:3000"
```
unless testing locally.
---
# Acceptance criteria
Codex should finish with a repo that satisfies:
```text
1. `npm run dev` opens editor locally.
2. Drawing reloads after refresh.
3. Drawing persists after app restart.
4. Docker Compose starts Caddy + app.
5. SQLite DB is created under ./data.
6. No direct public app port is exposed.
7. Export PNG downloads a PNG.
8. Export .excalidraw downloads editable JSON.
9. Autosave status visibly changes.
10. No collaboration code exists.
```
Manual test:
```text
1. Start app.
2. Draw rectangle and text.
3. Wait until status says Saved.
4. Refresh browser.
5. Confirm rectangle and text remain.
6. Restart container.
7. Confirm drawing remains.
8. Export PNG.
9. Export .excalidraw.
10. Import .excalidraw into official Excalidraw and confirm it opens.
```
---
# Future V2, only after V1 works
Add these later, not now:
```text
- multiple named drawings
- drawing list page
- thumbnails
- duplicate drawing
- delete drawing
- server-side export endpoint
- periodic backup UI
- WebDAV/S3 export
- basic file manager
```
For V2 multiple drawings, change routes to:
```text
GET /api/drawings
POST /api/drawings
GET /api/drawings/:id
PUT /api/drawings/:id
DELETE /api/drawings/:id
```
Schema:
```sql
CREATE TABLE drawings (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
---
# Instruction
Build the V1 exactly as specified. Keep code minimal. Do not add accounts, collaboration, sharing, Firebase, Prisma, Tailwind, or extra abstractions. Use the official `@excalidraw/excalidraw` package and SQLite persistence through `better-sqlite3`. Use Caddy for auth in the deployment example.