Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14bef2e633 | |||
| 7bfa1051c3 | |||
| be361e3896 |
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
coverage
|
||||
@@ -1,80 +0,0 @@
|
||||
name: Publish GHCR Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'release-*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-ghcr-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: bun run test
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Derive image metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${GITHUB_REF_NAME#release-}"
|
||||
version="${version#v}"
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "Release version is empty after stripping prefix from tag: $GITHUB_REF_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
|
||||
{
|
||||
echo "VERSION=$version"
|
||||
echo "IMAGE_NAME=$image_name"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.version=${{ env.VERSION }}
|
||||
@@ -1,7 +0,0 @@
|
||||
# Commit Style
|
||||
|
||||
- Use Conventional Commits.
|
||||
- Keep the type and subject lowercase.
|
||||
- Keep commit messages terse.
|
||||
|
||||
Example: `feat: dark mode by default`
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:80 {
|
||||
encode zstd gzip
|
||||
|
||||
@api path /api/*
|
||||
handle @api {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
@root path /
|
||||
handle @root {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
handle {
|
||||
root * /srv/public
|
||||
|
||||
@html path /index.html /d/*
|
||||
header @html Cache-Control "no-cache"
|
||||
|
||||
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
|
||||
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
FROM oven/bun:1.3.11-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock tsconfig.json ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
RUN bun run build
|
||||
|
||||
FROM caddy:2-alpine AS caddy
|
||||
|
||||
FROM oven/bun:1.3.11-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=127.0.0.1
|
||||
ENV PORT=3000
|
||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||
|
||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY --from=build /app/dist/public /srv/public
|
||||
COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
@@ -1,44 +0,0 @@
|
||||
# excali-box
|
||||
|
||||
Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
||||
|
||||
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.
|
||||
|
||||
> GPT 5.5 generated
|
||||
|
||||
## Pull from GHCR and run
|
||||
|
||||
Images are published to:
|
||||
|
||||
`ghcr.io/ruinivist/excalidraw-box:<version>`
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/ruinivist/excalidraw-box:latest
|
||||
docker run --rm -p 127.0.0.1:3000:80 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
|
||||
```
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
bun run test
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
## Build the image
|
||||
|
||||
```bash
|
||||
docker build -t excali .
|
||||
docker run --rm -p 127.0.0.1:3000:80 -v "$PWD/excali-box-data:/data" excali
|
||||
```
|
||||
|
||||
This will make the data persistent in the `excali-box-data` folder in the current directory, with Caddy serving the browser build on `localhost:3000`
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "excali",
|
||||
@@ -13,7 +14,6 @@
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -34,52 +34,6 @@
|
||||
|
||||
"@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@excalidraw/excalidraw": ["@excalidraw/excalidraw@0.18.1", "", { "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", "@excalidraw/mermaid-to-excalidraw": "2.2.2", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", "@radix-ui/react-tabs": "1.0.2", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", "es6-promise-pool": "2.5.0", "fractional-indexing": "3.2.0", "fuzzy": "0.1.3", "image-blob-reduce": "3.0.1", "jotai": "2.11.0", "jotai-scope": "0.7.2", "lodash.debounce": "4.0.8", "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", "pako": "2.0.3", "perfect-freehand": "1.2.0", "pica": "7.1.1", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", "png-chunks-extract": "1.0.0", "points-on-curve": "1.0.1", "pwacompat": "2.0.17", "roughjs": "4.6.4", "sass": "1.51.0", "tunnel-rat": "0.1.2" }, "peerDependencies": { "react": "^17.0.2 || ^18.2.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" } }, "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw=="],
|
||||
|
||||
"@excalidraw/laser-pointer": ["@excalidraw/laser-pointer@1.3.1", "", {}, "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g=="],
|
||||
@@ -154,56 +108,6 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||
@@ -268,8 +172,6 @@
|
||||
|
||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
@@ -400,8 +302,6 @@
|
||||
|
||||
"es6-promise-pool": ["es6-promise-pool@2.5.0", "", {}, "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
|
||||
@@ -484,8 +384,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"png-chunk-text": ["png-chunk-text@1.0.0", "", {}, "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw=="],
|
||||
@@ -498,8 +396,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
@@ -516,8 +412,6 @@
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
||||
"rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],
|
||||
|
||||
"roughjs": ["roughjs@4.6.4", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw=="],
|
||||
|
||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
@@ -560,8 +454,6 @@
|
||||
|
||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||
|
||||
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
|
||||
@@ -644,8 +536,6 @@
|
||||
|
||||
"points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="],
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
bun_pid=""
|
||||
caddy_pid=""
|
||||
|
||||
stop_children() {
|
||||
for pid in "$bun_pid" "$caddy_pid"; do
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
trap 'stop_children' INT TERM
|
||||
|
||||
bun /app/dist/server/server.js &
|
||||
bun_pid=$!
|
||||
|
||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
|
||||
caddy_pid=$!
|
||||
|
||||
exit_code=0
|
||||
|
||||
while :; do
|
||||
if ! kill -0 "$bun_pid" 2>/dev/null; then
|
||||
wait "$bun_pid" || exit_code=$?
|
||||
break
|
||||
fi
|
||||
|
||||
if ! kill -0 "$caddy_pid" 2>/dev/null; then
|
||||
wait "$caddy_pid" || exit_code=$?
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
stop_children
|
||||
wait "$bun_pid" 2>/dev/null || true
|
||||
wait "$caddy_pid" 2>/dev/null || true
|
||||
|
||||
exit "$exit_code"
|
||||
+3
-10
@@ -1,15 +1,9 @@
|
||||
{
|
||||
"name": "excali",
|
||||
"name": "excali-repro",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/dev-server.tsx",
|
||||
"build": "rm -rf dist && bun run build:client && bun run build:server",
|
||||
"build:client": "vite build",
|
||||
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server.tsx",
|
||||
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "rm -rf dist && bun build --target=browser --splitting --outdir ./dist/public ./src/index.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.1",
|
||||
@@ -20,7 +14,6 @@
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.0"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-505
@@ -1,512 +1,9 @@
|
||||
import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared";
|
||||
|
||||
type DrawingResponse = DrawingMeta & ScenePayload;
|
||||
|
||||
const EXCALIDRAW_THEME_TOKEN_MAP = [
|
||||
["--island-bg-color", "--app-island-bg"],
|
||||
["--sidebar-bg-color", "--app-sidebar-bg"],
|
||||
["--sidebar-border-color", "--app-sidebar-border"],
|
||||
["--sidebar-shadow", "--app-sidebar-shadow"],
|
||||
["--color-surface-lowest", "--app-surface-lowest"],
|
||||
["--color-surface-low", "--app-surface-low"],
|
||||
["--color-surface-primary-container", "--app-selected-bg"],
|
||||
["--color-on-surface", "--app-text-color"],
|
||||
["--color-on-primary-container", "--app-selected-text-color"],
|
||||
["--color-disabled", "--app-disabled-color"],
|
||||
["--input-bg-color", "--app-input-bg"],
|
||||
["--input-border-color", "--app-input-border"],
|
||||
["--input-label-color", "--app-input-color"],
|
||||
["--default-border-color", "--app-button-border"],
|
||||
["--button-hover-bg", "--app-button-hover-bg"],
|
||||
["--button-active-bg", "--app-button-active-bg"],
|
||||
["--button-active-border", "--app-button-active-border"],
|
||||
["--overlay-bg-color", "--app-overlay-bg"],
|
||||
] as const;
|
||||
|
||||
function pathToId(pathname = window.location.pathname): string | null {
|
||||
const match = pathname.match(/^\/d\/([^/]+)$/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
|
||||
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
|
||||
const filtered = drawings.filter((item) => item.id !== drawing.id);
|
||||
return sortDrawings([drawing, ...filtered]);
|
||||
}
|
||||
|
||||
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
|
||||
return scene as ExcalidrawInitialDataState;
|
||||
}
|
||||
|
||||
function sceneFromEditor(
|
||||
elements: readonly unknown[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
): ScenePayload {
|
||||
return {
|
||||
elements: [...elements],
|
||||
appState: appState as unknown as Record<string, unknown>,
|
||||
files: files as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { "content-type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const body = (await response.json()) as T & { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error ?? `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function DrawerIcon() {
|
||||
return (
|
||||
<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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(pathToId());
|
||||
const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE);
|
||||
const [scene, setScene] = useState<ScenePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
const currentIdRef = useRef<string | null>(activeId);
|
||||
const currentTitleRef = useRef(activeTitle);
|
||||
const latestSceneRef = useRef<ScenePayload | null>(null);
|
||||
const ignoreChangeRef = useRef(false);
|
||||
const appShellRef = useRef<HTMLDivElement | null>(null);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const inFlightSaveRef = useRef<Promise<void> | null>(null);
|
||||
const loadVersionRef = useRef(0);
|
||||
const themeSyncFrameRef = useRef<number | null>(null);
|
||||
|
||||
const activeDrawing = useMemo(
|
||||
() => drawings.find((drawing) => drawing.id === activeId) ?? null,
|
||||
[activeId, drawings],
|
||||
);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
const list = await requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
|
||||
setDrawings(sortDrawings(list));
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const saveScene = useCallback(async () => {
|
||||
const drawingId = currentIdRef.current;
|
||||
const nextScene = latestSceneRef.current;
|
||||
if (!drawingId || !nextScene) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(nextScene),
|
||||
})
|
||||
.then((body) => {
|
||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||
})
|
||||
.catch((saveError: unknown) => {
|
||||
setError(saveError instanceof Error ? saveError.message : "Save failed");
|
||||
throw saveError;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightSaveRef.current = null;
|
||||
});
|
||||
|
||||
inFlightSaveRef.current = promise;
|
||||
await promise;
|
||||
}, []);
|
||||
|
||||
const flushPendingSave = useCallback(async () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
await saveScene();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inFlightSaveRef.current) {
|
||||
await inFlightSaveRef.current;
|
||||
}
|
||||
}, [saveScene]);
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
void saveScene();
|
||||
}, 1500);
|
||||
}, [saveScene]);
|
||||
|
||||
const loadDrawing = useCallback(
|
||||
async (drawingId: string) => {
|
||||
const version = ++loadVersionRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [list, drawing] = await Promise.all([
|
||||
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
||||
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
|
||||
]);
|
||||
|
||||
if (version !== loadVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScene = {
|
||||
elements: drawing.elements,
|
||||
appState: drawing.appState,
|
||||
files: drawing.files,
|
||||
};
|
||||
|
||||
ignoreChangeRef.current = true;
|
||||
currentIdRef.current = drawing.id;
|
||||
currentTitleRef.current = drawing.title;
|
||||
latestSceneRef.current = nextScene;
|
||||
|
||||
setDrawings(sortDrawings(list));
|
||||
setActiveId(drawing.id);
|
||||
setActiveTitle(drawing.title);
|
||||
setScene(nextScene);
|
||||
} catch (loadError) {
|
||||
const list = await refreshList();
|
||||
if (list.length === 0) {
|
||||
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
||||
window.history.replaceState(null, "", `/d/${created.id}`);
|
||||
await loadDrawing(created.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = list[0];
|
||||
if (fallback && fallback.id !== drawingId) {
|
||||
window.history.replaceState(null, "", `/d/${fallback.id}`);
|
||||
await loadDrawing(fallback.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
|
||||
} finally {
|
||||
if (version === loadVersionRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[refreshList],
|
||||
);
|
||||
|
||||
const navigateToDrawing = useCallback(
|
||||
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
|
||||
const replace = options?.replace ?? false;
|
||||
const flush = options?.flush ?? true;
|
||||
|
||||
if (drawingId === currentIdRef.current && latestSceneRef.current) {
|
||||
if (replace) {
|
||||
window.history.replaceState(null, "", `/d/${drawingId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (flush) {
|
||||
await flushPendingSave();
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(null, "", `/d/${drawingId}`);
|
||||
} else {
|
||||
window.history.pushState(null, "", `/d/${drawingId}`);
|
||||
}
|
||||
|
||||
await loadDrawing(drawingId);
|
||||
},
|
||||
[flushPendingSave, loadDrawing],
|
||||
);
|
||||
|
||||
const createDrawing = useCallback(async () => {
|
||||
await flushPendingSave();
|
||||
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
||||
await navigateToDrawing(created.id, { flush: false });
|
||||
}, [flushPendingSave, navigateToDrawing]);
|
||||
|
||||
const deleteDrawing = useCallback(
|
||||
async (drawingId: string) => {
|
||||
if (!window.confirm("Delete this drawing?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (drawingId === currentIdRef.current) {
|
||||
await flushPendingSave();
|
||||
}
|
||||
|
||||
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (drawingId === currentIdRef.current && response.nextId) {
|
||||
await navigateToDrawing(response.nextId, { replace: true, flush: false });
|
||||
} else {
|
||||
await refreshList();
|
||||
}
|
||||
},
|
||||
[flushPendingSave, navigateToDrawing, refreshList],
|
||||
);
|
||||
|
||||
const handleTitleSubmit = useCallback(async () => {
|
||||
const drawingId = currentIdRef.current;
|
||||
if (!drawingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ title: currentTitleRef.current }),
|
||||
});
|
||||
|
||||
currentTitleRef.current = body.drawing.title;
|
||||
setActiveTitle(body.drawing.title);
|
||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||
} catch (renameError) {
|
||||
setError(renameError instanceof Error ? renameError.message : "Rename failed");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncThemeTokens = useCallback(() => {
|
||||
const appShell = appShellRef.current;
|
||||
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".excalidraw");
|
||||
if (!appShell || !excalidrawRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedStyles = window.getComputedStyle(excalidrawRoot);
|
||||
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
|
||||
const value = computedStyles.getPropertyValue(sourceToken).trim();
|
||||
if (value) {
|
||||
appShell.style.setProperty(targetToken, value);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleThemeTokenSync = useCallback(() => {
|
||||
if (themeSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
||||
}
|
||||
|
||||
themeSyncFrameRef.current = window.requestAnimationFrame(() => {
|
||||
themeSyncFrameRef.current = null;
|
||||
syncThemeTokens();
|
||||
});
|
||||
}, [syncThemeTokens]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPathId = pathToId();
|
||||
if (currentPathId) {
|
||||
void loadDrawing(currentPathId);
|
||||
} else {
|
||||
window.location.replace("/");
|
||||
}
|
||||
|
||||
const onPopState = () => {
|
||||
const nextId = pathToId();
|
||||
if (nextId) {
|
||||
void navigateToDrawing(nextId, { replace: true, flush: true });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
};
|
||||
}, [loadDrawing, navigateToDrawing]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
if (themeSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scene || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleThemeTokenSync();
|
||||
}, [activeId, loading, scene, scheduleThemeTokenSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSidebarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
const openSidebar = useCallback(() => {
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeSidebar = useCallback(() => {
|
||||
setIsSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectDrawing = useCallback(
|
||||
async (drawingId: string) => {
|
||||
setIsSidebarOpen(false);
|
||||
await navigateToDrawing(drawingId);
|
||||
},
|
||||
[navigateToDrawing],
|
||||
);
|
||||
|
||||
const handleCreateDrawing = useCallback(async () => {
|
||||
setIsSidebarOpen(false);
|
||||
await createDrawing();
|
||||
}, [createDrawing]);
|
||||
|
||||
return (
|
||||
<div className="app-shell" ref={appShellRef}>
|
||||
<main className="editor-shell">
|
||||
<div className="app-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button app-actions-toggle"
|
||||
onClick={openSidebar}
|
||||
aria-label="Open drawings"
|
||||
>
|
||||
<DrawerIcon />
|
||||
<span className="sr-only">Open drawings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading || !scene ? (
|
||||
<div className="editor-loading">{error ?? "Loading..."}</div>
|
||||
) : (
|
||||
<div className="editor-frame">
|
||||
<Excalidraw
|
||||
key={activeDrawing?.id ?? activeId}
|
||||
initialData={sceneToInitialData(scene)}
|
||||
onChange={(elements, appState, files) => {
|
||||
const nextScene = sceneFromEditor(elements, appState, files);
|
||||
latestSceneRef.current = nextScene;
|
||||
scheduleThemeTokenSync();
|
||||
|
||||
if (ignoreChangeRef.current) {
|
||||
ignoreChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleSave();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<div
|
||||
className={isSidebarOpen ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
|
||||
onClick={closeSidebar}
|
||||
aria-hidden={!isSidebarOpen}
|
||||
/>
|
||||
<aside className={isSidebarOpen ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!isSidebarOpen}>
|
||||
<div className="sidebar-header">
|
||||
<h1>excali-box</h1>
|
||||
<div className="sidebar-actions">
|
||||
<button type="button" className="primary-button" onClick={() => void handleCreateDrawing()}>
|
||||
New
|
||||
</button>
|
||||
<button type="button" className="icon-button" onClick={closeSidebar} aria-label="Close drawings">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="drawing-list">
|
||||
{drawings.map((drawing) => (
|
||||
<div
|
||||
key={drawing.id}
|
||||
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
|
||||
>
|
||||
{drawing.id === activeId ? (
|
||||
<div className="drawing-link">
|
||||
<input
|
||||
className="title-input"
|
||||
value={activeTitle}
|
||||
onChange={(event) => {
|
||||
currentTitleRef.current = event.target.value;
|
||||
setActiveTitle(event.target.value);
|
||||
}}
|
||||
onBlur={() => void handleTitleSubmit()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="drawing-link"
|
||||
onClick={() => void handleSelectDrawing(drawing.id)}
|
||||
>
|
||||
<span className="drawing-title">{drawing.title}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => void deleteDrawing(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createApi } from "./api";
|
||||
import { createDrawingStore } from "./db";
|
||||
|
||||
function withApi() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
|
||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||
const api = createApi(store);
|
||||
|
||||
return {
|
||||
api,
|
||||
cleanup() {
|
||||
store.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("api", () => {
|
||||
test("creates a drawing with dark theme by default", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
expect(created.appState.theme).toBe("dark");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns 400 for invalid JSON", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const drawing = await api.createDrawing().json();
|
||||
|
||||
const response = await api.updateDrawing(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${drawing.id}`, {
|
||||
method: "PUT",
|
||||
body: "{",
|
||||
}),
|
||||
{ params: { id: drawing.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns 404 for missing drawing", () => {
|
||||
const { api, cleanup } = withApi();
|
||||
|
||||
const response = api.getDrawing(
|
||||
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("updates a drawing scene", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
const response = await api.updateDrawing(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
elements: [{ id: "shape" }],
|
||||
appState: {},
|
||||
files: {},
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import { type DrawingStore } from "./db";
|
||||
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
|
||||
|
||||
type RouteRequest = Request & {
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
function json(data: unknown, init?: ResponseInit): Response {
|
||||
return Response.json(data, init);
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown): Response {
|
||||
if (error instanceof HttpError) {
|
||||
return json({ ok: false, error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
return json({ ok: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
|
||||
export function createApi(store: DrawingStore) {
|
||||
return {
|
||||
health() {
|
||||
return json({ ok: true });
|
||||
},
|
||||
|
||||
listDrawings() {
|
||||
return json(store.listDrawings());
|
||||
},
|
||||
|
||||
createDrawing() {
|
||||
const drawing = store.createDrawing();
|
||||
return json(
|
||||
{
|
||||
id: drawing.id,
|
||||
title: drawing.title,
|
||||
createdAt: drawing.createdAt,
|
||||
updatedAt: drawing.updatedAt,
|
||||
...drawing.scene,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
},
|
||||
|
||||
getDrawing(request: RouteRequest) {
|
||||
const id = request.params?.id;
|
||||
const drawing = id ? store.getDrawing(id) : null;
|
||||
|
||||
if (!drawing) {
|
||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
id: drawing.id,
|
||||
title: drawing.title,
|
||||
createdAt: drawing.createdAt,
|
||||
updatedAt: drawing.updatedAt,
|
||||
...drawing.scene,
|
||||
});
|
||||
},
|
||||
|
||||
async updateDrawing(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
throw new HttpError(400, "Missing drawing id");
|
||||
}
|
||||
|
||||
const text = await request.text();
|
||||
const raw = parseJsonBody<Record<string, unknown>>(text);
|
||||
const hasTitle = Object.hasOwn(raw, "title");
|
||||
const hasScene =
|
||||
Object.hasOwn(raw, "elements") ||
|
||||
Object.hasOwn(raw, "appState") ||
|
||||
Object.hasOwn(raw, "files");
|
||||
|
||||
if (!hasTitle && !hasScene) {
|
||||
throw new HttpError(400, "Nothing to update");
|
||||
}
|
||||
|
||||
const scene = hasScene ? parseSceneText(text) : undefined;
|
||||
const updated = store.updateDrawing(id, {
|
||||
scene,
|
||||
title: hasTitle ? normalizeTitle(raw.title) : undefined,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
drawing: {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
|
||||
deleteDrawing(request: RouteRequest) {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { deleted, next } = store.deleteDrawing(id);
|
||||
if (!deleted) {
|
||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ ok: true, nextId: next?.id ?? null });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDrawingStore } from "./db";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanup.length > 0) {
|
||||
const dir = cleanup.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function createTempStore() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-"));
|
||||
cleanup.push(dir);
|
||||
return createDrawingStore(join(dir, "test.sqlite"));
|
||||
}
|
||||
|
||||
describe("drawing store", () => {
|
||||
test("creates, updates, lists, and deletes drawings", () => {
|
||||
const store = createTempStore();
|
||||
const created = store.createDrawing();
|
||||
|
||||
expect(created.title).toBe("Untitled");
|
||||
expect(created.scene.appState.theme).toBe("dark");
|
||||
expect(store.listDrawings()).toHaveLength(1);
|
||||
|
||||
const updated = store.updateDrawing(created.id, {
|
||||
title: "Flow",
|
||||
scene: {
|
||||
elements: [{ id: "one" }],
|
||||
appState: { gridSize: 20 },
|
||||
files: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updated?.title).toBe("Flow");
|
||||
expect(updated?.scene.elements).toHaveLength(1);
|
||||
|
||||
const removed = store.deleteDrawing(created.id);
|
||||
expect(removed.deleted).toBe(true);
|
||||
expect(store.listDrawings()).toHaveLength(1);
|
||||
expect(removed.next?.id).not.toBe(created.id);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
|
||||
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
|
||||
|
||||
type DrawingRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
data: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||
|
||||
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||
|
||||
function createId(): string {
|
||||
return randomUUID().replaceAll("-", "").slice(0, 12);
|
||||
}
|
||||
|
||||
function resolveDatabasePath(): string {
|
||||
if (process.env.DATABASE_PATH) {
|
||||
return process.env.DATABASE_PATH;
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync("/data", { recursive: true });
|
||||
return "/data/excalidraw.sqlite";
|
||||
} catch {
|
||||
return `${process.cwd()}/data/excalidraw.sqlite`;
|
||||
}
|
||||
}
|
||||
|
||||
function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...readMeta(row)!,
|
||||
scene: coerceStoredScene(JSON.parse(row.data)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
mkdirSync(dirname(databasePath), { recursive: true });
|
||||
|
||||
const db = new Database(databasePath, { create: true, strict: true });
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drawings (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||
ON drawings(updated_at DESC, created_at DESC);
|
||||
`);
|
||||
|
||||
const listQuery = db.query(`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
created_at AS createdAt,
|
||||
updated_at AS updatedAt
|
||||
FROM drawings
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
`);
|
||||
|
||||
const getQuery = db.query(`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
data,
|
||||
created_at AS createdAt,
|
||||
updated_at AS updatedAt
|
||||
FROM drawings
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const createQuery = db.query(`
|
||||
INSERT INTO drawings (id, title, data)
|
||||
VALUES (?1, ?2, ?3)
|
||||
`);
|
||||
|
||||
const updateSceneQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET data = ?2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const updateTitleQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET title = ?2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const updateBothQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const deleteQuery = db.query(`
|
||||
DELETE FROM drawings
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
function listDrawings(): DrawingMeta[] {
|
||||
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
|
||||
}
|
||||
|
||||
function getDrawing(id: string): DrawingRecord | null {
|
||||
return readRow(getQuery.get(id) as DrawingRow | null | undefined);
|
||||
}
|
||||
|
||||
function createDrawing(title = DEFAULT_TITLE): DrawingRecord {
|
||||
const id = createId();
|
||||
createQuery.run(id, normalizeTitle(title), JSON.stringify(emptyScene()));
|
||||
return getDrawing(id)!;
|
||||
}
|
||||
|
||||
function getLatestDrawing(): DrawingMeta | null {
|
||||
return listDrawings()[0] ?? null;
|
||||
}
|
||||
|
||||
function ensureInitialDrawing(): DrawingMeta {
|
||||
return getLatestDrawing() ?? createDrawing();
|
||||
}
|
||||
|
||||
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
|
||||
const existing = getDrawing(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasScene = update.scene !== undefined;
|
||||
const hasTitle = update.title !== undefined;
|
||||
|
||||
if (!hasScene && !hasTitle) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (hasScene && hasTitle) {
|
||||
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
|
||||
} else if (hasScene) {
|
||||
updateSceneQuery.run(id, JSON.stringify(update.scene));
|
||||
} else {
|
||||
updateTitleQuery.run(id, normalizeTitle(update.title));
|
||||
}
|
||||
|
||||
return getDrawing(id);
|
||||
}
|
||||
|
||||
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
||||
const changes = Number(deleteQuery.run(id).changes);
|
||||
if (changes === 0) {
|
||||
return { deleted: false, next: null };
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
next: ensureInitialDrawing(),
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
db.close(false);
|
||||
}
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
close,
|
||||
listDrawings,
|
||||
getDrawing,
|
||||
createDrawing,
|
||||
getLatestDrawing,
|
||||
ensureInitialDrawing,
|
||||
updateDrawing,
|
||||
deleteDrawing,
|
||||
};
|
||||
}
|
||||
|
||||
let defaultStore: DrawingStore | null = null;
|
||||
|
||||
export function getDefaultDrawingStore(): DrawingStore {
|
||||
defaultStore ??= createDrawingStore();
|
||||
return defaultStore;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import index from "./index.html";
|
||||
import { createServer } from "./server";
|
||||
|
||||
export function createDevServer() {
|
||||
const server = createServer();
|
||||
|
||||
return {
|
||||
...server,
|
||||
routes: {
|
||||
...server.routes,
|
||||
"/d/:id": index,
|
||||
},
|
||||
} satisfies Parameters<typeof Bun.serve>[0];
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const server = Bun.serve(createDevServer());
|
||||
console.log(`Listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- to resolve the hs and css from root -->
|
||||
<base href="/" />
|
||||
<title>excali-box</title>
|
||||
<title>Repro</title>
|
||||
<script type="module" src="./client.tsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
|
||||
|
||||
describe("scene helpers", () => {
|
||||
test("creates empty scenes with dark theme by default", () => {
|
||||
const scene = emptyScene();
|
||||
|
||||
expect(scene.appState.theme).toBe("dark");
|
||||
});
|
||||
|
||||
test("normalizes volatile app state fields", () => {
|
||||
const scene = normalizeScene({
|
||||
elements: [],
|
||||
appState: {
|
||||
selectedElementIds: { one: true },
|
||||
viewModeEnabled: false,
|
||||
},
|
||||
files: {},
|
||||
});
|
||||
|
||||
expect(scene.appState.selectedElementIds).toBeUndefined();
|
||||
expect(scene.appState.viewModeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects oversized payloads", () => {
|
||||
const text = "x".repeat(MAX_SCENE_BYTES + 1);
|
||||
|
||||
expect(() => parseJsonBody(text)).toThrow(HttpError);
|
||||
expect(() => parseJsonBody(text)).toThrow("Payload too large");
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { DEFAULT_TITLE, type ScenePayload } from "./shared";
|
||||
|
||||
export const MAX_SCENE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const VOLATILE_APP_STATE_KEYS = new Set([
|
||||
"collaborators",
|
||||
"selectedElementIds",
|
||||
"selectedGroupIds",
|
||||
"editingElement",
|
||||
"editingLinearElement",
|
||||
"openMenu",
|
||||
"openSidebar",
|
||||
"contextMenu",
|
||||
"toast",
|
||||
]);
|
||||
|
||||
export function emptyScene(): ScenePayload {
|
||||
return {
|
||||
elements: [],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeTitle(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
return DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length > 0 ? trimmed : DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
export function normalizeScene(input: unknown): ScenePayload {
|
||||
if (!isRecord(input)) {
|
||||
throw new HttpError(400, "Body must be an object");
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.elements)) {
|
||||
throw new HttpError(400, "elements must be an array");
|
||||
}
|
||||
|
||||
if (!isRecord(input.appState)) {
|
||||
throw new HttpError(400, "appState must be an object");
|
||||
}
|
||||
|
||||
if (!isRecord(input.files)) {
|
||||
throw new HttpError(400, "files must be an object");
|
||||
}
|
||||
|
||||
const appState = Object.fromEntries(
|
||||
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: input.elements,
|
||||
appState,
|
||||
files: input.files,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJsonBody<T = unknown>(text: string): T {
|
||||
if (new TextEncoder().encode(text).byteLength > MAX_SCENE_BYTES) {
|
||||
throw new HttpError(413, "Payload too large");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new HttpError(400, "Invalid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSceneText(text: string): ScenePayload {
|
||||
return normalizeScene(parseJsonBody(text));
|
||||
}
|
||||
|
||||
export function coerceStoredScene(input: unknown): ScenePayload {
|
||||
try {
|
||||
return normalizeScene(input);
|
||||
} catch {
|
||||
return emptyScene();
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDrawingStore, type DrawingStore } from "./db";
|
||||
import { createServer } from "./server";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
const stores: DrawingStore[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (stores.length > 0) {
|
||||
stores.pop()?.close();
|
||||
}
|
||||
|
||||
while (cleanup.length > 0) {
|
||||
const dir = cleanup.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function createServerFixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-server-"));
|
||||
cleanup.push(dir);
|
||||
|
||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||
stores.push(store);
|
||||
|
||||
return {
|
||||
store,
|
||||
server: createServer({ drawingStore: store }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createServer", () => {
|
||||
test("redirects / to the latest drawing", () => {
|
||||
const { server, store } = createServerFixture();
|
||||
|
||||
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
|
||||
const created = store.listDrawings();
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
|
||||
});
|
||||
|
||||
test("keeps Bun responsible for API routes", async () => {
|
||||
const { server } = createServerFixture();
|
||||
const response = await server.routes["/api/health"]?.GET?.();
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("does not expose a Bun static route for drawings in production", () => {
|
||||
const { server } = createServerFixture();
|
||||
|
||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns JSON 404 for unmatched requests", async () => {
|
||||
const { server } = createServerFixture();
|
||||
const response = await server.fetch(new Request("http://local/index-abc123.js"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createApi } from "./api";
|
||||
import { type DrawingStore, getDefaultDrawingStore } from "./db";
|
||||
|
||||
type CreateServerOptions = {
|
||||
drawingStore?: DrawingStore;
|
||||
};
|
||||
|
||||
export function createServer(options: CreateServerOptions = {}) {
|
||||
const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
|
||||
const api = createApi(drawingStore);
|
||||
|
||||
return {
|
||||
port: Number(process.env.PORT ?? "3000"),
|
||||
hostname: process.env.HOST ?? "localhost",
|
||||
routes: {
|
||||
"/": (request: Request) => {
|
||||
const drawing = drawingStore.ensureInitialDrawing();
|
||||
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
|
||||
},
|
||||
"/api/health": {
|
||||
GET: () => api.health(),
|
||||
},
|
||||
"/api/drawings": {
|
||||
GET: () => api.listDrawings(),
|
||||
POST: () => api.createDrawing(),
|
||||
},
|
||||
"/api/drawings/:id": {
|
||||
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
|
||||
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
|
||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
||||
},
|
||||
},
|
||||
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
||||
} satisfies Parameters<typeof Bun.serve>[0];
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const server = Bun.serve(createServer());
|
||||
console.log(`Listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export type ScenePayload = {
|
||||
elements: unknown[];
|
||||
appState: Record<string, unknown>;
|
||||
files: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DrawingMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DrawingRecord = DrawingMeta & {
|
||||
scene: ScenePayload;
|
||||
};
|
||||
|
||||
export const DEFAULT_TITLE = "Untitled";
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f5f5f4;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100%;
|
||||
color: var(--app-text-color, #18181b);
|
||||
--app-sidebar-font-size: 0.875rem;
|
||||
--app-island-bg: #ffffff;
|
||||
--app-sidebar-bg: #ffffff;
|
||||
--app-sidebar-border: #e4e4e7;
|
||||
--app-sidebar-shadow: 0 24px 80px rgba(24, 24, 27, 0.22);
|
||||
--app-surface-lowest: #ffffff;
|
||||
--app-surface-low: #f4f4f5;
|
||||
--app-selected-bg: #f4f4f5;
|
||||
--app-text-color: #18181b;
|
||||
--app-selected-text-color: #18181b;
|
||||
--app-disabled-color: #a1a1aa;
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-border: #d4d4d8;
|
||||
--app-input-color: #18181b;
|
||||
--app-button-border: #d4d4d8;
|
||||
--app-button-hover-bg: #f4f4f5;
|
||||
--app-button-active-bg: #e4e4e7;
|
||||
--app-button-active-border: #5b8def;
|
||||
--app-overlay-bg: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: min(340px, calc(100vw - 32px));
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--app-sidebar-border);
|
||||
background: var(--app-sidebar-bg);
|
||||
box-shadow: var(--app-sidebar-shadow);
|
||||
transform: translateX(calc(-100% - 24px));
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--app-sidebar-border);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--app-sidebar-font-size);
|
||||
font-weight: 600;
|
||||
color: var(--app-text-color);
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-actions .primary-button,
|
||||
.sidebar-actions .icon-button {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.drawing-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.drawing-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.drawing-item-active {
|
||||
background: var(--app-selected-bg);
|
||||
color: var(--app-selected-text-color);
|
||||
}
|
||||
|
||||
.drawing-link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 0;
|
||||
text-align: left;
|
||||
font-size: var(--app-sidebar-font-size);
|
||||
}
|
||||
|
||||
.drawing-title,
|
||||
.title-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
border: 1px solid var(--app-input-border);
|
||||
border-radius: 6px;
|
||||
background: var(--app-input-bg);
|
||||
color: var(--app-input-color);
|
||||
font-size: var(--app-sidebar-font-size);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
position: fixed;
|
||||
right: max(16px, calc(env(safe-area-inset-right) + 16px));
|
||||
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-actions-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--lg-button-size, 2.25rem);
|
||||
height: var(--lg-button-size, 2.25rem);
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg, 12px);
|
||||
box-shadow: 0 0 0 1px var(--app-surface-lowest);
|
||||
background: var(--app-surface-low);
|
||||
color: var(--app-text-color);
|
||||
}
|
||||
|
||||
.app-actions-toggle svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
overflow: visible;
|
||||
transform: translateY(0.5px) scale(1.08);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.icon-button {
|
||||
border: 1px solid var(--app-button-border);
|
||||
border-radius: 8px;
|
||||
background: var(--app-island-bg);
|
||||
color: var(--app-text-color);
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
cursor: pointer;
|
||||
font-size: var(--app-sidebar-font-size);
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.secondary-button:hover,
|
||||
.icon-button:hover {
|
||||
background: var(--app-button-hover-bg);
|
||||
}
|
||||
|
||||
.primary-button:active,
|
||||
.secondary-button:active,
|
||||
.icon-button:active {
|
||||
background: var(--app-button-active-bg);
|
||||
border-color: var(--app-button-active-border);
|
||||
}
|
||||
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
color: var(--app-disabled-color);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: color-mix(in srgb, var(--app-overlay-bg) 72%, #000000);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.sidebar-backdrop-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editor-frame,
|
||||
.editor-loading {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--app-text-color);
|
||||
}
|
||||
|
||||
.app-actions-toggle:hover {
|
||||
background: var(--app-button-hover-bg);
|
||||
}
|
||||
|
||||
.app-actions-toggle:active {
|
||||
box-shadow: 0 0 0 1px var(--app-button-active-border);
|
||||
background: var(--app-button-active-bg);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
width: min(300px, calc(100vw - 20px));
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
right: max(16px, calc(env(safe-area-inset-right) + 16px));
|
||||
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
root: 'src',
|
||||
build: {
|
||||
outDir: '../dist/public',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: 'src/index.html',
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
|
||||
return 'react';
|
||||
}
|
||||
if (id.includes('node_modules/@excalidraw/excalidraw')) {
|
||||
return 'excalidraw';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user