Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d917d315 | |||
| 5887baf324 | |||
| 8db56d9ab7 | |||
| 6cc028305a | |||
| f64d5a73d2 | |||
| 22f13820ab | |||
| 459993a78d | |||
| 95d27c5142 | |||
| b60b921ff8 |
@@ -3,7 +3,7 @@ name: Publish GHCR Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'release-*'
|
- "release-*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
PLAN.md
|
|
||||||
|
|
||||||
excali-box-data/
|
excali-box-data/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
data
|
||||||
|
excali-box-data
|
||||||
@@ -5,3 +5,7 @@
|
|||||||
- Keep commit messages terse.
|
- Keep commit messages terse.
|
||||||
|
|
||||||
Example: `feat: dark mode by default`
|
Example: `feat: dark mode by default`
|
||||||
|
|
||||||
|
# Decisions on architecture and design
|
||||||
|
|
||||||
|
Read [DECISIONS.md](DECISIONS.md)
|
||||||
|
|||||||
@@ -6,25 +6,19 @@
|
|||||||
:80 {
|
:80 {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
@mcp path /mcp
|
handle /mcp {
|
||||||
handle @mcp {
|
|
||||||
reverse_proxy 127.0.0.1:3001
|
reverse_proxy 127.0.0.1:3001
|
||||||
}
|
}
|
||||||
|
|
||||||
@api path /api/*
|
@app path / /api/*
|
||||||
handle @api {
|
handle @app {
|
||||||
reverse_proxy 127.0.0.1:3000
|
|
||||||
}
|
|
||||||
|
|
||||||
@root path /
|
|
||||||
handle @root {
|
|
||||||
reverse_proxy 127.0.0.1:3000
|
reverse_proxy 127.0.0.1:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
root * /srv/public
|
root * /srv/public
|
||||||
|
|
||||||
@html path /index.html /d/*
|
@html path /index.html /d/* /p/*
|
||||||
header @html Cache-Control "no-cache"
|
header @html Cache-Control "no-cache"
|
||||||
|
|
||||||
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
|
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# a list of architectural decisions
|
||||||
|
|
||||||
|
( to remind me when I over-engineer and be fickle minded )
|
||||||
|
|
||||||
|
## Image is NOT meant to be exposed directly to the internet
|
||||||
|
|
||||||
|
- the defaults MUST take care of this
|
||||||
|
- and hence AUTH won't be a part of the image, whatever the user is using for reverse proxy should handle auth.
|
||||||
+5
-1
@@ -2,6 +2,7 @@ FROM oven/bun:1.3.11-alpine AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json bun.lock tsconfig.json ./
|
COPY package.json bun.lock tsconfig.json ./
|
||||||
|
COPY patches ./patches
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
@@ -19,14 +20,17 @@ ENV MCP_HOST=127.0.0.1
|
|||||||
ENV MCP_PORT=3001
|
ENV MCP_PORT=3001
|
||||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||||
|
|
||||||
|
RUN mkdir -p /config /data
|
||||||
|
|
||||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||||
COPY --from=build /app/dist/public /srv/public
|
COPY --from=build /app/dist/public /srv/public
|
||||||
COPY --from=build /app/dist/server /app/dist/server
|
COPY --from=build /app/dist/server /app/dist/server
|
||||||
COPY --from=build /app/dist/mcp /app/dist/mcp
|
COPY --from=build /app/dist/mcp /app/dist/mcp
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY STYLES_GUIDE.md /config/styles-guide.md
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
|||||||
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
||||||
No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
|
No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
|
||||||
|
|
||||||
> GPT 5.5 generated
|
|
||||||
|
|
||||||
## Tl;dr
|
## Tl;dr
|
||||||
|
|
||||||
When building the image yourself
|
When building the image yourself
|
||||||
@@ -16,7 +14,6 @@ docker run --rm \
|
|||||||
-p 127.0.0.1:3000:80 \
|
-p 127.0.0.1:3000:80 \
|
||||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
-v "$PWD/excali-box-data:/data" \
|
-v "$PWD/excali-box-data:/data" \
|
||||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
|
||||||
excali
|
excali
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -27,7 +24,6 @@ docker run --rm \
|
|||||||
-p 127.0.0.1:3000:80 \
|
-p 127.0.0.1:3000:80 \
|
||||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
-v "$PWD/excali-box-data:/data" \
|
-v "$PWD/excali-box-data:/data" \
|
||||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
|
||||||
ghcr.io/ruinivist/excalidraw-box:latest
|
ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,6 +50,7 @@ Open `http://localhost:3000`.
|
|||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
bun run format
|
||||||
bun run test
|
bun run test
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
```
|
```
|
||||||
@@ -70,7 +67,8 @@ This will make the data persistent in the `excali-box-data` folder in the curren
|
|||||||
## Optional MCP styles guide resource
|
## Optional MCP styles guide resource
|
||||||
|
|
||||||
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
|
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
|
||||||
discoverable as an MCP resource.
|
discoverable as an MCP resource. Container images already include the repo `STYLES_GUIDE.md` at
|
||||||
|
`/config/styles-guide.md`, so the default guide is exposed automatically in Docker/GHCR.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
@@ -81,4 +79,5 @@ docker run --rm \
|
|||||||
ghcr.io/ruinivist/excalidraw-box:latest
|
ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Repo-root `STYLES_GUIDE.md` which is what I wrote for myself is not used at all unless you explicitly mount it to `/config/styles-guide.md`.
|
Bind-mounting a different file to `/config/styles-guide.md` overrides the bundled default for that container.
|
||||||
|
Local non-container runs are unchanged; if you want a styles guide there, you still need a file at `/config/styles-guide.md`.
|
||||||
|
|||||||
@@ -17,10 +17,16 @@
|
|||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"pretty-quick": "^4.2.2",
|
||||||
|
"simple-git-hooks": "^2.13.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch",
|
||||||
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||||
|
|
||||||
@@ -66,6 +72,8 @@
|
|||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
|
"@pkgr/core": ["@pkgr/core@0.2.10", "", {}, "sha512-x6fFWCeak8aCGfqZfe6CXYt5xVjxe9Os1cIPmVRcToInKLjhJkRVXvJ/L3/1KxFkjDQdbZV/YsuLKqa8t/xKpA=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
||||||
|
|
||||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
|
||||||
@@ -426,6 +434,8 @@
|
|||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
"image-blob-reduce": ["image-blob-reduce@3.0.1", "", { "dependencies": { "pica": "^7.1.0" } }, "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q=="],
|
"image-blob-reduce": ["image-blob-reduce@3.0.1", "", { "dependencies": { "pica": "^7.1.0" } }, "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q=="],
|
||||||
|
|
||||||
"immutable": ["immutable@4.3.8", "", {}, "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw=="],
|
"immutable": ["immutable@4.3.8", "", {}, "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw=="],
|
||||||
@@ -492,6 +502,8 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||||
|
|
||||||
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"multimath": ["multimath@2.0.0", "", { "dependencies": { "glur": "^1.1.2", "object-assign": "^4.1.1" } }, "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g=="],
|
"multimath": ["multimath@2.0.0", "", { "dependencies": { "glur": "^1.1.2", "object-assign": "^4.1.1" } }, "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g=="],
|
||||||
@@ -528,7 +540,9 @@
|
|||||||
|
|
||||||
"pica": ["pica@7.1.1", "", { "dependencies": { "glur": "^1.1.2", "inherits": "^2.0.3", "multimath": "^2.0.0", "object-assign": "^4.1.1", "webworkify": "^1.5.0" } }, "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ=="],
|
"pica": ["pica@7.1.1", "", { "dependencies": { "glur": "^1.1.2", "inherits": "^2.0.3", "multimath": "^2.0.0", "object-assign": "^4.1.1", "webworkify": "^1.5.0" } }, "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
@@ -542,6 +556,10 @@
|
|||||||
|
|
||||||
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||||
|
|
||||||
|
"pretty-quick": ["pretty-quick@4.2.2", "", { "dependencies": { "@pkgr/core": "^0.2.7", "ignore": "^7.0.5", "mri": "^1.2.0", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "tinyexec": "^0.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.0" }, "bin": { "pretty-quick": "lib/cli.mjs" } }, "sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w=="],
|
||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
|
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
|
||||||
@@ -600,6 +618,8 @@
|
|||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"simple-git-hooks": ["simple-git-hooks@2.13.1", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="],
|
||||||
|
|
||||||
"sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="],
|
"sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
@@ -608,7 +628,7 @@
|
|||||||
|
|
||||||
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
|
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
@@ -664,6 +684,8 @@
|
|||||||
|
|
||||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||||
|
|
||||||
|
"@antfu/install-pkg/tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
|
||||||
|
|
||||||
"@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
"@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
||||||
|
|
||||||
"@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
"@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
||||||
@@ -708,6 +730,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="],
|
"@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="],
|
||||||
|
|
||||||
|
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
"chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
"chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
||||||
|
|
||||||
"chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
"chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
||||||
@@ -732,6 +756,8 @@
|
|||||||
|
|
||||||
"points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
"points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
||||||
|
|
||||||
|
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
||||||
|
|
||||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|||||||
@@ -8,10 +8,15 @@
|
|||||||
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
|
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
|
||||||
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
|
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
|
||||||
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
|
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"postinstall": "simple-git-hooks",
|
||||||
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
|
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
"simple-git-hooks": {
|
||||||
|
"pre-commit": "bunx pretty-quick --staged"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/excalidraw": "^0.18.1",
|
"@excalidraw/excalidraw": "^0.18.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
@@ -25,6 +30,12 @@
|
|||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"pretty-quick": "^4.2.2",
|
||||||
|
"simple-git-hooks": "^2.13.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+46
-1
@@ -1,10 +1,27 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
|
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
|
||||||
import { EditorCanvas } from "./components/EditorCanvas";
|
import { EditorCanvas } from "./components/EditorCanvas";
|
||||||
|
import { PublicViewer } from "./components/PublicViewer";
|
||||||
|
import { usePublicDrawing } from "./hooks/usePublicDrawing";
|
||||||
import { useDrawingSession } from "./hooks/useDrawingSession";
|
import { useDrawingSession } from "./hooks/useDrawingSession";
|
||||||
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
|
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
|
||||||
|
|
||||||
export function App() {
|
type AppRoute = { type: "private" } | { type: "public"; slug: string };
|
||||||
|
|
||||||
|
function routeFromPath(pathname = window.location.pathname): AppRoute {
|
||||||
|
if (!pathname.startsWith("/p/")) {
|
||||||
|
return { type: "private" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = pathname.slice(3);
|
||||||
|
if (slug.length === 0 || slug.includes("/")) {
|
||||||
|
return { type: "private" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "public", slug: decodeURIComponent(slug) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivateApp() {
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const appShellRef = useRef<HTMLDivElement | null>(null);
|
const appShellRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
|
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
|
||||||
@@ -17,12 +34,18 @@ export function App() {
|
|||||||
error,
|
error,
|
||||||
toastMessage,
|
toastMessage,
|
||||||
editorReloadNonce,
|
editorReloadNonce,
|
||||||
|
publication,
|
||||||
|
publicationSlug,
|
||||||
|
publicationBusy,
|
||||||
setActiveTitle,
|
setActiveTitle,
|
||||||
submitTitle,
|
submitTitle,
|
||||||
handleSceneChange,
|
handleSceneChange,
|
||||||
selectDrawing,
|
selectDrawing,
|
||||||
createDrawing,
|
createDrawing,
|
||||||
deleteDrawing,
|
deleteDrawing,
|
||||||
|
setPublicationSlug,
|
||||||
|
publishPublication,
|
||||||
|
disablePublication,
|
||||||
} = useDrawingSession();
|
} = useDrawingSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,13 +121,35 @@ export function App() {
|
|||||||
drawings={drawings}
|
drawings={drawings}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
activeTitle={activeTitle}
|
activeTitle={activeTitle}
|
||||||
|
publication={publication}
|
||||||
|
publicationSlug={publicationSlug}
|
||||||
|
publicationBusy={publicationBusy}
|
||||||
onClose={closeSidebar}
|
onClose={closeSidebar}
|
||||||
onCreate={handleCreateDrawing}
|
onCreate={handleCreateDrawing}
|
||||||
onSelect={handleSelectDrawing}
|
onSelect={handleSelectDrawing}
|
||||||
onDelete={(drawingId) => void deleteDrawing(drawingId)}
|
onDelete={(drawingId) => void deleteDrawing(drawingId)}
|
||||||
onTitleChange={setActiveTitle}
|
onTitleChange={setActiveTitle}
|
||||||
onTitleSubmit={() => void submitTitle()}
|
onTitleSubmit={() => void submitTitle()}
|
||||||
|
onPublicationSlugChange={setPublicationSlug}
|
||||||
|
onPublish={() => void publishPublication()}
|
||||||
|
onDisablePublication={() => void disablePublication()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PublicApp({ slug }: { slug: string }) {
|
||||||
|
const { drawing, loading, error } = usePublicDrawing(slug);
|
||||||
|
|
||||||
|
return <PublicViewer drawing={drawing} loading={loading} error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const route = routeFromPath();
|
||||||
|
|
||||||
|
if (route.type === "public") {
|
||||||
|
return <PublicApp slug={route.slug} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PrivateApp />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,51 @@
|
|||||||
import { type DrawingMeta } from "../../core/shared";
|
import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
|
||||||
|
|
||||||
type DrawingSidebarProps = {
|
type DrawingSidebarProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
drawings: DrawingMeta[];
|
drawings: DrawingMeta[];
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
activeTitle: string;
|
activeTitle: string;
|
||||||
|
publication: DrawingPublication;
|
||||||
|
publicationSlug: string;
|
||||||
|
publicationBusy: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onSelect: (drawingId: string) => void;
|
onSelect: (drawingId: string) => void;
|
||||||
onDelete: (drawingId: string) => void;
|
onDelete: (drawingId: string) => void;
|
||||||
onTitleChange: (title: string) => void;
|
onTitleChange: (title: string) => void;
|
||||||
onTitleSubmit: () => void;
|
onTitleSubmit: () => void;
|
||||||
|
onPublicationSlugChange: (slug: string) => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
onDisablePublication: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DrawerIcon() {
|
function DrawerIcon() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
<rect
|
||||||
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
x="3.5"
|
||||||
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
y="5"
|
||||||
|
width="17"
|
||||||
|
height="14"
|
||||||
|
rx="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9 5v14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,28 +69,49 @@ export function DrawingSidebar({
|
|||||||
drawings,
|
drawings,
|
||||||
activeId,
|
activeId,
|
||||||
activeTitle,
|
activeTitle,
|
||||||
|
publication,
|
||||||
|
publicationSlug,
|
||||||
|
publicationBusy,
|
||||||
onClose,
|
onClose,
|
||||||
onCreate,
|
onCreate,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
onTitleSubmit,
|
onTitleSubmit,
|
||||||
|
onPublicationSlugChange,
|
||||||
|
onPublish,
|
||||||
|
onDisablePublication,
|
||||||
}: DrawingSidebarProps) {
|
}: DrawingSidebarProps) {
|
||||||
|
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
|
||||||
|
const publicationStatus = publication.enabled
|
||||||
|
? `Published at ${publicPath}`
|
||||||
|
: "Not published";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
|
className={
|
||||||
|
open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"
|
||||||
|
}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
/>
|
/>
|
||||||
<aside className={open ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!open}>
|
<aside
|
||||||
|
className={open ? "sidebar sidebar-open" : "sidebar"}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<h1>excali-box</h1>
|
<h1>excali-box</h1>
|
||||||
<div className="sidebar-actions">
|
<div className="sidebar-actions">
|
||||||
<button type="button" className="primary-button" onClick={onCreate}>
|
<button type="button" className="primary-button" onClick={onCreate}>
|
||||||
New
|
New
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close drawings">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close drawings"
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,35 +120,106 @@ export function DrawingSidebar({
|
|||||||
{drawings.map((drawing) => (
|
{drawings.map((drawing) => (
|
||||||
<div
|
<div
|
||||||
key={drawing.id}
|
key={drawing.id}
|
||||||
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
|
className={
|
||||||
|
drawing.id === activeId
|
||||||
|
? "drawing-item drawing-item-active"
|
||||||
|
: "drawing-item"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{drawing.id === activeId ? (
|
{drawing.id === activeId ? (
|
||||||
<div className="drawing-link">
|
<div className="drawing-link drawing-link-active">
|
||||||
<input
|
<div className="drawing-item-header">
|
||||||
className="title-input"
|
<input
|
||||||
value={activeTitle}
|
className="title-input"
|
||||||
onChange={(event) => onTitleChange(event.target.value)}
|
value={activeTitle}
|
||||||
onBlur={onTitleSubmit}
|
onChange={(event) => onTitleChange(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onBlur={onTitleSubmit}
|
||||||
if (event.key === "Enter") {
|
onKeyDown={(event) => {
|
||||||
event.currentTarget.blur();
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button drawing-delete-button"
|
||||||
|
onClick={() => onDelete(drawing.id)}
|
||||||
|
aria-label={`Delete ${drawing.title}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="publication-panel">
|
||||||
|
<label
|
||||||
|
className="publication-label"
|
||||||
|
htmlFor="publication-slug"
|
||||||
|
>
|
||||||
|
Public name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="publication-slug"
|
||||||
|
className="title-input publication-input"
|
||||||
|
value={publicationSlug}
|
||||||
|
onChange={(event) =>
|
||||||
|
onPublicationSlugChange(event.target.value)
|
||||||
}
|
}
|
||||||
}}
|
spellCheck={false}
|
||||||
/>
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
/>
|
||||||
|
<div className="publication-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={publicationBusy}
|
||||||
|
>
|
||||||
|
{publication.enabled ? "Update link" : "Publish"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={onDisablePublication}
|
||||||
|
disabled={!publication.enabled || publicationBusy}
|
||||||
|
>
|
||||||
|
Unpublish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="publication-status">
|
||||||
|
{publication.enabled ? (
|
||||||
|
<a
|
||||||
|
href={publicPath ?? "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="publication-link"
|
||||||
|
>
|
||||||
|
{publicationStatus}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{publicationStatus}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawing-link"
|
||||||
|
onClick={() => onSelect(drawing.id)}
|
||||||
|
>
|
||||||
<span className="drawing-title">{drawing.title}</span>
|
<span className="drawing-title">{drawing.title}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{drawing.id !== activeId ? (
|
||||||
type="button"
|
<button
|
||||||
className="icon-button"
|
type="button"
|
||||||
onClick={() => onDelete(drawing.id)}
|
className="icon-button"
|
||||||
aria-label={`Delete ${drawing.title}`}
|
onClick={() => onDelete(drawing.id)}
|
||||||
>
|
aria-label={`Delete ${drawing.title}`}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
import { memo } from "react";
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
ExcalidrawInitialDataState,
|
ExcalidrawInitialDataState,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||||
import { type ScenePayload } from "../../core/shared";
|
import { type ScenePayload } from "../../core/shared";
|
||||||
|
|
||||||
type EditorCanvasProps = {
|
type EditorCanvasProps = {
|
||||||
@@ -33,7 +34,7 @@ function sceneFromEditor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorCanvas({
|
export const EditorCanvas = memo(function EditorCanvas({
|
||||||
activeId,
|
activeId,
|
||||||
scene,
|
scene,
|
||||||
loading,
|
loading,
|
||||||
@@ -58,4 +59,4 @@ export function EditorCanvas({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
|
||||||
|
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||||
|
import { type PublicDrawing } from "../../core/shared";
|
||||||
|
|
||||||
|
type PublicViewerProps = {
|
||||||
|
drawing: PublicDrawing | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawingToInitialData(
|
||||||
|
drawing: PublicDrawing,
|
||||||
|
): ExcalidrawInitialDataState {
|
||||||
|
return drawing as ExcalidrawInitialDataState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerUiOptions = {
|
||||||
|
canvasActions: {
|
||||||
|
changeViewBackgroundColor: false,
|
||||||
|
clearCanvas: false,
|
||||||
|
export: false,
|
||||||
|
loadScene: false,
|
||||||
|
saveAsImage: false,
|
||||||
|
saveToActiveFile: false,
|
||||||
|
toggleTheme: false,
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
image: false,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PublicViewer = memo(function PublicViewer({
|
||||||
|
drawing,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: PublicViewerProps) {
|
||||||
|
if (loading || !drawing) {
|
||||||
|
return <div className="editor-loading">{error ?? "Loading..."}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="public-viewer-shell">
|
||||||
|
<Excalidraw
|
||||||
|
initialData={drawingToInitialData(drawing)}
|
||||||
|
viewModeEnabled={true}
|
||||||
|
zenModeEnabled={true}
|
||||||
|
UIOptions={viewerUiOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/shared";
|
import {
|
||||||
|
DEFAULT_TITLE,
|
||||||
|
type DrawingMeta,
|
||||||
|
type DrawingPublication,
|
||||||
|
type ScenePayload,
|
||||||
|
} from "../../core/shared";
|
||||||
|
|
||||||
type DrawingResponse = DrawingMeta & ScenePayload;
|
type DrawingResponse = DrawingMeta & ScenePayload;
|
||||||
|
type PublicationResponse = DrawingPublication;
|
||||||
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
|
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
|
||||||
type UpdateResponse = { ok: true; drawing: DrawingMeta };
|
type UpdateResponse = { ok: true; drawing: DrawingMeta };
|
||||||
|
|
||||||
@@ -23,7 +29,10 @@ function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
|
|||||||
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
|
function replaceMeta(
|
||||||
|
drawings: DrawingMeta[],
|
||||||
|
drawing: DrawingMeta,
|
||||||
|
): DrawingMeta[] {
|
||||||
const filtered = drawings.filter((item) => item.id !== drawing.id);
|
const filtered = drawings.filter((item) => item.id !== drawing.id);
|
||||||
return sortDrawings([drawing, ...filtered]);
|
return sortDrawings([drawing, ...filtered]);
|
||||||
}
|
}
|
||||||
@@ -49,8 +58,14 @@ function fetchDrawingList() {
|
|||||||
return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
|
return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConflictError(error: unknown): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } {
|
function isConflictError(
|
||||||
return error instanceof RequestError && error.status === 409 && error.body.drawing !== undefined;
|
error: unknown,
|
||||||
|
): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } {
|
||||||
|
return (
|
||||||
|
error instanceof RequestError &&
|
||||||
|
error.status === 409 &&
|
||||||
|
error.body.drawing !== undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sceneFromDrawing(drawing: DrawingResponse): ScenePayload {
|
function sceneFromDrawing(drawing: DrawingResponse): ScenePayload {
|
||||||
@@ -70,10 +85,16 @@ export function useDrawingSession() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
const [editorReloadNonce, setEditorReloadNonce] = useState(0);
|
const [editorReloadNonce, setEditorReloadNonce] = useState(0);
|
||||||
|
const [publication, setPublication] = useState<DrawingPublication>({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const [publicationSlug, setPublicationSlugState] = useState("");
|
||||||
|
const [publicationBusy, setPublicationBusy] = useState(false);
|
||||||
|
|
||||||
const currentIdRef = useRef<string | null>(activeId);
|
const currentIdRef = useRef<string | null>(activeId);
|
||||||
const currentTitleRef = useRef(activeTitle);
|
const currentTitleRef = useRef(activeTitle);
|
||||||
const latestSceneRef = useRef<ScenePayload | null>(null);
|
const latestSceneRef = useRef<ScenePayload | null>(null);
|
||||||
|
const publicationEnabledRef = useRef(false);
|
||||||
const ignoreChangeRef = useRef(false);
|
const ignoreChangeRef = useRef(false);
|
||||||
const saveTimeoutRef = useRef<number | null>(null);
|
const saveTimeoutRef = useRef<number | null>(null);
|
||||||
const toastTimeoutRef = useRef<number | null>(null);
|
const toastTimeoutRef = useRef<number | null>(null);
|
||||||
@@ -88,17 +109,23 @@ export function useDrawingSession() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showToast = useCallback((message: string | null, timeoutMs?: number) => {
|
const showToast = useCallback(
|
||||||
if (toastTimeoutRef.current !== null) {
|
(message: string | null, timeoutMs?: number) => {
|
||||||
clearTimeout(toastTimeoutRef.current);
|
if (toastTimeoutRef.current !== null) {
|
||||||
toastTimeoutRef.current = null;
|
clearTimeout(toastTimeoutRef.current);
|
||||||
}
|
toastTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
setToastMessage(message);
|
setToastMessage(message);
|
||||||
if (message !== null && timeoutMs !== undefined) {
|
if (message !== null && timeoutMs !== undefined) {
|
||||||
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), timeoutMs);
|
toastTimeoutRef.current = window.setTimeout(
|
||||||
}
|
() => setToastMessage(null),
|
||||||
}, []);
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
const list = await fetchDrawingList();
|
const list = await fetchDrawingList();
|
||||||
@@ -106,6 +133,21 @@ export function useDrawingSession() {
|
|||||||
return list;
|
return list;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const applyPublication = useCallback(
|
||||||
|
(nextPublication: DrawingPublication) => {
|
||||||
|
publicationEnabledRef.current = nextPublication.enabled;
|
||||||
|
setPublication(nextPublication);
|
||||||
|
|
||||||
|
if (nextPublication.enabled) {
|
||||||
|
setPublicationSlugState(nextPublication.slug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicationSlugState("");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const loadDrawing = useCallback(
|
const loadDrawing = useCallback(
|
||||||
async (drawingId: string) => {
|
async (drawingId: string) => {
|
||||||
const version = ++loadVersionRef.current;
|
const version = ++loadVersionRef.current;
|
||||||
@@ -113,9 +155,15 @@ export function useDrawingSession() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [list, drawing] = await Promise.all([
|
const [list, drawing, nextPublication] = await Promise.all([
|
||||||
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
||||||
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
|
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
requestJson<PublicationResponse>(
|
||||||
|
`/api/drawings/${drawingId}/publication`,
|
||||||
|
{ method: "GET" },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (version !== loadVersionRef.current) {
|
if (version !== loadVersionRef.current) {
|
||||||
@@ -134,20 +182,25 @@ export function useDrawingSession() {
|
|||||||
setActiveId(drawing.id);
|
setActiveId(drawing.id);
|
||||||
setActiveTitleState(drawing.title);
|
setActiveTitleState(drawing.title);
|
||||||
setScene(nextScene);
|
setScene(nextScene);
|
||||||
|
applyPublication(nextPublication);
|
||||||
setEditorReloadNonce((current) => current + 1);
|
setEditorReloadNonce((current) => current + 1);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (version !== loadVersionRef.current) {
|
if (version !== loadVersionRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: "Failed to load drawing",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (version === loadVersionRef.current) {
|
if (version === loadVersionRef.current) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[applyPublication],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveScene = useCallback(
|
const saveScene = useCallback(
|
||||||
@@ -164,10 +217,13 @@ export function useDrawingSession() {
|
|||||||
showToast("Saving...");
|
showToast("Saving...");
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, {
|
const promise = requestJson<UpdateResponse>(
|
||||||
method: "PUT",
|
`/api/drawings/${drawingId}`,
|
||||||
body: JSON.stringify({ ...nextScene, expectedRevision }),
|
{
|
||||||
})
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ ...nextScene, expectedRevision }),
|
||||||
|
},
|
||||||
|
)
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||||
if (currentIdRef.current === drawingId) {
|
if (currentIdRef.current === drawingId) {
|
||||||
@@ -181,14 +237,18 @@ export function useDrawingSession() {
|
|||||||
if (isConflictError(saveError)) {
|
if (isConflictError(saveError)) {
|
||||||
clearPendingSaveTimeout();
|
clearPendingSaveTimeout();
|
||||||
setError(null);
|
setError(null);
|
||||||
setDrawings((current) => replaceMeta(current, saveError.body.drawing));
|
setDrawings((current) =>
|
||||||
|
replaceMeta(current, saveError.body.drawing),
|
||||||
|
);
|
||||||
if (isManual) {
|
if (isManual) {
|
||||||
showToast("Reloading latest...");
|
showToast("Reloading latest...");
|
||||||
}
|
}
|
||||||
return loadDrawing(drawingId);
|
return loadDrawing(drawingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(saveError instanceof Error ? saveError.message : "Save failed");
|
setError(
|
||||||
|
saveError instanceof Error ? saveError.message : "Save failed",
|
||||||
|
);
|
||||||
if (isManual) {
|
if (isManual) {
|
||||||
showToast("Save failed", 2000);
|
showToast("Save failed", 2000);
|
||||||
}
|
}
|
||||||
@@ -238,7 +298,10 @@ export function useDrawingSession() {
|
|||||||
}, [saveScene]);
|
}, [saveScene]);
|
||||||
|
|
||||||
const navigateToDrawing = useCallback(
|
const navigateToDrawing = useCallback(
|
||||||
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
|
async (
|
||||||
|
drawingId: string,
|
||||||
|
options?: { replace?: boolean; flush?: boolean },
|
||||||
|
) => {
|
||||||
const replace = options?.replace ?? false;
|
const replace = options?.replace ?? false;
|
||||||
const flush = options?.flush ?? true;
|
const flush = options?.flush ?? true;
|
||||||
|
|
||||||
@@ -266,7 +329,9 @@ export function useDrawingSession() {
|
|||||||
|
|
||||||
const createDrawing = useCallback(async () => {
|
const createDrawing = useCallback(async () => {
|
||||||
await flushPendingSave();
|
await flushPendingSave();
|
||||||
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
const created = await requestJson<DrawingResponse>("/api/drawings", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
await navigateToDrawing(created.id, { flush: false });
|
await navigateToDrawing(created.id, { flush: false });
|
||||||
}, [flushPendingSave, navigateToDrawing]);
|
}, [flushPendingSave, navigateToDrawing]);
|
||||||
|
|
||||||
@@ -280,12 +345,18 @@ export function useDrawingSession() {
|
|||||||
await flushPendingSave();
|
await flushPendingSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
|
const response = await requestJson<{ ok: true; nextId: string | null }>(
|
||||||
method: "DELETE",
|
`/api/drawings/${drawingId}`,
|
||||||
});
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (drawingId === currentIdRef.current && response.nextId) {
|
if (drawingId === currentIdRef.current && response.nextId) {
|
||||||
await navigateToDrawing(response.nextId, { replace: true, flush: false });
|
await navigateToDrawing(response.nextId, {
|
||||||
|
replace: true,
|
||||||
|
flush: false,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await refreshList();
|
await refreshList();
|
||||||
}
|
}
|
||||||
@@ -305,13 +376,16 @@ export function useDrawingSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, {
|
const body = await requestJson<UpdateResponse>(
|
||||||
method: "PUT",
|
`/api/drawings/${drawingId}`,
|
||||||
body: JSON.stringify({
|
{
|
||||||
title: currentTitleRef.current,
|
method: "PUT",
|
||||||
expectedRevision: loadedRevisionRef.current,
|
body: JSON.stringify({
|
||||||
}),
|
title: currentTitleRef.current,
|
||||||
});
|
expectedRevision: loadedRevisionRef.current,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
currentTitleRef.current = body.drawing.title;
|
currentTitleRef.current = body.drawing.title;
|
||||||
loadedRevisionRef.current = body.drawing.revision;
|
loadedRevisionRef.current = body.drawing.revision;
|
||||||
@@ -320,12 +394,16 @@ export function useDrawingSession() {
|
|||||||
} catch (renameError) {
|
} catch (renameError) {
|
||||||
if (isConflictError(renameError)) {
|
if (isConflictError(renameError)) {
|
||||||
clearPendingSaveTimeout();
|
clearPendingSaveTimeout();
|
||||||
setDrawings((current) => replaceMeta(current, renameError.body.drawing));
|
setDrawings((current) =>
|
||||||
|
replaceMeta(current, renameError.body.drawing),
|
||||||
|
);
|
||||||
await loadDrawing(drawingId);
|
await loadDrawing(drawingId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(renameError instanceof Error ? renameError.message : "Rename failed");
|
setError(
|
||||||
|
renameError instanceof Error ? renameError.message : "Rename failed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [clearPendingSaveTimeout, loadDrawing]);
|
}, [clearPendingSaveTimeout, loadDrawing]);
|
||||||
|
|
||||||
@@ -350,6 +428,82 @@ export function useDrawingSession() {
|
|||||||
[navigateToDrawing],
|
[navigateToDrawing],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setPublicationSlug = useCallback((slug: string) => {
|
||||||
|
setPublicationSlugState(slug);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const publishPublication = useCallback(async () => {
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
if (!drawingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicationBusy(true);
|
||||||
|
setError(null);
|
||||||
|
showToast("Publishing...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await flushPendingSave();
|
||||||
|
|
||||||
|
const currentDrawingId = currentIdRef.current;
|
||||||
|
if (!currentDrawingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await requestJson<PublicationResponse>(
|
||||||
|
`/api/drawings/${currentDrawingId}/publication`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: publicationSlug,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
applyPublication(body);
|
||||||
|
showToast("Published", 2000);
|
||||||
|
} catch (publishError) {
|
||||||
|
setError(
|
||||||
|
publishError instanceof Error ? publishError.message : "Publish failed",
|
||||||
|
);
|
||||||
|
showToast("Publish failed", 2000);
|
||||||
|
} finally {
|
||||||
|
setPublicationBusy(false);
|
||||||
|
}
|
||||||
|
}, [applyPublication, flushPendingSave, publicationSlug, showToast]);
|
||||||
|
|
||||||
|
const disablePublication = useCallback(async () => {
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
if (!drawingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicationBusy(true);
|
||||||
|
setError(null);
|
||||||
|
showToast("Disabling...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await requestJson<PublicationResponse>(
|
||||||
|
`/api/drawings/${drawingId}/publication`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled: false }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
applyPublication(body);
|
||||||
|
showToast("Publication disabled", 2000);
|
||||||
|
} catch (disableError) {
|
||||||
|
setError(
|
||||||
|
disableError instanceof Error ? disableError.message : "Disable failed",
|
||||||
|
);
|
||||||
|
showToast("Disable failed", 2000);
|
||||||
|
} finally {
|
||||||
|
setPublicationBusy(false);
|
||||||
|
}
|
||||||
|
}, [applyPublication, showToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||||
@@ -391,9 +545,15 @@ export function useDrawingSession() {
|
|||||||
const list = await refreshList();
|
const list = await refreshList();
|
||||||
const drawingId = currentIdRef.current;
|
const drawingId = currentIdRef.current;
|
||||||
const loadedRevision = loadedRevisionRef.current;
|
const loadedRevision = loadedRevisionRef.current;
|
||||||
const serverDrawing = drawingId ? list.find((drawing) => drawing.id === drawingId) : null;
|
const serverDrawing = drawingId
|
||||||
|
? list.find((drawing) => drawing.id === drawingId)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (serverDrawing && loadedRevision !== null && serverDrawing.revision > loadedRevision) {
|
if (
|
||||||
|
serverDrawing &&
|
||||||
|
loadedRevision !== null &&
|
||||||
|
serverDrawing.revision > loadedRevision
|
||||||
|
) {
|
||||||
clearPendingSaveTimeout();
|
clearPendingSaveTimeout();
|
||||||
await loadDrawing(serverDrawing.id);
|
await loadDrawing(serverDrawing.id);
|
||||||
}
|
}
|
||||||
@@ -441,11 +601,17 @@ export function useDrawingSession() {
|
|||||||
error,
|
error,
|
||||||
toastMessage,
|
toastMessage,
|
||||||
editorReloadNonce,
|
editorReloadNonce,
|
||||||
|
publication,
|
||||||
|
publicationSlug,
|
||||||
|
publicationBusy,
|
||||||
setActiveTitle,
|
setActiveTitle,
|
||||||
submitTitle,
|
submitTitle,
|
||||||
handleSceneChange,
|
handleSceneChange,
|
||||||
selectDrawing,
|
selectDrawing,
|
||||||
createDrawing,
|
createDrawing,
|
||||||
deleteDrawing,
|
deleteDrawing,
|
||||||
|
setPublicationSlug,
|
||||||
|
publishPublication,
|
||||||
|
disablePublication,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { type PublicDrawing } from "../../core/shared";
|
||||||
|
|
||||||
|
type PublicDrawingState = {
|
||||||
|
drawing: PublicDrawing | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPublicDrawing(slug: string): Promise<PublicDrawing> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/public/drawings/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as PublicDrawing & { error?: string };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RequestError(
|
||||||
|
response.status,
|
||||||
|
body.error ?? `Request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublicDrawing(slug: string): PublicDrawingState {
|
||||||
|
const [drawing, setDrawing] = useState<PublicDrawing | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
void requestPublicDrawing(slug)
|
||||||
|
.then((nextDrawing) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawing(nextDrawing);
|
||||||
|
document.title = nextDrawing.title;
|
||||||
|
})
|
||||||
|
.catch((loadError: unknown) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawing(null);
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: "Failed to load drawing",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawing,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ const EXCALIDRAW_THEME_TOKEN_MAP = [
|
|||||||
["--overlay-bg-color", "--app-overlay-bg"],
|
["--overlay-bg-color", "--app-overlay-bg"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>) {
|
export function useThemeTokenSync(
|
||||||
|
appShellRef: RefObject<HTMLDivElement | null>,
|
||||||
|
) {
|
||||||
const themeSyncFrameRef = useRef<number | null>(null);
|
const themeSyncFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const syncThemeTokens = useCallback(() => {
|
const syncThemeTokens = useCallback(() => {
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ input {
|
|||||||
font-size: var(--app-sidebar-font-size);
|
font-size: var(--app-sidebar-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawing-link-active {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-delete-button {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.drawing-title,
|
.drawing-title,
|
||||||
.title-input {
|
.title-input {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -152,6 +168,38 @@ input {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publication-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-label,
|
||||||
|
.publication-status {
|
||||||
|
color: color-mix(in srgb, var(--app-text-color) 75%, white);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-input {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-shell {
|
.editor-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -270,6 +318,7 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-frame,
|
.editor-frame,
|
||||||
|
.public-viewer-shell,
|
||||||
.editor-loading {
|
.editor-loading {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { HttpError } from "./scene";
|
||||||
|
|
||||||
|
const RESERVED_SLUGS = new Set(["api", "d", "p", "mcp"]);
|
||||||
|
|
||||||
|
export function normalizePublicationSlug(input: unknown): string {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
throw new HttpError(400, "slug must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new HttpError(400, "slug is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_SLUGS.has(normalized)) {
|
||||||
|
throw new HttpError(400, "slug is reserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
|
import {
|
||||||
|
HttpError,
|
||||||
|
MAX_SCENE_BYTES,
|
||||||
|
emptyScene,
|
||||||
|
normalizeScene,
|
||||||
|
parseJsonBody,
|
||||||
|
} from "./scene";
|
||||||
|
|
||||||
describe("scene helpers", () => {
|
describe("scene helpers", () => {
|
||||||
test("creates empty scenes with dark theme by default", () => {
|
test("creates empty scenes with dark theme by default", () => {
|
||||||
|
|||||||
+3
-1
@@ -62,7 +62,9 @@ export function normalizeScene(input: unknown): ScenePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appState = Object.fromEntries(
|
const appState = Object.fromEntries(
|
||||||
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
|
Object.entries(input.appState).filter(
|
||||||
|
([key]) => !VOLATILE_APP_STATE_KEYS.has(key),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ export type ScenePayload = {
|
|||||||
files: Record<string, unknown>;
|
files: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DrawingPublication =
|
||||||
|
| {
|
||||||
|
enabled: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
enabled: true;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicDrawing = {
|
||||||
|
title: string;
|
||||||
|
} & ScenePayload;
|
||||||
|
|
||||||
export type DrawingMeta = {
|
export type DrawingMeta = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
+22
-81
@@ -1,26 +1,17 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
||||||
import { afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { type ScenePayload } from "../core/shared";
|
import { type ScenePayload } from "../core/shared";
|
||||||
import { createDrawingStore, type DrawingStore } from "../server/db";
|
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||||
import {
|
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||||
DEFAULT_STYLES_GUIDE_PATH,
|
|
||||||
createExcaliMcpHandler,
|
|
||||||
createMcpToolHandlers,
|
|
||||||
resolvePublicBaseUrl,
|
|
||||||
resolveStylesGuidePath,
|
|
||||||
} from "./server";
|
|
||||||
|
|
||||||
const cleanup: Array<{ dir: string; store?: DrawingStore; client?: Client }> = [];
|
const cleanup: Array<{ dir: string; store?: DrawingStore }> = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
while (cleanup.length > 0) {
|
while (cleanup.length > 0) {
|
||||||
const item = cleanup.pop();
|
const item = cleanup.pop();
|
||||||
if (item) {
|
if (item) {
|
||||||
await item.client?.close().catch(() => undefined);
|
|
||||||
item.store?.close();
|
item.store?.close();
|
||||||
rmSync(item.dir, { recursive: true, force: true });
|
rmSync(item.dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -37,35 +28,6 @@ function createTempTools() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTempStylesGuidePath(content?: string) {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
|
||||||
const path = join(dir, DEFAULT_STYLES_GUIDE_PATH.slice(1));
|
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
|
||||||
if (content !== undefined) {
|
|
||||||
writeFileSync(path, content);
|
|
||||||
}
|
|
||||||
cleanup.push({ dir });
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTempClient(stylesGuidePath?: string) {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
|
||||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
|
||||||
const handler = createExcaliMcpHandler(
|
|
||||||
store,
|
|
||||||
"http://example.test",
|
|
||||||
stylesGuidePath ? resolveStylesGuidePath(stylesGuidePath) : undefined,
|
|
||||||
);
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL("http://localhost/mcp"), {
|
|
||||||
fetch: async (input, init) => handler(new Request(input, init)),
|
|
||||||
});
|
|
||||||
const client = new Client({ name: "excali-test", version: "1.0.0" });
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
cleanup.push({ dir, store, client });
|
|
||||||
return { client, store };
|
|
||||||
}
|
|
||||||
|
|
||||||
function testScene(): ScenePayload {
|
function testScene(): ScenePayload {
|
||||||
return {
|
return {
|
||||||
elements: [
|
elements: [
|
||||||
@@ -79,41 +41,9 @@ function testScene(): ScenePayload {
|
|||||||
|
|
||||||
describe("mcp tool handlers", () => {
|
describe("mcp tool handlers", () => {
|
||||||
test("PUBLIC_BASE_URL is required", () => {
|
test("PUBLIC_BASE_URL is required", () => {
|
||||||
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
|
expect(() => resolvePublicBaseUrl("")).toThrow(
|
||||||
});
|
"PUBLIC_BASE_URL is required",
|
||||||
|
);
|
||||||
test("missing fixed styles-guide path exposes no styles-guide resource", async () => {
|
|
||||||
const stylesGuidePath = createTempStylesGuidePath();
|
|
||||||
expect(resolveStylesGuidePath(stylesGuidePath)).toBeUndefined();
|
|
||||||
|
|
||||||
const { client } = await createTempClient(stylesGuidePath);
|
|
||||||
|
|
||||||
expect(client.getServerCapabilities()?.resources).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("valid fixed styles-guide path registers the styles-guide resource", async () => {
|
|
||||||
const contents = "# Scene style\n\nKeep labels terse.\n";
|
|
||||||
const stylesGuidePath = createTempStylesGuidePath(contents);
|
|
||||||
const resolved = resolveStylesGuidePath(stylesGuidePath);
|
|
||||||
const { client } = await createTempClient(stylesGuidePath);
|
|
||||||
const resources = await client.listResources();
|
|
||||||
const readResult = await client.readResource({ uri: "excali://styles-guide" });
|
|
||||||
|
|
||||||
expect(resolved).toEqual({ path: stylesGuidePath });
|
|
||||||
expect(resources.resources).toContainEqual({
|
|
||||||
name: "styles-guide",
|
|
||||||
uri: "excali://styles-guide",
|
|
||||||
title: "Styles Guide",
|
|
||||||
description: "User-provided drawing styles guide for scene generation",
|
|
||||||
mimeType: "text/markdown",
|
|
||||||
});
|
|
||||||
expect(readResult.contents).toEqual([
|
|
||||||
{
|
|
||||||
uri: "excali://styles-guide",
|
|
||||||
mimeType: "text/markdown",
|
|
||||||
text: contents,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
|
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
|
||||||
@@ -151,14 +81,21 @@ describe("mcp tool handlers", () => {
|
|||||||
|
|
||||||
test("replace_drawing updates the existing drawing", () => {
|
test("replace_drawing updates the existing drawing", () => {
|
||||||
const { store, tools } = createTempTools();
|
const { store, tools } = createTempTools();
|
||||||
const result = tools.create_drawing({ title: "replace", scene: testScene() });
|
const result = tools.create_drawing({
|
||||||
|
title: "replace",
|
||||||
|
scene: testScene(),
|
||||||
|
});
|
||||||
const nextScene: ScenePayload = {
|
const nextScene: ScenePayload = {
|
||||||
elements: [{ id: "manual", type: "rectangle" }],
|
elements: [{ id: "manual", type: "rectangle" }],
|
||||||
appState: { theme: "dark" },
|
appState: { theme: "dark" },
|
||||||
files: {},
|
files: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
|
const replaced = tools.replace_drawing({
|
||||||
|
id: result.id,
|
||||||
|
title: "replaced",
|
||||||
|
scene: nextScene,
|
||||||
|
});
|
||||||
|
|
||||||
expect(store.listDrawings()).toHaveLength(1);
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
expect(replaced.revision).toBe(2);
|
expect(replaced.revision).toBe(2);
|
||||||
@@ -193,13 +130,17 @@ describe("mcp tool handlers", () => {
|
|||||||
|
|
||||||
expect(patched.revision).toBe(2);
|
expect(patched.revision).toBe(2);
|
||||||
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
||||||
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
|
expect(store.getDrawing(result.id)?.scene.elements).toEqual([
|
||||||
|
{ id: "one", type: "rectangle", x: 42, y: 20 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing drawing IDs return tool errors", () => {
|
test("missing drawing IDs return tool errors", () => {
|
||||||
const { tools } = createTempTools();
|
const { tools } = createTempTools();
|
||||||
|
|
||||||
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing");
|
expect(() => tools.get_drawing({ id: "missing" })).toThrow(
|
||||||
|
"Drawing not found: missing",
|
||||||
|
);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
tools.replace_drawing({
|
tools.replace_drawing({
|
||||||
id: "missing",
|
id: "missing",
|
||||||
|
|||||||
+50
-16
@@ -47,7 +47,9 @@ function trimTrailingSlash(url: string): string {
|
|||||||
return url.replace(/\/+$/, "");
|
return url.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string {
|
export function resolvePublicBaseUrl(
|
||||||
|
raw = process.env.PUBLIC_BASE_URL,
|
||||||
|
): string {
|
||||||
const value = raw?.trim();
|
const value = raw?.trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
|
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
|
||||||
@@ -58,13 +60,14 @@ export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string
|
|||||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||||
return trimTrailingSlash(url.toString());
|
return trimTrailingSlash(url.toString());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
|
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesGuideResource | undefined {
|
export function resolveStylesGuidePath(
|
||||||
|
raw = DEFAULT_STYLES_GUIDE_PATH,
|
||||||
|
): StylesGuideResource | undefined {
|
||||||
const value = raw.trim();
|
const value = raw.trim();
|
||||||
|
|
||||||
if (!isAbsolute(value)) {
|
if (!isAbsolute(value)) {
|
||||||
@@ -75,7 +78,12 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
|
|||||||
try {
|
try {
|
||||||
stats = statSync(value);
|
stats = statSync(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === "object" &&
|
||||||
|
"code" in error &&
|
||||||
|
error.code === "ENOENT"
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +96,9 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
|
|||||||
|
|
||||||
const extension = extname(value).toLowerCase();
|
const extension = extname(value).toLowerCase();
|
||||||
if (extension !== ".md" && extension !== ".markdown") {
|
if (extension !== ".md" && extension !== ".markdown") {
|
||||||
throw new Error("Styles guide path must point to a Markdown file (.md or .markdown)");
|
throw new Error(
|
||||||
|
"Styles guide path must point to a Markdown file (.md or .markdown)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stats.isFile()) {
|
if (!stats.isFile()) {
|
||||||
@@ -127,7 +137,10 @@ function notFound(id: string): never {
|
|||||||
throw new Error(`Drawing not found: ${id}`);
|
throw new Error(`Drawing not found: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
|
function mutationResult(
|
||||||
|
publicBaseUrl: string,
|
||||||
|
drawing: { id: string; title: string; revision: number },
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: drawing.id,
|
id: drawing.id,
|
||||||
title: drawing.title,
|
title: drawing.title,
|
||||||
@@ -149,16 +162,26 @@ function updateExistingDrawing(
|
|||||||
return result.drawing;
|
return result.drawing;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
|
function applyScenePatch(
|
||||||
|
scene: ScenePayload,
|
||||||
|
patch: Operation[],
|
||||||
|
): ScenePayload {
|
||||||
try {
|
try {
|
||||||
const result = applyPatch(structuredClone(scene), patch, true, true, true);
|
const result = applyPatch(structuredClone(scene), patch, true, true, true);
|
||||||
return normalizeScene(result.newDocument);
|
return normalizeScene(result.newDocument);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch");
|
throw new Error(
|
||||||
|
error instanceof Error
|
||||||
|
? `Invalid JSON Patch: ${error.message}`
|
||||||
|
: "Invalid JSON Patch",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string) {
|
export function createMcpToolHandlers(
|
||||||
|
store: DrawingStore,
|
||||||
|
publicBaseUrl: string,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
list_drawings() {
|
list_drawings() {
|
||||||
return store.listDrawings();
|
return store.listDrawings();
|
||||||
@@ -224,7 +247,8 @@ export function createExcaliMcpHandler(
|
|||||||
"excali://styles-guide",
|
"excali://styles-guide",
|
||||||
{
|
{
|
||||||
title: "Styles Guide",
|
title: "Styles Guide",
|
||||||
description: "User-provided drawing styles guide for scene generation",
|
description:
|
||||||
|
"User-provided drawing styles guide for scene generation",
|
||||||
mimeType: "text/markdown",
|
mimeType: "text/markdown",
|
||||||
},
|
},
|
||||||
async (uri) => ({
|
async (uri) => ({
|
||||||
@@ -252,7 +276,8 @@ export function createExcaliMcpHandler(
|
|||||||
"get_drawing",
|
"get_drawing",
|
||||||
{
|
{
|
||||||
title: "Get Drawing",
|
title: "Get Drawing",
|
||||||
description: "Returns drawing metadata, browser URL, and raw ScenePayload.",
|
description:
|
||||||
|
"Returns drawing metadata, browser URL, and raw ScenePayload.",
|
||||||
inputSchema: idInput.shape,
|
inputSchema: idInput.shape,
|
||||||
},
|
},
|
||||||
async (input) => jsonText(tools.get_drawing(input)),
|
async (input) => jsonText(tools.get_drawing(input)),
|
||||||
@@ -262,7 +287,8 @@ export function createExcaliMcpHandler(
|
|||||||
"create_drawing",
|
"create_drawing",
|
||||||
{
|
{
|
||||||
title: "Create Drawing",
|
title: "Create Drawing",
|
||||||
description: "Creates a drawing from an explicit Excalidraw ScenePayload.",
|
description:
|
||||||
|
"Creates a drawing from an explicit Excalidraw ScenePayload.",
|
||||||
inputSchema: createDrawingInput.shape,
|
inputSchema: createDrawingInput.shape,
|
||||||
},
|
},
|
||||||
async (input) => jsonText(tools.create_drawing(input)),
|
async (input) => jsonText(tools.create_drawing(input)),
|
||||||
@@ -282,7 +308,8 @@ export function createExcaliMcpHandler(
|
|||||||
"patch_drawing",
|
"patch_drawing",
|
||||||
{
|
{
|
||||||
title: "Patch Drawing",
|
title: "Patch Drawing",
|
||||||
description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
|
description:
|
||||||
|
"Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
|
||||||
inputSchema: patchDrawingInput.shape,
|
inputSchema: patchDrawingInput.shape,
|
||||||
},
|
},
|
||||||
async (input) => jsonText(tools.patch_drawing(input)),
|
async (input) => jsonText(tools.patch_drawing(input)),
|
||||||
@@ -304,7 +331,11 @@ export function createExcaliMcpHandler(
|
|||||||
|
|
||||||
export function createMcpServer() {
|
export function createMcpServer() {
|
||||||
const store = createDrawingStore(process.env.DATABASE_PATH);
|
const store = createDrawingStore(process.env.DATABASE_PATH);
|
||||||
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath());
|
const handler = createExcaliMcpHandler(
|
||||||
|
store,
|
||||||
|
resolvePublicBaseUrl(),
|
||||||
|
resolveStylesGuidePath(),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: Number(process.env.MCP_PORT ?? "3001"),
|
port: Number(process.env.MCP_PORT ?? "3001"),
|
||||||
@@ -312,7 +343,10 @@ export function createMcpServer() {
|
|||||||
fetch(request: Request) {
|
fetch(request: Request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname !== "/mcp") {
|
if (url.pathname !== "/mcp") {
|
||||||
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
return Response.json(
|
||||||
|
{ ok: false, error: "Not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler(request);
|
return handler(request);
|
||||||
|
|||||||
+227
-4
@@ -52,7 +52,9 @@ describe("api", () => {
|
|||||||
const { api, cleanup } = withApi();
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
const response = api.getDrawing(
|
const response = api.getDrawing(
|
||||||
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
Object.assign(new Request("http://local/api/drawings/missing"), {
|
||||||
|
params: { id: "missing" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
@@ -199,10 +201,231 @@ describe("api", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const stored = await api.getDrawing(
|
const stored = await api
|
||||||
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }),
|
.getDrawing(
|
||||||
).json();
|
Object.assign(new Request(`http://local/api/drawings/${created.id}`), {
|
||||||
|
params: { id: created.id },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.json();
|
||||||
expect(stored.elements).toEqual([{ id: "server" }]);
|
expect(stored.elements).toEqual([{ id: "server" }]);
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns disabled and enabled publication shapes", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const disabled = await api.getDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(disabled.status).toBe(200);
|
||||||
|
expect(await disabled.json()).toEqual({ enabled: false });
|
||||||
|
|
||||||
|
const publish = await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "Flow Chart",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(publish.status).toBe(200);
|
||||||
|
expect(await publish.json()).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
slug: "flow-chart",
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabled = await api.getDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await enabled.json()).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
slug: "flow-chart",
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishes, renames, and disables publications", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "first",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const renamed = await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "second",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renamed.status).toBe(200);
|
||||||
|
expect(await renamed.json()).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
slug: "second",
|
||||||
|
});
|
||||||
|
|
||||||
|
const disable = await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled: false }),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(disable.status).toBe(200);
|
||||||
|
expect(await disable.json()).toEqual({ enabled: false });
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 400 for invalid publication slugs", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "API",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "slug is reserved",
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 409 for publication slug collisions", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const first = await api.createDrawing().json();
|
||||||
|
const second = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${first.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "shared",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: first.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${second.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "shared",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: second.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Published slug already exists",
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns public scene data for enabled slugs and 404 otherwise", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Public Flow",
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.updateDrawingPublication(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
slug: "public-flow",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const enabled = await api.getPublicDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request("http://local/api/public/drawings/public-flow"),
|
||||||
|
{ params: { slug: "public-flow" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(enabled.status).toBe(200);
|
||||||
|
expect(await enabled.json()).toEqual({
|
||||||
|
title: "Public Flow",
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const missing = await api.getPublicDrawing(
|
||||||
|
Object.assign(new Request("http://local/api/public/drawings/missing"), {
|
||||||
|
params: { slug: "missing" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+127
-6
@@ -1,6 +1,12 @@
|
|||||||
import { type DrawingStore } from "./db";
|
import { type DrawingStore } from "./db";
|
||||||
import { type DrawingMeta } from "../core/shared";
|
import { normalizePublicationSlug } from "../core/publication";
|
||||||
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
|
import { type DrawingMeta, type DrawingPublication } from "../core/shared";
|
||||||
|
import {
|
||||||
|
HttpError,
|
||||||
|
normalizeTitle,
|
||||||
|
parseJsonBody,
|
||||||
|
parseSceneText,
|
||||||
|
} from "../core/scene";
|
||||||
|
|
||||||
type RouteRequest = Request & {
|
type RouteRequest = Request & {
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
@@ -33,7 +39,9 @@ function toMeta(drawing: DrawingMeta): DrawingMeta {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined {
|
function parseExpectedRevision(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): number | undefined {
|
||||||
if (!Object.hasOwn(raw, "expectedRevision")) {
|
if (!Object.hasOwn(raw, "expectedRevision")) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -46,6 +54,10 @@ function parseExpectedRevision(raw: Record<string, unknown>): number | undefined
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonPublication(publication: DrawingPublication): Response {
|
||||||
|
return json(publication);
|
||||||
|
}
|
||||||
|
|
||||||
export function createApi(store: DrawingStore) {
|
export function createApi(store: DrawingStore) {
|
||||||
return {
|
return {
|
||||||
health() {
|
health() {
|
||||||
@@ -73,7 +85,10 @@ export function createApi(store: DrawingStore) {
|
|||||||
const drawing = id ? store.getDrawing(id) : null;
|
const drawing = id ? store.getDrawing(id) : null;
|
||||||
|
|
||||||
if (!drawing) {
|
if (!drawing) {
|
||||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
@@ -85,6 +100,27 @@ export function createApi(store: DrawingStore) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPublicDrawing(request: RouteRequest) {
|
||||||
|
try {
|
||||||
|
const slug = request.params?.slug;
|
||||||
|
if (!slug) {
|
||||||
|
throw new HttpError(400, "Missing published slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawing = store.getPublicDrawing(slug);
|
||||||
|
if (!drawing) {
|
||||||
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(drawing);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async updateDrawing(request: RouteRequest) {
|
async updateDrawing(request: RouteRequest) {
|
||||||
try {
|
try {
|
||||||
const id = request.params?.id;
|
const id = request.params?.id;
|
||||||
@@ -113,7 +149,10 @@ export function createApi(store: DrawingStore) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok && result.reason === "not_found") {
|
if (!result.ok && result.reason === "not_found") {
|
||||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.ok && result.reason === "conflict") {
|
if (!result.ok && result.reason === "conflict") {
|
||||||
@@ -140,7 +179,10 @@ export function createApi(store: DrawingStore) {
|
|||||||
deleteDrawing(request: RouteRequest) {
|
deleteDrawing(request: RouteRequest) {
|
||||||
const id = request.params?.id;
|
const id = request.params?.id;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
|
return json(
|
||||||
|
{ ok: false, error: "Missing drawing id" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { deleted, next } = store.deleteDrawing(id);
|
const { deleted, next } = store.deleteDrawing(id);
|
||||||
@@ -150,5 +192,84 @@ export function createApi(store: DrawingStore) {
|
|||||||
|
|
||||||
return json({ ok: true, nextId: next?.id ?? null });
|
return json({ ok: true, nextId: next?.id ?? null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDrawingPublication(request: RouteRequest) {
|
||||||
|
try {
|
||||||
|
const id = request.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new HttpError(400, "Missing drawing id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const publication = store.getDrawingPublication(id);
|
||||||
|
if (!publication) {
|
||||||
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonPublication(publication);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDrawingPublication(request: RouteRequest) {
|
||||||
|
try {
|
||||||
|
const id = request.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new HttpError(400, "Missing drawing id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = parseJsonBody<Record<string, unknown>>(
|
||||||
|
await request.text(),
|
||||||
|
);
|
||||||
|
if (raw.enabled === false) {
|
||||||
|
const result = store.disableDrawingPublication(id);
|
||||||
|
if (!result.ok) {
|
||||||
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonPublication(result.publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.enabled !== true) {
|
||||||
|
throw new HttpError(400, "enabled must be a boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawing = store.getDrawing(id);
|
||||||
|
if (!drawing) {
|
||||||
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = store.publishDrawing(id, {
|
||||||
|
slug: normalizePublicationSlug(raw.slug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok && result.reason === "not_found") {
|
||||||
|
return json(
|
||||||
|
{ ok: false, error: "Drawing not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.ok && result.reason === "slug_conflict") {
|
||||||
|
return json(
|
||||||
|
{ ok: false, error: "Published slug already exists" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonPublication(result.publication);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-6
@@ -79,7 +79,9 @@ describe("drawing store", () => {
|
|||||||
expect(stale.ok).toBe(false);
|
expect(stale.ok).toBe(false);
|
||||||
expect(stale.ok ? null : stale.reason).toBe("conflict");
|
expect(stale.ok ? null : stale.reason).toBe("conflict");
|
||||||
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
||||||
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
|
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
|
||||||
|
{ id: "first" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the revision unchanged when the scene is already stored", () => {
|
test("keeps the revision unchanged when the scene is already stored", () => {
|
||||||
@@ -109,13 +111,21 @@ describe("drawing store", () => {
|
|||||||
files: {},
|
files: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
const first = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
const repeated = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
expect(repeated.ok).toBe(true);
|
expect(repeated.ok).toBe(true);
|
||||||
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
||||||
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
|
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
|
||||||
|
{ id: "same" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("increments the revision when the title changes but the scene does not", () => {
|
test("increments the revision when the title changes but the scene does not", () => {
|
||||||
@@ -127,12 +137,113 @@ describe("drawing store", () => {
|
|||||||
files: {},
|
files: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
const first = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene });
|
const renamed = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 1,
|
||||||
|
title: "Renamed",
|
||||||
|
scene,
|
||||||
|
});
|
||||||
expect(renamed.ok).toBe(true);
|
expect(renamed.ok).toBe(true);
|
||||||
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
|
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
|
||||||
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
|
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("persists publication fields and exposes public drawings by slug", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing("Flow");
|
||||||
|
store.updateDrawing(created.id, {
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "one" }],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const published = store.publishDrawing(created.id, { slug: "flow" });
|
||||||
|
|
||||||
|
expect(published.ok).toBe(true);
|
||||||
|
expect(store.getDrawingPublication(created.id)).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
slug: "flow",
|
||||||
|
});
|
||||||
|
expect(store.getPublicDrawing("flow")).toEqual({
|
||||||
|
title: "Flow",
|
||||||
|
elements: [{ id: "one" }],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enforces unique active publication slugs", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const first = store.createDrawing("First");
|
||||||
|
const second = store.createDrawing("Second");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.publishDrawing(first.id, {
|
||||||
|
slug: "shared",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
publication: {
|
||||||
|
enabled: true,
|
||||||
|
slug: "shared",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.publishDrawing(second.id, {
|
||||||
|
slug: "shared",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "slug_conflict",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.disableDrawingPublication(first.id)).toEqual({
|
||||||
|
ok: true,
|
||||||
|
publication: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.publishDrawing(second.id, {
|
||||||
|
slug: "shared",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
publication: {
|
||||||
|
enabled: true,
|
||||||
|
slug: "shared",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears public fields on disable", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.publishDrawing(created.id, {
|
||||||
|
slug: "note",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
publication: {
|
||||||
|
enabled: true,
|
||||||
|
slug: "note",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.disableDrawingPublication(created.id)).toEqual({
|
||||||
|
ok: true,
|
||||||
|
publication: { enabled: false },
|
||||||
|
});
|
||||||
|
expect(store.getDrawingPublication(created.id)).toEqual({ enabled: false });
|
||||||
|
expect(store.getPublicDrawing("note")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+216
-10
@@ -2,7 +2,13 @@ import { Database } from "bun:sqlite";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "../core/shared";
|
import {
|
||||||
|
DEFAULT_TITLE,
|
||||||
|
type DrawingMeta,
|
||||||
|
type DrawingPublication,
|
||||||
|
type DrawingRecord,
|
||||||
|
type ScenePayload,
|
||||||
|
} from "../core/shared";
|
||||||
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
|
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
|
||||||
|
|
||||||
type DrawingRow = {
|
type DrawingRow = {
|
||||||
@@ -16,11 +22,26 @@ type DrawingRow = {
|
|||||||
|
|
||||||
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||||
|
|
||||||
|
type DrawingPublicationRow = {
|
||||||
|
publicEnabled: number;
|
||||||
|
publicSlug: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PublicDrawingRow = {
|
||||||
|
title: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DrawingUpdateResult =
|
export type DrawingUpdateResult =
|
||||||
| { ok: true; drawing: DrawingRecord }
|
| { ok: true; drawing: DrawingRecord }
|
||||||
| { ok: false; reason: "not_found" }
|
| { ok: false; reason: "not_found" }
|
||||||
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
|
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
|
||||||
|
|
||||||
|
export type DrawingPublicationUpdateResult =
|
||||||
|
| { ok: true; publication: DrawingPublication }
|
||||||
|
| { ok: false; reason: "not_found" }
|
||||||
|
| { ok: false; reason: "slug_conflict" };
|
||||||
|
|
||||||
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||||
|
|
||||||
function createId(): string {
|
function createId(): string {
|
||||||
@@ -54,6 +75,43 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPublication(
|
||||||
|
row: DrawingPublicationRow | null | undefined,
|
||||||
|
): DrawingPublication | null {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.publicEnabled !== 1 || !row.publicSlug) {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
slug: row.publicSlug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPublicDrawing(row: PublicDrawingRow | null | undefined) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene: ScenePayload;
|
||||||
|
try {
|
||||||
|
scene = normalizeScene(JSON.parse(row.data));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||||
|
throw new Error(`Invalid stored public scene: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: row.title,
|
||||||
|
...scene,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return null;
|
return null;
|
||||||
@@ -63,7 +121,8 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
|||||||
try {
|
try {
|
||||||
scene = normalizeScene(JSON.parse(row.data));
|
scene = normalizeScene(JSON.parse(row.data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||||
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
|
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +132,30 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensurePublicationColumns(db: Database) {
|
||||||
|
const columns = db.query("PRAGMA table_info(drawings)").all() as Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
const names = new Set(columns.map((column) => column.name));
|
||||||
|
|
||||||
|
if (!names.has("public_enabled")) {
|
||||||
|
db.exec(
|
||||||
|
"ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!names.has("public_slug")) {
|
||||||
|
db.exec("ALTER TABLE drawings ADD COLUMN public_slug TEXT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSlugConflictError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("UNIQUE constraint failed: drawings.public_slug")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||||
mkdirSync(dirname(databasePath), { recursive: true });
|
mkdirSync(dirname(databasePath), { recursive: true });
|
||||||
|
|
||||||
@@ -86,13 +169,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
revision INTEGER NOT NULL DEFAULT 0
|
revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
public_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
public_slug TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||||
ON drawings(updated_at DESC, created_at DESC);
|
ON drawings(updated_at DESC, created_at DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
ensurePublicationColumns(db);
|
||||||
|
db.exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS drawings_public_slug_active_idx
|
||||||
|
ON drawings(public_slug)
|
||||||
|
WHERE public_enabled = 1 AND public_slug IS NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
const listQuery = db.query(`
|
const listQuery = db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@@ -162,6 +254,38 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const getPublicationQuery = db.query(`
|
||||||
|
SELECT
|
||||||
|
public_enabled AS publicEnabled,
|
||||||
|
public_slug AS publicSlug
|
||||||
|
FROM drawings
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const publishQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET
|
||||||
|
public_enabled = 1,
|
||||||
|
public_slug = ?2
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const disablePublicationQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET
|
||||||
|
public_enabled = 0,
|
||||||
|
public_slug = NULL
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getPublicDrawingQuery = db.query(`
|
||||||
|
SELECT
|
||||||
|
title,
|
||||||
|
data
|
||||||
|
FROM drawings
|
||||||
|
WHERE public_enabled = 1 AND public_slug = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
function listDrawings(): DrawingMeta[] {
|
function listDrawings(): DrawingMeta[] {
|
||||||
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
|
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
|
||||||
}
|
}
|
||||||
@@ -201,8 +325,12 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
return { ok: true, drawing: existing };
|
return { ok: true, drawing: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
|
const nextTitle = hasTitle
|
||||||
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
|
? normalizeTitle(update.title)
|
||||||
|
: existingRow.title;
|
||||||
|
const nextScene = hasScene
|
||||||
|
? JSON.stringify(update.scene)
|
||||||
|
: existingRow.data;
|
||||||
const titleChanged = nextTitle !== existingRow.title;
|
const titleChanged = nextTitle !== existingRow.title;
|
||||||
const sceneChanged = nextScene !== existingRow.data;
|
const sceneChanged = nextScene !== existingRow.data;
|
||||||
|
|
||||||
@@ -210,7 +338,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
return { ok: true, drawing: existing };
|
return { ok: true, drawing: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) {
|
if (
|
||||||
|
update.expectedRevision !== undefined &&
|
||||||
|
update.expectedRevision !== existingRow.revision
|
||||||
|
) {
|
||||||
return { ok: false, reason: "conflict", drawing: existing };
|
return { ok: false, reason: "conflict", drawing: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,17 +350,36 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
changes =
|
changes =
|
||||||
update.expectedRevision === undefined
|
update.expectedRevision === undefined
|
||||||
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
|
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
|
||||||
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes);
|
: Number(
|
||||||
|
updateBothExpectedQuery.run(
|
||||||
|
id,
|
||||||
|
nextTitle,
|
||||||
|
nextScene,
|
||||||
|
update.expectedRevision,
|
||||||
|
).changes,
|
||||||
|
);
|
||||||
} else if (sceneChanged) {
|
} else if (sceneChanged) {
|
||||||
changes =
|
changes =
|
||||||
update.expectedRevision === undefined
|
update.expectedRevision === undefined
|
||||||
? Number(updateSceneQuery.run(id, nextScene).changes)
|
? Number(updateSceneQuery.run(id, nextScene).changes)
|
||||||
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes);
|
: Number(
|
||||||
|
updateSceneExpectedQuery.run(
|
||||||
|
id,
|
||||||
|
nextScene,
|
||||||
|
update.expectedRevision,
|
||||||
|
).changes,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
changes =
|
changes =
|
||||||
update.expectedRevision === undefined
|
update.expectedRevision === undefined
|
||||||
? Number(updateTitleQuery.run(id, nextTitle).changes)
|
? Number(updateTitleQuery.run(id, nextTitle).changes)
|
||||||
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
|
: Number(
|
||||||
|
updateTitleExpectedQuery.run(
|
||||||
|
id,
|
||||||
|
nextTitle,
|
||||||
|
update.expectedRevision,
|
||||||
|
).changes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawing = getDrawing(id);
|
const drawing = getDrawing(id);
|
||||||
@@ -244,7 +394,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
return { ok: false, reason: "conflict", drawing };
|
return { ok: false, reason: "conflict", drawing };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
function deleteDrawing(id: string): {
|
||||||
|
deleted: boolean;
|
||||||
|
next: DrawingMeta | null;
|
||||||
|
} {
|
||||||
const changes = Number(deleteQuery.run(id).changes);
|
const changes = Number(deleteQuery.run(id).changes);
|
||||||
if (changes === 0) {
|
if (changes === 0) {
|
||||||
return { deleted: false, next: null };
|
return { deleted: false, next: null };
|
||||||
@@ -256,6 +409,55 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDrawingPublication(id: string): DrawingPublication | null {
|
||||||
|
return readPublication(
|
||||||
|
getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishDrawing(
|
||||||
|
id: string,
|
||||||
|
publication: { slug: string },
|
||||||
|
): DrawingPublicationUpdateResult {
|
||||||
|
try {
|
||||||
|
const changes = Number(publishQuery.run(id, publication.slug).changes);
|
||||||
|
if (changes === 0) {
|
||||||
|
return { ok: false, reason: "not_found" };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isSlugConflictError(error)) {
|
||||||
|
return { ok: false, reason: "slug_conflict" };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
publication: getDrawingPublication(id)!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableDrawingPublication(
|
||||||
|
id: string,
|
||||||
|
): DrawingPublicationUpdateResult {
|
||||||
|
const changes = Number(disablePublicationQuery.run(id).changes);
|
||||||
|
if (changes === 0) {
|
||||||
|
return { ok: false, reason: "not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
publication: { enabled: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicDrawing(slug: string) {
|
||||||
|
return readPublicDrawing(
|
||||||
|
getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
db.close(false);
|
db.close(false);
|
||||||
}
|
}
|
||||||
@@ -270,6 +472,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
ensureInitialDrawing,
|
ensureInitialDrawing,
|
||||||
updateDrawing,
|
updateDrawing,
|
||||||
deleteDrawing,
|
deleteDrawing,
|
||||||
|
getDrawingPublication,
|
||||||
|
publishDrawing,
|
||||||
|
disableDrawingPublication,
|
||||||
|
getPublicDrawing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createDevServer } from "./dev-server";
|
||||||
|
|
||||||
|
describe("createDevServer", () => {
|
||||||
|
test("serves the spa for private and public drawing routes", () => {
|
||||||
|
const server = createDevServer();
|
||||||
|
|
||||||
|
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(true);
|
||||||
|
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ export function createDevServer() {
|
|||||||
routes: {
|
routes: {
|
||||||
...server.routes,
|
...server.routes,
|
||||||
"/d/:id": index,
|
"/d/:id": index,
|
||||||
|
"/p/:slug": index,
|
||||||
},
|
},
|
||||||
} satisfies Parameters<typeof Bun.serve>[0];
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,16 @@ describe("createServer", () => {
|
|||||||
test("redirects / to the latest drawing", () => {
|
test("redirects / to the latest drawing", () => {
|
||||||
const { server, store } = createServerFixture();
|
const { server, store } = createServerFixture();
|
||||||
|
|
||||||
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
|
const response = server.routes["/"]?.(
|
||||||
|
new Request("http://local/"),
|
||||||
|
) as Response;
|
||||||
const created = store.listDrawings();
|
const created = store.listDrawings();
|
||||||
|
|
||||||
expect(created).toHaveLength(1);
|
expect(created).toHaveLength(1);
|
||||||
expect(response.status).toBe(302);
|
expect(response.status).toBe(302);
|
||||||
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
|
expect(response.headers.get("location")).toBe(
|
||||||
|
`http://local/d/${created[0]?.id}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps Bun responsible for API routes", async () => {
|
test("keeps Bun responsible for API routes", async () => {
|
||||||
@@ -58,11 +62,49 @@ describe("createServer", () => {
|
|||||||
const { server } = createServerFixture();
|
const { server } = createServerFixture();
|
||||||
|
|
||||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
||||||
|
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves public drawing data through the public api", async () => {
|
||||||
|
const { server, store } = createServerFixture();
|
||||||
|
const drawing = store.createDrawing("Published");
|
||||||
|
store.publishDrawing(drawing.id, { slug: "published-note" });
|
||||||
|
|
||||||
|
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
|
||||||
|
Object.assign(
|
||||||
|
new Request("http://local/api/public/drawings/published-note"),
|
||||||
|
{
|
||||||
|
params: { slug: "published-note" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response?.status).toBe(200);
|
||||||
|
expect(await response?.json()).toEqual({
|
||||||
|
title: "Published",
|
||||||
|
elements: [],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for unknown or unpublished public slugs", async () => {
|
||||||
|
const { server } = createServerFixture();
|
||||||
|
|
||||||
|
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
|
||||||
|
Object.assign(new Request("http://local/api/public/drawings/missing"), {
|
||||||
|
params: { slug: "missing" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response?.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns JSON 404 for unmatched requests", async () => {
|
test("returns JSON 404 for unmatched requests", async () => {
|
||||||
const { server } = createServerFixture();
|
const { server } = createServerFixture();
|
||||||
const response = await server.fetch(new Request("http://local/index-abc123.js"));
|
const response = await server.fetch(
|
||||||
|
new Request("http://local/index-abc123.js"),
|
||||||
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
|
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
|
||||||
|
|||||||
@@ -25,12 +25,26 @@ export function createServer(options: CreateServerOptions = {}) {
|
|||||||
POST: () => api.createDrawing(),
|
POST: () => api.createDrawing(),
|
||||||
},
|
},
|
||||||
"/api/drawings/:id": {
|
"/api/drawings/:id": {
|
||||||
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
|
GET: (request: Request & { params: Record<string, string> }) =>
|
||||||
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
|
api.getDrawing(request),
|
||||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
PUT: (request: Request & { params: Record<string, string> }) =>
|
||||||
|
api.updateDrawing(request),
|
||||||
|
DELETE: (request: Request & { params: Record<string, string> }) =>
|
||||||
|
api.deleteDrawing(request),
|
||||||
|
},
|
||||||
|
"/api/public/drawings/:slug": {
|
||||||
|
GET: (request: Request & { params: Record<string, string> }) =>
|
||||||
|
api.getPublicDrawing(request),
|
||||||
|
},
|
||||||
|
"/api/drawings/:id/publication": {
|
||||||
|
GET: (request: Request & { params: Record<string, string> }) =>
|
||||||
|
api.getDrawingPublication(request),
|
||||||
|
PUT: (request: Request & { params: Record<string, string> }) =>
|
||||||
|
api.updateDrawingPublication(request),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
fetch: (_request: Request) =>
|
||||||
|
Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
||||||
} satisfies Parameters<typeof Bun.serve>[0];
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user