Initial rspace-online: FolkJS collaborative canvas with subdomain routing

- Pure FolkJS implementation with folk-shape, folk-markdown components
- Bun server with WebSocket sync and Host header subdomain detection
- Community creation API at /api/communities
- Docker setup with Traefik labels for wildcard *.rspace.online routing
- Landing page with community creation form
- Canvas page with basic markdown note creation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-01 16:27:07 +01:00
commit 1ec463f193
24 changed files with 2766 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build output
dist/
# Data storage
data/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Environment
.env
.env.local
.env.*.local
# Bun
bun.lockb

40
Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Build stage
FROM oven/bun:1 AS build
WORKDIR /app
# Copy package files
COPY package.json bun.lockb* ./
RUN bun install --frozen-lockfile
# Copy source
COPY . .
# Build frontend
RUN bun run build
# Production stage
FROM oven/bun:1-slim AS production
WORKDIR /app
# Copy built assets and server
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
COPY --from=build /app/package.json .
# Install production dependencies only
RUN bun install --production --frozen-lockfile
# Create data directory
RUN mkdir -p /data/communities
# Set environment
ENV NODE_ENV=production
ENV STORAGE_DIR=/data/communities
ENV PORT=3000
# Data volume for persistence
VOLUME /data/communities
EXPOSE 3000
CMD ["bun", "run", "server/index.ts"]

162
bun.lock Normal file
View File

@ -0,0 +1,162 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "rspace-online",
"dependencies": {
"@automerge/automerge": "^2.2.8",
"@lit/reactive-element": "^2.0.4",
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2",
},
"devDependencies": {
"@types/node": "^22.10.1",
"bun-types": "^1.1.38",
"typescript": "^5.7.2",
"vite": "^6.0.3",
},
},
},
"packages": {
"@automerge/automerge": ["@automerge/automerge@2.2.9", "", { "dependencies": { "uuid": "^9.0.0" } }, "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.0", "", {}, "sha512-HLomZXMmrCFHSRKESF5vklAKsDY7/fsT/ZhqCu3V0UoW/Qbv8wxmO4W9bx4KnCCF2Zak4yuk+AGraK/bPmI4kA=="],
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"perfect-arrows": ["perfect-arrows@0.3.7", "", {}, "sha512-wEN2gerTPVWl3yqoFEF8OeGbg3aRe2sxNUi9rnyYrCsL4JcI6K2tBDezRtqVrYG0BNtsWLdYiiTrYm+X//8yLQ=="],
"perfect-freehand": ["perfect-freehand@1.2.2", "", {}, "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
}
}

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
version: "3.8"
services:
rspace:
build: .
container_name: rspace-online
restart: unless-stopped
volumes:
- rspace-data:/data/communities
environment:
- NODE_ENV=production
- STORAGE_DIR=/data/communities
- PORT=3000
labels:
- "traefik.enable=true"
# Main domain
- "traefik.http.routers.rspace.rule=Host(`rspace.online`) || Host(`www.rspace.online`)"
- "traefik.http.routers.rspace.entrypoints=websecure"
- "traefik.http.routers.rspace.tls=true"
# Wildcard subdomain routing
- "traefik.http.routers.rspace-wildcard.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`)"
- "traefik.http.routers.rspace-wildcard.entrypoints=websecure"
- "traefik.http.routers.rspace-wildcard.tls=true"
# Service configuration
- "traefik.http.services.rspace.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public
volumes:
rspace-data:
networks:
traefik-public:
external: true

256
lib/DOMRectTransform.ts Normal file
View File

@ -0,0 +1,256 @@
import { Matrix } from "./Matrix";
import type { Point } from "./types";
interface DOMRectTransformInit {
height?: number;
width?: number;
x?: number;
y?: number;
rotation?: number;
transformOrigin?: Point;
rotateOrigin?: Point;
}
/**
* Represents a rectangle with position, size, and rotation,
* capable of transforming points between local and parent coordinate spaces.
*/
export class DOMRectTransform implements DOMRect {
#x: number;
#y: number;
#width: number;
#height: number;
#rotation: number;
#transformOrigin: Point;
#rotateOrigin: Point;
#transformMatrix: Matrix;
#inverseMatrix: Matrix;
constructor(init: DOMRectTransformInit = {}) {
this.#x = init.x ?? 0;
this.#y = init.y ?? 0;
this.#width = init.width ?? 0;
this.#height = init.height ?? 0;
this.#rotation = init.rotation ?? 0;
this.#transformOrigin = init.transformOrigin ?? { x: 0.5, y: 0.5 };
this.#rotateOrigin = init.rotateOrigin ?? { x: 0.5, y: 0.5 };
this.#transformMatrix = Matrix.Identity();
this.#inverseMatrix = Matrix.Identity();
this.#updateMatrices();
}
get x(): number {
return this.#x;
}
set x(value: number) {
this.#x = value;
this.#updateMatrices();
}
get y(): number {
return this.#y;
}
set y(value: number) {
this.#y = value;
this.#updateMatrices();
}
get width(): number {
return this.#width;
}
set width(value: number) {
this.#width = value;
this.#updateMatrices();
}
get height(): number {
return this.#height;
}
set height(value: number) {
this.#height = value;
this.#updateMatrices();
}
get rotation(): number {
return this.#rotation;
}
set rotation(value: number) {
this.#rotation = value;
this.#updateMatrices();
}
get transformOrigin(): Point {
return this.#transformOrigin;
}
set transformOrigin(value: Point) {
this.#transformOrigin = value;
this.#updateMatrices();
}
get rotateOrigin(): Point {
return this.#rotateOrigin;
}
set rotateOrigin(value: Point) {
this.#rotateOrigin = value;
this.#updateMatrices();
}
get left(): number {
return this.x;
}
get top(): number {
return this.y;
}
get right(): number {
return this.x + this.width;
}
get bottom(): number {
return this.y + this.height;
}
#updateMatrices() {
this.#transformMatrix.identity();
const transformOrigin = this.#getAbsoluteTransformOrigin();
const rotateOrigin = this.#getAbsoluteRotateOrigin();
this.#transformMatrix
.translate(this.#x, this.#y)
.translate(transformOrigin.x, transformOrigin.y)
.translate(rotateOrigin.x - transformOrigin.x, rotateOrigin.y - transformOrigin.y)
.rotate(this.#rotation)
.translate(-(rotateOrigin.x - transformOrigin.x), -(rotateOrigin.y - transformOrigin.y))
.translate(-transformOrigin.x, -transformOrigin.y);
this.#inverseMatrix = this.#transformMatrix.clone().invert();
}
#getAbsoluteTransformOrigin(): Point {
return {
x: this.#width * this.#transformOrigin.x,
y: this.#height * this.#transformOrigin.y,
};
}
#getAbsoluteRotateOrigin(): Point {
return {
x: this.#width * this.#rotateOrigin.x,
y: this.#height * this.#rotateOrigin.y,
};
}
get transformMatrix(): Matrix {
return this.#transformMatrix;
}
get inverseMatrix(): Matrix {
return this.#inverseMatrix;
}
toLocalSpace(point: Point): Point {
return this.#inverseMatrix.applyToPoint(point);
}
toParentSpace(point: Point): Point {
return this.#transformMatrix.applyToPoint(point);
}
get topLeft(): Point {
return { x: 0, y: 0 };
}
get topRight(): Point {
return { x: this.width, y: 0 };
}
get bottomRight(): Point {
return { x: this.width, y: this.height };
}
get bottomLeft(): Point {
return { x: 0, y: this.height };
}
get center(): Point {
return { x: this.x + this.width / 2, y: this.y + this.height / 2 };
}
set topLeft(point: Point) {
const bottomRightBefore = this.toParentSpace(this.bottomRight);
const deltaWidth = this.#width - point.x;
const deltaHeight = this.#height - point.y;
this.#x += point.x;
this.#y += point.y;
this.#width = deltaWidth;
this.#height = deltaHeight;
this.#updateMatrices();
const bottomRightAfter = this.toParentSpace(this.bottomRight);
this.#x -= bottomRightAfter.x - bottomRightBefore.x;
this.#y -= bottomRightAfter.y - bottomRightBefore.y;
this.#updateMatrices();
}
set topRight(point: Point) {
const bottomLeftBefore = this.toParentSpace(this.bottomLeft);
const deltaWidth = point.x;
const deltaHeight = this.#height - point.y;
this.#y += point.y;
this.#width = deltaWidth;
this.#height = deltaHeight;
this.#updateMatrices();
const bottomLeftAfter = this.toParentSpace(this.bottomLeft);
this.#x -= bottomLeftAfter.x - bottomLeftBefore.x;
this.#y -= bottomLeftAfter.y - bottomLeftBefore.y;
this.#updateMatrices();
}
set bottomRight(point: Point) {
const topLeftBefore = this.toParentSpace(this.topLeft);
this.#width = point.x;
this.#height = point.y;
this.#updateMatrices();
const topLeftAfter = this.toParentSpace(this.topLeft);
this.#x -= topLeftAfter.x - topLeftBefore.x;
this.#y -= topLeftAfter.y - topLeftBefore.y;
this.#updateMatrices();
}
set bottomLeft(point: Point) {
const topRightBefore = this.toParentSpace(this.topRight);
const deltaWidth = this.#width - point.x;
const deltaHeight = point.y;
this.#x += point.x;
this.#width = deltaWidth;
this.#height = deltaHeight;
this.#updateMatrices();
const topRightAfter = this.toParentSpace(this.topRight);
this.#x -= topRightAfter.x - topRightBefore.x;
this.#y -= topRightAfter.y - topRightBefore.y;
this.#updateMatrices();
}
vertices(): Point[] {
return [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft];
}
toCssString(): string {
return this.transformMatrix.toCssString();
}
toJSON() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
};
}
}
export class DOMRectTransformReadonly extends DOMRectTransform {
constructor(init: DOMRectTransformInit = {}) {
super(init);
}
override set x(_: number) {}
override set y(_: number) {}
override set width(_: number) {}
override set height(_: number) {}
override set rotation(_: number) {}
}

138
lib/Matrix.ts Normal file
View File

@ -0,0 +1,138 @@
import type { Point } from "./types";
export const toDOMPrecision = (value: number) => Math.round(value * 1e4) / 1e4;
const PI2 = Math.PI * 2;
const TAU = Math.PI / 2;
export interface MatrixInit {
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}
export class Matrix implements MatrixInit {
constructor(
public a: number = 1,
public b: number = 0,
public c: number = 0,
public d: number = 1,
public e: number = 0,
public f: number = 0,
) {}
equals(m: MatrixInit) {
return (
this.a === m.a &&
this.b === m.b &&
this.c === m.c &&
this.d === m.d &&
this.e === m.e &&
this.f === m.f
);
}
identity() {
this.a = 1.0;
this.b = 0.0;
this.c = 0.0;
this.d = 1.0;
this.e = 0.0;
this.f = 0.0;
return this;
}
multiply(m: MatrixInit) {
const { a, b, c, d, e, f } = this;
this.a = a * m.a + c * m.b;
this.c = a * m.c + c * m.d;
this.e = a * m.e + c * m.f + e;
this.b = b * m.a + d * m.b;
this.d = b * m.c + d * m.d;
this.f = b * m.e + d * m.f + f;
return this;
}
rotate(r: number, cx?: number, cy?: number) {
if (r === 0) return this;
if (cx === undefined) return this.multiply(Matrix.Rotate(r));
return this.translate(cx, cy!).multiply(Matrix.Rotate(r)).translate(-cx, -cy!);
}
translate(x: number, y: number): Matrix {
return this.multiply(Matrix.Translate(x, y!));
}
scale(x: number, y: number) {
return this.multiply(Matrix.Scale(x, y));
}
invert() {
const { a, b, c, d, e, f } = this;
const denominator = a * d - b * c;
this.a = d / denominator;
this.b = b / -denominator;
this.c = c / -denominator;
this.d = a / denominator;
this.e = (d * e - c * f) / -denominator;
this.f = (b * e - a * f) / denominator;
return this;
}
applyToPoint(point: Point) {
return Matrix.applyToPoint(this, point);
}
clone() {
return new Matrix(this.a, this.b, this.c, this.d, this.e, this.f);
}
toCssString() {
return Matrix.ToCssString(this);
}
static Rotate(r: number, cx?: number, cy?: number) {
if (r === 0) return Matrix.Identity();
const cosAngle = Math.cos(r);
const sinAngle = Math.sin(r);
const rotationMatrix = new Matrix(cosAngle, sinAngle, -sinAngle, cosAngle, 0.0, 0.0);
if (cx === undefined) return rotationMatrix;
return Matrix.Compose(Matrix.Translate(cx, cy!), rotationMatrix, Matrix.Translate(-cx, -cy!));
}
static Scale(x: number, y: number, cx?: number, cy?: number) {
const scaleMatrix = new Matrix(x, 0, 0, y, 0, 0);
if (cx === undefined) return scaleMatrix;
return Matrix.Compose(Matrix.Translate(cx, cy!), scaleMatrix, Matrix.Translate(-cx, -cy!));
}
static Compose(...matrices: MatrixInit[]) {
const matrix = Matrix.Identity();
for (let i = 0, n = matrices.length; i < n; i++) {
matrix.multiply(matrices[i]);
}
return matrix;
}
static Identity() {
return new Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
}
static Translate(x: number, y: number) {
return new Matrix(1.0, 0.0, 0.0, 1.0, x, y);
}
static applyToPoint(m: MatrixInit, point: Point) {
return {
x: m.a * point.x + m.c * point.y + m.e,
y: m.b * point.x + m.d * point.y + m.f,
};
}
static ToCssString(m: MatrixInit) {
return `matrix(${toDOMPrecision(m.a)}, ${toDOMPrecision(m.b)}, ${toDOMPrecision(m.c)}, ${toDOMPrecision(m.d)}, ${toDOMPrecision(m.e)}, ${toDOMPrecision(m.f)})`;
}
}

66
lib/TransformEvent.ts Normal file
View File

@ -0,0 +1,66 @@
import type { DOMRectTransformReadonly } from "./DOMRectTransform";
declare global {
interface HTMLElementEventMap {
transform: TransformEvent;
}
}
export class TransformEvent extends Event {
readonly #current: DOMRectTransformReadonly;
readonly #previous: DOMRectTransformReadonly;
constructor(current: DOMRectTransformReadonly, previous?: DOMRectTransformReadonly) {
super("transform", { cancelable: true, bubbles: true });
this.#current = current;
this.#previous = previous ?? current;
}
get current() {
return this.#current;
}
get previous() {
return this.#previous;
}
#xPrevented = false;
get xPrevented() {
return this.defaultPrevented || this.#xPrevented;
}
preventX() {
this.#xPrevented = true;
}
#yPrevented = false;
get yPrevented() {
return this.defaultPrevented || this.#yPrevented;
}
preventY() {
this.#yPrevented = true;
}
#heightPrevented = false;
get heightPrevented() {
return this.defaultPrevented || this.#heightPrevented;
}
preventHeight() {
this.#heightPrevented = true;
}
#widthPrevented = false;
get widthPrevented() {
return this.defaultPrevented || this.#widthPrevented;
}
preventWidth() {
this.#widthPrevented = true;
}
#rotatePrevented = false;
get rotatePrevented() {
return this.defaultPrevented || this.#rotatePrevented;
}
preventRotate() {
this.#rotatePrevented = true;
}
}

89
lib/Vector.ts Normal file
View File

@ -0,0 +1,89 @@
import type { Point } from "./types";
const { hypot, cos, sin, atan2 } = Math;
export class Vector {
static zero(): Point {
return { x: 0, y: 0 };
}
static sub(a: Point, b: Point): Point {
return { x: a.x - b.x, y: a.y - b.y };
}
static add(a: Point, b: Point): Point {
return { x: a.x + b.x, y: a.y + b.y };
}
static scale(v: Point, scaleFactor: number): Point {
return { x: v.x * scaleFactor, y: v.y * scaleFactor };
}
static mag(v: Point): number {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
static normalized(v: Point): Point {
const { x, y } = v;
const magnitude = hypot(x, y);
if (magnitude === 0) return { x: 0, y: 0 };
const invMag = 1 / magnitude;
return { x: x * invMag, y: y * invMag };
}
static distance(a: Point, b: Point): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
static distanceSquared(a: Point, b: Point): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
}
static lerp(a: Point, b: Point, t: number): Point {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
};
}
static rotate(v: Point, angle: number): Point {
const _cos = cos(angle);
const _sin = sin(angle);
return {
x: v.x * _cos - v.y * _sin,
y: v.x * _sin + v.y * _cos,
};
}
static rotateAround(point: Point, pivot: Point, angle: number): Point {
const dx = point.x - pivot.x;
const dy = point.y - pivot.y;
const c = cos(angle);
const s = sin(angle);
return {
x: pivot.x + dx * c - dy * s,
y: pivot.y + dx * s + dy * c,
};
}
static angle(v: Point): number {
return atan2(v.y, v.x);
}
static angleTo(a: Point, b: Point = { x: 1, y: 0 }): number {
const angleA = Vector.angle(a);
const angleB = Vector.angle(b);
return angleA - angleB;
}
static angleFromOrigin(point: Point, origin: Point): number {
return Vector.angleTo({
x: point.x - origin.x,
y: point.y - origin.y,
});
}
}

35
lib/cursors.ts Normal file
View File

@ -0,0 +1,35 @@
const resizeCursorCache = new Map<number, string>();
const rotateCursorCache = new Map<number, string>();
function getRoundedDegree(degrees: number, interval: number = 1): number {
return (Math.round(degrees / interval) * interval) % 360;
}
export function getResizeCursorUrl(degrees: number): string {
degrees = getRoundedDegree(degrees);
if (degrees > 180) {
degrees -= 180;
}
if (!resizeCursorCache.has(degrees)) {
const url = resizeCursorUrl(degrees);
resizeCursorCache.set(degrees, url);
}
return resizeCursorCache.get(degrees)!;
}
export function getRotateCursorUrl(degrees: number): string {
degrees = getRoundedDegree(degrees);
if (!rotateCursorCache.has(degrees)) {
const url = rotateCursorUrl(degrees);
rotateCursorCache.set(degrees, url);
}
return rotateCursorCache.get(degrees)!;
}
const resizeCursorUrl = (degrees: number) =>
`url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'><g fill='none' transform='rotate(${degrees} 16 16)'><path d='M9 9L21 21M9 9H12L9 12V9ZM21 21V18L18 21H21Z' stroke='white' stroke-width='3' stroke-linejoin='miter'/><path d='M9 9L21 21M9 9H12L9 12V9ZM21 21V18L18 21H21Z' stroke='black' stroke-width='1.5' stroke-linejoin='miter'/></g></svg>") 16 16, nwse-resize`;
const rotateCursorUrl = (degrees: number) =>
`url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: black;'><defs><filter id='shadow' y='-40%25' x='-40%25' width='180%25' height='180%25' color-interpolation-filters='sRGB'><feDropShadow dx='2' dy='2' stdDeviation='1.5' flood-opacity='.3'/></filter></defs><g filter='url(%23shadow)'><g fill='none' transform='rotate(${degrees} 16 16)'><path d='M22.4789 9.45728L25.9935 12.9942L22.4789 16.5283V14.1032C18.126 14.1502 14.6071 17.6737 14.5675 22.0283H17.05L13.513 25.543L9.97889 22.0283H12.5674C12.6071 16.5691 17.0214 12.1503 22.4789 12.1031L22.4789 9.45728Z' fill='black'/><path fill-rule='evenodd' clip-rule='evenodd' d='M21.4789 7.03223L27.4035 12.9945L21.4789 18.9521V15.1868C18.4798 15.6549 16.1113 18.0273 15.649 21.0284H19.475L13.5128 26.953L7.55519 21.0284H11.6189C12.1243 15.8155 16.2679 11.6677 21.4789 11.1559L21.4789 7.03223ZM22.4789 12.1031C17.0214 12.1503 12.6071 16.5691 12.5674 22.0284H9.97889L13.513 25.543L17.05 22.0284H14.5675C14.5705 21.6896 14.5947 21.3558 14.6386 21.0284C15.1157 17.4741 17.9266 14.6592 21.4789 14.1761C21.8063 14.1316 22.1401 14.1069 22.4789 14.1032V16.5284L25.9935 12.9942L22.4789 9.45729L22.4789 12.1031Z' fill='white'/></g></g></svg>") 16 16, pointer`;

15
lib/folk-element.ts Normal file
View File

@ -0,0 +1,15 @@
import { ReactiveElement } from "@lit/reactive-element";
/**
* Base class for all custom elements. Extends Lit's `ReactiveElement` and adds utilities for defining the element.
*/
export class FolkElement extends ReactiveElement {
/** Defines the name of the custom element, must include a hyphen. */
static tagName = "";
/** Defines the custom element with the global CustomElementRegistry. */
static define() {
if (customElements.get(this.tagName)) return;
customElements.define(this.tagName, this);
}
}

269
lib/folk-markdown.ts Normal file
View File

@ -0,0 +1,269 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 200px;
min-height: 100px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #14b8a6;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
padding: 12px;
height: calc(100% - 36px);
overflow: auto;
}
.editor {
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
}
.markdown-preview {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
}
.markdown-preview h1 {
font-size: 1.5em;
margin: 0 0 0.5em;
color: #14b8a6;
}
.markdown-preview h2 {
font-size: 1.25em;
margin: 0.5em 0;
color: #14b8a6;
}
.markdown-preview p {
margin: 0.5em 0;
}
.markdown-preview code {
background: #f1f5f9;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-preview pre {
background: #f1f5f9;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.markdown-preview pre code {
background: none;
padding: 0;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-preview blockquote {
border-left: 3px solid #14b8a6;
margin: 0.5em 0;
padding-left: 1em;
color: #64748b;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-markdown": FolkMarkdown;
}
}
export class FolkMarkdown extends FolkShape {
static override tagName = "folk-markdown";
// Merge parent and child styles
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#content = "";
#isEditing = false;
get content() {
return this.#content;
}
set content(value: string) {
this.#content = value;
this.requestUpdate("content");
this.dispatchEvent(new CustomEvent("content-change", { detail: { content: value } }));
}
override createRenderRoot() {
const root = super.createRenderRoot();
// Add markdown-specific UI
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>📝</span>
<span>Markdown</span>
</span>
<div class="header-actions">
<button class="edit-btn" title="Toggle Edit"></button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="markdown-preview"></div>
<textarea class="editor" style="display: none;" placeholder="Write markdown here..."></textarea>
</div>
`;
// Move existing slot content into our wrapper
const slot = root.querySelector("slot");
if (slot) {
slot.parentElement?.replaceChild(wrapper, slot.parentElement.querySelector("div")!);
}
// Get references to elements
const preview = wrapper.querySelector(".markdown-preview") as HTMLElement;
const editor = wrapper.querySelector(".editor") as HTMLTextAreaElement;
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Edit toggle
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#isEditing = !this.#isEditing;
if (this.#isEditing) {
editor.style.display = "block";
preview.style.display = "none";
editor.value = this.#content;
editor.focus();
} else {
editor.style.display = "none";
preview.style.display = "block";
this.content = editor.value;
preview.innerHTML = this.#renderMarkdown(this.#content);
}
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Editor input
editor.addEventListener("input", () => {
this.#content = editor.value;
});
editor.addEventListener("blur", () => {
this.#isEditing = false;
editor.style.display = "none";
preview.style.display = "block";
this.content = editor.value;
preview.innerHTML = this.#renderMarkdown(this.#content);
});
// Initial render
this.#content = this.getAttribute("content") || "# Hello World\n\nStart typing...";
preview.innerHTML = this.#renderMarkdown(this.#content);
return root;
}
#renderMarkdown(text: string): string {
// Simple markdown renderer
return text
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>")
.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>")
.replace(/\n\n/g, "</p><p>")
.replace(/^(.+)$/gm, (match) => {
if (
match.startsWith("<h") ||
match.startsWith("<ul") ||
match.startsWith("<li") ||
match.startsWith("<blockquote")
) {
return match;
}
return `<p>${match}</p>`;
});
}
toJSON() {
return {
type: "folk-markdown",
id: this.id,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
content: this.content,
};
}
}

544
lib/folk-shape.ts Normal file
View File

@ -0,0 +1,544 @@
import { getResizeCursorUrl, getRotateCursorUrl } from "./cursors";
import { DOMRectTransform, DOMRectTransformReadonly } from "./DOMRectTransform";
import { FolkElement } from "./folk-element";
import { ResizeManager } from "./resize-manager";
import { css, html } from "./tags";
import { TransformEvent } from "./TransformEvent";
import type { Point } from "./types";
import { MAX_Z_INDEX } from "./utils";
import { Vector } from "./Vector";
import type { PropertyValues } from "@lit/reactive-element";
const resizeManager = new ResizeManager();
type ResizeHandle =
| "resize-top-left"
| "resize-top-right"
| "resize-bottom-right"
| "resize-bottom-left";
type RotateHandle =
| "rotation-top-left"
| "rotation-top-right"
| "rotation-bottom-right"
| "rotation-bottom-left";
type Handle = ResizeHandle | RotateHandle | "move";
export type Dimension = number | "auto";
type HandleMap = Record<ResizeHandle, ResizeHandle>;
const oppositeHandleMap: HandleMap = {
"resize-bottom-right": "resize-top-left",
"resize-bottom-left": "resize-top-right",
"resize-top-left": "resize-bottom-right",
"resize-top-right": "resize-bottom-left",
};
const flipXHandleMap: HandleMap = {
"resize-bottom-right": "resize-bottom-left",
"resize-bottom-left": "resize-bottom-right",
"resize-top-left": "resize-top-right",
"resize-top-right": "resize-top-left",
};
const flipYHandleMap: HandleMap = {
"resize-bottom-right": "resize-top-right",
"resize-bottom-left": "resize-top-left",
"resize-top-left": "resize-bottom-left",
"resize-top-right": "resize-bottom-right",
};
const styles = css`
* {
box-sizing: border-box;
}
:host {
display: block;
position: absolute;
top: 0;
left: 0;
cursor: move;
transform-origin: 0 0;
box-sizing: border-box;
}
:host::before {
content: "";
position: absolute;
inset: -15px;
z-index: -1;
}
div {
height: 100%;
width: 100%;
overflow: scroll;
pointer-events: none;
}
::slotted(*) {
cursor: default;
pointer-events: auto;
}
:host(:focus-within),
:host(:focus-visible) {
z-index: calc(${MAX_Z_INDEX} - 1);
outline: solid 1px hsl(214, 84%, 56%);
}
:host(:hover),
:host(:state(highlighted)) {
outline: solid 2px hsl(214, 84%, 56%);
}
:host(:state(move)),
:host(:state(rotate)),
:host(:state(resize-top-left)),
:host(:state(resize-top-right)),
:host(:state(resize-bottom-right)),
:host(:state(resize-bottom-left)) {
user-select: none;
}
[part] {
aspect-ratio: 1;
display: none;
position: absolute;
z-index: calc(${MAX_Z_INDEX} - 1);
padding: 0;
}
[part^="resize"] {
background: hsl(210, 20%, 98%);
width: 9px;
transform: translate(-50%, -50%);
border: 1.5px solid hsl(214, 84%, 56%);
border-radius: 2px;
}
[part^="rotation"] {
opacity: 0;
width: 16px;
}
[part$="top-left"] {
top: 0;
left: 0;
}
[part="rotation-top-left"] {
translate: -100% -100%;
}
[part$="top-right"] {
top: 0;
left: 100%;
}
[part="rotation-top-right"] {
translate: 0% -100%;
}
[part$="bottom-right"] {
top: 100%;
left: 100%;
}
[part="rotation-bottom-right"] {
translate: 0% 0%;
}
[part$="bottom-left"] {
top: 100%;
left: 0;
}
[part="rotation-bottom-left"] {
translate: -100% 0%;
}
:host(:focus-within) :is([part^="resize"], [part^="rotation"]) {
display: block;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-shape": FolkShape;
}
}
export class FolkShape extends FolkElement {
static tagName = "folk-shape";
static styles = styles;
#internals = this.attachInternals();
#attrWidth: Dimension = 0;
#attrHeight: Dimension = 0;
#rect = new DOMRectTransform();
#previousRect = new DOMRectTransform();
#readonlyRect = new DOMRectTransformReadonly();
#handles!: Record<ResizeHandle | RotateHandle, HTMLElement>;
#startAngle = 0;
get x() {
return this.#rect.x;
}
set x(x) {
this.#previousRect.x = this.#rect.x;
this.#rect.x = x;
this.requestUpdate("x");
}
get y() {
return this.#rect.y;
}
set y(y) {
this.#previousRect.y = this.#rect.y;
this.#rect.y = y;
this.requestUpdate("y");
}
get width(): number {
return this.#rect.width;
}
set width(width: Dimension) {
if (width === "auto") {
resizeManager.observe(this, this.#onAutoResize);
} else {
if (this.#attrWidth === "auto" && this.#attrHeight !== "auto") {
resizeManager.unobserve(this, this.#onAutoResize);
}
this.#previousRect.width = this.#rect.width;
this.#rect.width = width;
}
this.#attrWidth = width;
this.requestUpdate("width");
}
get height(): number {
return this.#rect.height;
}
set height(height: Dimension) {
if (height === "auto") {
resizeManager.observe(this, this.#onAutoResize);
} else {
if (this.#attrHeight === "auto" && this.#attrWidth !== "auto") {
resizeManager.unobserve(this, this.#onAutoResize);
}
this.#previousRect.height = this.#rect.height;
this.#rect.height = height;
}
this.#attrHeight = height;
this.requestUpdate("height");
}
get rotation(): number {
return this.#rect.rotation;
}
set rotation(rotation: number) {
this.#previousRect.rotation = this.#rect.rotation;
this.#rect.rotation = rotation;
this.requestUpdate("rotation");
}
#highlighted = false;
get highlighted() {
return this.#highlighted;
}
set highlighted(highlighted) {
if (this.#highlighted === highlighted) return;
this.#highlighted = highlighted;
highlighted
? this.#internals.states.add("highlighted")
: this.#internals.states.delete("highlighted");
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.addEventListener("pointerdown", this);
this.addEventListener("touchmove", this, { passive: false });
this.addEventListener("keydown", this);
(root as ShadowRoot).setHTMLUnsafe(
html`<button part="rotation-top-left" tabindex="-1"></button>
<button part="rotation-top-right" tabindex="-1"></button>
<button part="rotation-bottom-right" tabindex="-1"></button>
<button part="rotation-bottom-left" tabindex="-1"></button>
<button part="resize-top-left" aria-label="Resize shape from top left"></button>
<button part="resize-top-right" aria-label="Resize shape from top right"></button>
<button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button>
<button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button>
<div><slot></slot></div>`,
);
this.#handles = Object.fromEntries(
Array.from(root.querySelectorAll("[part]")).map((el) => [
el.getAttribute("part") as ResizeHandle | RotateHandle,
el as HTMLElement,
]),
) as Record<ResizeHandle | RotateHandle, HTMLElement>;
this.#updateCursors();
this.x = Number(this.getAttribute("x")) || 0;
this.y = Number(this.getAttribute("y")) || 0;
this.width = Number(this.getAttribute("width")) || "auto";
this.height = Number(this.getAttribute("height")) || "auto";
this.rotation = (Number(this.getAttribute("rotation")) || 0) * (Math.PI / 180);
this.#rect.transformOrigin = { x: 0, y: 0 };
this.#rect.rotateOrigin = { x: 0.5, y: 0.5 };
this.#previousRect = new DOMRectTransform(this.#rect);
this.setAttribute("tabindex", "0");
return root;
}
getTransformDOMRect() {
return this.#readonlyRect;
}
handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) {
if (event instanceof TouchEvent) {
event.preventDefault();
return;
}
const focusedElement = (this.renderRoot as ShadowRoot).activeElement as HTMLElement | null;
const target = event.composedPath()[0] as HTMLElement;
let handle: Handle | null = null;
if (target) {
handle = target.getAttribute("part") as Handle | null;
} else if (focusedElement) {
handle = focusedElement.getAttribute("part") as Handle | null;
}
if (event instanceof PointerEvent) {
event.stopPropagation();
if (event.type === "pointerdown") {
if (target !== this && !handle) return;
if (handle?.startsWith("rotation")) {
const parentRotateOrigin = this.#rect.toParentSpace({
x: this.#rect.width * this.#rect.rotateOrigin.x,
y: this.#rect.height * this.#rect.rotateOrigin.y,
});
const mousePos = { x: event.clientX, y: event.clientY };
this.#startAngle = Vector.angleFromOrigin(mousePos, parentRotateOrigin) - this.#rect.rotation;
}
target.addEventListener("pointermove", this);
target.addEventListener("lostpointercapture", this);
target.setPointerCapture(event.pointerId);
this.#internals.states.add(handle || "move");
this.focus();
return;
}
if (event.type === "lostpointercapture") {
this.#internals.states.delete(handle || "move");
target.removeEventListener("pointermove", this);
target.removeEventListener("lostpointercapture", this);
this.#updateCursors();
if (handle?.startsWith("rotation")) {
target.style.removeProperty("cursor");
}
return;
}
}
let moveDelta: Point | null = null;
if (event instanceof KeyboardEvent) {
const MOVEMENT_MUL = event.shiftKey ? 20 : 2;
const arrowKeys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!arrowKeys.includes(event.key)) return;
moveDelta = {
x: (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0) * MOVEMENT_MUL,
y: (event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0) * MOVEMENT_MUL,
};
} else if (event.type === "pointermove") {
if (!target) return;
const zoom = window.visualViewport?.scale ?? 1;
moveDelta = {
x: event.movementX / zoom,
y: event.movementY / zoom,
};
}
if (!moveDelta) return;
if (target === this || (!handle && event instanceof KeyboardEvent)) {
if (event instanceof KeyboardEvent && event.altKey) {
const ROTATION_MUL = event.shiftKey ? Math.PI / 12 : Math.PI / 36;
const rotationDelta = moveDelta.x !== 0 ? (moveDelta.x > 0 ? ROTATION_MUL : -ROTATION_MUL) : 0;
this.rotation += rotationDelta;
} else {
this.x += moveDelta.x;
this.y += moveDelta.y;
}
event.preventDefault();
return;
}
if (handle?.startsWith("resize")) {
const rect = this.#rect;
const corner = {
"resize-top-left": rect.topLeft,
"resize-top-right": rect.topRight,
"resize-bottom-right": rect.bottomRight,
"resize-bottom-left": rect.bottomLeft,
}[handle as ResizeHandle];
const currentPos = rect.toParentSpace(corner);
const mousePos =
event instanceof KeyboardEvent
? { x: currentPos.x + moveDelta.x, y: currentPos.y + moveDelta.y }
: { x: event.clientX, y: event.clientY };
this.#handleResize(
handle as ResizeHandle,
mousePos,
target,
event instanceof PointerEvent ? event : undefined,
);
event.preventDefault();
return;
}
if (handle?.startsWith("rotation") && event instanceof PointerEvent) {
const parentRotateOrigin = this.#rect.toParentSpace({
x: this.#rect.width * this.#rect.rotateOrigin.x,
y: this.#rect.height * this.#rect.rotateOrigin.y,
});
const currentAngle = Vector.angleFromOrigin(
{ x: event.clientX, y: event.clientY },
parentRotateOrigin,
);
this.rotation = currentAngle - this.#startAngle;
const degrees = (this.#rect.rotation * 180) / Math.PI;
const cursorRotation = {
"rotation-top-left": degrees,
"rotation-top-right": (degrees + 90) % 360,
"rotation-bottom-right": (degrees + 180) % 360,
"rotation-bottom-left": (degrees + 270) % 360,
}[handle as RotateHandle];
target.style.setProperty("cursor", getRotateCursorUrl(cursorRotation));
return;
}
}
protected override update(changedProperties: PropertyValues): void {
this.#dispatchTransformEvent();
super.update(changedProperties);
}
#dispatchTransformEvent() {
const emittedRect = new DOMRectTransform(this.#rect);
const event = new TransformEvent(emittedRect, this.#previousRect);
this.dispatchEvent(event);
if (event.xPrevented) emittedRect.x = this.#previousRect.x;
if (event.yPrevented) emittedRect.y = this.#previousRect.y;
if (event.widthPrevented) emittedRect.width = this.#previousRect.width;
if (event.heightPrevented) emittedRect.height = this.#previousRect.height;
if (event.rotatePrevented) emittedRect.rotation = this.#previousRect.rotation;
this.style.transform = emittedRect.toCssString();
this.style.width = this.#attrWidth === "auto" ? "" : `${emittedRect.width}px`;
this.style.height = this.#attrHeight === "auto" ? "" : `${emittedRect.height}px`;
this.#readonlyRect = new DOMRectTransformReadonly(emittedRect);
}
#onAutoResize = (entry: ResizeObserverEntry) => {
this.#previousRect.height = this.#rect.height;
this.#rect.height = entry.contentRect.height;
this.#previousRect.width = this.#rect.width;
this.#rect.width = entry.contentRect.width;
this.#dispatchTransformEvent();
};
#updateCursors() {
const degrees = (this.#rect.rotation * 180) / Math.PI;
const resizeCursor0 = getResizeCursorUrl(degrees);
const resizeCursor90 = getResizeCursorUrl((degrees + 90) % 360);
this.#handles["resize-top-left"].style.setProperty("cursor", resizeCursor0);
this.#handles["resize-bottom-right"].style.setProperty("cursor", resizeCursor0);
this.#handles["resize-top-right"].style.setProperty("cursor", resizeCursor90);
this.#handles["resize-bottom-left"].style.setProperty("cursor", resizeCursor90);
this.#handles["rotation-top-left"].style.setProperty("cursor", getRotateCursorUrl(degrees));
this.#handles["rotation-top-right"].style.setProperty(
"cursor",
getRotateCursorUrl((degrees + 90) % 360),
);
this.#handles["rotation-bottom-right"].style.setProperty(
"cursor",
getRotateCursorUrl((degrees + 180) % 360),
);
this.#handles["rotation-bottom-left"].style.setProperty(
"cursor",
getRotateCursorUrl((degrees + 270) % 360),
);
}
#handleResize(handle: ResizeHandle, pointerPos: Point, target: HTMLElement, event?: PointerEvent) {
const localPointer = this.#rect.toLocalSpace(pointerPos);
switch (handle) {
case "resize-bottom-right":
this.#rect.bottomRight = localPointer;
break;
case "resize-bottom-left":
this.#rect.bottomLeft = localPointer;
break;
case "resize-top-left":
this.#rect.topLeft = localPointer;
break;
case "resize-top-right":
this.#rect.topRight = localPointer;
break;
}
let nextHandle: ResizeHandle = handle;
const flipWidth = this.#rect.width < 0;
const flipHeight = this.#rect.height < 0;
if (flipWidth && flipHeight) {
nextHandle = oppositeHandleMap[handle];
} else if (flipWidth) {
nextHandle = flipXHandleMap[handle];
} else if (flipHeight) {
nextHandle = flipYHandleMap[handle];
}
const newTarget = this.renderRoot.querySelector(`[part="${nextHandle}"]`) as HTMLElement;
if (newTarget) {
newTarget.focus();
this.#internals.states.delete(handle);
this.#internals.states.add(nextHandle);
if (event && "setPointerCapture" in target) {
target.removeEventListener("pointermove", this);
target.removeEventListener("lostpointercapture", this);
newTarget.addEventListener("pointermove", this);
newTarget.addEventListener("lostpointercapture", this);
target.releasePointerCapture(event.pointerId);
newTarget.setPointerCapture(event.pointerId);
}
}
this.requestUpdate();
}
}

23
lib/index.ts Normal file
View File

@ -0,0 +1,23 @@
// Core types
export * from "./types";
// Base element
export * from "./folk-element";
// Math utilities
export * from "./Matrix";
export * from "./Vector";
// DOM transforms
export * from "./DOMRectTransform";
export * from "./TransformEvent";
// Utilities
export * from "./resize-manager";
export * from "./cursors";
export * from "./utils";
export * from "./tags";
// Components
export * from "./folk-shape";
export * from "./folk-markdown";

43
lib/resize-manager.ts Normal file
View File

@ -0,0 +1,43 @@
export type ResizeManagerEntryCallback = (entry: ResizeObserverEntry) => void;
/** A composition interface for ResizeObserver, allowing multiple observers per element. */
export class ResizeManager {
#elementMap = new WeakMap<Element, Set<ResizeManagerEntryCallback>>();
#elementEntry = new WeakMap<Element, ResizeObserverEntry>();
#vo = new ResizeObserver((entries) => {
for (const entry of entries) {
this.#elementEntry.set(entry.target, entry);
this.#elementMap.get(entry.target)?.forEach((callback) => callback(entry));
}
});
observe(target: Element, callback: ResizeManagerEntryCallback): void {
let callbacks = this.#elementMap.get(target);
if (callbacks === undefined) {
this.#vo.observe(target);
this.#elementMap.set(target, (callbacks = new Set()));
} else {
const entry = this.#elementEntry.get(target);
if (entry) {
callback(entry);
}
}
callbacks.add(callback);
}
unobserve(target: Element, callback: ResizeManagerEntryCallback): void {
const callbacks = this.#elementMap.get(target);
if (callbacks === undefined) return;
callbacks.delete(callback);
if (callbacks.size === 0) {
this.#vo.unobserve(target);
this.#elementMap.delete(target);
}
}
}

8
lib/tags.ts Normal file
View File

@ -0,0 +1,8 @@
/** A raw tagged template literal that just provides HTML syntax highlighting/LSP support. */
export const html = String.raw;
export function css(strings: TemplateStringsArray, ...values: unknown[]) {
const styles = new CSSStyleSheet();
styles.replaceSync(String.raw(strings, ...values));
return styles;
}

1
lib/types.ts Normal file
View File

@ -0,0 +1 @@
export type Point = { x: number; y: number };

1
lib/utils.ts Normal file
View File

@ -0,0 +1 @@
export const MAX_Z_INDEX = 2147483647;

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "rspace-online",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"server": "bun run server/index.ts",
"start": "bun run build && bun run server"
},
"dependencies": {
"@automerge/automerge": "^2.2.8",
"@lit/reactive-element": "^2.0.4",
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2"
},
"devDependencies": {
"@types/node": "^22.10.1",
"bun-types": "^1.1.38",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

114
server/community-store.ts Normal file
View File

@ -0,0 +1,114 @@
import { mkdir, readdir } from "node:fs/promises";
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
export interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
}
export interface CommunityDoc {
meta: CommunityMeta;
shapes: Record<
string,
{
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation?: number;
content?: string;
}
>;
}
// In-memory cache of community docs
const communities = new Map<string, CommunityDoc>();
// Ensure storage directory exists
await mkdir(STORAGE_DIR, { recursive: true });
export async function loadCommunity(slug: string): Promise<CommunityDoc | null> {
// Check cache first
if (communities.has(slug)) {
return communities.get(slug)!;
}
// Try to load from disk
const path = `${STORAGE_DIR}/${slug}.json`;
const file = Bun.file(path);
if (await file.exists()) {
try {
const data = (await file.json()) as CommunityDoc;
communities.set(slug, data);
return data;
} catch (e) {
console.error(`Failed to load community ${slug}:`, e);
return null;
}
}
return null;
}
export async function saveCommunity(slug: string, doc: CommunityDoc): Promise<void> {
communities.set(slug, doc);
const path = `${STORAGE_DIR}/${slug}.json`;
await Bun.write(path, JSON.stringify(doc, null, 2));
}
export async function createCommunity(name: string, slug: string): Promise<CommunityDoc> {
const doc: CommunityDoc = {
meta: {
name,
slug,
createdAt: new Date().toISOString(),
},
shapes: {},
};
await saveCommunity(slug, doc);
return doc;
}
export async function communityExists(slug: string): Promise<boolean> {
if (communities.has(slug)) return true;
const path = `${STORAGE_DIR}/${slug}.json`;
const file = Bun.file(path);
return file.exists();
}
export async function listCommunities(): Promise<string[]> {
try {
const files = await readdir(STORAGE_DIR);
return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
} catch {
return [];
}
}
export function updateShape(
slug: string,
shapeId: string,
data: CommunityDoc["shapes"][string],
): void {
const doc = communities.get(slug);
if (doc) {
doc.shapes[shapeId] = data;
// Save async without blocking
saveCommunity(slug, doc);
}
}
export function deleteShape(slug: string, shapeId: string): void {
const doc = communities.get(slug);
if (doc) {
delete doc.shapes[shapeId];
saveCommunity(slug, doc);
}
}

260
server/index.ts Normal file
View File

@ -0,0 +1,260 @@
import { resolve } from "node:path";
import type { ServerWebSocket } from "bun";
import {
communityExists,
createCommunity,
deleteShape,
loadCommunity,
updateShape,
type CommunityDoc,
} from "./community-store";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../dist");
// WebSocket data type
interface WSData {
communitySlug: string;
}
// Track connected clients per community
const communityClients = new Map<string, Set<ServerWebSocket<WSData>>>();
// Helper to broadcast to all clients in a community
function broadcastToCommunity(slug: string, message: object, excludeWs?: ServerWebSocket<WSData>) {
const clients = communityClients.get(slug);
if (!clients) return;
const data = JSON.stringify(message);
for (const client of clients) {
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
// Parse subdomain from host header
function getSubdomain(host: string | null): string | null {
if (!host) return null;
// Handle localhost for development
if (host.includes("localhost") || host.includes("127.0.0.1")) {
return null;
}
// Extract subdomain from *.rspace.online
const parts = host.split(".");
if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") {
const subdomain = parts[0];
if (subdomain !== "www" && subdomain !== "rspace") {
return subdomain;
}
}
return null;
}
// Serve static files
async function serveStatic(path: string): Promise<Response | null> {
const filePath = resolve(DIST_DIR, path);
const file = Bun.file(filePath);
if (await file.exists()) {
const contentType = getContentType(path);
return new Response(file, {
headers: { "Content-Type": contentType },
});
}
return null;
}
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
return "text/plain";
}
// Main server
const server = Bun.serve<WSData>({
port: PORT,
async fetch(req, server) {
const url = new URL(req.url);
const host = req.headers.get("host");
const subdomain = getSubdomain(host);
// Handle WebSocket upgrade
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
const upgraded = server.upgrade(req, { data: { communitySlug } });
if (upgraded) return undefined;
}
return new Response("WebSocket upgrade failed", { status: 400 });
}
// API routes
if (url.pathname.startsWith("/api/")) {
return handleAPI(req, url);
}
// Community canvas route (subdomain detected)
if (subdomain) {
const community = await loadCommunity(subdomain);
if (!community) {
return new Response("Community not found", { status: 404 });
}
// Serve canvas.html for community
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
}
// Static files
let filePath = url.pathname;
if (filePath === "/") filePath = "/index.html";
if (filePath === "/canvas") filePath = "/canvas.html";
// Remove leading slash
filePath = filePath.slice(1);
const staticResponse = await serveStatic(filePath);
if (staticResponse) return staticResponse;
// Fallback to index.html for SPA routing
const indexResponse = await serveStatic("index.html");
if (indexResponse) return indexResponse;
return new Response("Not Found", { status: 404 });
},
websocket: {
open(ws: ServerWebSocket<WSData>) {
const { communitySlug } = ws.data;
// Add to clients set
if (!communityClients.has(communitySlug)) {
communityClients.set(communitySlug, new Set());
}
communityClients.get(communitySlug)!.add(ws);
console.log(`Client connected to ${communitySlug}`);
// Send current state
loadCommunity(communitySlug).then((doc) => {
if (doc) {
ws.send(JSON.stringify({ type: "sync", shapes: doc.shapes }));
}
});
},
message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
const { communitySlug } = ws.data;
try {
const data = JSON.parse(message.toString());
if (data.type === "update" && data.id && data.data) {
// Update local store
updateShape(communitySlug, data.id, data.data);
// Broadcast to other clients
broadcastToCommunity(communitySlug, data, ws);
} else if (data.type === "delete" && data.id) {
// Delete from store
deleteShape(communitySlug, data.id);
// Broadcast to other clients
broadcastToCommunity(communitySlug, data, ws);
}
} catch (e) {
console.error("Failed to parse WebSocket message:", e);
}
},
close(ws: ServerWebSocket<WSData>) {
const { communitySlug } = ws.data;
// Remove from clients set
const clients = communityClients.get(communitySlug);
if (clients) {
clients.delete(ws);
if (clients.size === 0) {
communityClients.delete(communitySlug);
}
}
console.log(`Client disconnected from ${communitySlug}`);
},
},
});
// API handler
async function handleAPI(req: Request, url: URL): Promise<Response> {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// POST /api/communities - Create new community
if (url.pathname === "/api/communities" && req.method === "POST") {
try {
const body = (await req.json()) as { name?: string; slug?: string };
const { name, slug } = body;
if (!name || !slug) {
return Response.json({ error: "Name and slug are required" }, { status: 400, headers: corsHeaders });
}
// Validate slug format
if (!/^[a-z0-9-]+$/.test(slug)) {
return Response.json(
{ error: "Slug must contain only lowercase letters, numbers, and hyphens" },
{ status: 400, headers: corsHeaders },
);
}
// Check if exists
if (await communityExists(slug)) {
return Response.json({ error: "Community already exists" }, { status: 409, headers: corsHeaders });
}
// Create community
await createCommunity(name, slug);
// Return URL to new community
return Response.json(
{ url: `https://${slug}.rspace.online`, slug, name },
{ headers: corsHeaders },
);
} catch (e) {
console.error("Failed to create community:", e);
return Response.json({ error: "Failed to create community" }, { status: 500, headers: corsHeaders });
}
}
// GET /api/communities/:slug - Get community info
if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
const slug = url.pathname.split("/")[3];
const community = await loadCommunity(slug);
if (!community) {
return Response.json({ error: "Community not found" }, { status: 404, headers: corsHeaders });
}
return Response.json({ meta: community.meta }, { headers: corsHeaders });
}
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
}
console.log(`rSpace server running on http://localhost:${PORT}`);

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"allowImportingTsExtensions": true,
"useDefineForClassFields": false,
"experimentalDecorators": true,
"skipLibCheck": true,
"noUnusedLocals": false,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@types/node", "bun-types"],
"baseUrl": ".",
"paths": {
"@lib": ["lib"],
"@lib/*": ["lib/*"]
}
},
"include": ["**/*.ts", "vite.config.ts"],
"exclude": ["node_modules/**/*", "dist/**/*"]
}

28
vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { resolve } from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
root: "website",
resolve: {
alias: {
"@lib": resolve(__dirname, "./lib"),
},
},
build: {
target: "esnext",
rollupOptions: {
input: {
index: resolve(__dirname, "./website/index.html"),
canvas: resolve(__dirname, "./website/canvas.html"),
},
},
modulePreload: {
polyfill: false,
},
outDir: "../dist",
emptyOutDir: true,
},
server: {
port: 5173,
},
});

302
website/canvas.html Normal file
View File

@ -0,0 +1,302 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rSpace Canvas</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#toolbar {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 8px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
#toolbar button {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #f1f5f9;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
#toolbar button:hover {
background: #e2e8f0;
}
#toolbar button.active {
background: #14b8a6;
color: white;
}
#community-info {
position: fixed;
top: 16px;
left: 16px;
padding: 8px 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
#community-info h2 {
font-size: 14px;
color: #0f172a;
}
#community-info p {
font-size: 12px;
color: #64748b;
}
#status {
position: fixed;
bottom: 16px;
right: 16px;
padding: 8px 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
font-size: 12px;
color: #64748b;
z-index: 1000;
}
#status.connected {
color: #22c55e;
}
#status.disconnected {
color: #ef4444;
}
#canvas {
width: 100%;
height: 100%;
background: linear-gradient(#f1f5f9 1px, transparent 1px),
linear-gradient(90deg, #f1f5f9 1px, transparent 1px);
background-size: 20px 20px;
background-position: -1px -1px;
position: relative;
overflow: hidden;
}
folk-markdown {
position: absolute;
}
</style>
</head>
<body>
<div id="community-info">
<h2 id="community-name">Loading...</h2>
<p id="community-slug"></p>
</div>
<div id="toolbar">
<button id="add-markdown" title="Add Markdown Note">📝 Add Note</button>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out">-</button>
<button id="reset-view" title="Reset View"></button>
</div>
<div id="status" class="disconnected">Connecting...</div>
<div id="canvas"></div>
<script type="module">
import { FolkShape, FolkMarkdown } from "@lib";
// Register custom elements
FolkShape.define();
FolkMarkdown.define();
// Get community info from URL
const hostname = window.location.hostname;
const subdomain = hostname.split(".")[0];
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1";
const communitySlug = isLocalhost ? "demo" : subdomain;
// Update UI
document.getElementById("community-name").textContent = communitySlug;
document.getElementById("community-slug").textContent = `${communitySlug}.rspace.online`;
const canvas = document.getElementById("canvas");
const status = document.getElementById("status");
let shapeCounter = 0;
// WebSocket connection for sync
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
function connectWebSocket() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/${communitySlug}`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
status.textContent = "Connected";
status.className = "connected";
reconnectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleSyncMessage(data);
} catch (e) {
console.error("Failed to parse message:", e);
}
};
ws.onclose = () => {
status.textContent = "Disconnected";
status.className = "disconnected";
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
setTimeout(connectWebSocket, 2000 * reconnectAttempts);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
} catch (e) {
console.error("Failed to connect WebSocket:", e);
status.textContent = "Offline Mode";
}
}
function handleSyncMessage(data) {
if (data.type === "sync") {
// Full state sync
Object.entries(data.shapes).forEach(([id, shapeData]) => {
let shape = document.getElementById(id);
if (!shape) {
shape = createShape(shapeData);
shape.id = id;
canvas.appendChild(shape);
}
updateShapeFromData(shape, shapeData);
});
} else if (data.type === "update") {
// Incremental update
let shape = document.getElementById(data.id);
if (!shape && data.data) {
shape = createShape(data.data);
shape.id = data.id;
canvas.appendChild(shape);
} else if (shape && data.data) {
updateShapeFromData(shape, data.data);
}
} else if (data.type === "delete") {
const shape = document.getElementById(data.id);
if (shape) shape.remove();
}
}
function createShape(data) {
const shape = document.createElement("folk-markdown");
shape.x = data.x || 100;
shape.y = data.y || 100;
shape.width = data.width || 300;
shape.height = data.height || 200;
if (data.content) shape.content = data.content;
return shape;
}
function updateShapeFromData(shape, data) {
if (data.x !== undefined) shape.x = data.x;
if (data.y !== undefined) shape.y = data.y;
if (data.width !== undefined) shape.width = data.width;
if (data.height !== undefined) shape.height = data.height;
if (data.content !== undefined) shape.content = data.content;
}
function broadcastUpdate(shape) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "update",
id: shape.id,
data: shape.toJSON(),
})
);
}
}
// Add markdown note button
document.getElementById("add-markdown").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-markdown");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 300;
shape.height = 200;
shape.content = "# New Note\n\nStart typing...";
shape.addEventListener("transform", () => {
broadcastUpdate(shape);
});
shape.addEventListener("content-change", () => {
broadcastUpdate(shape);
});
shape.addEventListener("close", () => {
shape.remove();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "delete", id: shape.id }));
}
});
canvas.appendChild(shape);
broadcastUpdate(shape);
});
// Zoom controls (placeholder)
document.getElementById("zoom-in").addEventListener("click", () => {
console.log("Zoom in - to be implemented");
});
document.getElementById("zoom-out").addEventListener("click", () => {
console.log("Zoom out - to be implemented");
});
document.getElementById("reset-view").addEventListener("click", () => {
console.log("Reset view - to be implemented");
});
// Initialize
connectWebSocket();
</script>
</body>
</html>

261
website/index.html Normal file
View File

@ -0,0 +1,261 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rSpace - Collaborative Community Spaces</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: white;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
max-width: 600px;
padding: 40px 20px;
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: 1.25rem;
color: #94a3b8;
margin-bottom: 3rem;
}
.create-form {
display: flex;
flex-direction: column;
gap: 1rem;
background: rgba(255, 255, 255, 0.05);
padding: 2rem;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: left;
}
label {
font-size: 0.875rem;
color: #94a3b8;
}
input {
padding: 12px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: white;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #14b8a6;
}
input::placeholder {
color: #64748b;
}
.slug-preview {
font-size: 0.875rem;
color: #64748b;
margin-top: 0.25rem;
}
.slug-preview span {
color: #14b8a6;
}
button {
padding: 14px 28px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #14b8a6, #0d9488);
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(20, 184, 166, 0.3);
}
button:active {
transform: translateY(0);
}
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-top: 4rem;
}
.feature {
text-align: center;
}
.feature-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.feature-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.feature-desc {
font-size: 0.875rem;
color: #64748b;
}
.error {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.success {
color: #22c55e;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="container">
<h1>rSpace</h1>
<p class="tagline">Collaborative community spaces powered by FolkJS</p>
<form class="create-form" id="create-form">
<div class="form-group">
<label for="community-name">Community Name</label>
<input
type="text"
id="community-name"
placeholder="e.g. MycoFi Commons"
required
/>
</div>
<div class="form-group">
<label for="community-slug">Community Slug</label>
<input
type="text"
id="community-slug"
placeholder="e.g. mycofi"
pattern="[a-z0-9-]+"
required
/>
<p class="slug-preview">Your space will be at: <span id="slug-preview">___.rspace.online</span></p>
</div>
<button type="submit">Create Community Space</button>
<p class="error" id="error-message" style="display: none;"></p>
</form>
<div class="features">
<div class="feature">
<div class="feature-icon">🎨</div>
<div class="feature-title">Spatial Canvas</div>
<div class="feature-desc">Infinite collaborative workspace</div>
</div>
<div class="feature">
<div class="feature-icon">🔄</div>
<div class="feature-title">Real-time Sync</div>
<div class="feature-desc">Powered by Automerge CRDT</div>
</div>
<div class="feature">
<div class="feature-icon">🌐</div>
<div class="feature-title">Your Subdomain</div>
<div class="feature-desc">community.rspace.online</div>
</div>
</div>
</div>
<script>
const nameInput = document.getElementById("community-name");
const slugInput = document.getElementById("community-slug");
const slugPreview = document.getElementById("slug-preview");
const form = document.getElementById("create-form");
const errorMessage = document.getElementById("error-message");
// Auto-generate slug from name
nameInput.addEventListener("input", () => {
const slug = nameInput.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
slugInput.value = slug;
slugPreview.textContent = slug ? `${slug}.rspace.online` : "___.rspace.online";
});
slugInput.addEventListener("input", () => {
const slug = slugInput.value.toLowerCase().replace(/[^a-z0-9-]/g, "");
slugInput.value = slug;
slugPreview.textContent = slug ? `${slug}.rspace.online` : "___.rspace.online";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorMessage.style.display = "none";
const name = nameInput.value.trim();
const slug = slugInput.value.trim();
if (!name || !slug) {
errorMessage.textContent = "Please fill in all fields";
errorMessage.style.display = "block";
return;
}
try {
const response = await fetch("/api/communities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slug }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to create community");
}
// Redirect to the new community space
window.location.href = data.url;
} catch (err) {
errorMessage.textContent = err.message;
errorMessage.style.display = "block";
}
});
</script>
</body>
</html>