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:
commit
1ec463f193
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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)})`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type Point = { x: number; y: number };
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const MAX_Z_INDEX = 2147483647;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue