Compare commits
86 Commits
ba351d0c3e
...
d9c374f784
| Author | SHA1 | Date |
|---|---|---|
|
|
d9c374f784 | |
|
|
bd0916b60f | |
|
|
54ead05224 | |
|
|
bfdb09fc4b | |
|
|
15cdadfbf3 | |
|
|
f84b0b9914 | |
|
|
53b3cb5b30 | |
|
|
19ce7080e8 | |
|
|
ba8a87727e | |
|
|
138716660b | |
|
|
2cfe2c744d | |
|
|
7698e32774 | |
|
|
8b9ccff256 | |
|
|
74642a57ca | |
|
|
034e3b308a | |
|
|
c934ed3fd0 | |
|
|
0a795006d3 | |
|
|
69cb105758 | |
|
|
1bedc4e504 | |
|
|
92bec8243d | |
|
|
03c843254b | |
|
|
a5f8389239 | |
|
|
ae9bbebb4a | |
|
|
abca93757b | |
|
|
f9bda6c35d | |
|
|
a483cbe0af | |
|
|
0f7d4eb7bb | |
|
|
fcf350d40a | |
|
|
cc116e6be8 | |
|
|
ddd5f957b4 | |
|
|
b299caf433 | |
|
|
e7ce57ce0b | |
|
|
38a5d84f72 | |
|
|
4587387dec | |
|
|
1ff0f69218 | |
|
|
cd33f7c050 | |
|
|
b872e8e053 | |
|
|
7e6d78f4cb | |
|
|
db78dbeeb9 | |
|
|
54671b62c6 | |
|
|
22a461ed86 | |
|
|
f82781e5d6 | |
|
|
3c9741a809 | |
|
|
8e9fc97ec8 | |
|
|
6d6476e917 | |
|
|
3b1bde7d02 | |
|
|
e1b108a329 | |
|
|
2f1b53cff0 | |
|
|
e3fc578465 | |
|
|
ad2aa7eebb | |
|
|
0fb4135ac6 | |
|
|
346b406b80 | |
|
|
f84e45fc5a | |
|
|
ca5dff072c | |
|
|
91cb68a09f | |
|
|
86fc403138 | |
|
|
23ebfd2a0d | |
|
|
4cef36f450 | |
|
|
af17153a41 | |
|
|
f6bb47bb8b | |
|
|
05fc9d142a | |
|
|
64aad9bb68 | |
|
|
5c85f8a253 | |
|
|
2d5103c7d6 | |
|
|
125964dbae | |
|
|
05d2280a2b | |
|
|
519f13045e | |
|
|
c25361f5d6 | |
|
|
a11f449353 | |
|
|
977d0e2e20 | |
|
|
d6f9b4e83c | |
|
|
e721c20382 | |
|
|
7394d999ae | |
|
|
f504ca1a68 | |
|
|
12818b4689 | |
|
|
a6ad8c2264 | |
|
|
f003534d8a | |
|
|
3ad004d011 | |
|
|
a7e364ef5f | |
|
|
b0173b4833 | |
|
|
f7615f4aad | |
|
|
cc388c0a6c | |
|
|
91dd701619 | |
|
|
6b1a8ade9c | |
|
|
835d15215e | |
|
|
59d00588b4 |
|
|
@ -6,6 +6,7 @@ dist/
|
|||
|
||||
# Data storage
|
||||
data/
|
||||
!modules/data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
|
|
|||
38
Dockerfile
38
Dockerfile
|
|
@ -8,7 +8,7 @@ COPY package.json bun.lock* ./
|
|||
# Copy local SDK dependency (package.json references file:../encryptid-sdk)
|
||||
COPY --from=encryptid-sdk . /encryptid-sdk/
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
|
@ -16,31 +16,59 @@ COPY . .
|
|||
# Build frontend (skip tsc in Docker — type checking is done in CI/local dev)
|
||||
RUN bunx vite build
|
||||
|
||||
# Typst binary stage — download once, reuse in production
|
||||
FROM debian:bookworm-slim AS typst
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \
|
||||
&& curl -fsSL https://github.com/typst/typst/releases/download/v0.13.1/typst-x86_64-unknown-linux-musl.tar.xz \
|
||||
-o /tmp/typst.tar.xz \
|
||||
&& tar xf /tmp/typst.tar.xz -C /tmp \
|
||||
&& mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst \
|
||||
&& rm -rf /tmp/typst* \
|
||||
&& chmod +x /usr/local/bin/typst
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1-slim AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Install Typst binary (for rPubs PDF generation)
|
||||
COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst
|
||||
|
||||
# Copy built assets and server
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/server ./server
|
||||
COPY --from=build /app/lib ./lib
|
||||
COPY --from=build /app/shared ./shared
|
||||
COPY --from=build /app/modules ./modules
|
||||
COPY --from=build /app/package.json .
|
||||
COPY --from=build /encryptid-sdk /encryptid-sdk
|
||||
|
||||
# Install production dependencies only
|
||||
RUN bun install --production --frozen-lockfile
|
||||
RUN bun install --production
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data/communities
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files /data/splats
|
||||
|
||||
# Copy entrypoint for Infisical secret injection
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV STORAGE_DIR=/data/communities
|
||||
ENV BOOKS_DIR=/data/books
|
||||
ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts
|
||||
ENV FILES_DIR=/data/files
|
||||
ENV SPLATS_DIR=/data/splats
|
||||
ENV PORT=3000
|
||||
|
||||
# Data volume for persistence
|
||||
# Data volumes for persistence
|
||||
VOLUME /data/communities
|
||||
VOLUME /data/books
|
||||
VOLUME /data/swag-artifacts
|
||||
VOLUME /data/files
|
||||
VOLUME /data/splats
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["bun", "run", "server/index.ts"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
id: m-0
|
||||
title: "Canvas → rSpace Migration"
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Complete migration of all custom shapes and functionality from canvas-website (tldraw) to rspace-online (Web Components). rspace-online replaces canvas-website as the production platform.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
id: m-1
|
||||
title: "rSpace App Ecosystem"
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Transform rSpace from a flat shape canvas into a composable app ecosystem with data pipes, event broadcasting, semantic grouping, shape nesting, and cross-app embedding from the r-ecosystem (rWallet, rVote, rMaps, etc.).
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: TASK-21
|
||||
title: Offline-first support with IndexedDB persistence and Service Worker
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:39'
|
||||
labels:
|
||||
- feature
|
||||
- offline
|
||||
- infrastructure
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Added full offline-first capability to rSpace apps. Automerge documents and sync state now persist to IndexedDB via a new OfflineStore class, enabling instant load from cache, offline editing, and automatic incremental merge on reconnect. A Service Worker caches the app shell (HTML/JS/WASM) for loading without network.
|
||||
|
||||
## Changes
|
||||
- **lib/offline-store.ts** (new): IndexedDB wrapper storing Automerge doc binary + SyncState per community slug, with debounced saves
|
||||
- **lib/community-sync.ts**: Integrated offline persistence — initFromCache(), #scheduleSave(), #persistSyncState(), saveBeforeUnload(), infinite reconnect with capped backoff
|
||||
- **website/sw.ts** (new): Service Worker — cache-first for hashed assets, network-first for HTML, skip WS/API
|
||||
- **website/canvas.html**: SW registration, OfflineStore init, offline/online status UI, beforeunload save
|
||||
- **vite.config.ts**: build-sw plugin to produce dist/sw.js without content hash
|
||||
- **tsconfig.json**: Excluded sw.ts from main typecheck (WebWorker types)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 IndexedDB persists Automerge doc binary per community slug
|
||||
- [ ] #2 Cached content loads instantly on page refresh (before WebSocket connects)
|
||||
- [ ] #3 Offline editing works — shapes can be created/moved/deleted without network
|
||||
- [ ] #4 Changes merge automatically on reconnect via Automerge sync protocol
|
||||
- [ ] #5 Service Worker caches HTML/JS/WASM for offline app shell loading
|
||||
- [ ] #6 Reconnect retries indefinitely (30s max backoff) when offline store is present
|
||||
- [ ] #7 beforeunload saves pending changes immediately
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented offline-first support for rSpace. Created OfflineStore (IndexedDB wrapper) and Service Worker. Integrated into CommunitySync with debounced saves after every doc mutation, SyncState persistence for incremental reconnect, and infinite retry with capped backoff. Canvas UI shows offline/online status. Build produces dist/sw.js alongside main app. Pushed to Gitea main branch (6b06168).
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
id: TASK-22
|
||||
title: Add offline-first section to rSpace landing page
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:41'
|
||||
labels:
|
||||
- website
|
||||
- content
|
||||
dependencies: []
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Added an "Offline-First" feature card to the hero grid and a dedicated content section on the rSpace.online landing page explaining local IndexedDB persistence, Automerge auto-merge, and incremental sync. Matches existing visual style with pillars and identity cards.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added 4th feature card (Offline-First) to hero grid, updated grid to 4 columns (2 on mobile), and added a new section with 3 pillars (Local Persistence, Auto-Merge, Incremental Sync) plus a "How it works" explanation card. Pushed to Gitea main (ea8f1b3).
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
id: TASK-23
|
||||
title: 'Feature parity audit: 13 overlapping shapes'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:49'
|
||||
labels:
|
||||
- audit
|
||||
- phase-0
|
||||
milestone: m-0
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Compare each of the 13 shapes that exist in both canvas-website (tldraw) and rspace-online (folk-*) side-by-side. Document missing features in the rspace versions.
|
||||
|
||||
Pairs to audit:
|
||||
1. ChatBox ↔ folk-chat
|
||||
2. VideoChat ↔ folk-video-chat
|
||||
3. Embed ↔ folk-embed
|
||||
4. Markdown ↔ folk-markdown
|
||||
5. Slide ↔ folk-slide
|
||||
6. Prompt ↔ folk-prompt
|
||||
7. ObsNote ↔ folk-obs-note
|
||||
8. Transcription ↔ folk-transcription
|
||||
9. ImageGen ↔ folk-image-gen
|
||||
10. VideoGen ↔ folk-video-gen
|
||||
11. GoogleItem ↔ folk-google-item
|
||||
12. Map ↔ folk-map
|
||||
13. WorkflowBlock ↔ folk-workflow-block
|
||||
|
||||
For each pair: read both implementations, note feature gaps, classify as critical/nice-to-have.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 All 13 shape pairs compared side-by-side
|
||||
- [ ] #2 Feature gaps documented with severity (critical/nice-to-have)
|
||||
- [ ] #3 Critical gaps identified for immediate fix
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: TASK-24
|
||||
title: Add infrastructure dependencies for shape migration
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:49'
|
||||
labels:
|
||||
- infrastructure
|
||||
- phase-1
|
||||
milestone: m-0
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Install npm dependencies required by shapes being ported from canvas-website:
|
||||
- h3-js (HolonShape geospatial)
|
||||
- @xterm/xterm + @xterm/addon-fit (Multmux terminal)
|
||||
- safe-apps-sdk or ethers (TransactionBuilder, if needed)
|
||||
|
||||
Also verify existing deps like perfect-freehand are sufficient for Drawfast.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 All required npm packages installed
|
||||
- [ ] #2 No build errors after adding dependencies
|
||||
- [ ] #3 WASM plugins configured if needed (h3-js)
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
id: TASK-25
|
||||
title: Add server API proxy endpoints for new shapes
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:49'
|
||||
labels:
|
||||
- infrastructure
|
||||
- phase-1
|
||||
- server
|
||||
milestone: m-0
|
||||
dependencies: []
|
||||
references:
|
||||
- rspace-online/server/index.ts
|
||||
- canvas-website/src/shapes/ImageGenShapeUtil.tsx (API pattern reference)
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add API routes to server/index.ts for shapes that call external services:
|
||||
- POST /api/blender-gen — Proxy to Blender generation service
|
||||
- POST /api/mycrozine — Proxy for zine generation LLM calls
|
||||
- POST /api/fathom/* — Proxy to Fathom API (meeting transcripts)
|
||||
- POST /api/obsidian/* — Local Obsidian vault file operations
|
||||
- POST /api/holon/* — HoloSphere network queries
|
||||
- WebSocket endpoint for terminal sessions (Multmux)
|
||||
|
||||
Follow existing pattern from /api/image-gen endpoint.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 All proxy endpoints return expected responses
|
||||
- [ ] #2 WebSocket terminal endpoint accepts connections
|
||||
- [ ] #3 Error handling and auth middleware applied
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
id: TASK-26
|
||||
title: Port folk-blender-gen shape (3D procedural generation)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:49'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-2
|
||||
- ai
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/shapes/BlenderGenShapeUtil.tsx
|
||||
- rspace-online/lib/folk-image-gen.ts (pattern reference)
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port BlenderGenShapeUtil from canvas-website to rspace-online as a FolkShape Web Component.
|
||||
|
||||
Source: canvas-website/src/shapes/BlenderGenShapeUtil.tsx (693 lines)
|
||||
Target: rspace-online/lib/folk-blender-gen.ts
|
||||
|
||||
Features to implement:
|
||||
- Blender script editor textarea
|
||||
- LLM code generation (prompt → Blender Python script)
|
||||
- 3D preview iframe/canvas
|
||||
- Model download button
|
||||
- Loading/error states
|
||||
|
||||
Follow folk-image-gen.ts pattern for API-calling shape with loading/result states.
|
||||
Needs /api/blender-gen server endpoint (TASK-25).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Shape renders with script editor and 3D preview
|
||||
- [ ] #2 LLM code generation produces valid Blender scripts
|
||||
- [ ] #3 Results sync across clients via Automerge
|
||||
- [ ] #4 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
id: TASK-27
|
||||
title: Port folk-mycrozine-template shape (zine template picker)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-2
|
||||
- ai
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
references:
|
||||
- canvas-website/src/shapes/MycrozineTemplateShapeUtil.tsx
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port MycrozineTemplateShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/MycrozineTemplateShapeUtil.tsx (80 lines)
|
||||
Target: rspace-online/lib/folk-mycrozine-template.ts
|
||||
|
||||
Features: Template gallery picker, preview panel, template selection that launches MycroZineGenerator.
|
||||
Small shape — mostly a launcher UI for the generator.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Template gallery displays available templates
|
||||
- [ ] #2 Selecting a template creates/configures a MycroZineGenerator shape
|
||||
- [ ] #3 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: TASK-28
|
||||
title: Port folk-mycrozine-gen shape (AI zine generator)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-2
|
||||
- ai
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/shapes/MycroZineGeneratorShapeUtil.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port MycroZineGeneratorShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/MycroZineGeneratorShapeUtil.tsx (1,222 lines)
|
||||
Target: rspace-online/lib/folk-mycrozine-gen.ts
|
||||
|
||||
Features to implement:
|
||||
- Prompt input for zine content
|
||||
- Style and layout settings UI
|
||||
- AI-powered multi-page zine generation via LLM
|
||||
- Page preview/navigation
|
||||
- Export/download functionality
|
||||
|
||||
Largest AI shape to port. Needs /api/mycrozine server endpoint (TASK-25).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Prompt input generates zine pages via LLM
|
||||
- [ ] #2 Multi-page navigation works
|
||||
- [ ] #3 Style/layout settings affect output
|
||||
- [ ] #4 Results sync across clients via Automerge
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
id: TASK-29
|
||||
title: Port folk-drawfast shape (collaborative drawing/gesture recognition)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-2
|
||||
- creative
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
references:
|
||||
- canvas-website/src/shapes/DrawfastShapeUtil.tsx
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port DrawfastShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/DrawfastShapeUtil.tsx (652 lines)
|
||||
Target: rspace-online/lib/folk-drawfast.ts
|
||||
|
||||
Features to implement:
|
||||
- Freehand sketch input canvas
|
||||
- Gesture recognition (circles, lines, rectangles, arrows)
|
||||
- Shape detection and conversion
|
||||
- Real-time collaborative drawing
|
||||
- May use perfect-freehand (already in rspace deps)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Freehand drawing works with pointer/touch input
|
||||
- [ ] #2 Gesture recognition detects basic shapes
|
||||
- [ ] #3 Drawing state syncs across clients
|
||||
- [ ] #4 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
id: TASK-30
|
||||
title: Port folk-holon shape (H3 geospatial hex hierarchy)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-3
|
||||
- data-integration
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/shapes/HolonShapeUtil.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port HolonShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/HolonShapeUtil.tsx (1,216 lines)
|
||||
Target: rspace-online/lib/folk-holon.ts
|
||||
|
||||
Features to implement:
|
||||
- H3 geospatial hexagonal hierarchy visualization
|
||||
- HoloSphere network connection (real-time)
|
||||
- Multi-lens data viewing (switch between data perspectives)
|
||||
- In-place editing with auto-resize
|
||||
- Location props: latitude, longitude, resolution
|
||||
- Content props: name, description, holonId
|
||||
- State: isConnected, isEditing, selectedLens, data, connections
|
||||
|
||||
Dependencies: h3-js (TASK-24), /api/holon/* endpoints (TASK-25)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 H3 hex hierarchy renders correctly
|
||||
- [ ] #2 HoloSphere network connection works
|
||||
- [ ] #3 Multi-lens data switching functional
|
||||
- [ ] #4 Geospatial props sync across clients
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
id: TASK-31
|
||||
title: Port folk-holon-browser shape (Holon network browser)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-3
|
||||
- data-integration
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-30
|
||||
references:
|
||||
- canvas-website/src/shapes/HolonBrowserShapeUtil.tsx
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port HolonBrowserShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/HolonBrowserShapeUtil.tsx (202 lines)
|
||||
Target: rspace-online/lib/folk-holon-browser.ts
|
||||
|
||||
Features: Network visualization, search, filtering through Holon data. Companion to folk-holon — shares HoloSphere service client.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Browse/search Holon network data
|
||||
- [ ] #2 Visualization renders network graph
|
||||
- [ ] #3 Can open individual Holons from browser
|
||||
- [ ] #4 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
id: TASK-32
|
||||
title: Port folk-obsidian-browser shape (Obsidian vault explorer)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-3
|
||||
- data-integration
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/shapes/ObsidianBrowserShapeUtil.tsx
|
||||
- canvas-website/src/components/ObsidianVaultBrowser.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port ObsidianBrowserShapeUtil + ObsidianVaultBrowser component from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/ObsidianBrowserShapeUtil.tsx (413 lines) + canvas-website/src/components/ObsidianVaultBrowser.tsx (1,694 lines)
|
||||
Target: rspace-online/lib/folk-obsidian-browser.ts
|
||||
|
||||
Total: 2,107 lines — one of the largest ports.
|
||||
|
||||
Features to implement:
|
||||
- Obsidian vault file tree navigation
|
||||
- Full-text search across vault
|
||||
- Backlink preview and navigation
|
||||
- Note opening (creates folk-obs-note shapes)
|
||||
- Vault metadata display
|
||||
|
||||
Needs /api/obsidian/* server endpoints for local vault file operations (TASK-25).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 File tree renders vault directory structure
|
||||
- [ ] #2 Full-text search returns matching notes
|
||||
- [ ] #3 Backlink preview displays on hover/click
|
||||
- [ ] #4 Selecting a note creates folk-obs-note shape
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: TASK-33
|
||||
title: Port folk-fathom-browser shape (Fathom meetings integration)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-3
|
||||
- data-integration
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/shapes/FathomMeetingsBrowserShapeUtil.tsx
|
||||
- canvas-website/src/components/FathomMeetingsPanel.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port FathomMeetingsBrowserShapeUtil + FathomMeetingsPanel from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/FathomMeetingsBrowserShapeUtil.tsx (549 lines) + canvas-website/src/components/FathomMeetingsPanel.tsx (705 lines)
|
||||
Target: rspace-online/lib/folk-fathom-browser.ts
|
||||
|
||||
Features to implement:
|
||||
- Fathom API integration (meeting list, transcripts)
|
||||
- Meeting list with search/filter
|
||||
- Transcript search with speaker identification
|
||||
- Open individual meetings as folk-fathom-note shapes
|
||||
|
||||
Needs /api/fathom/* proxy endpoints (TASK-25).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Fathom API returns meeting list
|
||||
- [ ] #2 Search/filter meetings works
|
||||
- [ ] #3 Speaker identification displayed
|
||||
- [ ] #4 Selecting meeting creates folk-fathom-note
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
id: TASK-34
|
||||
title: Port folk-fathom-note shape (individual meeting note)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-3
|
||||
- data-integration
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-33
|
||||
references:
|
||||
- canvas-website/src/shapes/FathomNoteShapeUtil.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port FathomNoteShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/FathomNoteShapeUtil.tsx (667 lines)
|
||||
Target: rspace-online/lib/folk-fathom-note.ts
|
||||
|
||||
Features to implement:
|
||||
- Individual meeting note display
|
||||
- Speaker clips with timestamps
|
||||
- Timestamp linking (click to jump)
|
||||
- Note editing and annotation
|
||||
- Created by folk-fathom-browser when user selects a meeting
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Meeting note renders with transcript content
|
||||
- [ ] #2 Speaker clips display correctly
|
||||
- [ ] #3 Timestamp links navigate within transcript
|
||||
- [ ] #4 Note editing syncs across clients
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
id: TASK-35
|
||||
title: Port folk-multmux shape (xterm.js terminal emulator)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-4
|
||||
- dev-tools
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/shapes/MultmuxShapeUtil.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port MultmuxShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/MultmuxShapeUtil.tsx (850 lines)
|
||||
Target: rspace-online/lib/folk-multmux.ts
|
||||
|
||||
Features to implement:
|
||||
- xterm.js terminal emulator in a shape
|
||||
- WebSocket session management with auto-reconnect
|
||||
- Session naming and persistence
|
||||
- Fit addon for responsive terminal sizing
|
||||
- Shape migration support (versioning)
|
||||
|
||||
Dependencies: @xterm/xterm, @xterm/addon-fit (TASK-24)
|
||||
Needs WebSocket terminal endpoint on server (TASK-25).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Terminal renders with xterm.js
|
||||
- [ ] #2 WebSocket connection to terminal session works
|
||||
- [ ] #3 Auto-reconnect on disconnect
|
||||
- [ ] #4 Session state persists across page reloads
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
id: TASK-36
|
||||
title: Port folk-private-workspace shape (data sovereignty zone)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:50'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-4
|
||||
- privacy
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
references:
|
||||
- canvas-website/src/shapes/PrivateWorkspaceShapeUtil.tsx
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port PrivateWorkspaceShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/PrivateWorkspaceShapeUtil.tsx (370 lines)
|
||||
Target: rspace-online/lib/folk-private-workspace.ts
|
||||
|
||||
Features to implement:
|
||||
- Data sovereignty container zone
|
||||
- Visibility badges (public/private indicators)
|
||||
- Private data compartmentalization
|
||||
- Works with existing folk-google-item shape
|
||||
- Drag-in/drag-out items to change privacy scope
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Workspace zone renders with privacy boundary
|
||||
- [ ] #2 Visibility badges display correctly
|
||||
- [ ] #3 Items inside zone respect privacy scope
|
||||
- [ ] #4 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
id: TASK-37
|
||||
title: Port folk-transaction-builder shape (Safe multisig)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-4
|
||||
- web3
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
references:
|
||||
- canvas-website/src/shapes/TransactionBuilderShapeUtil.tsx
|
||||
- canvas-website/src/components/safe/TransactionComposer.tsx
|
||||
- canvas-website/src/components/safe/PendingTransactions.tsx
|
||||
- canvas-website/src/components/safe/TransactionHistory.tsx
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port TransactionBuilderShapeUtil + Safe components from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/TransactionBuilderShapeUtil.tsx (157 lines) + canvas-website/src/components/safe/ (585 lines total: SafeHeader, TransactionComposer, PendingTransactions, TransactionHistory)
|
||||
Target: rspace-online/lib/folk-transaction-builder.ts
|
||||
|
||||
Features to implement:
|
||||
- Transaction composition UI (select recipient, amount, data)
|
||||
- Pending transaction queue display
|
||||
- Transaction history view
|
||||
- Mode switching: compose/pending/history
|
||||
- Safe wallet integration
|
||||
|
||||
May need safe-apps-sdk or ethers.js dependency (TASK-24).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Transaction composer creates valid transactions
|
||||
- [ ] #2 Pending queue displays waiting transactions
|
||||
- [ ] #3 History view shows past transactions
|
||||
- [ ] #4 Mode switching works (compose/pending/history)
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: TASK-38
|
||||
title: Port folk-calendar-event shape (calendar event sub-shape)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-4
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
references:
|
||||
- canvas-website/src/shapes/CalendarEventShapeUtil.tsx
|
||||
- rspace-online/lib/folk-calendar.ts
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port CalendarEventShapeUtil from canvas-website to rspace-online.
|
||||
|
||||
Source: canvas-website/src/shapes/CalendarEventShapeUtil.tsx (457 lines)
|
||||
Target: rspace-online/lib/folk-calendar-event.ts
|
||||
|
||||
Features to implement:
|
||||
- Individual calendar event display
|
||||
- Time/date formatting and display
|
||||
- Recurrence info visualization
|
||||
- Color coding by event type/calendar
|
||||
- Created by existing folk-calendar when user drags out an event
|
||||
|
||||
Companion to existing folk-calendar shape.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Event renders with title, time, and color
|
||||
- [ ] #2 Recurrence info displays correctly
|
||||
- [ ] #3 folk-calendar can create calendar-event shapes
|
||||
- [ ] #4 Event data syncs across clients
|
||||
- [ ] #5 Toolbar button added to canvas.html
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
id: TASK-39
|
||||
title: Port MycelialIntelligence system (global AI bar + shape)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-5
|
||||
- ai
|
||||
- infrastructure
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-25
|
||||
references:
|
||||
- canvas-website/src/ui/MycelialIntelligenceBar.tsx
|
||||
- canvas-website/src/shapes/MycelialIntelligenceShapeUtil.tsx
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port the MycelialIntelligence system from canvas-website to rspace-online. This is a GLOBAL UI element (not just a shape).
|
||||
|
||||
Sources:
|
||||
- canvas-website/src/ui/MycelialIntelligenceBar.tsx (2,231 lines) — the main AI bar
|
||||
- canvas-website/src/shapes/MycelialIntelligenceShapeUtil.tsx (69 lines) — backward-compat shape
|
||||
|
||||
Target: rspace-online/lib/mycelial-intelligence-bar.ts (Web Component) + rspace-online/lib/folk-mycelial-intelligence.ts (shape)
|
||||
|
||||
This is the largest single migration item. Implement in phases:
|
||||
|
||||
Phase A: Basic chat UI bar (fixed bottom bar with prompt input + response display)
|
||||
Phase B: Canvas context awareness (knows selected shapes, viewport contents)
|
||||
Phase C: Shape creation/modification via AI commands (create shapes, edit properties)
|
||||
Phase D: Full tool integration (all AI capabilities available through bar)
|
||||
|
||||
The bar should be added as a persistent element in canvas.html, independent of the shape system.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 AI bar renders as persistent bottom UI element
|
||||
- [ ] #2 Chat prompt sends to LLM and displays responses
|
||||
- [ ] #3 Bar is context-aware of selected shapes and canvas state
|
||||
- [ ] #4 Can create/modify shapes via AI commands
|
||||
- [ ] #5 Backward-compat folk-mycelial-intelligence shape exists
|
||||
- [ ] #6 Toolbar button toggles bar visibility
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
id: TASK-40
|
||||
title: Port workflow engine (propagators + execution)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
labels:
|
||||
- infrastructure
|
||||
- phase-6
|
||||
- workflow
|
||||
milestone: m-0
|
||||
dependencies:
|
||||
- TASK-24
|
||||
references:
|
||||
- canvas-website/src/lib/workflow/
|
||||
- canvas-website/src/propagators/WorkflowPropagator.ts
|
||||
- canvas-website/src/propagators/ScopedPropagators.ts
|
||||
- rspace-online/lib/folk-workflow-block.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Port the workflow execution engine from canvas-website to rspace-online. This powers the existing folk-workflow-block shape with actual data flow and execution.
|
||||
|
||||
Source files (3,676 lines total):
|
||||
- canvas-website/src/lib/workflow/types.ts (159 lines) — type definitions
|
||||
- canvas-website/src/lib/workflow/blockRegistry.ts (438 lines) — block definition registry
|
||||
- canvas-website/src/lib/workflow/executor.ts (548 lines) — block execution engine
|
||||
- canvas-website/src/lib/workflow/portBindings.ts (464 lines) — port connection logic
|
||||
- canvas-website/src/lib/workflow/validation.ts (417 lines) — graph validation
|
||||
- canvas-website/src/lib/workflow/serialization.ts (571 lines) — save/load workflows
|
||||
- canvas-website/src/propagators/WorkflowPropagator.ts (326 lines) — real-time data flow
|
||||
|
||||
Target: rspace-online/lib/workflow/ directory
|
||||
|
||||
Key adaptation needed: canvas-website uses tldraw store for state; rspace-online uses Automerge. The execution engine needs to read/write shape data through CommunitySync instead of tldraw's store.
|
||||
|
||||
Also port relevant propagator concepts:
|
||||
- canvas-website/src/propagators/ScopedPropagators.ts (314 lines)
|
||||
- canvas-website/src/propagators/SpatialIndex.ts (164 lines)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Block registry loads available block types
|
||||
- [ ] #2 Port connections transfer data between blocks
|
||||
- [ ] #3 Execution engine runs blocks in correct order
|
||||
- [ ] #4 Validation catches invalid graph configurations
|
||||
- [ ] #5 Workflows serialize/deserialize through Automerge
|
||||
- [ ] #6 Real-time propagation updates connected blocks
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
id: TASK-41
|
||||
title: Build dynamic Shape Registry to replace hardcoded switch statements
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 20:06'
|
||||
labels:
|
||||
- infrastructure
|
||||
- phase-0
|
||||
- ecosystem
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- rspace-online/lib/folk-shape.ts
|
||||
- rspace-online/website/canvas.html
|
||||
- rspace-online/lib/community-sync.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the 170-line switch statement in canvas.html's `createShapeElement()` and the 100-line type-switch in community-sync.ts's `#updateShapeElement()` with a dynamic ShapeRegistry.
|
||||
|
||||
Create lib/shape-registry.ts with:
|
||||
- ShapeRegistration interface (tagName, elementClass, defaults, category, portDescriptors, eventDescriptors)
|
||||
- ShapeRegistry class with register(), createElement(), updateElement(), listAll(), getByCategory()
|
||||
- Each folk-*.ts gets a static `registration` property and static `fromData()` method
|
||||
|
||||
This is the prerequisite for all other ecosystem features (pipes, events, groups, nesting, embedding).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 ShapeRegistry class created with register/createElement/updateElement methods
|
||||
- [ ] #2 All 30+ folk-*.ts shapes have static registration property
|
||||
- [ ] #3 canvas.html switch statement replaced with registry.createElement()
|
||||
- [ ] #4 community-sync.ts type-switch replaced with registry.updateElement()
|
||||
- [ ] #5 All existing shapes still create and sync correctly
|
||||
- [ ] #6 No regression in shape creation or remote sync
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
id: TASK-42
|
||||
title: 'Implement Data Pipes: typed data flow through arrows'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 20:06'
|
||||
labels:
|
||||
- feature
|
||||
- phase-1
|
||||
- ecosystem
|
||||
milestone: m-1
|
||||
dependencies:
|
||||
- TASK-41
|
||||
references:
|
||||
- rspace-online/lib/folk-arrow.ts
|
||||
- rspace-online/lib/folk-shape.ts
|
||||
- rspace-online/lib/folk-image-gen.ts
|
||||
- rspace-online/lib/folk-prompt.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Transform folk-arrow from visual-only connector into a typed data conduit between shapes.
|
||||
|
||||
New file lib/data-types.ts:
|
||||
- DataType enum: string, number, boolean, image-url, video-url, text, json, trigger, any
|
||||
- Type compatibility matrix and isCompatible() function
|
||||
|
||||
Add port mixin to FolkShape:
|
||||
- ports map, getPort(), setPortValue(), onPortValueChanged()
|
||||
- Port values stored in Automerge: doc.shapes[id].ports[name].value
|
||||
- 100ms debounce on port propagation to prevent keystroke thrashing
|
||||
|
||||
Enhance folk-arrow:
|
||||
- sourcePort/targetPort fields referencing named ports
|
||||
- Listen for port-value-changed on source, push to target
|
||||
- Type compatibility check before pushing
|
||||
- Visual: arrows tinted by data type, flow animation when active
|
||||
- Port handle UI during connect mode
|
||||
|
||||
Add port descriptors to AI shapes:
|
||||
- folk-image-gen: input "prompt" (text), output "image" (image-url)
|
||||
- folk-video-gen: input "prompt" (text), input "image" (image-url), output "video" (video-url)
|
||||
- folk-prompt: input "context" (text), output "response" (text)
|
||||
- folk-transcription: output "transcript" (text)
|
||||
|
||||
Example pipeline: Transcription →[text]→ Prompt →[text]→ ImageGen →[image-url]→ VideoGen
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 DataType system with compatibility matrix works
|
||||
- [ ] #2 Shapes can declare input/output ports via registration
|
||||
- [ ] #3 setPortValue() writes to Automerge and dispatches event
|
||||
- [ ] #4 folk-arrow pipes data from source port to target port
|
||||
- [ ] #5 Type incompatible connections show warning
|
||||
- [ ] #6 Arrows visually indicate data type and active flow
|
||||
- [ ] #7 Port values sync to remote clients via Automerge
|
||||
- [ ] #8 100ms debounce prevents thrashing on rapid changes
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
id: TASK-43
|
||||
title: 'Implement Event Broadcasting: canvas-wide pub/sub system'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 20:06'
|
||||
labels:
|
||||
- feature
|
||||
- phase-2
|
||||
- ecosystem
|
||||
milestone: m-1
|
||||
dependencies:
|
||||
- TASK-41
|
||||
references:
|
||||
- rspace-online/lib/community-sync.ts
|
||||
- rspace-online/server/community-store.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add a pub/sub event system so shapes can broadcast and subscribe to named events across the canvas.
|
||||
|
||||
New file lib/event-bus.ts:
|
||||
- CanvasEventBus class with emit(), subscribe(), unsubscribe(), getSubscribers()
|
||||
- Events written to CRDT doc.eventLog (bounded ring buffer, last 100 entries)
|
||||
- Remote users see events replayed via Automerge patch application
|
||||
- Re-entrancy guard kills chains after 10 levels to prevent infinite loops
|
||||
|
||||
Automerge schema additions:
|
||||
- doc.eventLog: EventEntry[] (id, channel, sourceShapeId, payload, timestamp)
|
||||
- shapes[id].subscriptions: string[] (channel names)
|
||||
|
||||
Shapes opt in with onEventReceived(channel, payload) method.
|
||||
|
||||
Example: Timer emits "timer:done" → all subscribed Budget shapes recalculate.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 CanvasEventBus emits events to CRDT eventLog
|
||||
- [ ] #2 Shapes can subscribe to channels and receive events
|
||||
- [ ] #3 Events sync to remote users via Automerge
|
||||
- [ ] #4 Ring buffer bounded at 100 entries with GC
|
||||
- [ ] #5 Re-entrancy guard prevents infinite event loops
|
||||
- [ ] #6 Works offline (events queued in CRDT, replayed on reconnect)
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
id: TASK-44
|
||||
title: 'Implement Semantic Grouping: named shape clusters with templates'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 20:06'
|
||||
labels:
|
||||
- feature
|
||||
- phase-3
|
||||
- ecosystem
|
||||
milestone: m-1
|
||||
dependencies:
|
||||
- TASK-41
|
||||
references:
|
||||
- rspace-online/lib/DOMRectTransform.ts
|
||||
- rspace-online/lib/community-sync.ts
|
||||
- rspace-online/server/community-store.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add the ability to group shapes into named clusters that can be collapsed, moved together, and saved as reusable templates.
|
||||
|
||||
New file lib/group-manager.ts:
|
||||
- GroupManager class: createGroup(), dissolveGroup(), addToGroup(), removeFromGroup()
|
||||
- collapseGroup() / expandGroup() — hide members, show summary card
|
||||
- moveGroup(groupId, dx, dy) — moves all members by delta
|
||||
- saveAsTemplate() / instantiateTemplate() — serialize group as reusable JSON template
|
||||
|
||||
Automerge schema additions:
|
||||
- doc.groups: { [groupId]: GroupData } — top-level CRDT entity
|
||||
- GroupData: id, name, color, icon, memberShapeIds[], collapsed, x, y, width, height
|
||||
- shapes[id].groupId: string — which group a shape belongs to
|
||||
|
||||
Visual rendering:
|
||||
- folk-group-frame — lightweight overlay element (NOT a FolkShape), dashed border + header bar
|
||||
- Recalculates bounds from member shapes on each animation frame
|
||||
- Uses existing DOMRectTransform.ts for rotated shape bounding boxes
|
||||
- Collapse button, group name, color indicator
|
||||
|
||||
Canvas.html additions:
|
||||
- Rubber-band or shift-click multi-select → right-click "Group"
|
||||
- Group context menu (rename, change color, collapse, save as template, dissolve)
|
||||
- Template library panel for saved templates
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 GroupManager creates/dissolves groups stored in Automerge
|
||||
- [ ] #2 Moving group header moves all member shapes together
|
||||
- [ ] #3 Collapse hides members and shows summary card
|
||||
- [ ] #4 Expand restores all members to original positions
|
||||
- [ ] #5 Groups sync to remote users via Automerge
|
||||
- [ ] #6 Save as template serializes group + internal arrows as JSON
|
||||
- [ ] #7 Instantiate template creates new shapes from template
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
id: TASK-45
|
||||
title: 'Implement Shape Nesting: shapes containing shapes + recursive canvas'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 20:06'
|
||||
labels:
|
||||
- feature
|
||||
- phase-4
|
||||
- ecosystem
|
||||
milestone: m-1
|
||||
dependencies:
|
||||
- TASK-44
|
||||
references:
|
||||
- rspace-online/lib/folk-shape.ts
|
||||
- rspace-online/lib/community-sync.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Allow shapes to contain other shapes, including a recursive canvas shape.
|
||||
|
||||
Automerge schema additions (flat references, NOT nested objects):
|
||||
- shapes[id].parentId: string — nested inside this shape
|
||||
- shapes[id].childIds: string[] — contains these shapes
|
||||
|
||||
New shape lib/folk-canvas.ts:
|
||||
- A shape that IS a canvas — renders child shapes inside scrollable/zoomable container
|
||||
- Optional linkedCommunitySlug to show shapes from another community (cross-canvas embedding)
|
||||
- Own zoom/pan controls within the mini-canvas
|
||||
|
||||
Coordinate system:
|
||||
- Children store ABSOLUTE canvas coordinates in CRDT (simplifies sync, prevents jitter)
|
||||
- folk-canvas applies CSS transform offset so children appear inside it
|
||||
- When parent moves, nesting manager applies delta to all children
|
||||
- Nesting is a render-time concern, not a data-model concern
|
||||
|
||||
FolkShape additions:
|
||||
- parentShape getter, childShapes getter
|
||||
- addChild(), removeChild()
|
||||
- toParentCoords(), toCanvasCoords() for coordinate transforms
|
||||
|
||||
Canvas.html: drag-drop shape onto folk-canvas to nest it.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Shapes can be nested inside folk-canvas via drag-drop
|
||||
- [ ] #2 Nested shapes move with parent when parent is moved
|
||||
- [ ] #3 folk-canvas has its own zoom/pan controls
|
||||
- [ ] #4 parentId/childIds sync correctly via Automerge
|
||||
- [ ] #5 Un-nesting a shape restores it to top-level canvas
|
||||
- [ ] #6 No coordinate jitter when two users move parent and child simultaneously
|
||||
- [ ] #7 Optional cross-canvas linking via linkedCommunitySlug
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
id: TASK-46
|
||||
title: 'Implement Cross-App Embedding: r-ecosystem apps in rSpace canvases'
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-02-18 20:07'
|
||||
updated_date: '2026-02-26 03:50'
|
||||
labels:
|
||||
- feature
|
||||
- phase-5
|
||||
- ecosystem
|
||||
milestone: m-1
|
||||
dependencies:
|
||||
- TASK-41
|
||||
- TASK-42
|
||||
references:
|
||||
- rspace-online/lib/shape-registry.ts
|
||||
- rspace-online/server/index.ts
|
||||
- rspace-online/website/canvas.html
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Allow r-ecosystem apps (rWallet, rVote, rMaps, etc.) to embed their live UI into rSpace canvases via dynamically loaded Web Components.
|
||||
|
||||
Ecosystem App Manifest Protocol:
|
||||
- Each app hosts /.well-known/rspace-manifest.json
|
||||
- Manifest declares: appId, name, icon, moduleUrl, shapes[] (tagName, defaults, portDescriptors, eventDescriptors)
|
||||
|
||||
New file lib/ecosystem-bridge.ts:
|
||||
- EcosystemBridge class: loadManifest(), loadModule(), createSandboxedEmbed()
|
||||
- Two embedding modes:
|
||||
1. Trusted (Web Component): dynamic import(), shares CRDT directly, full port/event access
|
||||
2. Sandboxed (iframe): postMessage bridge for untrusted apps, limited API
|
||||
|
||||
New Automerge shape type:
|
||||
- type: "ecosystem-embed", appId, moduleUrl, config, sandboxed boolean
|
||||
|
||||
Server additions:
|
||||
- GET /api/ecosystem/:appId/manifest — proxy to avoid CORS
|
||||
- Server pre-fetches and caches ecosystem manifests
|
||||
|
||||
Canvas.html additions:
|
||||
- Dynamic toolbar section for ecosystem apps (loaded from manifests)
|
||||
- Lazy module loading on first use
|
||||
- Service Worker caches modules for offline
|
||||
|
||||
Runtime:
|
||||
1. Server fetches ecosystem manifests → toolbar shows app buttons
|
||||
2. User adds ecosystem shape → module import()-ed → Web Component registered → shape created
|
||||
3. Remote sync: create-shape for ecosystem-embed triggers lazy module load on other clients
|
||||
4. Embedded components participate in port/event system like native shapes
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Ecosystem manifest protocol defined and documented
|
||||
- [ ] #2 EcosystemBridge loads manifests and dynamic imports modules
|
||||
- [ ] #3 Trusted Web Components share CRDT and port/event system
|
||||
- [ ] #4 Sandboxed iframe mode works with postMessage bridge
|
||||
- [ ] #5 Server proxy avoids CORS for manifest/module loading
|
||||
- [x] #6 Toolbar dynamically shows ecosystem app buttons
|
||||
- [x] #7 Remote clients lazy-load modules when ecosystem shapes appear
|
||||
- [ ] #8 Service Worker caches ecosystem modules for offline
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
POC implemented in commit 50f0e11: folk-rapp shape type embeds live rApp modules as iframes on the canvas. Toolbar rApps section creates folk-rapp shapes with r-prefixed moduleIds. Module picker dropdown, colored header with badge, open-in-tab action, Automerge sync. Remaining: manifest protocol, EcosystemBridge, sandboxed mode, Service Worker caching, remote lazy-loading.
|
||||
|
||||
Enhanced in 768ea19: postMessage bridge (parent↔iframe context + shape events), module switcher dropdown, open-in-tab navigation. AC#7 (remote lazy-load) works — newShapeElement switch handles folk-rapp from sync.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: TASK-48
|
||||
title: Fix TypeScript build errors blocking deployment
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-24 03:51'
|
||||
labels:
|
||||
- bugfix
|
||||
- typescript
|
||||
- build
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Resolved all TypeScript compilation errors that were preventing `npm run build` from succeeding on the server. The build command `tsc --noEmit && vite build` was failing with ~50 errors across multiple modules.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 tsc --noEmit passes with zero errors
|
||||
- [ ] #2 vite build completes successfully
|
||||
- [ ] #3 Docker build and deploy succeeds on Netcup
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed ~50 TypeScript errors across the codebase:\n\n- **9 modules** (cal, cart, forum, inbox, notes, providers, trips, vote, work): Changed `unknown[]` → `any[]` for `sql.unsafe()` parameter arrays. The postgres.js `ParameterOrJSON<never>` type was too restrictive when no custom types are defined.\n- **website/sw.ts**: Excluded from tsconfig — service worker needs WebWorker types, not DOM types, and is built separately by Vite.\n- **src/lib/demo-sync.ts**: Excluded from tsconfig — React hook for external consumers, React not a project dependency.\n- **modules/splat**: Fixed Hono context `c.get()` typing via cast, `instanceof File` via `unknown` cast, and inline sql.unsafe arrays.\n- **modules/swag/folk-swag-designer.ts**: Renamed `private title` → `private designTitle` to avoid collision with `HTMLElement.title`.\n- **types/gaussian-splats-3d.d.ts**: Added type declaration stub for CDN-loaded package.\n\nCommit: b2f1beb on main. Deployed to Netcup successfully.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: TASK-49
|
||||
title: Add admin dashboard at /admin with space overview
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-24 23:29'
|
||||
labels:
|
||||
- feature
|
||||
- admin
|
||||
- frontend
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Added an admin dashboard page at /admin that shows all rSpace spaces with detailed stats including shape count, member count, file size on disk, visibility, creation date, and owner DID. Includes search, filter by visibility, and sort controls. Also added /api/spaces/admin API endpoint returning all space data.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Admin page accessible at /admin
|
||||
- [ ] #2 Shows all spaces with shape count, member count, file size
|
||||
- [ ] #3 Search bar filters by name/slug/owner
|
||||
- [ ] #4 Visibility filter buttons work
|
||||
- [ ] #5 Sort dropdown works (date, name, shapes, size)
|
||||
- [ ] #6 API endpoint at /api/spaces/admin returns detailed space data
|
||||
- [ ] #7 Vite build includes admin.html
|
||||
- [ ] #8 Consistent styling with existing rSpace dark theme
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
id: TASK-50
|
||||
title: Implement nested spaces architecture with permission cascade
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 02:27'
|
||||
updated_date: '2026-02-25 02:43'
|
||||
labels:
|
||||
- architecture
|
||||
- spaces
|
||||
- permissions
|
||||
- encryptid
|
||||
dependencies: []
|
||||
references:
|
||||
- server/community-store.ts
|
||||
- server/spaces.ts
|
||||
- src/encryptid/server.ts
|
||||
- lib/community-sync.ts
|
||||
documentation:
|
||||
- docs/SPACE-ARCHITECTURE.md
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Spaces are now nestable — any space can embed references to other spaces via SpaceRef, with a permission cascade model (most-restrictive-wins at each nesting boundary). Every EncryptID registration auto-provisions a sovereign space at <username>.rspace.online with consent-based nesting controls.
|
||||
|
||||
Core principle: a space is a space is a space. No type field distinguishing personal vs community. The "personal" quality emerges from ownership + permissions, not a schema distinction.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 NestPolicy type with consent levels (open/members/approval/closed)
|
||||
- [ ] #2 SpaceRef CRUD endpoints on /api/spaces/:slug/nest
|
||||
- [ ] #3 Permission cascade via intersection (most-restrictive-wins)
|
||||
- [ ] #4 Approval flow for nest requests with admin review
|
||||
- [ ] #5 Source space admins can always revoke nestings (sovereignty guarantee)
|
||||
- [ ] #6 Auto-provision <username>.rspace.online on EncryptID registration
|
||||
- [ ] #7 defaultPermissions ceiling caps requested permissions
|
||||
- [ ] #8 Allowlist/blocklist per space
|
||||
- [ ] #9 Reverse lookup (nested-in) endpoint
|
||||
- [ ] #10 Client-side types for nested space rendering
|
||||
- [ ] #11 TypeScript compiles clean
|
||||
- [ ] #12 Full architecture spec at docs/SPACE-ARCHITECTURE.md
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Phase 3-5 implemented and pushed to dev:
|
||||
- Phase 3: folk-canvas nested space shape with live WS, auto-scaling, collapsed/expanded views
|
||||
- Phase 4: WS cascade enforcement — nest filter on broadcasts, addShapes/deleteShapes checks
|
||||
- Phase 5: AES-256-GCM at-rest encryption with transparent encrypt/decrypt and API endpoints
|
||||
- All phases type-check clean (npx tsc --noEmit)
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
## Phase 1+2 Implementation Complete
|
||||
|
||||
### Schema changes (community-store.ts)
|
||||
- New types: NestPermissions, NestNotifications, NestPolicy, SpaceRef, SpaceRefFilter, PendingNestRequest
|
||||
- Default policies: DEFAULT_USER_NEST_POLICY (approval consent) and DEFAULT_COMMUNITY_NEST_POLICY (members consent)
|
||||
- Updated CommunityMeta with enabledModules, description, avatar, nestPolicy, encrypted fields
|
||||
- Updated CommunityDoc with nestedSpaces map
|
||||
- CRUD: addNestedSpace, updateNestedSpace, removeNestedSpace, getNestPolicy, updateNestPolicy, setEnabledModules
|
||||
- Permission logic: capPermissions (ceiling), cascadePermissions (intersection)
|
||||
- Reverse lookup: findNestedIn
|
||||
|
||||
### REST API (spaces.ts)
|
||||
- GET/PATCH /:slug/nest-policy
|
||||
- GET/POST /:slug/nest (with full consent flow)
|
||||
- GET/PATCH/DELETE /:slug/nest/:refId
|
||||
- GET /:slug/nested-in
|
||||
- GET/PATCH /:slug/nest-requests (approval flow)
|
||||
|
||||
### Auto-provisioning (encryptid/server.ts)
|
||||
- After registration, creates <username>.rspace.online with members_only visibility, user nest policy, default modules
|
||||
|
||||
### Remaining phases
|
||||
- Phase 3: folk-canvas shape renderer for SpaceRef entries
|
||||
- Phase 4: Full cascade enforcement on WebSocket writes
|
||||
- Phase 5: Encryption integration
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: TASK-51
|
||||
title: Consolidate standalone r*.online domains → rspace.online
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:46'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
dependencies: []
|
||||
references:
|
||||
- server/index.ts (lines 457-521 — standalone rewrite logic)
|
||||
- shared/module.ts (standaloneDomain interface)
|
||||
- shared/components/rstack-app-switcher.ts (external link arrows)
|
||||
- docker-compose.yml (lines 44-114 — Traefik labels)
|
||||
- src/encryptid/server.ts (allowedOrigins list)
|
||||
- src/encryptid/session.ts (JWT aud claim)
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Migrate ~20 standalone domains (rbooks.online, rmaps.online, rfunds.online, etc.) into path-based routing under rspace.online. Currently these domains are silently rewritten to /:space/:moduleId internally — this migration makes rspace.online the canonical URL, adds 301 redirects, removes dead infrastructure, and lets domains expire.
|
||||
|
||||
Execution order: Phase 2 → 1 → 3 → 4 → 5 → 6 (fix external service URLs before enabling redirects).
|
||||
|
||||
Key risks:
|
||||
- rnetwork.online is dual-purpose (module alias AND TWENTY_API_URL) — must decouple before redirects
|
||||
- sync.rmaps.online is a separate WebSocket service, not the rmaps module
|
||||
- PWA/service worker caches on old domains may need self-unregistering workers
|
||||
- keepStandalone domains (rcart.online, rfiles.online, swag.mycofi.earth, providers.mycofi.earth) need separate evaluation
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 All standalone domain hits return 301 → rspace.online/demo/{moduleId}/...
|
||||
- [ ] #2 No hardcoded references to r*.online domains remain in codebase
|
||||
- [ ] #3 WebAuthn/EncryptID auth works solely on rspace.online
|
||||
- [ ] #4 Standalone Traefik labels and docker-compose.standalone.yml removed
|
||||
- [ ] #5 Standalone .ts entry points deleted
|
||||
- [ ] #6 Domain registrations allowed to expire
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: TASK-51.1
|
||||
title: 'Phase 2: Fix external service URLs (analytics, maps sync, Twenty CRM)'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:47'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
dependencies: []
|
||||
parent_task_id: TASK-51
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Update hardcoded references to standalone domains used as service endpoints. Must be done BEFORE Phase 1 redirects to avoid breaking analytics, maps sync, and network module.
|
||||
|
||||
Files: website/index.html, website/create-space.html (collect.js), docker-compose.yml (MAPS_SYNC_URL, TWENTY_API_URL), modules/maps/mod.ts, modules/network/mod.ts.
|
||||
|
||||
DECISION NEEDED: Is Twenty CRM (rnetwork.online) a separate container or proxied through network module?
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Analytics collect.js loads from relative path on rspace.online
|
||||
- [ ] #2 Maps sync WebSocket connects via new URL
|
||||
- [ ] #3 Network module reaches Twenty CRM without depending on rnetwork.online domain
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: TASK-51.2
|
||||
title: 'Phase 1: Convert standalone domain rewrite to 301 redirects'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:47'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
dependencies:
|
||||
- TASK-51.1
|
||||
parent_task_id: TASK-51
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Change server/index.ts standalone domain handling from silent rewrite to 301 redirect for HTML page loads. Keep API/WS rewriting so running apps don't break. Traefik labels stay — domains must still route to the container to serve the 301.
|
||||
|
||||
Target: server/index.ts lines 482-521. Redirect HTML page loads, continue proxying /api/* and /ws/* requests. keepStandalone domains unaffected.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 rmaps.online/some-room returns 301 to rspace.online/demo/maps/some-room
|
||||
- [ ] #2 rbooks.online/ returns 301 to rspace.online/demo/books
|
||||
- [ ] #3 API and WebSocket requests still proxied without redirect
|
||||
- [ ] #4 keepStandalone domains unaffected
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: TASK-51.3
|
||||
title: 'Phase 3: Update UI links (app switcher, landing page)'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:47'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
- ui
|
||||
dependencies:
|
||||
- TASK-51.2
|
||||
parent_task_id: TASK-51
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Remove standalone domain references from user-facing UI. Remove external link arrow from app switcher, update landing page ecosystem links to path-based routes, remove standaloneDomain from ModuleInfo interface.
|
||||
|
||||
Files: shared/components/rstack-app-switcher.ts, shared/module.ts, website/index.html, website/create-space.html.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 App switcher shows no external link arrows
|
||||
- [ ] #2 Landing page ecosystem links use /demo/{moduleId} paths
|
||||
- [ ] #3 ModuleInfo no longer exposes standaloneDomain to client
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: TASK-51.4
|
||||
title: 'Phase 4: Simplify EncryptID and WebAuthn for single domain'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:47'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
- auth
|
||||
dependencies:
|
||||
- TASK-51.3
|
||||
parent_task_id: TASK-51
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Prune WebAuthn Related Origins, JWT audience claims, and CORS allowedOrigins now that all modules are on rspace.online.
|
||||
|
||||
Files: server/index.ts (.well-known/webauthn), public/.well-known/webauthn, src/encryptid/session.ts (JWT aud), src/encryptid/server.ts (allowedOrigins + HTML templates).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Passkey login works on rspace.online
|
||||
- [ ] #2 No CORS errors for auth flows
|
||||
- [ ] #3 JWT aud is rspace.online only
|
||||
- [ ] #4 .well-known/webauthn no longer lists standalone domains
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: TASK-51.5
|
||||
title: 'Phase 5: Remove standalone domain dead code and infrastructure'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:48'
|
||||
updated_date: '2026-02-25 07:48'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
- cleanup
|
||||
dependencies:
|
||||
- TASK-51.4
|
||||
parent_task_id: TASK-51
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
After 301 redirects have been live 3-6 months, strip all standalone domain machinery. Delete domainToModule map, keepStandalone set, rewrite/redirect block in server/index.ts. Remove standaloneDomain from RSpaceModule interface and all 22 mod.ts files. Delete all 20 standalone.ts entry points. Remove Traefik labels. Delete docker-compose.standalone.yml.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 No references to standaloneDomain remain in codebase
|
||||
- [ ] #2 No standalone.ts files exist
|
||||
- [ ] #3 docker-compose.standalone.yml deleted
|
||||
- [ ] #4 Traefik config only has rspace.online and *.rspace.online routers
|
||||
- [ ] #5 All modules work via path-based routing
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: TASK-51.6
|
||||
title: 'Phase 6: DNS cleanup and domain expiry'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:48'
|
||||
updated_date: '2026-02-25 07:48'
|
||||
labels:
|
||||
- infrastructure
|
||||
- domains
|
||||
- migration
|
||||
- dns
|
||||
dependencies:
|
||||
- TASK-51.5
|
||||
parent_task_id: TASK-51
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Operational phase — no code changes. Monitor Cloudflare analytics on old domains for 3-6 months. Remove tunnel hostname entries, DNS zones. Let Porkbun registrations expire. Keep rspace.online, ridentity.online, rstack.online, rmail.online. Evaluate keepStandalone domains separately.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 No Cloudflare tunnel entries for expired domains
|
||||
- [ ] #2 No DNS zones for expired domains
|
||||
- [ ] #3 Domain renewals cancelled at Porkbun
|
||||
- [ ] #4 Core domains retained
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: TASK-52
|
||||
title: Redesign canvas toolbar with grouped dropdowns and collapse
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 21:07'
|
||||
labels:
|
||||
- ui
|
||||
- canvas
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
The canvas toolbar had 28+ flat tool buttons in a single horizontal row that ran off screen on most displays. Redesigned with grouped dropdown menus and a collapse/minimize toggle.
|
||||
|
||||
**Changes:**
|
||||
- 6 category dropdowns: Create, Media, Embed, AI, Travel, Decide
|
||||
- Direct-access buttons for Connect, Memory, and Zoom controls
|
||||
- Collapse toggle (◀/▶) to minimize toolbar to a single button
|
||||
- Mobile responsive: accordion-style groups instead of floating dropdowns
|
||||
- Click-outside-to-close and auto-close-on-tool-select behavior
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Replaced 28 flat toolbar buttons with 6 grouped dropdowns (Create, Media, Embed, AI, Travel, Decide) plus direct-access Connect/Memory/Zoom buttons. Added collapsible toolbar toggle. Mobile-responsive with accordion-style groups. Commit: 5c3db2c on dev branch.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
id: TASK-53
|
||||
title: Redesign shared identity modal and user dropdown
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 22:59'
|
||||
labels:
|
||||
- identity
|
||||
- ux
|
||||
- shared-header
|
||||
dependencies: []
|
||||
references:
|
||||
- shared/components/rstack-identity.ts
|
||||
- server/shell.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Redesign the rstack-identity web component shared across all rApps. Replace the old auth modal with a unified "Sign up / Sign in" landing page, and replace Profile/Recovery dropdown items (which routed to auth.ridentity.online) with in-app account settings modals.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Auth modal shows unified Sign up / Sign in with stacked passkey buttons
|
||||
- [ ] #2 Close X button and Powered by EncryptID link to ridentity.online on all modal views
|
||||
- [ ] #3 Register view has Back button instead of inline toggle
|
||||
- [ ] #4 Logged-in dropdown shows username header with Add Email, Add Second Device, Add Social Recovery
|
||||
- [ ] #5 No more navigation to auth.ridentity.online from the header
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented in commit 0647f19. Auth modal redesigned with unified landing, close buttons, EncryptID branding link. User dropdown restructured with account settings items replacing old Profile/Recovery links. All changes in rstack-identity.ts web component, shared across all 22 rApps via the shell renderer.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
id: TASK-54
|
||||
title: Update space switcher with emoji visibility badges and button styling
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 22:59'
|
||||
labels:
|
||||
- ux
|
||||
- shared-header
|
||||
dependencies: []
|
||||
references:
|
||||
- shared/components/rstack-space-switcher.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Update rstack-space-switcher to use emoji visibility indicators (green unlocked, yellow key, red lock) instead of text labels, and match the trigger button styling to the rApp switcher.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Public spaces show green unlocked emoji
|
||||
- [ ] #2 Permissioned spaces show yellow key emoji
|
||||
- [ ] #3 Private spaces show red lock emoji
|
||||
- [ ] #4 Trigger button matches app-switcher style (font-size, colors, no slash prefix)
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented in commit 0647f19. Visibility badges changed from text (PUBLIC/PRIVATE/PERMISSIONED) to emojis (🔓/🔑/🔒) with color-coded backgrounds. Trigger button updated: removed slash prefix, bumped font to 0.9rem, matched app-switcher color scheme.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: TASK-55
|
||||
title: >-
|
||||
Wire up account settings endpoints (email verification, device registration,
|
||||
guardians)
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 22:59'
|
||||
labels:
|
||||
- identity
|
||||
- backend
|
||||
- encryptid
|
||||
dependencies: []
|
||||
references:
|
||||
- src/encryptid/server.ts
|
||||
- src/encryptid/db.ts
|
||||
- shared/components/rstack-identity.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add server-side endpoints for the three account settings features and wire up the client modals to use them. Email verification uses SMTP with 6-digit codes. Device registration uses WebAuthn for same-device passkey addition. Social recovery uses the existing guardian API.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 POST /api/account/email/start sends 6-digit code via SMTP
|
||||
- [ ] #2 POST /api/account/email/verify validates code and sets email on account
|
||||
- [ ] #3 POST /api/account/device/start returns WebAuthn creation options for authenticated user
|
||||
- [ ] #4 POST /api/account/device/complete stores new credential under existing account
|
||||
- [ ] #5 Social recovery modal loads guardians from GET /api/guardians on open
|
||||
- [ ] #6 Adding guardian calls POST /api/guardians with name + optional email
|
||||
- [ ] #7 Removing guardian calls DELETE /api/guardians/:id
|
||||
- [ ] #8 StoredChallenge.type includes device_registration
|
||||
- [ ] #9 StoredRecoveryToken.type includes email_verification
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented in commit 914d0e6. Added 4 new server endpoints under /api/account/ namespace. Email verification sends styled HTML email with 6-digit code via Mailcow SMTP, stores as recovery token. Device registration reuses existing challenge/credential infrastructure with new device_registration type. Client social recovery modal rewritten to use existing guardian API (add/remove individual guardians, load on open, show status). DB types extended for new token/challenge types.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
id: TASK-56
|
||||
title: Consistent headers across all rApps + mi AI assistant
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 23:10'
|
||||
labels:
|
||||
- ux
|
||||
- shared-header
|
||||
- ai
|
||||
- mi
|
||||
dependencies: []
|
||||
references:
|
||||
- shared/components/rstack-mi.ts
|
||||
- server/index.ts
|
||||
- server/shell.ts
|
||||
- website/public/shell.css
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make all rApp headers identical (fix height mismatches, remove custom overrides) and add the mi AI assistant search bar to every page header.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 All 7 module CSS files use 56px header height (not 52px)
|
||||
- [ ] #2 No module CSS overrides the shared header background
|
||||
- [ ] #3 All headers have 3-section layout: left (switchers) / center (mi) / right (identity)
|
||||
- [ ] #4 rstack-mi component registered in shell.ts and present in all 4 HTML pages + both shell renderers
|
||||
- [ ] #5 POST /api/mi/ask streams from Ollama with rApp-aware system prompt
|
||||
- [ ] #6 Fallback responses when Ollama unavailable
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented in commit 0813eed. Fixed 52px→56px in 7 module CSS files (pubs, funds, providers, books, swag, choices, cart). Removed header background overrides from books.css and pubs.css. Created rstack-mi web component with streaming chat UI and added to all pages. Server endpoint /api/mi/ask proxies to Ollama with dynamic system prompt built from all 22 registered modules. Keyword-based fallback when AI service is offline. Configurable via MI_MODEL and OLLAMA_URL env vars.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
id: TASK-57
|
||||
title: Layered tab system with inter-layer flows and bidirectional feeds
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 23:31'
|
||||
updated_date: '2026-02-25 23:31'
|
||||
labels:
|
||||
- feature
|
||||
- canvas
|
||||
- architecture
|
||||
milestone: rspace-app-ecosystem
|
||||
dependencies: []
|
||||
references:
|
||||
- lib/layer-types.ts
|
||||
- lib/folk-feed.ts
|
||||
- shared/components/rstack-tab-bar.ts
|
||||
- lib/community-sync.ts
|
||||
- shared/module.ts
|
||||
- server/shell.ts
|
||||
- website/canvas.html
|
||||
- website/shell.ts
|
||||
- website/public/shell.css
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement a layered tab system where each rApp becomes a "layer" that can be viewed as tabs (flat mode) or stacked strata (stack view). Layers connect via typed flows (economic, trust, data, attention, governance, resource) enabling inter-app data sharing. Feed shapes on the canvas pull live data from other layers' APIs with bidirectional write-back support.
|
||||
|
||||
## 4-Phase Implementation
|
||||
|
||||
**Phase 1 — Tab Bar UI + Layer Configuration**
|
||||
- Created `rstack-tab-bar` web component with flat (tabs) and stack (SVG side-view) modes
|
||||
- Drag-to-reorder tabs, add/close layers
|
||||
- Extended Automerge CommunityDoc with layers, flows, activeLayerId, layerViewMode
|
||||
- Core types: Layer, LayerFlow, FlowKind in `lib/layer-types.ts`
|
||||
|
||||
**Phase 2 — Feed Definitions on Modules**
|
||||
- Added FeedDefinition interface to shared/module.ts
|
||||
- Added feeds and acceptsFeeds to 10 modules: funds, notes, vote, choices, wallet, data, work, network, trips, canvas
|
||||
- Each module declares what feed kinds it exposes and accepts
|
||||
|
||||
**Phase 3 — Folk Feed Shape**
|
||||
- Built `folk-feed` canvas shape that fetches live data from other layers' module APIs
|
||||
- Module-specific endpoint mapping and response normalization
|
||||
- Auto-refresh on configurable interval
|
||||
- Auto-flow detection when creating feed shapes
|
||||
|
||||
**Phase 4 — Bidirectional Flows**
|
||||
- Edit overlay with module-specific fields for write-back via PUT/PATCH
|
||||
- Click-through navigation (double-click items)
|
||||
- Drag-to-connect flows in stack view with kind/label/strength dialog
|
||||
- Right-click to delete flows
|
||||
- Full event wiring in shell.ts for all layer/flow CRUD operations
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Tab bar renders above canvas with flat/stack view toggle
|
||||
- [x] #2 Layers persist in Automerge CommunityDoc for real-time sync
|
||||
- [x] #3 10 modules declare feed definitions with FlowKind types
|
||||
- [x] #4 folk-feed shape fetches live data from source module APIs
|
||||
- [x] #5 Bidirectional write-back saves edits to source module
|
||||
- [x] #6 Drag-to-connect in stack view creates typed flows
|
||||
- [x] #7 Flow creation dialog with kind, label, and strength
|
||||
- [x] #8 Auto-flow detection when creating feed shapes
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
## Summary
|
||||
Implemented a 4-phase layered tab system enabling inter-app data flows across the rSpace canvas.
|
||||
|
||||
## Files Changed (18 files, +2539 lines)
|
||||
|
||||
**New files:**
|
||||
- `lib/layer-types.ts` (100 lines) — Core types: FlowKind, Layer, LayerFlow, FLOW_COLORS/FLOW_LABELS
|
||||
- `lib/folk-feed.ts` (887 lines) — Canvas shape fetching live data from other layers with bidirectional write-back
|
||||
- `shared/components/rstack-tab-bar.ts` (1080 lines) — Tab bar web component with flat/stack views, drag-to-connect flows
|
||||
|
||||
**Modified files:**
|
||||
- `lib/community-sync.ts` (+149 lines) — Extended CommunityDoc with layers/flows, 11 new CRDT methods
|
||||
- `shared/module.ts` (+31 lines) — FeedDefinition interface, feeds/acceptsFeeds on RSpaceModule
|
||||
- `server/shell.ts` (+50 lines) — Tab bar HTML, event wiring, CommunitySync integration
|
||||
- `website/canvas.html` (+87 lines) — folk-feed registration, toolbar button, auto-flow detection
|
||||
- `website/shell.ts` (+2 lines) — Component registration
|
||||
- `website/public/shell.css` (+25 lines) — Tab row positioning
|
||||
- `lib/index.ts` (+3 lines) — folk-feed barrel export
|
||||
- 10 module mod.ts files — Feed definitions for funds, notes, vote, choices, wallet, data, work, network, trips, canvas
|
||||
|
||||
## Commit
|
||||
`cd440f1` feat: layered tab system with inter-layer flows and bidirectional feeds — merged to main
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
id: TASK-58
|
||||
title: >-
|
||||
Auto-route users to personal/demo space + landing overlay + demo content for
|
||||
all rApps
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-25 23:53'
|
||||
labels:
|
||||
- feature
|
||||
- routing
|
||||
- ux
|
||||
- demo
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Anon users automatically land on the demo space when viewing any rApp (standalone or unified). Logged-in users auto-redirect to their personal space (auto-provisioned on first visit). Landing page renders as a quarter-screen popup overlay on the demo space. Demo space seeded with content for all 22 rApps.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Anon users on standalone domains (rpubs.online/) see demo space
|
||||
- [ ] #2 Logged-in users auto-redirect from demo to personal space
|
||||
- [ ] #3 POST /api/spaces/auto-provision creates personal space on first auth visit
|
||||
- [ ] #4 Standalone domains support /<space> path prefix (rpubs.online/jeff)
|
||||
- [ ] #5 rspace.online/ redirects to /demo/canvas
|
||||
- [ ] #6 Quarter-screen welcome overlay shows on first demo visit
|
||||
- [ ] #7 Full landing page accessible at /about
|
||||
- [ ] #8 Sign-in/register triggers auto-space-resolution redirect
|
||||
- [ ] #9 Demo space seeded with shapes for all 22 rApps (55 shapes total)
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented auto-routing: anon→demo, auth→personal space. Added POST /api/spaces/auto-provision endpoint, modified standalone domain rewrite to support /<space> paths, added welcome overlay to shell, wired auth flow to auto-redirect after sign-in. Seeded demo with 18 new shapes covering files, forum, books, pubs, swag, providers, work, cal, network, tube, inbox, data, choices, splat. Deployed to production, demo reset to 55 shapes.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: TASK-59
|
||||
title: Add rPhotos module + finish header standardization across all rApps
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-26 00:06'
|
||||
labels:
|
||||
- photos
|
||||
- headers
|
||||
- modules
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Created modules/photos/ with Immich API proxy, gallery component (shared albums, lightbox viewer), and standard rapp-nav header. Registered in server/index.ts with vite build step. Fixed remaining module headers in books (shelf + reader), splat, swag, tube. All 23 modules now use consistent rapp-nav pattern. Immich confirmed running at demo.rphotos.online, landing page live at rphotos.online.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 modules/photos/ created with mod.ts, component, and CSS
|
||||
- [ ] #2 Immich API proxy routes for albums, assets, thumbnails
|
||||
- [ ] #3 folk-photo-gallery component with rapp-nav header
|
||||
- [ ] #4 Module registered in server/index.ts
|
||||
- [ ] #5 Vite build step added in vite.config.ts
|
||||
- [ ] #6 books shelf-header and reader-header replaced with rapp-nav
|
||||
- [ ] #7 splat h1 branding removed
|
||||
- [ ] #8 swag h2 branding removed
|
||||
- [ ] #9 tube tabs converted to rapp-nav
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Commit eba0aaf merged to main. All 23 modules standardized with rapp-nav headers. rPhotos module added as #23 with Immich gateway. Both rphotos.online (landing) and demo.rphotos.online (Immich) confirmed live and healthy.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
id: TASK-60
|
||||
title: Canonical subdomain routing + rSwag landing page simplification
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-26 00:44'
|
||||
labels:
|
||||
- routing
|
||||
- infrastructure
|
||||
- simplification
|
||||
dependencies: []
|
||||
references:
|
||||
- server/index.ts
|
||||
- server/shell.ts
|
||||
- shared/url-helpers.ts
|
||||
- shared/components/rstack-app-switcher.ts
|
||||
- shared/components/rstack-space-switcher.ts
|
||||
- shared/components/rstack-identity.ts
|
||||
- website/shell.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Consolidated URL routing so all rApps flow through `{space}.rspace.online/{moduleId}` as the canonical URL pattern, replacing the previous silent URL rewriting from standalone domains.
|
||||
|
||||
**rspace-online changes:**
|
||||
- Subdomain handler now routes ALL modules (previously only served canvas)
|
||||
- Standalone domains (rvote.online, rphotos.online, etc.) now 301 redirect to canonical `{space}.rspace.online/{moduleId}`
|
||||
- Created `shared/url-helpers.ts` with `rspaceNavUrl()`, `getCurrentSpace()`, `getCurrentModule()`, `isSubdomain()`
|
||||
- Updated app-switcher, space-switcher, identity component, and tab-bar navigation to use subdomain-aware URL generation
|
||||
- Shell inline scripts use global `__rspaceNavUrl()` for all URL generation
|
||||
- Path-based `rspace.online/:space/:moduleId` still works as fallback
|
||||
- WebSocket connections on standalone domains still rewritten (WS can't follow redirects)
|
||||
|
||||
**rswag-online changes:**
|
||||
- Replaced full Next.js + FastAPI + PostgreSQL + Redis docker-compose with simple static nginx landing page
|
||||
- Updated landing page CTA: "Try the Demo" → `https://rspace.online/demo/swag`
|
||||
- Aligns with the "simple components, JS and HTML wherever possible" philosophy
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 demo.rspace.online/vote serves vote module in demo space
|
||||
- [ ] #2 Standalone domains (rvote.online etc) 301 redirect to canonical subdomain URL
|
||||
- [ ] #3 App-switcher and space-switcher generate subdomain-aware links
|
||||
- [ ] #4 Auto-provision redirect uses subdomain URL pattern
|
||||
- [ ] #5 Path-based rspace.online/:space/:moduleId still works
|
||||
- [ ] #6 rswag.online serves static landing page instead of Next.js app
|
||||
- [ ] #7 TypeScript compiles cleanly
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented canonical subdomain routing `{space}.rspace.online/{moduleId}` across rspace-online and simplified rswag-online from Next.js to static landing page.\n\nCommits:\n- rspace-online `eab24e2`: feat: canonical subdomain routing\n- rswag-online `1eca70d`: feat: replace Next.js app with static landing page, add demo CTA\n\nBoth repos pushed to main on Gitea.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: TASK-61
|
||||
title: Canvas tab bar + rApps toolbar + iframe shell for all modules
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-26 02:04'
|
||||
updated_date: '2026-02-26 02:04'
|
||||
labels:
|
||||
- canvas
|
||||
- rApps
|
||||
- toolbar
|
||||
- tab-bar
|
||||
- iframe-shell
|
||||
dependencies: []
|
||||
references:
|
||||
- website/canvas.html
|
||||
- server/shell.ts
|
||||
- shared/components/rstack-tab-bar.ts
|
||||
- shared/url-helpers.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add the missing tab bar to the canvas page so users can switch between rApp layers (with full CommunitySync/Automerge persistence). Add an "rApps" toolbar group that embeds any of the 18 remaining modules as interactive iframes directly on the canvas. Switch all 20 module page routes to renderIframeShell, loading standalone domains inside the unified shell.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Canvas page renders rstack-tab-bar between header and toolbar
|
||||
- [x] #2 Tab bar initializes with canvas as default layer and persists via CommunitySync
|
||||
- [x] #3 New rApps toolbar group with 18 module embed buttons
|
||||
- [x] #4 Each rApp button creates a folk-embed shape with correct module URL
|
||||
- [x] #5 All 20 module page routes use renderIframeShell with standalone domains
|
||||
- [x] #6 renderIframeShell function added to server/shell.ts
|
||||
- [x] #7 Build passes with no errors
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
## Changes (22 files, +343 / -142)\n\n### Canvas tab bar (website/canvas.html)\n- Added `<rstack-tab-bar>` element between header and toolbar\n- Tab bar init JS: default canvas layer, layer-switch/add/close navigation\n- CommunitySync wiring: persists layers, flows, reorder, view mode to Automerge CRDT\n- Syncs remote layer/flow changes from other users\n\n### rApps toolbar group (website/canvas.html)\n- New \"rApps\" dropdown with 18 module embed buttons\n- Each creates a folk-embed shape pointing to the module URL via rspaceNavUrl()\n- Modules: rNotes, rPhotos, rBooks, rPubs, rFiles, rWork, rForum, rInbox, rTube, rFunds, rWallet, rVote, rCart, rData, rNetwork, rSplat, rProviders, rSwag\n\n### Iframe shell (server/shell.ts + 20 modules)\n- New renderIframeShell() wraps standalone app domains in the unified rSpace shell\n- All 20 module page routes switched from renderShell to renderIframeShell\n- Modules load their standalone domain (e.g. rnotes.online) in a seamless iframe\n\nCommit: 5c2c7f4 on dev, merged to main
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: TASK-62
|
||||
title: Tab bar persistence + iframe loading/error states
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-26 02:19'
|
||||
updated_date: '2026-02-26 02:19'
|
||||
labels:
|
||||
- bugfix
|
||||
- shell
|
||||
- iframe
|
||||
dependencies:
|
||||
- TASK-61
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Fix two issues from the iframe shell rollout: (1) tab bar was resetting to a single tab on every page navigation — now persists in localStorage, (2) iframed standalone apps that aren't running showed a blank page — now shows spinner + 12s timeout error panel with retry/open-directly actions. Also converts rSwag to iframe shell (missed in TASK-61).
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Tabs persist across full-page navigations via localStorage
|
||||
- [x] #2 Opening a new rApp adds it as a tab alongside existing ones
|
||||
- [x] #3 Closing active tab navigates to first remaining tab
|
||||
- [x] #4 Iframe shows loading spinner while standalone app loads
|
||||
- [x] #5 12s timeout shows error panel if standalone app unreachable
|
||||
- [x] #6 Error panel has Retry button and Open Directly link
|
||||
- [x] #7 rSwag module converted to iframe shell
|
||||
- [x] #8 CommunitySync merges with localStorage tabs when connected
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Rewrote tab bar initialization in `server/shell.ts` to use `localStorage` keyed per space (`rspace_tabs_{spaceSlug}`). Previously `tabBar.setLayers([defaultLayer])` was called with only the current module on every page load, wiping all previous tabs. Now reads from localStorage first, adds the current module if missing, and persists before rendering.\n\nAdded loading/error states to `renderIframeShell()`: spinner overlay during load, 12s timeout that shows an error panel with the module name, domain that failed, Retry button, and Open Directly link. Iframe fades in on successful load.\n\nConverted `modules/swag/mod.ts` to use `renderIframeShell` with `swag.mycofi.earth` (missed in TASK-61).\n\nCommit: `fa6a2ce` — merged to main.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
id: TASK-63
|
||||
title: Remove iframe shell — render all modules directly via web components
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-26 02:46'
|
||||
updated_date: '2026-02-26 02:46'
|
||||
labels:
|
||||
- refactor
|
||||
- bug-fix
|
||||
- shell
|
||||
dependencies: []
|
||||
references:
|
||||
- server/shell.ts
|
||||
- server/index.ts
|
||||
- modules/*/mod.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Every module except canvas was using renderIframeShell() to embed standalone domains (rdata.online, rwork.online, etc.) via iframe. None of these domains had independent deployments — they routed back to the same container, causing infinite redirect loops (for modules not in keepStandalone) or 404s (for modules in keepStandalone).
|
||||
|
||||
Root cause: renderIframeShell created an iframe pointing to e.g. rdata.online, which Traefik routed back to the same rspace-online container. For non-keepStandalone modules, the server 301-redirected back to demo.rspace.online/work, which rendered another iframe shell → infinite loop. For keepStandalone modules, the request fell through to Hono but no root route matched → connection refused.
|
||||
|
||||
Fix: All 22 modules now render their web components directly inside renderShell(), eliminating cross-origin failures, iframe loading spinners, and ~820 lines of dead code. Standalone domain requests are internally rewritten to module routes instead of 301 redirecting.
|
||||
|
||||
Changes:
|
||||
- Removed renderIframeShell(), renderStandaloneShell(), IframeShellOptions from server/shell.ts
|
||||
- Removed keepStandalone set; standalone domains now internally rewrite to /{space}/{moduleId}
|
||||
- Converted all 22 module GET / handlers from renderIframeShell → renderShell + direct <folk-*> web components
|
||||
- Deleted 20 standalone.ts entry points (were circular/broken)
|
||||
- Net: +129 / -947 lines across 43 files
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All 22 modules render via renderShell() with direct web component tags
|
||||
- [x] #2 renderIframeShell and IframeShellOptions removed from server/shell.ts
|
||||
- [x] #3 Standalone domains (rdata.online etc.) internally rewrite to module routes instead of 301 redirect
|
||||
- [x] #4 keepStandalone set removed
|
||||
- [x] #5 All 20 standalone.ts files deleted
|
||||
- [x] #6 TypeScript compiles cleanly (bunx tsc --noEmit passes)
|
||||
- [x] #7 No remaining references to renderIframeShell in source code
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Removed the broken iframe shell architecture. All modules now render their `<folk-*>` web components directly in the unified shell, fixing connection failures across rData, rWork, rNetwork, rWallet, rFiles, and all other modules. Standalone domains are internally rewritten instead of 301 redirected. Commit c2729fb, merged to main.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
id: TASK-64
|
||||
title: r-prefix module slugs + landing page + clickable rStack header
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-26 03:04'
|
||||
updated_date: '2026-02-26 03:05'
|
||||
labels:
|
||||
- refactor
|
||||
- routing
|
||||
- shell
|
||||
dependencies: []
|
||||
references:
|
||||
- server/index.ts
|
||||
- shared/components/rstack-app-switcher.ts
|
||||
- shared/components/rstack-tab-bar.ts
|
||||
- shared/url-helpers.ts
|
||||
- modules/*/mod.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Rename all 23 module IDs from bare names to r-prefixed slugs (canvas→rspace, notes→rnotes, vote→rvote, etc.) so URLs are consistent with rApp branding. Root rspace.online/ now serves the landing page instead of redirecting to demo. rStack header in app switcher is now a clickable link. rSpace itself appears as a module peer alongside all other rApps.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All 23 module IDs use r-prefix slugs (rspace, rnotes, rvote, etc.)
|
||||
- [x] #2 Root rspace.online/ serves landing page (not redirect to demo)
|
||||
- [x] #3 rStack header in app switcher dropdown is clickable (links to rstack.online)
|
||||
- [x] #4 Space root redirects to /rspace instead of /canvas
|
||||
- [x] #5 All internal navigation links updated to r-prefixed paths
|
||||
- [x] #6 Badge maps in app switcher and tab bar use r-prefixed keys
|
||||
- [x] #7 TypeScript compiles cleanly
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Renamed all 23 module IDs to r-prefixed slugs across 33 files. Root domain now serves the landing page. rStack header is clickable. All badge maps, URL helpers, internal links, and redirects updated. Commit 4895af1, merged to main.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
id: TASK-HIGH.1
|
||||
title: 'Bare-domain module routing — rspace.online/{moduleId} as default'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-26 03:34'
|
||||
updated_date: '2026-02-26 06:20'
|
||||
labels: []
|
||||
dependencies: []
|
||||
parent_task_id: TASK-HIGH
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
App dropdown links go to rspace.online/r* (bare domain) instead of demo.rspace.online/r*. Only 'Try Demo' button links to demo subdomain. Server internally rewrites bare-domain module paths to /demo/{moduleId}.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 App dropdown generates bare-domain URLs (/{moduleId}) on rspace.online
|
||||
- [x] #2 Server rewrites rspace.online/{moduleId} → /demo/{moduleId} internally
|
||||
- [x] #3 url-helpers isBareDomain() + getCurrentSpace/Module handle bare domain
|
||||
- [x] #4 Try Demo button visible on bare domain, hidden on demo.rspace.online
|
||||
- [x] #5 Auto-provision redirects authenticated users to personal subdomain
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented in commit a732478. Changed url-helpers.ts (isBareDomain, rspaceNavUrl), server/index.ts (fetch handler rewrite), server/shell.ts (data-hide + client JS for Try Demo). Merged to main.
|
||||
|
||||
Follow-up fix (c15fc15): app switcher fallback was 'personal' → 'demo', landing page demo links & ecosystem app links updated to use rspace.online/r* bare domain pattern
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- rSpace shared PostgreSQL — per-module schema isolation
|
||||
-- Each module owns its schema. Modules that don't need a DB skip this.
|
||||
|
||||
-- Extensions available to all schemas
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "cube";
|
||||
CREATE EXTENSION IF NOT EXISTS "earthdistance";
|
||||
|
||||
-- Module schemas (created on init, populated by module migrations)
|
||||
CREATE SCHEMA IF NOT EXISTS rbooks;
|
||||
CREATE SCHEMA IF NOT EXISTS rcart;
|
||||
CREATE SCHEMA IF NOT EXISTS providers;
|
||||
CREATE SCHEMA IF NOT EXISTS rfiles;
|
||||
CREATE SCHEMA IF NOT EXISTS rforum;
|
||||
CREATE SCHEMA IF NOT EXISTS rsplat;
|
||||
|
||||
-- Grant usage to the rspace user
|
||||
GRANT ALL ON SCHEMA rbooks TO rspace;
|
||||
GRANT ALL ON SCHEMA rcart TO rspace;
|
||||
GRANT ALL ON SCHEMA providers TO rspace;
|
||||
GRANT ALL ON SCHEMA rfiles TO rspace;
|
||||
GRANT ALL ON SCHEMA rforum TO rspace;
|
||||
GRANT ALL ON SCHEMA rsplat TO rspace;
|
||||
|
|
@ -25,7 +25,7 @@ services:
|
|||
labels:
|
||||
# Traefik auto-discovery
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.encryptid.rule=Host(`auth.rspace.online`) || Host(`encryptid.jeffemmett.com`)"
|
||||
- "traefik.http.routers.encryptid.rule=Host(`auth.rspace.online`) || Host(`auth.ridentity.online`) || Host(`encryptid.jeffemmett.com`)"
|
||||
- "traefik.http.routers.encryptid.entrypoints=web"
|
||||
- "traefik.http.routers.encryptid.priority=150"
|
||||
- "traefik.http.services.encryptid.loadbalancer.server.port=3000"
|
||||
|
|
@ -34,6 +34,11 @@ services:
|
|||
- "traefik.http.routers.encryptid-wellknown.entrypoints=web"
|
||||
- "traefik.http.routers.encryptid-wellknown.priority=200"
|
||||
- "traefik.http.routers.encryptid-wellknown.service=encryptid"
|
||||
# Serve .well-known/webauthn from ridentity.online too
|
||||
- "traefik.http.routers.encryptid-wellknown-rid.rule=Host(`ridentity.online`) && PathPrefix(`/.well-known/webauthn`)"
|
||||
- "traefik.http.routers.encryptid-wellknown-rid.entrypoints=web"
|
||||
- "traefik.http.routers.encryptid-wellknown-rid.priority=200"
|
||||
- "traefik.http.routers.encryptid-wellknown-rid.service=encryptid"
|
||||
networks:
|
||||
- traefik-public
|
||||
- encryptid-internal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,316 @@
|
|||
# Standalone module deployments — each module runs as its own container.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d rbooks-standalone
|
||||
#
|
||||
# All services reuse the same rspace-online image (built by the main compose).
|
||||
# They share the rspace-db database and traefik-public network.
|
||||
# To deploy ALL standalone modules at once:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d
|
||||
#
|
||||
# NOTE: Standalone services override the default CMD to run standalone.ts.
|
||||
# The unified rspace-online container still serves all domains by default.
|
||||
# To switch a domain from unified to standalone, remove its Traefik label
|
||||
# from the main compose and bring up its standalone service here.
|
||||
|
||||
x-standalone-base: &standalone-base
|
||||
image: rspace-online-rspace:latest
|
||||
restart: unless-stopped
|
||||
environment: &base-env
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgres://rspace:${POSTGRES_PASSWORD}@rspace-db:5432/rspace
|
||||
depends_on:
|
||||
rspace-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
|
||||
x-traefik-labels: &traefik-enabled
|
||||
traefik.enable: "true"
|
||||
|
||||
services:
|
||||
# ── rBooks ──
|
||||
rbooks-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rbooks-standalone
|
||||
command: ["bun", "run", "modules/books/standalone.ts"]
|
||||
volumes:
|
||||
- rspace-books:/data/books
|
||||
environment:
|
||||
<<: *base-env
|
||||
BOOKS_DIR: /data/books
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rbooks-sa.rule: Host(`rbooks.online`)
|
||||
traefik.http.routers.rbooks-sa.entrypoints: web
|
||||
traefik.http.services.rbooks-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rPubs ──
|
||||
rpubs-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rpubs-standalone
|
||||
command: ["bun", "run", "modules/pubs/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rpubs-sa.rule: Host(`rpubs.online`)
|
||||
traefik.http.routers.rpubs-sa.entrypoints: web
|
||||
traefik.http.services.rpubs-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rCart ──
|
||||
rcart-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rcart-standalone
|
||||
command: ["bun", "run", "modules/cart/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
FLOW_SERVICE_URL: http://payment-flow:3010
|
||||
FLOW_ID: a79144ec-e6a2-4e30-a42a-6d8237a5953d
|
||||
FUNNEL_ID: 0ff6a9ac-1667-4fc7-9a01-b1620810509f
|
||||
X402_PAY_TO: ${X402_PAY_TO:-}
|
||||
X402_NETWORK: ${X402_NETWORK:-eip155:84532}
|
||||
X402_UPLOAD_PRICE: ${X402_UPLOAD_PRICE:-0.01}
|
||||
X402_FACILITATOR_URL: ${X402_FACILITATOR_URL:-https://x402.org/facilitator}
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
- payment-network
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rcart-sa.rule: Host(`rcart.online`)
|
||||
traefik.http.routers.rcart-sa.entrypoints: web
|
||||
traefik.http.services.rcart-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rSwag ──
|
||||
rswag-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rswag-standalone
|
||||
command: ["bun", "run", "modules/swag/standalone.ts"]
|
||||
volumes:
|
||||
- rspace-swag:/data/swag-artifacts
|
||||
environment:
|
||||
<<: *base-env
|
||||
SWAG_ARTIFACTS_DIR: /data/swag-artifacts
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rswag-sa.rule: Host(`rswag.online`)
|
||||
traefik.http.routers.rswag-sa.entrypoints: web
|
||||
traefik.http.services.rswag-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rChoices ──
|
||||
rchoices-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rchoices-standalone
|
||||
command: ["bun", "run", "modules/choices/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rchoices-sa.rule: Host(`rchoices.online`)
|
||||
traefik.http.routers.rchoices-sa.entrypoints: web
|
||||
traefik.http.services.rchoices-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rFunds ──
|
||||
rfunds-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rfunds-standalone
|
||||
command: ["bun", "run", "modules/funds/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
FLOW_SERVICE_URL: http://payment-flow:3010
|
||||
FLOW_ID: a79144ec-e6a2-4e30-a42a-6d8237a5953d
|
||||
FUNNEL_ID: 0ff6a9ac-1667-4fc7-9a01-b1620810509f
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
- payment-network
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rfunds-sa.rule: Host(`rfunds.online`)
|
||||
traefik.http.routers.rfunds-sa.entrypoints: web
|
||||
traefik.http.services.rfunds-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rFiles ──
|
||||
rfiles-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rfiles-standalone
|
||||
command: ["bun", "run", "modules/files/standalone.ts"]
|
||||
volumes:
|
||||
- rspace-files:/data/files
|
||||
environment:
|
||||
<<: *base-env
|
||||
FILES_DIR: /data/files
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rfiles-sa.rule: Host(`rfiles.online`)
|
||||
traefik.http.routers.rfiles-sa.entrypoints: web
|
||||
traefik.http.services.rfiles-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rForum ──
|
||||
rforum-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rforum-standalone
|
||||
command: ["bun", "run", "modules/forum/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
HETZNER_API_TOKEN: ${HETZNER_API_TOKEN}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||
CLOUDFLARE_ZONE_ID: ${CLOUDFLARE_ZONE_ID}
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rforum-sa.rule: Host(`rforum.online`)
|
||||
traefik.http.routers.rforum-sa.entrypoints: web
|
||||
traefik.http.services.rforum-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rVote ──
|
||||
rvote-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rvote-standalone
|
||||
command: ["bun", "run", "modules/vote/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rvote-sa.rule: Host(`rvote.online`)
|
||||
traefik.http.routers.rvote-sa.entrypoints: web
|
||||
traefik.http.services.rvote-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rNotes ──
|
||||
rnotes-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rnotes-standalone
|
||||
command: ["bun", "run", "modules/notes/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rnotes-sa.rule: Host(`rnotes.online`)
|
||||
traefik.http.routers.rnotes-sa.entrypoints: web
|
||||
traefik.http.services.rnotes-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rMaps ──
|
||||
rmaps-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rmaps-standalone
|
||||
command: ["bun", "run", "modules/maps/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
MAPS_SYNC_URL: wss://sync.rmaps.online
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rmaps-sa.rule: Host(`rmaps.online`)
|
||||
traefik.http.routers.rmaps-sa.entrypoints: web
|
||||
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rWork ──
|
||||
rwork-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rwork-standalone
|
||||
command: ["bun", "run", "modules/work/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
|
||||
traefik.http.routers.rwork-sa.entrypoints: web
|
||||
traefik.http.services.rwork-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rTrips ──
|
||||
rtrips-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rtrips-standalone
|
||||
command: ["bun", "run", "modules/trips/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
OSRM_URL: http://osrm-backend:5000
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rtrips-sa.rule: Host(`rtrips.online`)
|
||||
traefik.http.routers.rtrips-sa.entrypoints: web
|
||||
traefik.http.services.rtrips-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rCal ──
|
||||
rcal-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rcal-standalone
|
||||
command: ["bun", "run", "modules/cal/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rcal-sa.rule: Host(`rcal.online`)
|
||||
traefik.http.routers.rcal-sa.entrypoints: web
|
||||
traefik.http.services.rcal-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rNetwork ──
|
||||
rnetwork-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rnetwork-standalone
|
||||
command: ["bun", "run", "modules/network/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
TWENTY_API_URL: https://rnetwork.online
|
||||
TWENTY_API_TOKEN: ${TWENTY_API_TOKEN}
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rnetwork-sa.rule: Host(`rnetwork.online`)
|
||||
traefik.http.routers.rnetwork-sa.entrypoints: web
|
||||
traefik.http.services.rnetwork-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rTube ──
|
||||
rtube-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rtube-standalone
|
||||
command: ["bun", "run", "modules/tube/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
R2_ENDPOINT: ${R2_ENDPOINT}
|
||||
R2_BUCKET: ${R2_BUCKET:-rtube-videos}
|
||||
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID}
|
||||
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY}
|
||||
R2_PUBLIC_URL: ${R2_PUBLIC_URL}
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rtube-sa.rule: Host(`rtube.online`)
|
||||
traefik.http.routers.rtube-sa.entrypoints: web
|
||||
traefik.http.services.rtube-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rInbox ──
|
||||
rinbox-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rinbox-standalone
|
||||
command: ["bun", "run", "modules/inbox/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
IMAP_HOST: mail.rmail.online
|
||||
IMAP_PORT: "993"
|
||||
IMAP_TLS_REJECT_UNAUTHORIZED: "false"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
- rmail-mailcow
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rinbox-sa.rule: Host(`rinbox.online`)
|
||||
traefik.http.routers.rinbox-sa.entrypoints: web
|
||||
traefik.http.services.rinbox-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rData ──
|
||||
rdata-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rdata-standalone
|
||||
command: ["bun", "run", "modules/data/standalone.ts"]
|
||||
environment:
|
||||
<<: *base-env
|
||||
UMAMI_URL: https://analytics.rspace.online
|
||||
UMAMI_WEBSITE_ID: 292f6ac6-79f8-497b-ba6a-7a51e3b87b9f
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rdata-sa.rule: Host(`rdata.online`)
|
||||
traefik.http.routers.rdata-sa.entrypoints: web
|
||||
traefik.http.services.rdata-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rWallet ──
|
||||
rwallet-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rwallet-standalone
|
||||
command: ["bun", "run", "modules/wallet/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rwallet-sa.rule: Host(`rwallet.online`)
|
||||
traefik.http.routers.rwallet-sa.entrypoints: web
|
||||
traefik.http.services.rwallet-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# Volumes and networks inherited from main docker-compose.yml
|
||||
# Use: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d <service>
|
||||
|
|
@ -8,26 +8,178 @@ services:
|
|||
restart: unless-stopped
|
||||
volumes:
|
||||
- rspace-data:/data/communities
|
||||
- rspace-books:/data/books
|
||||
- rspace-swag:/data/swag-artifacts
|
||||
- rspace-files:/data/files
|
||||
- rspace-splats:/data/splats
|
||||
- rspace-docs:/data/docs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- STORAGE_DIR=/data/communities
|
||||
- BOOKS_DIR=/data/books
|
||||
- SWAG_ARTIFACTS_DIR=/data/swag-artifacts
|
||||
- PORT=3000
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
||||
- FILES_DIR=/data/files
|
||||
- SPLATS_DIR=/data/splats
|
||||
- DOCS_STORAGE_DIR=/data/docs
|
||||
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
|
||||
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
|
||||
- INFISICAL_PROJECT_SLUG=rspace
|
||||
- INFISICAL_ENV=prod
|
||||
- INFISICAL_URL=http://infisical:8080
|
||||
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD}@rspace-db:5432/rspace
|
||||
- FLOW_SERVICE_URL=http://payment-flow:3010
|
||||
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
|
||||
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
|
||||
- UMAMI_URL=https://analytics.rspace.online
|
||||
- UMAMI_WEBSITE_ID=292f6ac6-79f8-497b-ba6a-7a51e3b87b9f
|
||||
- MAPS_SYNC_URL=wss://sync.rmaps.online
|
||||
- IMAP_HOST=mail.rmail.online
|
||||
- IMAP_PORT=993
|
||||
- IMAP_TLS_REJECT_UNAUTHORIZED=false
|
||||
- TWENTY_API_URL=https://rnetwork.online
|
||||
depends_on:
|
||||
rspace-db:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Only handle subdomains (rspace-prod handles main domain)
|
||||
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`)"
|
||||
# Main domain — serves landing + path-based routing
|
||||
- "traefik.http.routers.rspace-main.rule=Host(`rspace.online`)"
|
||||
- "traefik.http.routers.rspace-main.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-main.priority=110"
|
||||
# Subdomains — backward compat for *.rspace.online canvas
|
||||
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`) && !Host(`auth.rspace.online`)"
|
||||
- "traefik.http.routers.rspace-canvas.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-canvas.priority=100"
|
||||
# ── Standalone domain routing (priority 120) ──
|
||||
- "traefik.http.routers.rspace-rbooks.rule=Host(`rbooks.online`)"
|
||||
- "traefik.http.routers.rspace-rbooks.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rbooks.priority=120"
|
||||
- "traefik.http.routers.rspace-rbooks.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rpubs.rule=Host(`rpubs.online`)"
|
||||
- "traefik.http.routers.rspace-rpubs.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rpubs.priority=120"
|
||||
- "traefik.http.routers.rspace-rpubs.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rchoices.rule=Host(`rchoices.online`)"
|
||||
- "traefik.http.routers.rspace-rchoices.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rchoices.priority=120"
|
||||
- "traefik.http.routers.rspace-rchoices.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rfunds.rule=Host(`rfunds.online`)"
|
||||
- "traefik.http.routers.rspace-rfunds.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rfunds.priority=120"
|
||||
- "traefik.http.routers.rspace-rfunds.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rforum.rule=Host(`rforum.online`)"
|
||||
- "traefik.http.routers.rspace-rforum.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rforum.priority=120"
|
||||
- "traefik.http.routers.rspace-rforum.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rvote.rule=Host(`rvote.online`)"
|
||||
- "traefik.http.routers.rspace-rvote.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rvote.priority=120"
|
||||
- "traefik.http.routers.rspace-rvote.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rwork.rule=Host(`rwork.online`)"
|
||||
- "traefik.http.routers.rspace-rwork.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rwork.priority=120"
|
||||
- "traefik.http.routers.rspace-rwork.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rcal.rule=Host(`rcal.online`)"
|
||||
- "traefik.http.routers.rspace-rcal.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rcal.priority=120"
|
||||
- "traefik.http.routers.rspace-rcal.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rtrips.rule=Host(`rtrips.online`)"
|
||||
- "traefik.http.routers.rspace-rtrips.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rtrips.priority=120"
|
||||
- "traefik.http.routers.rspace-rtrips.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rwallet.rule=Host(`rwallet.online`)"
|
||||
- "traefik.http.routers.rspace-rwallet.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rwallet.priority=120"
|
||||
- "traefik.http.routers.rspace-rwallet.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rdata.rule=Host(`rdata.online`)"
|
||||
- "traefik.http.routers.rspace-rdata.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rdata.priority=120"
|
||||
- "traefik.http.routers.rspace-rdata.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rnetwork.rule=Host(`rnetwork.online`)"
|
||||
- "traefik.http.routers.rspace-rnetwork.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rnetwork.priority=120"
|
||||
- "traefik.http.routers.rspace-rnetwork.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rtube.rule=Host(`rtube.online`)"
|
||||
- "traefik.http.routers.rspace-rtube.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rtube.priority=120"
|
||||
- "traefik.http.routers.rspace-rtube.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rmaps.rule=Host(`rmaps.online`)"
|
||||
- "traefik.http.routers.rspace-rmaps.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rmaps.priority=120"
|
||||
- "traefik.http.routers.rspace-rmaps.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rnotes.rule=Host(`rnotes.online`)"
|
||||
- "traefik.http.routers.rspace-rnotes.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rnotes.priority=120"
|
||||
- "traefik.http.routers.rspace-rnotes.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rfiles.rule=Host(`rfiles.online`)"
|
||||
- "traefik.http.routers.rspace-rfiles.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rfiles.priority=120"
|
||||
- "traefik.http.routers.rspace-rfiles.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rphotos.rule=Host(`rphotos.online`)"
|
||||
- "traefik.http.routers.rspace-rphotos.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rphotos.priority=120"
|
||||
- "traefik.http.routers.rspace-rphotos.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rinbox.rule=Host(`rinbox.online`)"
|
||||
- "traefik.http.routers.rspace-rinbox.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rinbox.priority=120"
|
||||
- "traefik.http.routers.rspace-rinbox.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rcart.rule=Host(`rcart.online`)"
|
||||
- "traefik.http.routers.rspace-rcart.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rcart.priority=120"
|
||||
- "traefik.http.routers.rspace-rcart.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rsplat.rule=Host(`rsplat.online`)"
|
||||
- "traefik.http.routers.rspace-rsplat.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rsplat.priority=120"
|
||||
- "traefik.http.routers.rspace-rsplat.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rswag.rule=Host(`rswag.online`)"
|
||||
- "traefik.http.routers.rspace-rswag.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rswag.priority=120"
|
||||
- "traefik.http.routers.rspace-rswag.service=rspace-online"
|
||||
# Service configuration
|
||||
- "traefik.http.services.rspace-canvas.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.rspace-online.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
- payment-network
|
||||
- rmail-mailcow
|
||||
|
||||
rspace-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: rspace-db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- rspace-pgdata:/var/lib/postgresql/data
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
environment:
|
||||
- POSTGRES_DB=rspace
|
||||
- POSTGRES_USER=rspace
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U rspace"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- rspace-internal
|
||||
|
||||
volumes:
|
||||
rspace-data:
|
||||
rspace-books:
|
||||
rspace-swag:
|
||||
rspace-files:
|
||||
rspace-splats:
|
||||
rspace-docs:
|
||||
rspace-pgdata:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
payment-network:
|
||||
name: payment-infra_payment-network
|
||||
external: true
|
||||
rmail-mailcow:
|
||||
name: mailcowdockerized_mailcow-network
|
||||
external: true
|
||||
rspace-internal:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,824 @@
|
|||
# Space Architecture: Nested Spaces & Permission Cascade
|
||||
|
||||
*Version 0.1 — February 2026*
|
||||
|
||||
---
|
||||
|
||||
## Core Principle: A Space Is a Space Is a Space
|
||||
|
||||
There is no `type` field. There is no distinction between "personal" and "community" at the schema level. A space is the singular primitive. What varies between spaces is:
|
||||
|
||||
- **Who owns it** — the `ownerDID` and admin set
|
||||
- **Who can see into it** — visibility + membership
|
||||
- **What it nests** — references to other spaces
|
||||
- **What apps are deployed on it** — enabled modules
|
||||
|
||||
A space where one person is the sole admin with `members_only` visibility *behaves* like a personal space. A space with many members and `public_read` visibility *behaves* like a community. But they are the same data structure, the same Automerge document, the same API surface. This uniformity is what makes arbitrary nesting depth work without special cases.
|
||||
|
||||
---
|
||||
|
||||
## `<username>.rspace.online` — Identity-Provisioned Spaces
|
||||
|
||||
Every EncryptID registration auto-provisions a space:
|
||||
|
||||
```
|
||||
Registration → EncryptID created → Space provisioned at <username>.rspace.online
|
||||
```
|
||||
|
||||
This space is not special. It's a space where:
|
||||
- The user is the sole `admin`
|
||||
- Visibility defaults to `members_only` (sovereign by default)
|
||||
- The user's chosen apps are enabled
|
||||
- It exists at a predictable, memorable URL
|
||||
|
||||
### What this gives the user
|
||||
|
||||
1. **A sovereign home on the network** — `alice.rspace.online` is Alice's address. Her canvas, her apps, her data.
|
||||
2. **An app deployment surface** — Alice enables rWallet, rVote, rNotes on her space. They're live immediately.
|
||||
3. **The root of her sharing tree** — When Alice shares content into a DAO, it originates from her space. When she revokes, it disappears from the DAO. Her space is always the canonical source.
|
||||
4. **A URL she can give someone** — "Find me at `alice.rspace.online`." The equivalent of a homepage, but living.
|
||||
|
||||
### What this does NOT mean
|
||||
|
||||
- It is NOT a different kind of space
|
||||
- It does NOT have special server-side logic beyond auto-provisioning
|
||||
- It CAN be reconfigured — Alice could make it `public`, add members, change its name
|
||||
- Other spaces CAN also be provisioned at `<anything>.rspace.online` — the subdomain is just a slug
|
||||
|
||||
---
|
||||
|
||||
## Nested Spaces
|
||||
|
||||
Nesting is the mechanism for both sharing and composition. A space can reference other spaces, which appear as embedded regions on its canvas.
|
||||
|
||||
### SpaceRef: The Nesting Primitive
|
||||
|
||||
```typescript
|
||||
interface SpaceRef {
|
||||
id: string; // unique ref ID within this space
|
||||
sourceSlug: string; // the nested space's slug
|
||||
sourceDID?: string; // who created this nesting (provenance)
|
||||
|
||||
// What to show from the nested space
|
||||
filter?: SpaceRefFilter;
|
||||
|
||||
// Where it appears on the parent canvas
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
|
||||
// What the parent space's members can do with nested content
|
||||
permissions: NestPermissions;
|
||||
|
||||
// Display
|
||||
collapsed?: boolean; // render as a card summary vs. full canvas
|
||||
label?: string; // override display name
|
||||
}
|
||||
|
||||
interface SpaceRefFilter {
|
||||
// Show all shapes, or a subset?
|
||||
shapeTypes?: string[]; // only these shape types (e.g., ['folk-note', 'folk-budget'])
|
||||
shapeIds?: string[]; // only these specific shapes
|
||||
tags?: string[]; // only shapes with these tags (future)
|
||||
moduleIds?: string[]; // only content from these modules
|
||||
}
|
||||
|
||||
interface NestPermissions {
|
||||
read: boolean; // can members of the parent space see this nested content?
|
||||
write: boolean; // can they edit shapes in the nested space?
|
||||
addShapes: boolean; // can they add new shapes to the nested space?
|
||||
deleteShapes: boolean; // can they delete shapes from the nested space?
|
||||
reshare: boolean; // can they nest this space further into other spaces?
|
||||
expiry?: number; // unix timestamp — auto-revoke after this time
|
||||
}
|
||||
```
|
||||
|
||||
### How Nesting Works at the CRDT Level
|
||||
|
||||
Each space's `CommunityDoc` gains a `nestedSpaces` map alongside its existing `shapes` map:
|
||||
|
||||
```typescript
|
||||
interface CommunityDoc {
|
||||
meta: CommunityMeta;
|
||||
shapes: { [id: string]: ShapeData };
|
||||
members: { [did: string]: SpaceMember };
|
||||
nestedSpaces: { [refId: string]: SpaceRef }; // NEW
|
||||
}
|
||||
```
|
||||
|
||||
When a client renders a space, it:
|
||||
|
||||
1. Renders all local `shapes` as before
|
||||
2. For each entry in `nestedSpaces`:
|
||||
a. Opens a secondary WebSocket to the nested space's sync endpoint
|
||||
b. Receives that space's shapes (filtered by `SpaceRefFilter` if set)
|
||||
c. Renders them inside the `SpaceRef`'s bounding box on the canvas
|
||||
d. Applies `NestPermissions` — e.g., if `write: false`, nested shapes are non-editable
|
||||
|
||||
This is the same mechanism planned for `folk-canvas` (Task-45), but elevated from a shape type to a first-class document field. The `folk-canvas` shape becomes the *renderer* for `SpaceRef` entries.
|
||||
|
||||
### Nesting Depth
|
||||
|
||||
There is no hard limit on nesting depth. A space can nest a space that nests a space. Each boundary applies its own `NestPermissions`, and the **permission cascade** (see below) ensures the most restrictive permission always wins.
|
||||
|
||||
```
|
||||
alice.rspace.online
|
||||
└── nestedSpaces:
|
||||
├── ref-1 → dao.rspace.online (read + write)
|
||||
│ └── nestedSpaces:
|
||||
│ ├── ref-a → working-group (read + write)
|
||||
│ │ └── nestedSpaces:
|
||||
│ │ └── ref-x → alice.rspace.online/project-x (read-only)
|
||||
│ │ ↑ circular reference is fine — alice
|
||||
│ │ shared a filtered view of her own
|
||||
│ │ space back into the working group
|
||||
│ └── ref-b → treasury (read-only, rWallet shapes only)
|
||||
│
|
||||
└── ref-2 → art-collab.rspace.online (read-only)
|
||||
```
|
||||
|
||||
### Circular References
|
||||
|
||||
A space nesting itself (directly or through a chain) is permitted. The renderer detects cycles and displays the nested instance as a static snapshot rather than a live recursive embed, preventing infinite recursion. The canonical data still syncs — only the rendering is bounded.
|
||||
|
||||
---
|
||||
|
||||
## Permission Cascade Model
|
||||
|
||||
### The Rule: Most Restrictive Wins (Intersection)
|
||||
|
||||
When a space is nested, the effective permissions at any depth are the **intersection** of all permissions along the nesting chain. No downstream nest can grant more access than its parent granted.
|
||||
|
||||
### Formal Definition
|
||||
|
||||
```
|
||||
EffectivePermissions(depth N) = NestPermissions[1] ∩ NestPermissions[2] ∩ ... ∩ NestPermissions[N]
|
||||
```
|
||||
|
||||
Where `∩` for each boolean field is logical AND:
|
||||
|
||||
```typescript
|
||||
function cascadePermissions(chain: NestPermissions[]): NestPermissions {
|
||||
return {
|
||||
read: chain.every(p => p.read),
|
||||
write: chain.every(p => p.write),
|
||||
addShapes: chain.every(p => p.addShapes),
|
||||
deleteShapes: chain.every(p => p.deleteShapes),
|
||||
reshare: chain.every(p => p.reshare),
|
||||
expiry: Math.min(...chain.filter(p => p.expiry).map(p => p.expiry!)) || undefined,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Example 1: Permission narrowing across depth**
|
||||
|
||||
```
|
||||
Alice's space
|
||||
└── nests DAO space (read: ✓, write: ✓, reshare: ✓)
|
||||
└── nests Working Group (read: ✓, write: ✓, reshare: ✗)
|
||||
└── nests Bob's space (read: ✓, write: ✗)
|
||||
|
||||
Effective permissions at each level:
|
||||
|
||||
Level 1 (DAO in Alice's view): read: ✓ write: ✓ reshare: ✓
|
||||
Level 2 (WG in Alice's → DAO view): read: ✓ write: ✓ reshare: ✗ ← narrowed
|
||||
Level 3 (Bob in Alice → DAO → WG): read: ✓ write: ✗ reshare: ✗ ← narrowed further
|
||||
```
|
||||
|
||||
**Example 2: Revocation propagates**
|
||||
|
||||
```
|
||||
Alice revokes the DAO nest from her space
|
||||
→ DAO content disappears from Alice's canvas
|
||||
→ Working Group content (nested inside DAO) also disappears
|
||||
→ Bob's content (nested inside WG) also disappears
|
||||
```
|
||||
|
||||
One action, clean propagation. Alice doesn't need to know the nesting depth.
|
||||
|
||||
**Example 3: reshare: false stops propagation**
|
||||
|
||||
```
|
||||
Alice nests her project into DAO space with reshare: false
|
||||
→ DAO members can see and interact with Alice's project
|
||||
→ DAO members CANNOT nest Alice's project further into other spaces
|
||||
→ If they try, the server rejects the SpaceRef creation
|
||||
```
|
||||
|
||||
This is Alice's control over how far her content travels.
|
||||
|
||||
### Permission Enforcement Points
|
||||
|
||||
| Point | What's checked | Who checks |
|
||||
|-------|---------------|------------|
|
||||
| **SpaceRef creation** | 1. Source space's `nestPolicy.consent` — is this requester allowed? 2. Source space's `nestPolicy.blocklist/allowlist` 3. Requested permissions capped by `nestPolicy.defaultPermissions` 4. Does the creator have `reshare` permission if nesting an already-nested space? 5. Does the creator have `admin` or `moderator` role in the target? | Server, at `POST /api/spaces/:slug/nest` or approval flow |
|
||||
| **WebSocket upgrade** | Does the connecting user have access to this space AND to all nested spaces in the chain? | Server, at WS handshake via `authenticateWSUpgrade()` |
|
||||
| **Shape write** | Is the effective `write` permission `true` across the full nesting chain? Does the user's role in the *source* space permit this action? | Server, in WS message handler |
|
||||
| **Shape read** | Is the effective `read` permission `true`? Does the user meet the source space's visibility requirements? | Server, when syncing nested space data |
|
||||
| **Revocation** | Only the `SpaceRef` creator, an admin of the nesting space, or an admin of the *source* space can remove a `SpaceRef`. Source space admins can always revoke — this is the "pull the plug" guarantee. | Server, at `DELETE /api/spaces/:slug/nest/:refId` |
|
||||
|
||||
### Dual Authority: Nesting Permission + Space Role
|
||||
|
||||
A user's effective capability in a nested space is determined by TWO factors:
|
||||
|
||||
1. **The NestPermissions chain** — what the nesting allows at a structural level
|
||||
2. **The user's role in the source space** — what the user is allowed to do in that space independently
|
||||
|
||||
Both must permit the action. If the nesting grants `write: true` but the user is only a `viewer` in the source space, they still can't write. If the user is an `admin` in the source space but the nesting says `write: false`, they still can't write *through the nest*.
|
||||
|
||||
```typescript
|
||||
function canPerformAction(
|
||||
action: 'read' | 'write' | 'addShapes' | 'deleteShapes',
|
||||
nestChain: NestPermissions[],
|
||||
userRoleInSource: SpaceRole,
|
||||
): boolean {
|
||||
const cascaded = cascadePermissions(nestChain);
|
||||
|
||||
// Nesting must allow it
|
||||
if (!cascaded[action]) return false;
|
||||
|
||||
// User's role in the source space must also allow it
|
||||
const requiredRole = actionToMinimumRole(action);
|
||||
return roleRank(userRoleInSource) >= roleRank(requiredRole);
|
||||
}
|
||||
|
||||
function actionToMinimumRole(action: string): SpaceRole {
|
||||
switch (action) {
|
||||
case 'read': return 'viewer';
|
||||
case 'write': return 'participant';
|
||||
case 'addShapes': return 'participant';
|
||||
case 'deleteShapes': return 'moderator';
|
||||
default: return 'admin';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Identity-Space Binding
|
||||
|
||||
### Auto-Provisioning at Registration
|
||||
|
||||
When `POST /api/register/complete` succeeds in EncryptID, the server also:
|
||||
|
||||
1. Calls `createCommunity(username, username, did, 'members_only')`
|
||||
2. Fires `onSpaceCreate(username)` for all registered modules
|
||||
3. Enables the user's selected default modules (or a sensible default set)
|
||||
4. The space is immediately available at `<username>.rspace.online`
|
||||
|
||||
```typescript
|
||||
// In src/encryptid/server.ts — after successful registration
|
||||
|
||||
// Auto-provision user's space
|
||||
const userSpace = await createCommunity(
|
||||
username, // name
|
||||
username, // slug (becomes <username>.rspace.online)
|
||||
did, // ownerDID
|
||||
'members_only', // sovereign by default
|
||||
);
|
||||
|
||||
// Enable default modules
|
||||
await setEnabledModules(username, DEFAULT_USER_MODULES);
|
||||
|
||||
// Fire module hooks
|
||||
for (const mod of getAllModules()) {
|
||||
if (mod.onSpaceCreate) {
|
||||
await mod.onSpaceCreate(username);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Default Module Set
|
||||
|
||||
New users get a sensible starting set. They can add or remove modules at any time.
|
||||
|
||||
```typescript
|
||||
const DEFAULT_USER_MODULES = [
|
||||
'canvas', // the canvas itself
|
||||
'notes', // note-taking
|
||||
'files', // file storage
|
||||
'wallet', // identity-linked wallet
|
||||
];
|
||||
```
|
||||
|
||||
### Space Settings Stored in Meta
|
||||
|
||||
```typescript
|
||||
interface CommunityMeta {
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
visibility: SpaceVisibility;
|
||||
ownerDID: string | null;
|
||||
enabledModules: string[]; // NEW — which modules are active in this space
|
||||
description?: string; // NEW — optional space description
|
||||
avatar?: string; // NEW — optional space avatar/icon URL
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nest Consent & Notification
|
||||
|
||||
### The Problem
|
||||
|
||||
Without consent controls, anyone who can access a space could nest it into another space without the owner knowing. The `reshare` flag on `NestPermissions` controls whether *already-nested* content can be nested further — but it doesn't govern the *initial* nesting. The space owner needs to control whether their space can be nested at all, by whom, and whether they're notified.
|
||||
|
||||
### NestPolicy: Per-Space Inbound Nesting Rules
|
||||
|
||||
Every space has a `nestPolicy` in its metadata that governs how *other* spaces can nest it:
|
||||
|
||||
```typescript
|
||||
interface NestPolicy {
|
||||
// Who can nest this space into their space?
|
||||
consent: 'open' | 'members' | 'approval' | 'closed';
|
||||
|
||||
// Should the space owner/admins be notified of nesting events?
|
||||
notifications: NestNotifications;
|
||||
|
||||
// Default permissions granted when this space is nested
|
||||
// (the nester can request less, but not more)
|
||||
defaultPermissions: NestPermissions;
|
||||
|
||||
// Spaces that are always allowed to nest this one (bypass consent)
|
||||
allowlist?: string[]; // slugs
|
||||
|
||||
// Spaces that are never allowed to nest this one
|
||||
blocklist?: string[]; // slugs
|
||||
}
|
||||
|
||||
interface NestNotifications {
|
||||
onNestRequest: boolean; // notify when someone requests to nest this space
|
||||
onNestCreated: boolean; // notify when nesting is established
|
||||
onNestRevoked: boolean; // notify when a nesting is removed
|
||||
onReshare: boolean; // notify when nested content is re-nested further
|
||||
channel: 'inbox' | 'email' | 'both'; // where to send notifications
|
||||
}
|
||||
```
|
||||
|
||||
### Consent Levels
|
||||
|
||||
| Level | Behavior | Default for |
|
||||
|-------|----------|-------------|
|
||||
| `open` | Anyone with access to the space can nest it. No approval needed. | Public community spaces |
|
||||
| `members` | Only members of this space can nest it into other spaces. | Collaborative workgroups |
|
||||
| `approval` | Nesting creates a pending request. Space admin must approve before the nest becomes active. | Auto-provisioned user spaces |
|
||||
| `closed` | No one can nest this space. It exists only at its own URL. | Sensitive/private spaces |
|
||||
|
||||
### How Consent Flows
|
||||
|
||||
**`open` — no friction:**
|
||||
```
|
||||
Bob nests alice.rspace.online into dao.rspace.online
|
||||
→ SpaceRef created immediately
|
||||
→ Alice notified (if notifications.onNestCreated: true)
|
||||
```
|
||||
|
||||
**`members` — membership gate:**
|
||||
```
|
||||
Bob attempts to nest alice.rspace.online into dao.rspace.online
|
||||
→ Server checks: is Bob a member of alice.rspace.online?
|
||||
→ YES → SpaceRef created, Alice notified
|
||||
→ NO → 403 Forbidden
|
||||
```
|
||||
|
||||
**`approval` — request/approve flow:**
|
||||
```
|
||||
Bob requests to nest alice.rspace.online into dao.rspace.online
|
||||
→ Server creates a PendingNestRequest (not a live SpaceRef yet)
|
||||
→ Alice notified via her preferred channel
|
||||
→ Alice reviews: sees who's requesting, which space, what permissions
|
||||
→ Alice approves → SpaceRef created, Bob notified
|
||||
→ Alice denies → request deleted, Bob notified
|
||||
→ Alice can also modify the permissions before approving
|
||||
(e.g., downgrade write: true to write: false)
|
||||
```
|
||||
|
||||
**`closed` — hard block:**
|
||||
```
|
||||
Bob attempts to nest alice.rspace.online
|
||||
→ 403 Forbidden, no request created
|
||||
```
|
||||
|
||||
### PendingNestRequest
|
||||
|
||||
```typescript
|
||||
interface PendingNestRequest {
|
||||
id: string;
|
||||
sourceSlug: string; // the space being nested (e.g., alice)
|
||||
targetSlug: string; // where it's being nested into (e.g., dao)
|
||||
requestedBy: string; // DID of requester
|
||||
requestedPermissions: NestPermissions;
|
||||
message?: string; // optional note from requester ("sharing my project notes")
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
createdAt: number;
|
||||
resolvedAt?: number;
|
||||
resolvedBy?: string; // DID of admin who approved/denied
|
||||
modifiedPermissions?: NestPermissions; // if admin adjusted before approving
|
||||
}
|
||||
```
|
||||
|
||||
### Default Permissions Cap
|
||||
|
||||
The `defaultPermissions` in `NestPolicy` acts as a ceiling. When someone nests the space, they can request permissions *up to* this level, but not beyond:
|
||||
|
||||
```
|
||||
Alice's nestPolicy.defaultPermissions:
|
||||
read: true, write: false, addShapes: false, deleteShapes: false, reshare: false
|
||||
|
||||
Bob requests to nest Alice's space with write: true
|
||||
→ Server caps it: write: false (Alice's default ceiling)
|
||||
→ Bob's SpaceRef gets read: true, write: false
|
||||
```
|
||||
|
||||
This means Alice can set her policy once — "my space can be read but not written to when nested" — and every future nesting respects it without her reviewing each request (unless she wants to, via `approval` consent).
|
||||
|
||||
### Allowlist & Blocklist
|
||||
|
||||
For spaces where `consent: 'approval'` is too much friction for trusted relationships but `open` is too permissive:
|
||||
|
||||
- **Allowlist**: These slugs bypass consent entirely. If `climate-dao` is on Alice's allowlist, the DAO can nest her space without approval.
|
||||
- **Blocklist**: These slugs are always denied, even if consent is `open`. Overrides everything.
|
||||
|
||||
Blocklist takes priority over allowlist. If a slug appears in both, it's blocked.
|
||||
|
||||
### Notifications in Practice
|
||||
|
||||
Notifications are delivered through rSpace's existing channels:
|
||||
|
||||
- **`inbox`**: Appears in the user's rSpace inbox (the rInbox module). Non-intrusive, checked at the user's pace.
|
||||
- **`email`**: Sent to the user's EncryptID-linked email via Mailcow. For important events.
|
||||
- **`both`**: Both channels.
|
||||
|
||||
Example notification:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔗 Nest Request
|
||||
|
||||
@bob wants to nest your space into climate-dao.rspace.online
|
||||
|
||||
Requested permissions:
|
||||
Read: ✓ Write: ✗ Reshare: ✗
|
||||
|
||||
Message: "Sharing your research notes with the funding working group"
|
||||
|
||||
[Approve] [Approve with changes] [Deny]
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
|
||||
### Updated CommunityMeta
|
||||
|
||||
```typescript
|
||||
interface CommunityMeta {
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
visibility: SpaceVisibility;
|
||||
ownerDID: string | null;
|
||||
enabledModules: string[];
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
encrypted?: boolean;
|
||||
encryptionKeyId?: string;
|
||||
nestPolicy: NestPolicy; // NEW — governs inbound nesting
|
||||
}
|
||||
```
|
||||
|
||||
### Sensible Defaults
|
||||
|
||||
```typescript
|
||||
// Auto-provisioned user space: sovereign by default
|
||||
const DEFAULT_USER_NEST_POLICY: NestPolicy = {
|
||||
consent: 'approval',
|
||||
notifications: {
|
||||
onNestRequest: true,
|
||||
onNestCreated: true,
|
||||
onNestRevoked: false,
|
||||
onReshare: true,
|
||||
channel: 'inbox',
|
||||
},
|
||||
defaultPermissions: {
|
||||
read: true,
|
||||
write: false,
|
||||
addShapes: false,
|
||||
deleteShapes: false,
|
||||
reshare: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Newly created community space: open collaboration
|
||||
const DEFAULT_COMMUNITY_NEST_POLICY: NestPolicy = {
|
||||
consent: 'members',
|
||||
notifications: {
|
||||
onNestRequest: false,
|
||||
onNestCreated: true,
|
||||
onNestRevoked: true,
|
||||
onReshare: false,
|
||||
channel: 'inbox',
|
||||
},
|
||||
defaultPermissions: {
|
||||
read: true,
|
||||
write: true,
|
||||
addShapes: true,
|
||||
deleteShapes: false,
|
||||
reshare: true,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Note: these are not enforced by a `type` field. The auto-provisioned space gets `DEFAULT_USER_NEST_POLICY` at creation time, and a manually created space gets `DEFAULT_COMMUNITY_NEST_POLICY` — but the user can change either to anything. A "personal" space could be set to `open` and a "community" space could be set to `closed`. The defaults just encode sensible starting points.
|
||||
|
||||
### API Surface for Consent
|
||||
|
||||
```
|
||||
GET /api/spaces/:slug/nest-policy Get space's nest policy
|
||||
PATCH /api/spaces/:slug/nest-policy Update nest policy (admin only)
|
||||
|
||||
POST /api/spaces/:slug/nest-requests Create a pending nest request
|
||||
GET /api/spaces/:slug/nest-requests List pending requests (admin only)
|
||||
GET /api/spaces/:slug/nest-requests/:id Get a specific request
|
||||
PATCH /api/spaces/:slug/nest-requests/:id Approve/deny (admin only)
|
||||
```
|
||||
|
||||
When a request is approved, the server automatically creates the `SpaceRef` in the target space's `nestedSpaces` map with the (potentially modified) permissions.
|
||||
|
||||
---
|
||||
|
||||
## How Sharing Actually Works
|
||||
|
||||
### Scenario: Alice shares her project notes into a DAO
|
||||
|
||||
1. Alice has shapes on her canvas at `alice.rspace.online` — notes, a budget, a timeline.
|
||||
2. Alice is a member of `climate-dao.rspace.online`.
|
||||
3. Alice opens her space settings or right-clicks a group of shapes → "Nest into space..."
|
||||
4. She selects `climate-dao.rspace.online` and sets permissions:
|
||||
- `read: true` — DAO members can see the shapes
|
||||
- `write: false` — only Alice can edit them (they're her canonical data)
|
||||
- `reshare: false` — the DAO can't nest these further
|
||||
5. Server creates a `SpaceRef` in `climate-dao`'s `nestedSpaces` map, pointing to `alice` with the chosen filter and permissions.
|
||||
6. DAO members visiting `climate-dao.rspace.online/canvas` now see Alice's shapes rendered inside a bordered region on the canvas.
|
||||
7. The shapes are live — if Alice updates them on her space, the DAO sees the update in real time.
|
||||
|
||||
### Scenario: Alice revokes
|
||||
|
||||
1. Alice opens her space settings → sees "Shared into: climate-dao"
|
||||
2. Clicks revoke.
|
||||
3. Server removes the `SpaceRef` from `climate-dao`'s `nestedSpaces` map.
|
||||
4. The shapes vanish from the DAO canvas. The DAO's Automerge doc is unchanged — it never contained Alice's shapes directly, only a reference.
|
||||
5. Alice's shapes remain on her own canvas, untouched.
|
||||
|
||||
### Scenario: DAO nests a working group
|
||||
|
||||
1. `climate-dao.rspace.online` creates a new space: `climate-dao-wg-funding` (or it could be at `funding.climate-dao.rspace.online` if wildcard subdomains are supported)
|
||||
2. The DAO admin nests the working group into the DAO space with `read: true, write: true, reshare: true`
|
||||
3. Working group members can also be members of the DAO — their role in each space is independent
|
||||
4. Content created in the working group space appears on both the working group canvas AND the DAO canvas (via the nest)
|
||||
5. Alice's content that was nested into the DAO with `reshare: false` does NOT appear in the working group — `reshare: false` blocked further nesting
|
||||
|
||||
### Scenario: Browsing nested spaces
|
||||
|
||||
When viewing a canvas with nested spaces, the user sees:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ climate-dao.rspace.online/canvas │
|
||||
│ │
|
||||
│ [folk-note] [folk-budget] [folk-poll] │
|
||||
│ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ ≡ alice.rspace.online │ ← nested space border │
|
||||
│ │ (shared by @alice, read-only) │
|
||||
│ │ │ │
|
||||
│ │ [folk-note] [folk-timeline] │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ ≡ funding working group │ │
|
||||
│ │ (read + write) │ │
|
||||
│ │ │ │
|
||||
│ │ [folk-budget] [folk-note] │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each nested region is:
|
||||
- Visually bounded (border, header with source space name)
|
||||
- Scrollable/zoomable independently (it's a canvas within a canvas)
|
||||
- Badged with the source and permission level
|
||||
- Clickable to "enter" — navigating to the full nested space
|
||||
|
||||
---
|
||||
|
||||
## Encryption & Nested Spaces
|
||||
|
||||
### At-Rest Encryption
|
||||
|
||||
Each space's Automerge document can optionally be encrypted at rest using the owner's EncryptID Layer 2 AES-256 key:
|
||||
|
||||
```typescript
|
||||
interface CommunityMeta {
|
||||
// ... existing fields ...
|
||||
encrypted: boolean; // is this space's document encrypted at rest?
|
||||
encryptionKeyId?: string; // which key was used (for key rotation)
|
||||
}
|
||||
```
|
||||
|
||||
For the user's own space, this is straightforward — encrypt with their derived key, decrypt on their device.
|
||||
|
||||
### Sharing Encrypted Content via Nesting
|
||||
|
||||
When an encrypted space is nested into another space, the nested content must be readable by the parent space's members. Two approaches:
|
||||
|
||||
**Approach A: Re-encryption at the boundary**
|
||||
The space owner re-encrypts the shared subset of shapes with a shared key that the parent space's members can access. This key is distributed via the parent space's membership (each member's public key encrypts a copy of the shared key).
|
||||
|
||||
**Approach B: Plaintext projection**
|
||||
The server (which must hold decryption capability for sync purposes, or the owner's client does) creates a plaintext projection of the filtered shapes and serves that to the parent space's WebSocket. The canonical encrypted version stays in the source space.
|
||||
|
||||
**Approach C: Client-side decryption with delegated keys**
|
||||
The source space owner wraps their content key with each authorized viewer's public key. Authorized clients decrypt on their device. This is true E2E but adds complexity to the nesting chain.
|
||||
|
||||
**Recommendation:** Start with Approach B (plaintext projection, server-mediated) for simplicity. Graduate to Approach C (delegated keys) for spaces that require E2E encryption. The `encrypted` flag on `CommunityMeta` determines which path is used.
|
||||
|
||||
---
|
||||
|
||||
## Schema Changes Summary
|
||||
|
||||
### CommunityDoc (Automerge)
|
||||
|
||||
```typescript
|
||||
interface CommunityDoc {
|
||||
meta: CommunityMeta;
|
||||
shapes: { [id: string]: ShapeData };
|
||||
members: { [did: string]: SpaceMember };
|
||||
nestedSpaces: { [refId: string]: SpaceRef }; // NEW
|
||||
}
|
||||
```
|
||||
|
||||
### CommunityMeta
|
||||
|
||||
```typescript
|
||||
interface CommunityMeta {
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
visibility: SpaceVisibility;
|
||||
ownerDID: string | null;
|
||||
enabledModules: string[]; // NEW
|
||||
description?: string; // NEW
|
||||
avatar?: string; // NEW
|
||||
encrypted?: boolean; // NEW
|
||||
encryptionKeyId?: string; // NEW
|
||||
}
|
||||
```
|
||||
|
||||
### New Types
|
||||
|
||||
```typescript
|
||||
interface SpaceRef {
|
||||
id: string;
|
||||
sourceSlug: string;
|
||||
sourceDID?: string;
|
||||
filter?: SpaceRefFilter;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
permissions: NestPermissions;
|
||||
collapsed?: boolean;
|
||||
label?: string;
|
||||
createdAt: number;
|
||||
createdBy: string; // DID of who created this nesting
|
||||
}
|
||||
|
||||
interface SpaceRefFilter {
|
||||
shapeTypes?: string[];
|
||||
shapeIds?: string[];
|
||||
tags?: string[];
|
||||
moduleIds?: string[];
|
||||
}
|
||||
|
||||
interface NestPermissions {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
addShapes: boolean;
|
||||
deleteShapes: boolean;
|
||||
reshare: boolean;
|
||||
expiry?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### No Changes To
|
||||
|
||||
- `ShapeData` — shapes remain the same. They don't know or care whether they're being viewed directly or through a nest.
|
||||
- `SpaceMember` — membership is per-space, unchanged.
|
||||
- `SpaceVisibility` — the four levels remain. They govern direct access to the space; nesting adds a second access path that still must satisfy visibility.
|
||||
|
||||
---
|
||||
|
||||
## API Surface
|
||||
|
||||
### New Endpoints
|
||||
|
||||
```
|
||||
POST /api/spaces/:slug/nest Create a SpaceRef (nest a space)
|
||||
GET /api/spaces/:slug/nest List all nested space refs
|
||||
GET /api/spaces/:slug/nest/:refId Get a specific SpaceRef
|
||||
PATCH /api/spaces/:slug/nest/:refId Update SpaceRef (permissions, filter, position)
|
||||
DELETE /api/spaces/:slug/nest/:refId Remove a SpaceRef (un-nest)
|
||||
|
||||
GET /api/spaces/:slug/nested-in List all spaces this space is nested into
|
||||
(so the owner can see where their content appears)
|
||||
```
|
||||
|
||||
### WebSocket Protocol Additions
|
||||
|
||||
```
|
||||
Message Type Direction Purpose
|
||||
─────────────────────────────────────────────────────────────
|
||||
nest-sync Server→Client Sync data from a nested space
|
||||
nest-subscribe Client→Server Subscribe to a specific nested space's updates
|
||||
nest-unsubscribe Client→Server Unsubscribe from a nested space
|
||||
nest-permission-check Client→Server Check effective permissions for an action in a nest
|
||||
```
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
```
|
||||
POST /api/register/complete Now also provisions <username>.rspace.online
|
||||
GET /api/spaces Returns user's own space first, pinned
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Schema & Auto-Provisioning
|
||||
- Add `enabledModules`, `description`, `avatar`, `nestPolicy` to `CommunityMeta`
|
||||
- Add `nestedSpaces` map to `CommunityDoc`
|
||||
- Add auto-provisioning in EncryptID registration flow (with `DEFAULT_USER_NEST_POLICY`)
|
||||
- Add subdomain routing for `<username>.rspace.online`
|
||||
|
||||
### Phase 2: Nesting CRUD & Consent
|
||||
- Implement `SpaceRef` creation, update, deletion endpoints
|
||||
- Implement `nested-in` reverse lookup
|
||||
- Implement `nestPolicy` CRUD (get/update)
|
||||
- Implement consent flow: `open` (immediate), `members` (membership gate), `approval` (request/approve/deny), `closed` (block)
|
||||
- Implement `PendingNestRequest` lifecycle (create, list, approve, deny)
|
||||
- Implement `defaultPermissions` ceiling on requested permissions
|
||||
- Implement allowlist/blocklist evaluation
|
||||
- Permission validation on nest creation (consent + reshare check + role check)
|
||||
- Notification dispatch (inbox module integration)
|
||||
|
||||
### Phase 3: Nested Space Rendering
|
||||
- Build `folk-canvas` shape (Task-45) as the renderer for `SpaceRef` entries
|
||||
- Implement secondary WebSocket connections for nested space data
|
||||
- Render nested spaces as bordered, labeled regions on the canvas
|
||||
- Handle collapsed vs. expanded views
|
||||
|
||||
### Phase 4: Permission Cascade
|
||||
- Implement `cascadePermissions()` across nesting chains
|
||||
- Enforce dual authority (nest permissions + space role) on all write operations
|
||||
- Implement revocation propagation
|
||||
- Add `reshare: false` enforcement
|
||||
|
||||
### Phase 5: Encryption Integration (Approach B — Server-Mediated)
|
||||
- `encrypted` and `encryptionKeyId` flags on `CommunityMeta` — DONE
|
||||
- AES-256-GCM encryption at rest for Automerge documents — DONE
|
||||
- Custom file format: magic bytes `rSEN` + keyId length + keyId + IV + ciphertext
|
||||
- Transparent encrypt-on-save, decrypt-on-load in community-store
|
||||
- Key derivation via HMAC-SHA256 from server secret + keyId (placeholder for EncryptID L2)
|
||||
- API endpoints: `GET/PATCH /api/spaces/:slug/encryption` — DONE
|
||||
- Plaintext projection for nested views: server decrypts and serves via WS — inherent in Approach B
|
||||
- Future: EncryptID Layer 2 client-side key delegation (Approach C) for true E2E encryption
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Subdomain wildcards** — Should `working-group.climate-dao.rspace.online` be supported for hierarchical subdomain nesting? Or keep it flat with `climate-dao-wg-funding.rspace.online`?
|
||||
|
||||
2. **Offline nesting** — When offline, should the client cache nested space content from the last sync? How stale can nested content be before we show a warning?
|
||||
|
||||
3. ~~**Notification of nesting**~~ — **RESOLVED**: See *Nest Consent & Notification* section. Space owners configure consent level (`open`/`members`/`approval`/`closed`) and notification preferences in their space's `nestPolicy`. Auto-provisioned user spaces default to `approval` consent with inbox notifications.
|
||||
|
||||
4. **Billing/quotas** — Does each `<username>.rspace.online` space count against storage? Are there limits on how many spaces a user can create or how deeply they can nest?
|
||||
|
||||
5. **Presence across nests** — When Alice is editing shapes in her space that are visible via nesting in the DAO space, do DAO members see Alice's presence cursor? Or only direct space visitors?
|
||||
|
||||
6. **Nest request expiry** — Should pending nest requests expire after a period of inactivity? (e.g., 30 days with no response → auto-denied and cleaned up)
|
||||
|
||||
7. **Bulk consent** — Should there be a way to approve/deny nest requests in bulk? DAOs with many members nesting content could generate a lot of requests if set to `approval`.
|
||||
|
||||
---
|
||||
|
||||
*This document describes the target architecture. Implementation should proceed incrementally through the phases above, with each phase usable independently.*
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/sh
|
||||
# Infisical secret injection entrypoint
|
||||
# Fetches secrets from Infisical API and injects them as env vars before starting the app.
|
||||
# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET
|
||||
# Optional: INFISICAL_PROJECT_SLUG (default: rspace), INFISICAL_ENV (default: prod),
|
||||
# INFISICAL_URL (default: http://infisical:8080)
|
||||
|
||||
set -e
|
||||
|
||||
INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
|
||||
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
|
||||
INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rspace}"
|
||||
|
||||
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
|
||||
echo "[infisical] No credentials set, starting without secret injection"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
|
||||
|
||||
# Use Bun's built-in fetch API for HTTP calls and JSON parsing
|
||||
EXPORTS=$(bun -e "
|
||||
(async () => {
|
||||
try {
|
||||
const base = process.env.INFISICAL_URL;
|
||||
const auth = await fetch(base + '/api/v1/auth/universal-auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientId: process.env.INFISICAL_CLIENT_ID,
|
||||
clientSecret: process.env.INFISICAL_CLIENT_SECRET
|
||||
})
|
||||
}).then(r => r.json());
|
||||
if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); }
|
||||
|
||||
const slug = process.env.INFISICAL_PROJECT_SLUG;
|
||||
const env = process.env.INFISICAL_ENV;
|
||||
const secrets = await fetch(base + '/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', {
|
||||
headers: { 'Authorization': 'Bearer ' + auth.accessToken }
|
||||
}).then(r => r.json());
|
||||
|
||||
if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); }
|
||||
|
||||
for (const s of secrets.secrets) {
|
||||
const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" );
|
||||
console.log('export ' + s.secretKey + \"='\" + escaped + \"'\");
|
||||
}
|
||||
} catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); }
|
||||
})();
|
||||
" 2>&1) || {
|
||||
echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
# Check if we got export statements or error messages
|
||||
if echo "$EXPORTS" | grep -q "^export "; then
|
||||
COUNT=$(echo "$EXPORTS" | grep -c "^export ")
|
||||
eval "$EXPORTS"
|
||||
echo "[infisical] Injected ${COUNT} secrets"
|
||||
else
|
||||
echo "[infisical] WARNING: $EXPORTS"
|
||||
echo "[infisical] Starting with existing env vars"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import * as Automerge from "@automerge/automerge";
|
||||
import type { FolkShape } from "./folk-shape";
|
||||
import type { OfflineStore } from "./offline-store";
|
||||
import type { Layer, LayerFlow } from "./layer-types";
|
||||
|
||||
// Shape data stored in Automerge document
|
||||
export interface ShapeData {
|
||||
|
|
@ -28,16 +29,69 @@ export interface ShapeData {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Nested space types (client-side) ──
|
||||
|
||||
export interface NestPermissions {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
addShapes: boolean;
|
||||
deleteShapes: boolean;
|
||||
reshare: boolean;
|
||||
expiry?: number;
|
||||
}
|
||||
|
||||
export interface SpaceRefFilter {
|
||||
shapeTypes?: string[];
|
||||
shapeIds?: string[];
|
||||
tags?: string[];
|
||||
moduleIds?: string[];
|
||||
}
|
||||
|
||||
export interface SpaceRef {
|
||||
id: string;
|
||||
sourceSlug: string;
|
||||
sourceDID?: string;
|
||||
filter?: SpaceRefFilter;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
permissions: NestPermissions;
|
||||
collapsed?: boolean;
|
||||
label?: string;
|
||||
createdAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
// Automerge document structure
|
||||
export interface CommunityDoc {
|
||||
meta: {
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
enabledModules?: string[];
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
shapes: {
|
||||
[id: string]: ShapeData;
|
||||
};
|
||||
nestedSpaces?: {
|
||||
[refId: string]: SpaceRef;
|
||||
};
|
||||
/** Tab/layer system — each layer is an rApp page in this space */
|
||||
layers?: {
|
||||
[id: string]: Layer;
|
||||
};
|
||||
/** Inter-layer flows (economic, trust, data, etc.) */
|
||||
flows?: {
|
||||
[id: string]: LayerFlow;
|
||||
};
|
||||
/** Currently active layer ID */
|
||||
activeLayerId?: string;
|
||||
/** Layer view mode: flat (tabs) or stack (side view) */
|
||||
layerViewMode?: "flat" | "stack";
|
||||
}
|
||||
|
||||
type SyncState = Automerge.SyncState;
|
||||
|
|
@ -696,6 +750,16 @@ export class CommunitySync extends EventTarget {
|
|||
if (data.rankings !== undefined) rank.rankings = data.rankings;
|
||||
}
|
||||
|
||||
// Update nested canvas properties
|
||||
if (data.type === "folk-canvas") {
|
||||
const canvas = shape as any;
|
||||
if (data.sourceSlug !== undefined && canvas.sourceSlug !== data.sourceSlug) canvas.sourceSlug = data.sourceSlug;
|
||||
if (data.sourceDID !== undefined && canvas.sourceDID !== data.sourceDID) canvas.sourceDID = data.sourceDID;
|
||||
if (data.permissions !== undefined) canvas.permissions = data.permissions;
|
||||
if (data.collapsed !== undefined && canvas.collapsed !== data.collapsed) canvas.collapsed = data.collapsed;
|
||||
if (data.label !== undefined && canvas.label !== data.label) canvas.label = data.label;
|
||||
}
|
||||
|
||||
// Update choice-spider properties
|
||||
if (data.type === "folk-choice-spider") {
|
||||
const spider = shape as any;
|
||||
|
|
@ -705,6 +769,25 @@ export class CommunitySync extends EventTarget {
|
|||
if (data.scores !== undefined) spider.scores = data.scores;
|
||||
}
|
||||
|
||||
// Update rApp embed properties
|
||||
if (data.type === "folk-rapp") {
|
||||
const rapp = shape as any;
|
||||
if (data.moduleId !== undefined && rapp.moduleId !== data.moduleId) rapp.moduleId = data.moduleId;
|
||||
if (data.spaceSlug !== undefined && rapp.spaceSlug !== data.spaceSlug) rapp.spaceSlug = data.spaceSlug;
|
||||
}
|
||||
|
||||
// Update feed shape properties
|
||||
if (data.type === "folk-feed") {
|
||||
const feed = shape as any;
|
||||
if (data.sourceLayer !== undefined && feed.sourceLayer !== data.sourceLayer) feed.sourceLayer = data.sourceLayer;
|
||||
if (data.sourceModule !== undefined && feed.sourceModule !== data.sourceModule) feed.sourceModule = data.sourceModule;
|
||||
if (data.feedId !== undefined && feed.feedId !== data.feedId) feed.feedId = data.feedId;
|
||||
if (data.flowKind !== undefined && feed.flowKind !== data.flowKind) feed.flowKind = data.flowKind;
|
||||
if (data.feedFilter !== undefined && feed.feedFilter !== data.feedFilter) feed.feedFilter = data.feedFilter;
|
||||
if (data.maxItems !== undefined && feed.maxItems !== data.maxItems) feed.maxItems = data.maxItems;
|
||||
if (data.refreshInterval !== undefined && feed.refreshInterval !== data.refreshInterval) feed.refreshInterval = data.refreshInterval;
|
||||
}
|
||||
|
||||
// Update social-post properties
|
||||
if (data.type === "folk-social-post") {
|
||||
const post = shape as any;
|
||||
|
|
@ -778,6 +861,130 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Layer & Flow API ──
|
||||
|
||||
/** Add a layer to the document */
|
||||
addLayer(layer: Layer): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Add layer ${layer.id}`, (doc) => {
|
||||
if (!doc.layers) doc.layers = {};
|
||||
doc.layers[layer.id] = layer;
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
this.dispatchEvent(new CustomEvent("layer-added", { detail: layer }));
|
||||
}
|
||||
|
||||
/** Remove a layer */
|
||||
removeLayer(layerId: string): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Remove layer ${layerId}`, (doc) => {
|
||||
if (doc.layers && doc.layers[layerId]) {
|
||||
delete doc.layers[layerId];
|
||||
}
|
||||
// Remove flows connected to this layer
|
||||
if (doc.flows) {
|
||||
for (const [fid, flow] of Object.entries(doc.flows)) {
|
||||
if (flow.sourceLayerId === layerId || flow.targetLayerId === layerId) {
|
||||
delete doc.flows[fid];
|
||||
}
|
||||
}
|
||||
}
|
||||
// If active layer was removed, switch to first remaining
|
||||
if (doc.activeLayerId === layerId) {
|
||||
const remaining = doc.layers ? Object.keys(doc.layers) : [];
|
||||
doc.activeLayerId = remaining[0] || "";
|
||||
}
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
this.dispatchEvent(new CustomEvent("layer-removed", { detail: { layerId } }));
|
||||
}
|
||||
|
||||
/** Update a layer's properties */
|
||||
updateLayer(layerId: string, updates: Partial<Layer>): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Update layer ${layerId}`, (doc) => {
|
||||
if (doc.layers && doc.layers[layerId]) {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
(doc.layers[layerId] as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/** Set active layer */
|
||||
setActiveLayer(layerId: string): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Switch to layer ${layerId}`, (doc) => {
|
||||
doc.activeLayerId = layerId;
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } }));
|
||||
}
|
||||
|
||||
/** Set layer view mode */
|
||||
setLayerViewMode(mode: "flat" | "stack"): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Set view mode ${mode}`, (doc) => {
|
||||
doc.layerViewMode = mode;
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/** Add a flow between layers */
|
||||
addFlow(flow: LayerFlow): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Add flow ${flow.id}`, (doc) => {
|
||||
if (!doc.flows) doc.flows = {};
|
||||
doc.flows[flow.id] = flow;
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
this.dispatchEvent(new CustomEvent("flow-added", { detail: flow }));
|
||||
}
|
||||
|
||||
/** Remove a flow */
|
||||
removeFlow(flowId: string): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Remove flow ${flowId}`, (doc) => {
|
||||
if (doc.flows && doc.flows[flowId]) {
|
||||
delete doc.flows[flowId];
|
||||
}
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/** Update flow properties */
|
||||
updateFlow(flowId: string, updates: Partial<LayerFlow>): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Update flow ${flowId}`, (doc) => {
|
||||
if (doc.flows && doc.flows[flowId]) {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
(doc.flows[flowId] as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/** Get all layers (sorted by order) */
|
||||
getLayers(): Layer[] {
|
||||
const layers = this.#doc.layers || {};
|
||||
return Object.values(layers).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Get all flows */
|
||||
getFlows(): LayerFlow[] {
|
||||
const flows = this.#doc.flows || {};
|
||||
return Object.values(flows);
|
||||
}
|
||||
|
||||
/** Get flows for a specific layer (as source or target) */
|
||||
getFlowsForLayer(layerId: string): LayerFlow[] {
|
||||
return this.getFlows().filter(
|
||||
f => f.sourceLayerId === layerId || f.targetLayerId === layerId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,546 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
import type { ShapeData, SpaceRef, NestPermissions } from "./community-sync";
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
min-width: 300px;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: #334155;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: move;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-left .icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-read {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.badge-write {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: calc(100% - 32px);
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.nested-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.nested-shape {
|
||||
position: absolute;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.nested-shape .shape-type {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nested-shape .shape-content {
|
||||
color: #334155;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 12px;
|
||||
background: #f1f5f9;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status-connected { background: #22c55e; }
|
||||
.status-connecting { background: #eab308; }
|
||||
.status-disconnected { background: #ef4444; }
|
||||
|
||||
.collapsed-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100% - 32px);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collapsed-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.collapsed-label {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.collapsed-meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
margin-top: 8px;
|
||||
background: #334155;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.enter-btn:hover {
|
||||
background: #1e293b;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-canvas": FolkCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkCanvas extends FolkShape {
|
||||
static override tagName = "folk-canvas";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#sourceSlug: string = "";
|
||||
#parentSlug: string = ""; // slug of the space this shape lives in (for nest-from context)
|
||||
#permissions: NestPermissions = {
|
||||
read: true, write: false, addShapes: false, deleteShapes: false, reshare: false
|
||||
};
|
||||
#collapsed = false;
|
||||
#label: string | null = null;
|
||||
#sourceDID: string | null = null;
|
||||
|
||||
// WebSocket connection to nested space
|
||||
#ws: WebSocket | null = null;
|
||||
#connectionStatus: "disconnected" | "connecting" | "connected" = "disconnected";
|
||||
#nestedShapes: Map<string, ShapeData> = new Map();
|
||||
#reconnectAttempts = 0;
|
||||
#maxReconnectAttempts = 5;
|
||||
|
||||
// DOM refs
|
||||
#nestedCanvasEl: HTMLElement | null = null;
|
||||
#statusIndicator: HTMLElement | null = null;
|
||||
#statusText: HTMLElement | null = null;
|
||||
#shapeCountEl: HTMLElement | null = null;
|
||||
|
||||
get sourceSlug() { return this.#sourceSlug; }
|
||||
set sourceSlug(value: string) {
|
||||
if (this.#sourceSlug === value) return;
|
||||
this.#sourceSlug = value;
|
||||
this.requestUpdate("sourceSlug");
|
||||
this.dispatchEvent(new CustomEvent("content-change", { detail: { sourceSlug: value } }));
|
||||
// Reconnect to new source
|
||||
this.#disconnect();
|
||||
if (value) this.#connectToSource();
|
||||
}
|
||||
|
||||
get permissions(): NestPermissions { return this.#permissions; }
|
||||
set permissions(value: NestPermissions) {
|
||||
this.#permissions = value;
|
||||
this.requestUpdate("permissions");
|
||||
}
|
||||
|
||||
get collapsed() { return this.#collapsed; }
|
||||
set collapsed(value: boolean) {
|
||||
this.#collapsed = value;
|
||||
this.requestUpdate("collapsed");
|
||||
this.#renderView();
|
||||
}
|
||||
|
||||
get label() { return this.#label; }
|
||||
set label(value: string | null) {
|
||||
this.#label = value;
|
||||
this.requestUpdate("label");
|
||||
}
|
||||
|
||||
get sourceDID() { return this.#sourceDID; }
|
||||
set sourceDID(value: string | null) { this.#sourceDID = value; }
|
||||
|
||||
get parentSlug() { return this.#parentSlug; }
|
||||
set parentSlug(value: string) { this.#parentSlug = value; }
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
// Read initial attributes
|
||||
this.#sourceSlug = this.getAttribute("source-slug") || "";
|
||||
this.#label = this.getAttribute("label") || null;
|
||||
this.#collapsed = this.getAttribute("collapsed") === "true";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.cssText = "width: 100%; height: 100%; display: flex; flex-direction: column;";
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header" data-drag>
|
||||
<div class="header-left">
|
||||
<span class="icon">\u{1F5BC}</span>
|
||||
<span class="source-name"></span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="permission-badge badge"></span>
|
||||
<div class="header-actions">
|
||||
<button class="collapse-btn" title="Toggle collapse">\u25BC</button>
|
||||
<button class="enter-space-btn" title="Open space">\u2197</button>
|
||||
<button class="close-btn" title="Remove">\u00D7</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="nested-canvas"></div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span><span class="status-indicator status-disconnected"></span><span class="status-text">Disconnected</span></span>
|
||||
<span class="shape-count">0 shapes</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Replace the slot container
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) containerDiv.replaceWith(wrapper);
|
||||
|
||||
// Cache DOM refs
|
||||
this.#nestedCanvasEl = wrapper.querySelector(".nested-canvas");
|
||||
this.#statusIndicator = wrapper.querySelector(".status-indicator");
|
||||
this.#statusText = wrapper.querySelector(".status-text");
|
||||
this.#shapeCountEl = wrapper.querySelector(".shape-count");
|
||||
|
||||
const sourceNameEl = wrapper.querySelector(".source-name") as HTMLElement;
|
||||
const permBadge = wrapper.querySelector(".permission-badge") as HTMLElement;
|
||||
const collapseBtn = wrapper.querySelector(".collapse-btn") as HTMLButtonElement;
|
||||
const enterBtn = wrapper.querySelector(".enter-space-btn") as HTMLButtonElement;
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
|
||||
// Set header text
|
||||
sourceNameEl.textContent = this.#label || this.#sourceSlug || "Nested Space";
|
||||
|
||||
// Permission badge
|
||||
if (this.#permissions.write) {
|
||||
permBadge.textContent = "read + write";
|
||||
permBadge.className = "badge badge-write";
|
||||
} else {
|
||||
permBadge.textContent = "read-only";
|
||||
permBadge.className = "badge badge-read";
|
||||
}
|
||||
|
||||
// Collapse toggle
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.collapsed = !this.#collapsed;
|
||||
collapseBtn.textContent = this.#collapsed ? "\u25B6" : "\u25BC";
|
||||
this.dispatchEvent(new CustomEvent("content-change", { detail: { collapsed: this.#collapsed } }));
|
||||
});
|
||||
|
||||
// Enter space (navigate)
|
||||
enterBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.#sourceSlug) {
|
||||
window.open(`/${this.#sourceSlug}/canvas`, "_blank");
|
||||
}
|
||||
});
|
||||
|
||||
// Close (remove nesting)
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
// Prevent drag on interactive content
|
||||
const content = wrapper.querySelector(".content") as HTMLElement;
|
||||
content.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
// Connect to the nested space
|
||||
if (this.#sourceSlug && !this.#collapsed) {
|
||||
this.#connectToSource();
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
#connectToSource(): void {
|
||||
if (!this.#sourceSlug || this.#connectionStatus === "connected" || this.#connectionStatus === "connecting") return;
|
||||
|
||||
this.#setStatus("connecting");
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const nestParam = this.#parentSlug ? `&nest-from=${encodeURIComponent(this.#parentSlug)}` : "";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/${this.#sourceSlug}?mode=json${nestParam}`;
|
||||
|
||||
this.#ws = new WebSocket(wsUrl);
|
||||
|
||||
this.#ws.onopen = () => {
|
||||
this.#connectionStatus = "connected";
|
||||
this.#reconnectAttempts = 0;
|
||||
this.#setStatus("connected");
|
||||
};
|
||||
|
||||
this.#ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "snapshot" && msg.shapes) {
|
||||
this.#nestedShapes.clear();
|
||||
for (const [id, shape] of Object.entries(msg.shapes)) {
|
||||
const s = shape as ShapeData;
|
||||
if (!s.forgotten) {
|
||||
this.#nestedShapes.set(id, s);
|
||||
}
|
||||
}
|
||||
this.#renderNestedShapes();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FolkCanvas] Failed to handle message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.#ws.onclose = () => {
|
||||
this.#connectionStatus = "disconnected";
|
||||
this.#setStatus("disconnected");
|
||||
this.#attemptReconnect();
|
||||
};
|
||||
|
||||
this.#ws.onerror = () => {
|
||||
this.#setStatus("disconnected");
|
||||
};
|
||||
}
|
||||
|
||||
#attemptReconnect(): void {
|
||||
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return;
|
||||
this.#reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, this.#reconnectAttempts - 1), 16000);
|
||||
setTimeout(() => {
|
||||
if (this.#connectionStatus === "disconnected") {
|
||||
this.#connectToSource();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#disconnect(): void {
|
||||
if (this.#ws) {
|
||||
this.#ws.onclose = null; // prevent reconnect
|
||||
this.#ws.close();
|
||||
this.#ws = null;
|
||||
}
|
||||
this.#connectionStatus = "disconnected";
|
||||
this.#nestedShapes.clear();
|
||||
this.#setStatus("disconnected");
|
||||
}
|
||||
|
||||
#setStatus(status: "disconnected" | "connecting" | "connected"): void {
|
||||
this.#connectionStatus = status;
|
||||
if (this.#statusIndicator) {
|
||||
this.#statusIndicator.className = `status-indicator status-${status}`;
|
||||
}
|
||||
if (this.#statusText) {
|
||||
this.#statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
#renderView(): void {
|
||||
if (!this.shadowRoot) return;
|
||||
const content = this.shadowRoot.querySelector(".content") as HTMLElement;
|
||||
const statusBar = this.shadowRoot.querySelector(".status-bar") as HTMLElement;
|
||||
if (!content || !statusBar) return;
|
||||
|
||||
if (this.#collapsed) {
|
||||
content.innerHTML = `
|
||||
<div class="collapsed-view">
|
||||
<div class="collapsed-icon">\u{1F5BC}</div>
|
||||
<div class="collapsed-label">${this.#label || this.#sourceSlug}</div>
|
||||
<div class="collapsed-meta">${this.#nestedShapes.size} shapes</div>
|
||||
<button class="enter-btn">Open space \u2192</button>
|
||||
</div>
|
||||
`;
|
||||
statusBar.style.display = "none";
|
||||
const enterBtn = content.querySelector(".enter-btn");
|
||||
enterBtn?.addEventListener("click", () => {
|
||||
if (this.#sourceSlug) window.open(`/${this.#sourceSlug}/canvas`, "_blank");
|
||||
});
|
||||
// Disconnect when collapsed
|
||||
this.#disconnect();
|
||||
} else {
|
||||
content.innerHTML = `<div class="nested-canvas"></div>`;
|
||||
this.#nestedCanvasEl = content.querySelector(".nested-canvas");
|
||||
statusBar.style.display = "flex";
|
||||
// Reconnect when expanded
|
||||
if (this.#sourceSlug) this.#connectToSource();
|
||||
this.#renderNestedShapes();
|
||||
}
|
||||
}
|
||||
|
||||
#renderNestedShapes(): void {
|
||||
if (!this.#nestedCanvasEl) return;
|
||||
|
||||
this.#nestedCanvasEl.innerHTML = "";
|
||||
|
||||
if (this.#nestedShapes.size === 0) {
|
||||
this.#nestedCanvasEl.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:12px;">
|
||||
${this.#connectionStatus === "connected" ? "Empty space" : "Connecting..."}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Calculate bounding box of all shapes to fit within our viewport
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const shape of this.#nestedShapes.values()) {
|
||||
minX = Math.min(minX, shape.x);
|
||||
minY = Math.min(minY, shape.y);
|
||||
maxX = Math.max(maxX, shape.x + (shape.width || 300));
|
||||
maxY = Math.max(maxY, shape.y + (shape.height || 200));
|
||||
}
|
||||
|
||||
const contentWidth = maxX - minX || 1;
|
||||
const contentHeight = maxY - minY || 1;
|
||||
const canvasWidth = this.#nestedCanvasEl.clientWidth || 600;
|
||||
const canvasHeight = this.#nestedCanvasEl.clientHeight || 400;
|
||||
const scale = Math.min(canvasWidth / contentWidth, canvasHeight / contentHeight, 1) * 0.9;
|
||||
const offsetX = (canvasWidth - contentWidth * scale) / 2;
|
||||
const offsetY = (canvasHeight - contentHeight * scale) / 2;
|
||||
|
||||
for (const shape of this.#nestedShapes.values()) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "nested-shape";
|
||||
el.style.left = `${offsetX + (shape.x - minX) * scale}px`;
|
||||
el.style.top = `${offsetY + (shape.y - minY) * scale}px`;
|
||||
el.style.width = `${(shape.width || 300) * scale}px`;
|
||||
el.style.height = `${(shape.height || 200) * scale}px`;
|
||||
|
||||
const typeLabel = shape.type.replace("folk-", "").replace(/-/g, " ");
|
||||
const content = shape.content
|
||||
? shape.content.slice(0, 100) + (shape.content.length > 100 ? "..." : "")
|
||||
: "";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="shape-type">${typeLabel}</div>
|
||||
<div class="shape-content">${content}</div>
|
||||
`;
|
||||
|
||||
this.#nestedCanvasEl.appendChild(el);
|
||||
}
|
||||
|
||||
// Update shape count
|
||||
if (this.#shapeCountEl) {
|
||||
this.#shapeCountEl.textContent = `${this.#nestedShapes.size} shape${this.#nestedShapes.size !== 1 ? "s" : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#disconnect();
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-canvas",
|
||||
sourceSlug: this.#sourceSlug,
|
||||
sourceDID: this.#sourceDID,
|
||||
permissions: this.#permissions,
|
||||
collapsed: this.#collapsed,
|
||||
label: this.#label,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,887 @@
|
|||
/**
|
||||
* <folk-feed> — Canvas shape that renders a live feed from another layer.
|
||||
*
|
||||
* Bridges layers by pulling data from a source layer's module API endpoint
|
||||
* and rendering it as a live, updating feed within the current canvas.
|
||||
*
|
||||
* Attributes:
|
||||
* source-layer — source layer ID
|
||||
* source-module — source module ID (e.g. "notes", "funds", "vote")
|
||||
* feed-id — which feed to pull (e.g. "recent-notes", "proposals")
|
||||
* flow-kind — flow type for visual styling ("economic", "trust", "data", etc.)
|
||||
* feed-filter — optional JSON filter string
|
||||
* max-items — max items to display (default 10)
|
||||
* refresh-interval — auto-refresh ms (default 30000, 0 = manual only)
|
||||
*
|
||||
* The shape auto-fetches from /{space}/{source-module}/api/{feed-endpoint}
|
||||
* and renders results as a scrollable card list.
|
||||
*/
|
||||
|
||||
import { FolkShape } from "./folk-shape";
|
||||
import { FLOW_COLORS, FLOW_LABELS } from "./layer-types";
|
||||
import type { FlowKind } from "./layer-types";
|
||||
|
||||
export class FolkFeed extends FolkShape {
|
||||
static tagName = "folk-feed";
|
||||
|
||||
#feedData: any[] = [];
|
||||
#loading = false;
|
||||
#error: string | null = null;
|
||||
#refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
#inner: HTMLElement | null = null;
|
||||
#editingIndex: number | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
...FolkShape.observedAttributes,
|
||||
"source-layer", "source-module", "feed-id", "flow-kind",
|
||||
"feed-filter", "max-items", "refresh-interval",
|
||||
];
|
||||
}
|
||||
|
||||
get sourceLayer(): string { return this.getAttribute("source-layer") || ""; }
|
||||
set sourceLayer(v: string) { this.setAttribute("source-layer", v); }
|
||||
|
||||
get sourceModule(): string { return this.getAttribute("source-module") || ""; }
|
||||
set sourceModule(v: string) { this.setAttribute("source-module", v); }
|
||||
|
||||
get feedId(): string { return this.getAttribute("feed-id") || ""; }
|
||||
set feedId(v: string) { this.setAttribute("feed-id", v); }
|
||||
|
||||
get flowKind(): FlowKind { return (this.getAttribute("flow-kind") as FlowKind) || "data"; }
|
||||
set flowKind(v: FlowKind) { this.setAttribute("flow-kind", v); }
|
||||
|
||||
get feedFilter(): string { return this.getAttribute("feed-filter") || ""; }
|
||||
set feedFilter(v: string) { this.setAttribute("feed-filter", v); }
|
||||
|
||||
get maxItems(): number { return parseInt(this.getAttribute("max-items") || "10", 10); }
|
||||
set maxItems(v: number) { this.setAttribute("max-items", String(v)); }
|
||||
|
||||
get refreshInterval(): number { return parseInt(this.getAttribute("refresh-interval") || "30000", 10); }
|
||||
set refreshInterval(v: number) { this.setAttribute("refresh-interval", String(v)); }
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#buildUI();
|
||||
this.#fetchFeed();
|
||||
this.#startAutoRefresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.#stopAutoRefresh();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
|
||||
super.attributeChangedCallback(name, oldVal, newVal);
|
||||
if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) {
|
||||
this.#fetchFeed();
|
||||
}
|
||||
if (name === "refresh-interval") {
|
||||
this.#stopAutoRefresh();
|
||||
this.#startAutoRefresh();
|
||||
}
|
||||
if (name === "flow-kind") {
|
||||
this.#updateHeader();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build the inner UI ──
|
||||
|
||||
#buildUI() {
|
||||
if (this.#inner) return;
|
||||
|
||||
this.#inner = document.createElement("div");
|
||||
this.#inner.className = "folk-feed-inner";
|
||||
this.#inner.innerHTML = `
|
||||
<div class="feed-header">
|
||||
<div class="feed-kind-dot"></div>
|
||||
<div class="feed-title"></div>
|
||||
<button class="feed-navigate" title="Go to source layer">↗</button>
|
||||
<button class="feed-refresh" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="feed-items"></div>
|
||||
<div class="feed-edit-overlay"></div>
|
||||
<div class="feed-status"></div>
|
||||
`;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = FEED_STYLES;
|
||||
this.#inner.prepend(style);
|
||||
|
||||
this.appendChild(this.#inner);
|
||||
|
||||
// Refresh button
|
||||
this.#inner.querySelector(".feed-refresh")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#fetchFeed();
|
||||
});
|
||||
|
||||
// Navigate to source layer
|
||||
this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.sourceModule) {
|
||||
const space = this.#getSpaceSlug();
|
||||
window.location.href = `/${space}/${this.sourceModule}`;
|
||||
}
|
||||
});
|
||||
|
||||
this.#updateHeader();
|
||||
}
|
||||
|
||||
#updateHeader() {
|
||||
if (!this.#inner) return;
|
||||
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
||||
const label = FLOW_LABELS[this.flowKind] || "Feed";
|
||||
const dot = this.#inner.querySelector<HTMLElement>(".feed-kind-dot");
|
||||
const title = this.#inner.querySelector<HTMLElement>(".feed-title");
|
||||
if (dot) dot.style.background = color;
|
||||
if (title) title.textContent = `${this.sourceModule} / ${this.feedId || label}`;
|
||||
}
|
||||
|
||||
// ── Fetch feed data ──
|
||||
|
||||
async #fetchFeed() {
|
||||
if (!this.sourceModule) return;
|
||||
if (this.#loading) return;
|
||||
|
||||
this.#loading = true;
|
||||
this.#updateStatus("loading");
|
||||
|
||||
try {
|
||||
// Construct feed URL based on feed ID
|
||||
const space = this.#getSpaceSlug();
|
||||
const feedEndpoint = this.#getFeedEndpoint();
|
||||
const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Normalize: extract the array from common response shapes
|
||||
if (Array.isArray(data)) {
|
||||
this.#feedData = data.slice(0, this.maxItems);
|
||||
} else if (data.notes) {
|
||||
this.#feedData = data.notes.slice(0, this.maxItems);
|
||||
} else if (data.notebooks) {
|
||||
this.#feedData = data.notebooks.slice(0, this.maxItems);
|
||||
} else if (data.proposals) {
|
||||
this.#feedData = data.proposals.slice(0, this.maxItems);
|
||||
} else if (data.tasks) {
|
||||
this.#feedData = data.tasks.slice(0, this.maxItems);
|
||||
} else if (data.nodes) {
|
||||
this.#feedData = data.nodes.slice(0, this.maxItems);
|
||||
} else if (data.flows) {
|
||||
this.#feedData = data.flows.slice(0, this.maxItems);
|
||||
} else {
|
||||
// Try to use the data as-is if it has array-like fields
|
||||
const firstArray = Object.values(data).find(v => Array.isArray(v));
|
||||
this.#feedData = firstArray ? (firstArray as any[]).slice(0, this.maxItems) : [data];
|
||||
}
|
||||
|
||||
this.#error = null;
|
||||
this.#renderItems();
|
||||
this.#updateStatus("ok");
|
||||
} catch (err: any) {
|
||||
this.#error = err.message || "Failed to fetch";
|
||||
this.#updateStatus("error");
|
||||
} finally {
|
||||
this.#loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
#getFeedEndpoint(): string {
|
||||
// Map feed IDs to actual API endpoints
|
||||
const FEED_ENDPOINTS: Record<string, Record<string, string>> = {
|
||||
notes: {
|
||||
"notes-by-tag": "notes",
|
||||
"recent-notes": "notes",
|
||||
default: "notes",
|
||||
},
|
||||
funds: {
|
||||
"treasury-flows": "flows",
|
||||
"transactions": "flows",
|
||||
default: "flows",
|
||||
},
|
||||
vote: {
|
||||
proposals: "proposals",
|
||||
decisions: "proposals?status=PASSED,FAILED",
|
||||
default: "proposals",
|
||||
},
|
||||
choices: {
|
||||
"poll-results": "choices",
|
||||
default: "choices",
|
||||
},
|
||||
wallet: {
|
||||
balances: "safe/detect",
|
||||
transfers: "safe/detect",
|
||||
default: "safe/detect",
|
||||
},
|
||||
data: {
|
||||
analytics: "stats",
|
||||
"active-users": "active",
|
||||
default: "stats",
|
||||
},
|
||||
work: {
|
||||
"task-activity": "spaces",
|
||||
"board-summary": "spaces",
|
||||
default: "spaces",
|
||||
},
|
||||
network: {
|
||||
"trust-graph": "graph",
|
||||
connections: "people",
|
||||
default: "graph",
|
||||
},
|
||||
trips: {
|
||||
"trip-expenses": "trips",
|
||||
itinerary: "trips",
|
||||
default: "trips",
|
||||
},
|
||||
};
|
||||
|
||||
const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule];
|
||||
if (!moduleEndpoints) return this.feedId || "info";
|
||||
return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId;
|
||||
}
|
||||
|
||||
#getSpaceSlug(): string {
|
||||
// Try to get from URL
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
return parts[0] || "demo";
|
||||
}
|
||||
|
||||
// ── Render feed items ──
|
||||
|
||||
#renderItems() {
|
||||
const container = this.#inner?.querySelector(".feed-items");
|
||||
if (!container) return;
|
||||
|
||||
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
||||
|
||||
if (this.#feedData.length === 0) {
|
||||
container.innerHTML = `<div class="feed-empty">No data</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.#feedData.map((item, i) => {
|
||||
const title = item.title || item.name || item.label || item.id || `Item ${i + 1}`;
|
||||
const subtitle = item.description || item.content_plain?.slice(0, 80) || item.status || item.type || "";
|
||||
const badge = item.status || item.kind || item.type || "";
|
||||
const editable = this.#isEditable(item);
|
||||
|
||||
return `
|
||||
<div class="feed-item ${editable ? "feed-item--editable" : ""}" data-index="${i}" data-item-id="${item.id || ""}">
|
||||
<div class="feed-item-line" style="background:${color}"></div>
|
||||
<div class="feed-item-content">
|
||||
<div class="feed-item-title">${this.#escapeHtml(String(title))}</div>
|
||||
${subtitle ? `<div class="feed-item-subtitle">${this.#escapeHtml(String(subtitle).slice(0, 100))}</div>` : ""}
|
||||
</div>
|
||||
<div class="feed-item-actions">
|
||||
${editable ? `<button class="feed-item-edit" data-edit="${i}" title="Edit">✎</button>` : ""}
|
||||
${badge ? `<div class="feed-item-badge" style="color:${color}">${this.#escapeHtml(String(badge))}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
// Attach item click and edit events
|
||||
container.querySelectorAll<HTMLElement>(".feed-item").forEach(el => {
|
||||
// Double-click to navigate to source
|
||||
el.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(el.dataset.index || "0", 10);
|
||||
const item = this.#feedData[idx];
|
||||
if (item) this.#navigateToItem(item);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLElement>(".feed-item-edit").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.edit || "0", 10);
|
||||
this.#openEditOverlay(idx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if an item supports write-back */
|
||||
#isEditable(item: any): boolean {
|
||||
// Items with an ID from modules that support PUT/PATCH are editable
|
||||
if (!item.id) return false;
|
||||
const editableModules = ["notes", "work", "vote", "trips"];
|
||||
return editableModules.includes(this.sourceModule);
|
||||
}
|
||||
|
||||
/** Navigate to the source item in its module */
|
||||
#navigateToItem(item: any) {
|
||||
const space = this.#getSpaceSlug();
|
||||
const mod = this.sourceModule;
|
||||
|
||||
// Build a deep link to the item in its source module
|
||||
// Emit an event so the canvas/shell can handle it
|
||||
this.dispatchEvent(new CustomEvent("feed-navigate", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
sourceModule: mod,
|
||||
itemId: item.id,
|
||||
item,
|
||||
url: `/${space}/${mod}`,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Edit overlay (bidirectional write-back) ──
|
||||
|
||||
#openEditOverlay(index: number) {
|
||||
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
|
||||
if (!overlay) return;
|
||||
|
||||
const item = this.#feedData[index];
|
||||
if (!item) return;
|
||||
|
||||
this.#editingIndex = index;
|
||||
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
||||
|
||||
// Build edit fields based on item properties
|
||||
const editableFields = this.#getEditableFields(item);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="edit-panel">
|
||||
<div class="edit-header">
|
||||
<span style="color:${color}">Edit: ${this.#escapeHtml(item.title || item.name || "Item")}</span>
|
||||
<button class="edit-close">×</button>
|
||||
</div>
|
||||
<div class="edit-fields">
|
||||
${editableFields.map(f => `
|
||||
<label class="edit-field">
|
||||
<span class="edit-label">${f.label}</span>
|
||||
${f.type === "textarea"
|
||||
? `<textarea class="edit-input" data-field="${f.key}" rows="3">${this.#escapeHtml(String(f.value))}</textarea>`
|
||||
: f.type === "select"
|
||||
? `<select class="edit-input" data-field="${f.key}">
|
||||
${f.options!.map(o => `<option value="${o}" ${o === f.value ? "selected" : ""}>${o}</option>`).join("")}
|
||||
</select>`
|
||||
: `<input class="edit-input" type="text" data-field="${f.key}" value="${this.#escapeHtml(String(f.value))}" />`
|
||||
}
|
||||
</label>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button class="edit-cancel">Cancel</button>
|
||||
<button class="edit-save" style="background:${color}20; color:${color}; border-color:${color}40">Save & Push</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.classList.add("open");
|
||||
|
||||
// Events
|
||||
overlay.querySelector(".edit-close")?.addEventListener("click", () => this.#closeEditOverlay());
|
||||
overlay.querySelector(".edit-cancel")?.addEventListener("click", () => this.#closeEditOverlay());
|
||||
overlay.querySelector(".edit-save")?.addEventListener("click", () => this.#saveEdit());
|
||||
}
|
||||
|
||||
#closeEditOverlay() {
|
||||
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
|
||||
if (overlay) {
|
||||
overlay.classList.remove("open");
|
||||
overlay.innerHTML = "";
|
||||
}
|
||||
this.#editingIndex = null;
|
||||
}
|
||||
|
||||
async #saveEdit() {
|
||||
if (this.#editingIndex === null) return;
|
||||
const item = this.#feedData[this.#editingIndex];
|
||||
if (!item?.id) return;
|
||||
|
||||
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
|
||||
if (!overlay) return;
|
||||
|
||||
// Collect edited values
|
||||
const updates: Record<string, string> = {};
|
||||
overlay.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(".edit-input").forEach(el => {
|
||||
const field = el.dataset.field;
|
||||
if (field && el.value !== String(item[field] ?? "")) {
|
||||
updates[field] = el.value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
this.#closeEditOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Write back to source module API
|
||||
try {
|
||||
const space = this.#getSpaceSlug();
|
||||
const endpoint = this.#getWriteBackEndpoint(item);
|
||||
const url = `/${space}/${this.sourceModule}/api/${endpoint}`;
|
||||
|
||||
const method = this.#getWriteBackMethod();
|
||||
const token = this.#getAuthToken();
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Failed" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// Update local data
|
||||
Object.assign(item, updates);
|
||||
this.#renderItems();
|
||||
this.#closeEditOverlay();
|
||||
|
||||
// Emit event for flow tracking
|
||||
this.dispatchEvent(new CustomEvent("feed-writeback", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
sourceModule: this.sourceModule,
|
||||
itemId: item.id,
|
||||
updates,
|
||||
flowKind: this.flowKind,
|
||||
}
|
||||
}));
|
||||
} catch (err: any) {
|
||||
// Show error in overlay
|
||||
const actions = overlay.querySelector(".edit-actions");
|
||||
if (actions) {
|
||||
const existing = actions.querySelector(".edit-error");
|
||||
if (existing) existing.remove();
|
||||
const errorEl = document.createElement("div");
|
||||
errorEl.className = "edit-error";
|
||||
errorEl.textContent = err.message;
|
||||
actions.prepend(errorEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getEditableFields(item: any): { key: string; label: string; value: string; type: string; options?: string[] }[] {
|
||||
const fields: { key: string; label: string; value: string; type: string; options?: string[] }[] = [];
|
||||
|
||||
// Module-specific editable fields
|
||||
switch (this.sourceModule) {
|
||||
case "notes":
|
||||
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
||||
if (item.content !== undefined) fields.push({ key: "content", label: "Content", value: item.content || "", type: "textarea" });
|
||||
break;
|
||||
case "work":
|
||||
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
||||
if (item.status !== undefined) fields.push({
|
||||
key: "status", label: "Status", value: item.status,
|
||||
type: "select", options: ["TODO", "IN_PROGRESS", "REVIEW", "DONE"],
|
||||
});
|
||||
if (item.priority !== undefined) fields.push({
|
||||
key: "priority", label: "Priority", value: item.priority || "MEDIUM",
|
||||
type: "select", options: ["LOW", "MEDIUM", "HIGH", "URGENT"],
|
||||
});
|
||||
break;
|
||||
case "vote":
|
||||
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
||||
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
|
||||
break;
|
||||
case "trips":
|
||||
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
||||
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
|
||||
break;
|
||||
default:
|
||||
// Generic: expose title and description if present
|
||||
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
||||
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
#getWriteBackEndpoint(item: any): string {
|
||||
switch (this.sourceModule) {
|
||||
case "notes": return `notes/${item.id}`;
|
||||
case "work": return `tasks/${item.id}`;
|
||||
case "vote": return `proposals/${item.id}`;
|
||||
case "trips": return `trips/${item.id}`;
|
||||
default: return `${item.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
#getWriteBackMethod(): string {
|
||||
switch (this.sourceModule) {
|
||||
case "work": return "PATCH";
|
||||
default: return "PUT";
|
||||
}
|
||||
}
|
||||
|
||||
#getAuthToken(): string | null {
|
||||
// Try to get token from EncryptID (stored in localStorage by rstack-identity)
|
||||
try {
|
||||
return localStorage.getItem("encryptid-token") || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#updateStatus(state: "loading" | "ok" | "error") {
|
||||
const el = this.#inner?.querySelector<HTMLElement>(".feed-status");
|
||||
if (!el) return;
|
||||
|
||||
if (state === "loading") {
|
||||
el.textContent = "Loading...";
|
||||
el.style.color = "#94a3b8";
|
||||
} else if (state === "error") {
|
||||
el.textContent = this.#error || "Error";
|
||||
el.style.color = "#ef4444";
|
||||
} else {
|
||||
el.textContent = `${this.#feedData.length} items`;
|
||||
el.style.color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-refresh ──
|
||||
|
||||
#startAutoRefresh() {
|
||||
if (this.refreshInterval > 0) {
|
||||
this.#refreshTimer = setInterval(() => this.#fetchFeed(), this.refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
#stopAutoRefresh() {
|
||||
if (this.#refreshTimer) {
|
||||
clearInterval(this.#refreshTimer);
|
||||
this.#refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serialization ──
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-feed",
|
||||
sourceLayer: this.sourceLayer,
|
||||
sourceModule: this.sourceModule,
|
||||
feedId: this.feedId,
|
||||
flowKind: this.flowKind,
|
||||
feedFilter: this.feedFilter,
|
||||
maxItems: this.maxItems,
|
||||
refreshInterval: this.refreshInterval,
|
||||
};
|
||||
}
|
||||
|
||||
#escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
static define(tag = "folk-feed") {
|
||||
if (!customElements.get(tag)) customElements.define(tag, FolkFeed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
|
||||
const FEED_STYLES = `
|
||||
.folk-feed-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-kind-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.feed-refresh {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.feed-refresh:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.feed-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148,163,184,0.2) transparent;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
transition: background 0.12s;
|
||||
cursor: default;
|
||||
}
|
||||
.feed-item:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.feed-item-line {
|
||||
width: 3px;
|
||||
min-height: 24px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.feed-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feed-item-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.feed-item-subtitle {
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feed-item-badge {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.feed-navigate {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.feed-navigate:hover {
|
||||
color: #22d3ee;
|
||||
background: rgba(34,211,238,0.1);
|
||||
}
|
||||
|
||||
.feed-item--editable { cursor: pointer; }
|
||||
.feed-item--editable:hover { background: rgba(255,255,255,0.05); }
|
||||
|
||||
.feed-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-item-edit {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.feed-item:hover .feed-item-edit { opacity: 0.7; }
|
||||
.feed-item-edit:hover { opacity: 1 !important; color: #22d3ee; background: rgba(34,211,238,0.1); }
|
||||
|
||||
.feed-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.feed-status {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.6rem;
|
||||
text-align: right;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Edit overlay ── */
|
||||
|
||||
.feed-edit-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border-radius: 8px;
|
||||
z-index: 10;
|
||||
overflow: auto;
|
||||
}
|
||||
.feed-edit-overlay.open { display: flex; }
|
||||
|
||||
.edit-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.edit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-close {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.edit-close:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
|
||||
|
||||
.edit-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.edit-label {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 5px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
resize: vertical;
|
||||
}
|
||||
.edit-input:focus { border-color: rgba(34,211,238,0.4); }
|
||||
|
||||
select.edit-input { cursor: pointer; }
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit-cancel, .edit-save {
|
||||
padding: 5px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.edit-cancel {
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.edit-cancel:hover { color: #e2e8f0; }
|
||||
|
||||
.edit-save {
|
||||
border: 1px solid;
|
||||
}
|
||||
.edit-save:hover { opacity: 0.8; }
|
||||
|
||||
.edit-error {
|
||||
font-size: 0.65rem;
|
||||
color: #ef4444;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,608 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||
|
||||
/**
|
||||
* <folk-rapp> — Embeds a live rApp module as a shape on the canvas.
|
||||
*
|
||||
* Unlike folk-embed (generic URL iframe), folk-rapp understands the module
|
||||
* system: it stores moduleId + spaceSlug, derives the iframe URL, shows
|
||||
* the module's icon/badge in the header, and can switch modules in-place.
|
||||
*
|
||||
* PostMessage protocol:
|
||||
* Parent → iframe: { source: "rspace-parent", type: "context", shapeId, space, moduleId }
|
||||
* iframe → parent: { source: "rspace-canvas", type: "shape-updated", ... } (from CommunitySync)
|
||||
* iframe → parent: { source: "rspace-rapp", type: "navigate", moduleId }
|
||||
*/
|
||||
|
||||
// Module metadata for header display (subset of rstack-app-switcher badges)
|
||||
const MODULE_META: Record<string, { badge: string; color: string; name: string; icon: string }> = {
|
||||
rnotes: { badge: "rN", color: "#fcd34d", name: "rNotes", icon: "📝" },
|
||||
rphotos: { badge: "rPh", color: "#f9a8d4", name: "rPhotos", icon: "📸" },
|
||||
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
|
||||
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
|
||||
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" },
|
||||
rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" },
|
||||
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
|
||||
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
|
||||
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
|
||||
rfunds: { badge: "rF", color: "#bef264", name: "rFunds", icon: "🌊" },
|
||||
rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" },
|
||||
rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" },
|
||||
rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" },
|
||||
rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" },
|
||||
rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" },
|
||||
rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" },
|
||||
rswag: { badge: "rSw", color: "#fda4af", name: "rSwag", icon: "🎨" },
|
||||
rchoices: { badge: "rCo", color: "#f0abfc", name: "rChoices", icon: "🤔" },
|
||||
rcal: { badge: "rC", color: "#7dd3fc", name: "rCal", icon: "📅" },
|
||||
rtrips: { badge: "rT", color: "#6ee7b7", name: "rTrips", icon: "✈️" },
|
||||
rmaps: { badge: "rM", color: "#86efac", name: "rMaps", icon: "🗺️" },
|
||||
};
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: #1e293b;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
min-width: 320px;
|
||||
min-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rapp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--rapp-color, #334155);
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.rapp-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rapp-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rapp-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rapp-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rapp-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rapp-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.rapp-actions button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.rapp-content {
|
||||
width: 100%;
|
||||
height: calc(100% - 34px);
|
||||
position: relative;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.rapp-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.rapp-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rapp-loading .spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(100, 116, 139, 0.3);
|
||||
border-top-color: #64748b;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.rapp-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Module picker (shown when no moduleId set) */
|
||||
.rapp-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rapp-picker-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rapp-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
color: #e2e8f0;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rapp-picker-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.rapp-picker-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Module switcher dropdown */
|
||||
.rapp-switcher {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 180px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rapp-switcher.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rapp-switcher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.rapp-switcher-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.rapp-switcher-item.active {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.rapp-switcher-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.45rem;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status indicator for postMessage connection */
|
||||
.rapp-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #475569;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.rapp-status.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-rapp": FolkRApp;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkRApp extends FolkShape {
|
||||
static override tagName = "folk-rapp";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#moduleId: string = "";
|
||||
#spaceSlug: string = "";
|
||||
#iframe: HTMLIFrameElement | null = null;
|
||||
#contentEl: HTMLElement | null = null;
|
||||
#messageHandler: ((e: MessageEvent) => void) | null = null;
|
||||
#statusEl: HTMLElement | null = null;
|
||||
|
||||
get moduleId() { return this.#moduleId; }
|
||||
set moduleId(value: string) {
|
||||
if (this.#moduleId === value) return;
|
||||
this.#moduleId = value;
|
||||
this.requestUpdate("moduleId");
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
this.#loadModule();
|
||||
}
|
||||
|
||||
get spaceSlug() { return this.#spaceSlug; }
|
||||
set spaceSlug(value: string) {
|
||||
if (this.#spaceSlug === value) return;
|
||||
this.#spaceSlug = value;
|
||||
this.requestUpdate("spaceSlug");
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
this.#loadModule();
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
this.#moduleId = this.getAttribute("module-id") || "";
|
||||
this.#spaceSlug = this.getAttribute("space-slug") || "";
|
||||
|
||||
const meta = MODULE_META[this.#moduleId];
|
||||
const headerColor = meta?.color || "#475569";
|
||||
const headerName = meta?.name || this.#moduleId || "rApp";
|
||||
const headerBadge = meta?.badge || "r?";
|
||||
const headerIcon = meta?.icon || "📱";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="rapp-header" style="--rapp-color: ${headerColor}">
|
||||
<div class="rapp-header-left">
|
||||
<span class="rapp-badge">${headerBadge}</span>
|
||||
<span class="rapp-name">${headerName}</span>
|
||||
<span class="rapp-icon">${headerIcon}</span>
|
||||
<span class="rapp-status" title="Not connected"></span>
|
||||
<div class="rapp-switcher"></div>
|
||||
</div>
|
||||
<div class="rapp-actions">
|
||||
<button class="switch-btn" title="Switch module">⇄</button>
|
||||
<button class="open-tab-btn" title="Open in tab">↗</button>
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rapp-content"></div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) {
|
||||
containerDiv.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
|
||||
this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement;
|
||||
const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement;
|
||||
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement;
|
||||
|
||||
// Module switcher dropdown
|
||||
this.#buildSwitcher(switcherEl);
|
||||
switchBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
switcherEl.classList.toggle("open");
|
||||
});
|
||||
|
||||
// Close switcher when clicking elsewhere
|
||||
const closeSwitcher = () => switcherEl.classList.remove("open");
|
||||
root.addEventListener("click", closeSwitcher);
|
||||
|
||||
// Open in tab — navigate to the module's page via tab bar
|
||||
openTabBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.#moduleId && this.#spaceSlug) {
|
||||
window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
|
||||
}
|
||||
});
|
||||
|
||||
// Close button
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
// Set up postMessage listener
|
||||
this.#messageHandler = (e: MessageEvent) => this.#handleMessage(e);
|
||||
window.addEventListener("message", this.#messageHandler);
|
||||
|
||||
// Load content
|
||||
if (this.#moduleId) {
|
||||
this.#loadModule();
|
||||
} else {
|
||||
this.#showPicker();
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
if (this.#messageHandler) {
|
||||
window.removeEventListener("message", this.#messageHandler);
|
||||
this.#messageHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
#buildSwitcher(switcherEl: HTMLElement) {
|
||||
const items = Object.entries(MODULE_META)
|
||||
.map(([id, meta]) => `
|
||||
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
|
||||
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
|
||||
<span>${meta.name} ${meta.icon}</span>
|
||||
</button>
|
||||
`)
|
||||
.join("");
|
||||
|
||||
switcherEl.innerHTML = items;
|
||||
|
||||
switcherEl.querySelectorAll(".rapp-switcher-item").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const modId = (btn as HTMLElement).dataset.module;
|
||||
if (modId && modId !== this.#moduleId) {
|
||||
this.moduleId = modId;
|
||||
this.#buildSwitcher(switcherEl);
|
||||
}
|
||||
switcherEl.classList.remove("open");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle postMessage from embedded iframe */
|
||||
#handleMessage(e: MessageEvent) {
|
||||
if (!this.#iframe) return;
|
||||
|
||||
// Only accept messages from our iframe
|
||||
if (e.source !== this.#iframe.contentWindow) return;
|
||||
|
||||
const msg = e.data;
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
|
||||
// CommunitySync shape updates from the embedded module
|
||||
if (msg.source === "rspace-canvas" && msg.type === "shape-updated") {
|
||||
this.dispatchEvent(new CustomEvent("rapp-data", {
|
||||
detail: { moduleId: this.#moduleId, shapeId: msg.shapeId, data: msg.data },
|
||||
bubbles: true,
|
||||
}));
|
||||
|
||||
// Mark as connected
|
||||
if (this.#statusEl) {
|
||||
this.#statusEl.classList.add("connected");
|
||||
this.#statusEl.title = "Connected — receiving data";
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation request from embedded module
|
||||
if (msg.source === "rspace-rapp" && msg.type === "navigate" && msg.moduleId) {
|
||||
this.moduleId = msg.moduleId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send context to the iframe after it loads */
|
||||
#sendContext() {
|
||||
if (!this.#iframe?.contentWindow) return;
|
||||
try {
|
||||
this.#iframe.contentWindow.postMessage({
|
||||
source: "rspace-parent",
|
||||
type: "context",
|
||||
shapeId: this.id,
|
||||
space: this.#spaceSlug,
|
||||
moduleId: this.#moduleId,
|
||||
embedded: true,
|
||||
}, "*");
|
||||
} catch {
|
||||
// cross-origin or iframe not ready
|
||||
}
|
||||
}
|
||||
|
||||
#loadModule() {
|
||||
if (!this.#contentEl || !this.#moduleId) return;
|
||||
|
||||
// Update header
|
||||
const meta = MODULE_META[this.#moduleId];
|
||||
const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement;
|
||||
if (header && meta) {
|
||||
header.style.setProperty("--rapp-color", meta.color);
|
||||
const badge = header.querySelector(".rapp-badge");
|
||||
const name = header.querySelector(".rapp-name");
|
||||
const icon = header.querySelector(".rapp-icon");
|
||||
if (badge) badge.textContent = meta.badge;
|
||||
if (name) name.textContent = meta.name;
|
||||
if (icon) icon.textContent = meta.icon;
|
||||
}
|
||||
|
||||
// Reset connection status
|
||||
if (this.#statusEl) {
|
||||
this.#statusEl.classList.remove("connected");
|
||||
this.#statusEl.title = "Loading...";
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.#contentEl.innerHTML = `
|
||||
<div class="rapp-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading ${meta?.name || this.#moduleId}...</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create iframe
|
||||
const space = this.#spaceSlug || "demo";
|
||||
const iframeUrl = `/${space}/${this.#moduleId}`;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "rapp-iframe";
|
||||
iframe.src = iframeUrl;
|
||||
iframe.loading = "lazy";
|
||||
iframe.allow = "clipboard-write";
|
||||
|
||||
iframe.addEventListener("load", () => {
|
||||
// Remove loading indicator
|
||||
const loading = this.#contentEl?.querySelector(".rapp-loading");
|
||||
if (loading) loading.remove();
|
||||
|
||||
// Send context to the newly loaded iframe
|
||||
this.#sendContext();
|
||||
});
|
||||
|
||||
iframe.addEventListener("error", () => {
|
||||
if (this.#contentEl) {
|
||||
this.#contentEl.innerHTML = `
|
||||
<div class="rapp-error">
|
||||
<span>Failed to load ${meta?.name || this.#moduleId}</span>
|
||||
<button class="rapp-picker-item" style="justify-content: center; color: #94a3b8;">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadModule());
|
||||
}
|
||||
});
|
||||
|
||||
this.#contentEl.appendChild(iframe);
|
||||
this.#iframe = iframe;
|
||||
}
|
||||
|
||||
#showPicker() {
|
||||
if (!this.#contentEl) return;
|
||||
|
||||
const items = Object.entries(MODULE_META)
|
||||
.map(([id, meta]) => `
|
||||
<button class="rapp-picker-item" data-module="${id}">
|
||||
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>
|
||||
<span>${meta.name}</span>
|
||||
<span>${meta.icon}</span>
|
||||
</button>
|
||||
`)
|
||||
.join("");
|
||||
|
||||
this.#contentEl.innerHTML = `
|
||||
<div class="rapp-picker">
|
||||
<span class="rapp-picker-title">Choose an rApp to embed</span>
|
||||
${items}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.#contentEl.querySelectorAll(".rapp-picker-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const modId = (btn as HTMLElement).dataset.module;
|
||||
if (modId) {
|
||||
this.moduleId = modId;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-rapp",
|
||||
moduleId: this.#moduleId,
|
||||
spaceSlug: this.#spaceSlug,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,15 @@ export * from "./folk-choice-vote";
|
|||
export * from "./folk-choice-rank";
|
||||
export * from "./folk-choice-spider";
|
||||
|
||||
// Nested Space Shape
|
||||
export * from "./folk-canvas";
|
||||
|
||||
// rApp Embed Shape (cross-app embedding)
|
||||
export * from "./folk-rapp";
|
||||
|
||||
// Feed Shape (inter-layer data flow)
|
||||
export * from "./folk-feed";
|
||||
|
||||
// Sync
|
||||
export * from "./community-sync";
|
||||
export * from "./presence";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Layer & Flow types for the rSpace tab/layer system.
|
||||
*
|
||||
* Each "tab" is a Layer — a named canvas page backed by a module.
|
||||
* Layers stack vertically. Flows are typed connections (economic, trust,
|
||||
* data, attention, governance) that move between shapes on different layers.
|
||||
*
|
||||
* The "stack view" renders all layers from the side, showing flows as
|
||||
* arcs/lines between strata.
|
||||
*/
|
||||
|
||||
// ── Flow types ──
|
||||
|
||||
export type FlowKind =
|
||||
| "economic" // token/currency/value flows
|
||||
| "trust" // reputation, attestation, endorsement
|
||||
| "data" // information, content, feeds
|
||||
| "attention" // views, engagement, focus
|
||||
| "governance" // votes, proposals, decisions
|
||||
| "resource" // files, assets, media
|
||||
| "custom"; // user-defined
|
||||
|
||||
export const FLOW_COLORS: Record<FlowKind, string> = {
|
||||
economic: "#bef264", // lime
|
||||
trust: "#c4b5fd", // violet
|
||||
data: "#67e8f9", // cyan
|
||||
attention: "#fcd34d", // amber
|
||||
governance: "#f0abfc", // fuchsia
|
||||
resource: "#6ee7b7", // emerald
|
||||
custom: "#94a3b8", // slate
|
||||
};
|
||||
|
||||
export const FLOW_LABELS: Record<FlowKind, string> = {
|
||||
economic: "Economic",
|
||||
trust: "Trust",
|
||||
data: "Data",
|
||||
attention: "Attention",
|
||||
governance: "Governance",
|
||||
resource: "Resource",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
// ── Layer definition ──
|
||||
|
||||
export interface Layer {
|
||||
/** Unique layer ID (e.g. "layer-abc123") */
|
||||
id: string;
|
||||
/** Module ID this layer is bound to (e.g. "canvas", "notes", "funds") */
|
||||
moduleId: string;
|
||||
/** Display label (defaults to module name, user-customizable) */
|
||||
label: string;
|
||||
/** Position in the tab bar (0-indexed, left to right) */
|
||||
order: number;
|
||||
/** Layer color for the stack view strata */
|
||||
color: string;
|
||||
/** Whether this layer is visible in stack view */
|
||||
visible: boolean;
|
||||
/** Created timestamp */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ── Inter-layer flow ──
|
||||
|
||||
export interface LayerFlow {
|
||||
/** Unique flow ID */
|
||||
id: string;
|
||||
/** Flow type */
|
||||
kind: FlowKind;
|
||||
/** Source layer ID */
|
||||
sourceLayerId: string;
|
||||
/** Source shape ID (optional — can be layer-wide) */
|
||||
sourceShapeId?: string;
|
||||
/** Target layer ID */
|
||||
targetLayerId: string;
|
||||
/** Target shape ID (optional — can be layer-wide) */
|
||||
targetShapeId?: string;
|
||||
/** Human-readable label */
|
||||
label?: string;
|
||||
/** Flow strength/weight (0-1, affects visual thickness) */
|
||||
strength: number;
|
||||
/** Whether this flow is currently active */
|
||||
active: boolean;
|
||||
/** Custom color override */
|
||||
color?: string;
|
||||
/** Metadata for the flow */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Layer config stored in Automerge doc ──
|
||||
|
||||
export interface LayerConfig {
|
||||
/** Ordered list of layers */
|
||||
layers: { [id: string]: Layer };
|
||||
/** Inter-layer flows */
|
||||
flows: { [id: string]: LayerFlow };
|
||||
/** Currently active layer ID */
|
||||
activeLayerId: string;
|
||||
/** View mode: 'flat' (normal tabs) or 'stack' (side/3D view) */
|
||||
viewMode: "flat" | "stack";
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/* Books module — additional styles for shell-wrapped pages */
|
||||
|
||||
/* Dark theme for reader page */
|
||||
body[data-theme="dark"] {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
/* Library grid page */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
|
@ -0,0 +1,520 @@
|
|||
/**
|
||||
* <folk-book-reader> — Flipbook PDF reader using pdf.js + StPageFlip.
|
||||
*
|
||||
* Renders each PDF page to canvas, converts to images, then displays
|
||||
* in a realistic page-flip animation. Caches rendered pages in IndexedDB.
|
||||
* Saves reading position to localStorage.
|
||||
*
|
||||
* Attributes:
|
||||
* pdf-url — URL to the PDF file
|
||||
* book-id — Unique ID for caching/position tracking
|
||||
* title — Book title (for display)
|
||||
* author — Book author (for display)
|
||||
*/
|
||||
|
||||
// pdf.js is loaded from CDN; StPageFlip is imported from npm
|
||||
// (we'll load both dynamically to avoid bundling issues)
|
||||
|
||||
const PDFJS_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.min.mjs";
|
||||
const PDFJS_WORKER_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs";
|
||||
const STPAGEFLIP_CDN = "https://unpkg.com/page-flip@2.0.7/dist/js/page-flip.browser.js";
|
||||
|
||||
interface CachedBook {
|
||||
images: string[];
|
||||
numPages: number;
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export class FolkBookReader extends HTMLElement {
|
||||
private _pdfUrl = "";
|
||||
private _bookId = "";
|
||||
private _title = "";
|
||||
private _author = "";
|
||||
private _pageImages: string[] = [];
|
||||
private _numPages = 0;
|
||||
private _currentPage = 0;
|
||||
private _aspectRatio = 1.414; // A4 default
|
||||
private _isLoading = true;
|
||||
private _loadingProgress = 0;
|
||||
private _loadingStatus = "Preparing...";
|
||||
private _error: string | null = null;
|
||||
private _flipBook: any = null;
|
||||
private _db: IDBDatabase | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["pdf-url", "book-id", "title", "author"];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
if (name === "pdf-url") this._pdfUrl = val;
|
||||
else if (name === "book-id") this._bookId = val;
|
||||
else if (name === "title") this._title = val;
|
||||
else if (name === "author") this._author = val;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this._pdfUrl = this.getAttribute("pdf-url") || "";
|
||||
this._bookId = this.getAttribute("book-id") || "";
|
||||
this._title = this.getAttribute("title") || "";
|
||||
this._author = this.getAttribute("author") || "";
|
||||
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.renderLoading();
|
||||
|
||||
// Restore reading position
|
||||
const savedPage = localStorage.getItem(`book-position-${this._bookId}`);
|
||||
if (savedPage) this._currentPage = parseInt(savedPage) || 0;
|
||||
|
||||
try {
|
||||
await this.openDB();
|
||||
const cached = await this.loadFromCache();
|
||||
|
||||
if (cached) {
|
||||
this._pageImages = cached.images;
|
||||
this._numPages = cached.numPages;
|
||||
this._aspectRatio = cached.aspectRatio;
|
||||
this._isLoading = false;
|
||||
this.renderReader();
|
||||
} else {
|
||||
await this.loadAndRenderPDF();
|
||||
}
|
||||
} catch (e: any) {
|
||||
this._error = e.message || "Failed to load book";
|
||||
this._isLoading = false;
|
||||
this.renderError();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Save position
|
||||
localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage));
|
||||
this._flipBook?.destroy();
|
||||
this._db?.close();
|
||||
}
|
||||
|
||||
// ── IndexedDB cache ──
|
||||
|
||||
private openDB(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open("rspace-books-cache", 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("book-images")) {
|
||||
db.createObjectStore("book-images");
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => { this._db = req.result; resolve(); };
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
private loadFromCache(): Promise<CachedBook | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this._db) { resolve(null); return; }
|
||||
const tx = this._db.transaction("book-images", "readonly");
|
||||
const store = tx.objectStore("book-images");
|
||||
const req = store.get(this._bookId);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
private saveToCache(data: CachedBook): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this._db) { resolve(); return; }
|
||||
const tx = this._db.transaction("book-images", "readwrite");
|
||||
const store = tx.objectStore("book-images");
|
||||
store.put(data, this._bookId);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ── PDF rendering ──
|
||||
|
||||
private async loadAndRenderPDF() {
|
||||
this._loadingStatus = "Loading PDF.js...";
|
||||
this.updateLoadingUI();
|
||||
|
||||
// Load pdf.js
|
||||
const pdfjsLib = await import(/* @vite-ignore */ PDFJS_CDN);
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN;
|
||||
|
||||
this._loadingStatus = "Downloading PDF...";
|
||||
this.updateLoadingUI();
|
||||
|
||||
const pdf = await pdfjsLib.getDocument(this._pdfUrl).promise;
|
||||
this._numPages = pdf.numPages;
|
||||
this._pageImages = [];
|
||||
|
||||
// Get aspect ratio from first page
|
||||
const firstPage = await pdf.getPage(1);
|
||||
const viewport = firstPage.getViewport({ scale: 1 });
|
||||
this._aspectRatio = viewport.width / viewport.height;
|
||||
|
||||
const scale = 2; // 2x for quality
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
this._loadingStatus = `Rendering page ${i} of ${pdf.numPages}...`;
|
||||
this._loadingProgress = Math.round((i / pdf.numPages) * 100);
|
||||
this.updateLoadingUI();
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const vp = page.getViewport({ scale });
|
||||
canvas.width = vp.width;
|
||||
canvas.height = vp.height;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise;
|
||||
this._pageImages.push(canvas.toDataURL("image/jpeg", 0.85));
|
||||
}
|
||||
|
||||
// Cache
|
||||
await this.saveToCache({
|
||||
images: this._pageImages,
|
||||
numPages: this._numPages,
|
||||
aspectRatio: this._aspectRatio,
|
||||
});
|
||||
|
||||
this._isLoading = false;
|
||||
this.renderReader();
|
||||
}
|
||||
|
||||
// ── UI rendering ──
|
||||
|
||||
private renderLoading() {
|
||||
if (!this.shadowRoot) return;
|
||||
this.shadowRoot.innerHTML = `
|
||||
${this.getStyles()}
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-status">${this._loadingStatus}</div>
|
||||
<div class="loading-bar">
|
||||
<div class="loading-fill" style="width:${this._loadingProgress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateLoadingUI() {
|
||||
if (!this.shadowRoot) return;
|
||||
const status = this.shadowRoot.querySelector(".loading-status");
|
||||
const fill = this.shadowRoot.querySelector(".loading-fill") as HTMLElement;
|
||||
if (status) status.textContent = this._loadingStatus;
|
||||
if (fill) fill.style.width = `${this._loadingProgress}%`;
|
||||
}
|
||||
|
||||
private renderError() {
|
||||
if (!this.shadowRoot) return;
|
||||
this.shadowRoot.innerHTML = `
|
||||
${this.getStyles()}
|
||||
<div class="error">
|
||||
<h3>Failed to load book</h3>
|
||||
<p>${this.escapeHtml(this._error || "Unknown error")}</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderReader() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
// Calculate dimensions
|
||||
const maxW = Math.min(window.innerWidth * 0.9, 800);
|
||||
const maxH = window.innerHeight - 160;
|
||||
let pageW = maxW / 2;
|
||||
let pageH = pageW / this._aspectRatio;
|
||||
if (pageH > maxH) {
|
||||
pageH = maxH;
|
||||
pageW = pageH * this._aspectRatio;
|
||||
}
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
${this.getStyles()}
|
||||
<div class="reader-container">
|
||||
<div class="rapp-nav">
|
||||
<a class="rapp-nav__back" href="/${window.location.pathname.split('/')[1]}/rbooks">\u2190 Library</a>
|
||||
<span class="rapp-nav__title">${this.escapeHtml(this._title)}</span>
|
||||
${this._author ? `<span class="rapp-nav__subtitle">by ${this.escapeHtml(this._author)}</span>` : ""}
|
||||
<span class="rapp-nav__meta">
|
||||
Page <span class="current-page">${this._currentPage + 1}</span> of ${this._numPages}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flipbook-wrapper">
|
||||
<button class="nav-btn nav-prev" title="Previous page">‹</button>
|
||||
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
|
||||
<button class="nav-btn nav-next" title="Next page">›</button>
|
||||
</div>
|
||||
<div class="reader-footer">
|
||||
<button class="nav-text-btn" data-action="prev">← Previous</button>
|
||||
<button class="nav-text-btn" data-action="next">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.initFlipbook(pageW, pageH);
|
||||
this.bindReaderEvents();
|
||||
}
|
||||
|
||||
private async initFlipbook(pageW: number, pageH: number) {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
const container = this.shadowRoot.querySelector(".flipbook-container") as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
// Load StPageFlip
|
||||
await this.loadStPageFlip();
|
||||
|
||||
const PageFlip = (window as any).St?.PageFlip;
|
||||
if (!PageFlip) {
|
||||
console.error("StPageFlip not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
this._flipBook = new PageFlip(container, {
|
||||
width: Math.round(pageW),
|
||||
height: Math.round(pageH),
|
||||
showCover: true,
|
||||
maxShadowOpacity: 0.5,
|
||||
mobileScrollSupport: false,
|
||||
useMouseEvents: true,
|
||||
swipeDistance: 30,
|
||||
clickEventForward: false,
|
||||
flippingTime: 600,
|
||||
startPage: this._currentPage,
|
||||
});
|
||||
|
||||
// Create page elements
|
||||
const pages: HTMLElement[] = [];
|
||||
for (let i = 0; i < this._pageImages.length; i++) {
|
||||
const page = document.createElement("div");
|
||||
page.className = "page-content";
|
||||
page.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url(${this._pageImages[i]});
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
`;
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
this._flipBook.loadFromHTML(pages);
|
||||
|
||||
this._flipBook.on("flip", (e: any) => {
|
||||
this._currentPage = e.data;
|
||||
this.updatePageCounter();
|
||||
localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage));
|
||||
});
|
||||
}
|
||||
|
||||
private loadStPageFlip(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).St?.PageFlip) { resolve(); return; }
|
||||
const script = document.createElement("script");
|
||||
script.src = STPAGEFLIP_CDN;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Failed to load StPageFlip"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
private bindReaderEvents() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
// Nav buttons
|
||||
this.shadowRoot.querySelector(".nav-prev")?.addEventListener("click", () => {
|
||||
this._flipBook?.flipPrev();
|
||||
});
|
||||
this.shadowRoot.querySelector(".nav-next")?.addEventListener("click", () => {
|
||||
this._flipBook?.flipNext();
|
||||
});
|
||||
this.shadowRoot.querySelectorAll(".nav-text-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = (btn as HTMLElement).dataset.action;
|
||||
if (action === "prev") this._flipBook?.flipPrev();
|
||||
else if (action === "next") this._flipBook?.flipNext();
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard nav
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowLeft") this._flipBook?.flipPrev();
|
||||
else if (e.key === "ArrowRight") this._flipBook?.flipNext();
|
||||
});
|
||||
|
||||
// Resize handler
|
||||
let resizeTimer: number;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => this.renderReader(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
private updatePageCounter() {
|
||||
if (!this.shadowRoot) return;
|
||||
const el = this.shadowRoot.querySelector(".current-page");
|
||||
if (el) el.textContent = String(this._currentPage + 1);
|
||||
}
|
||||
|
||||
private getStyles(): string {
|
||||
return `<style>
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 52px);
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 52px);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #334155;
|
||||
border-top-color: #60a5fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-status {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: #1e293b;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-fill {
|
||||
height: 100%;
|
||||
background: #60a5fa;
|
||||
transition: width 0.3s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 52px);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.error h3 { color: #f87171; margin: 0; }
|
||||
.error p { color: #94a3b8; margin: 0; }
|
||||
.error button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.rapp-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.rapp-nav__back {
|
||||
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);
|
||||
background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px;
|
||||
text-decoration: none; transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title {
|
||||
font-size: 15px; font-weight: 600; color: #e2e8f0;
|
||||
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.rapp-nav__subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.rapp-nav__meta {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flipbook-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flipbook-container {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 44px;
|
||||
height: 80px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: #334155; }
|
||||
|
||||
.reader-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-text-btn {
|
||||
padding: 0.375rem 1rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-text-btn:hover { border-color: #60a5fa; color: #f1f5f9; }
|
||||
</style>`;
|
||||
}
|
||||
|
||||
private escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-book-reader", FolkBookReader);
|
||||
|
|
@ -0,0 +1,588 @@
|
|||
/**
|
||||
* <folk-book-shelf> — Book grid with search, tags, and upload.
|
||||
*
|
||||
* Displays community books in a responsive grid. Clicking a book
|
||||
* navigates to the flipbook reader. Authenticated users can upload.
|
||||
*/
|
||||
|
||||
interface BookData {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
description: string | null;
|
||||
pdf_size_bytes: number;
|
||||
page_count: number;
|
||||
tags: string[];
|
||||
cover_color: string;
|
||||
contributor_name: string | null;
|
||||
featured: boolean;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class FolkBookShelf extends HTMLElement {
|
||||
private _books: BookData[] = [];
|
||||
private _filtered: BookData[] = [];
|
||||
private _spaceSlug = "personal";
|
||||
private _searchTerm = "";
|
||||
private _selectedTag: string | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["space-slug"];
|
||||
}
|
||||
|
||||
set books(val: BookData[]) {
|
||||
this._books = val;
|
||||
this._filtered = val;
|
||||
this.render();
|
||||
}
|
||||
|
||||
get books() {
|
||||
return this._books;
|
||||
}
|
||||
|
||||
set spaceSlug(val: string) {
|
||||
this._spaceSlug = val;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
if (name === "space-slug") this._spaceSlug = val;
|
||||
}
|
||||
|
||||
private get allTags(): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const b of this._books) {
|
||||
for (const t of b.tags || []) tags.add(t);
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
}
|
||||
|
||||
private applyFilters() {
|
||||
let result = this._books;
|
||||
|
||||
if (this._searchTerm) {
|
||||
const term = this._searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(b) =>
|
||||
b.title.toLowerCase().includes(term) ||
|
||||
(b.author && b.author.toLowerCase().includes(term)) ||
|
||||
(b.description && b.description.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
if (this._selectedTag) {
|
||||
const tag = this._selectedTag;
|
||||
result = result.filter((b) => b.tags?.includes(tag));
|
||||
}
|
||||
|
||||
this._filtered = result;
|
||||
}
|
||||
|
||||
private formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
const tags = this.allTags;
|
||||
const books = this._filtered;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.rapp-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; color: #e2e8f0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rapp-nav__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__btn:hover { background: #6366f1; }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.search-input::placeholder { color: #64748b; }
|
||||
.search-input:focus { outline: none; border-color: #60a5fa; }
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #334155;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tag:hover { border-color: #60a5fa; color: #e2e8f0; }
|
||||
.tag.active { background: #1e3a5f; border-color: #60a5fa; color: #60a5fa; }
|
||||
|
||||
.upload-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.upload-btn:hover { background: #1d4ed8; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.book-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
aspect-ratio: 3/4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.book-cover-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
background: rgba(250, 204, 21, 0.9);
|
||||
color: #1e293b;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
padding: 0.75rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-author {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
margin-top: auto;
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.empty h3 { margin: 0 0 0.5rem; color: #94a3b8; }
|
||||
|
||||
/* Upload modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay[hidden] { display: none; }
|
||||
|
||||
.modal {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 1rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.modal input,
|
||||
.modal textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal textarea { min-height: 80px; resize: vertical; }
|
||||
.modal input:focus, .modal textarea:focus { outline: none; border-color: #60a5fa; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { border-color: #64748b; }
|
||||
|
||||
.btn-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-submit:hover { background: #1d4ed8; }
|
||||
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.dragover { border-color: #60a5fa; color: #94a3b8; }
|
||||
.drop-zone .selected { color: #60a5fa; font-weight: 500; }
|
||||
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Library</span>
|
||||
<div class="rapp-nav__actions">
|
||||
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input class="search-input" type="text" placeholder="Search books..." />
|
||||
</div>
|
||||
|
||||
${tags.length > 0 ? `
|
||||
<div class="tags">
|
||||
${tags.map((t) => `<span class="tag" data-tag="${t}">${t}</span>`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${books.length === 0
|
||||
? `<div class="empty">
|
||||
<h3>No books yet</h3>
|
||||
<p>Upload a PDF to share with the community</p>
|
||||
</div>`
|
||||
: `<div class="grid">
|
||||
${books.map((b) => `
|
||||
<a class="book-card" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
|
||||
<div class="book-cover" style="background:${b.cover_color}">
|
||||
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
|
||||
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
|
||||
</div>
|
||||
<div class="book-info">
|
||||
<div class="book-title">${this.escapeHtml(b.title)}</div>
|
||||
${b.author ? `<div class="book-author">${this.escapeHtml(b.author)}</div>` : ""}
|
||||
<div class="book-meta">
|
||||
<span>${this.formatSize(b.pdf_size_bytes)}</span>
|
||||
<span>${b.view_count} views</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`).join("")}
|
||||
</div>`
|
||||
}
|
||||
|
||||
<div class="modal-overlay" hidden>
|
||||
<div class="modal">
|
||||
<h3>Share a Book</h3>
|
||||
<div class="error-msg" hidden></div>
|
||||
|
||||
<div class="drop-zone">
|
||||
<input type="file" accept="application/pdf" style="display:none" />
|
||||
Drop a PDF here or click to browse
|
||||
</div>
|
||||
|
||||
<label>Title *</label>
|
||||
<input type="text" name="title" required />
|
||||
|
||||
<label>Author</label>
|
||||
<input type="text" name="author" />
|
||||
|
||||
<label>Description</label>
|
||||
<textarea name="description"></textarea>
|
||||
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input type="text" name="tags" placeholder="e.g. science, philosophy" />
|
||||
|
||||
<label>License</label>
|
||||
<input type="text" name="license" value="CC BY-SA 4.0" />
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel">Cancel</button>
|
||||
<button class="btn-submit" disabled>Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
// Search
|
||||
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
|
||||
searchInput?.addEventListener("input", () => {
|
||||
this._searchTerm = searchInput.value;
|
||||
this.applyFilters();
|
||||
this.updateGrid();
|
||||
});
|
||||
|
||||
// Tags
|
||||
this.shadowRoot.querySelectorAll(".tag").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const tag = (el as HTMLElement).dataset.tag!;
|
||||
if (this._selectedTag === tag) {
|
||||
this._selectedTag = null;
|
||||
el.classList.remove("active");
|
||||
} else {
|
||||
this.shadowRoot!.querySelectorAll(".tag").forEach((t) => t.classList.remove("active"));
|
||||
this._selectedTag = tag;
|
||||
el.classList.add("active");
|
||||
}
|
||||
this.applyFilters();
|
||||
this.updateGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Upload modal
|
||||
const uploadBtn = this.shadowRoot.querySelector(".upload-btn");
|
||||
const overlay = this.shadowRoot.querySelector(".modal-overlay") as HTMLElement;
|
||||
const cancelBtn = this.shadowRoot.querySelector(".btn-cancel");
|
||||
const submitBtn = this.shadowRoot.querySelector(".btn-submit") as HTMLButtonElement;
|
||||
const dropZone = this.shadowRoot.querySelector(".drop-zone") as HTMLElement;
|
||||
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const titleInput = this.shadowRoot.querySelector('input[name="title"]') as HTMLInputElement;
|
||||
const errorEl = this.shadowRoot.querySelector(".error-msg") as HTMLElement;
|
||||
|
||||
let selectedFile: File | null = null;
|
||||
|
||||
uploadBtn?.addEventListener("click", () => {
|
||||
overlay.hidden = false;
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener("click", () => {
|
||||
overlay.hidden = true;
|
||||
selectedFile = null;
|
||||
});
|
||||
|
||||
overlay?.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.hidden = true;
|
||||
});
|
||||
|
||||
dropZone?.addEventListener("click", () => fileInput?.click());
|
||||
dropZone?.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); });
|
||||
dropZone?.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
|
||||
dropZone?.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
const file = (e as DragEvent).dataTransfer?.files[0];
|
||||
if (file?.type === "application/pdf") {
|
||||
selectedFile = file;
|
||||
dropZone.innerHTML = `<span class="selected">${file.name}</span>`;
|
||||
if (titleInput.value) submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files?.[0]) {
|
||||
selectedFile = fileInput.files[0];
|
||||
dropZone.innerHTML = `<span class="selected">${selectedFile.name}</span>`;
|
||||
if (titleInput.value) submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
titleInput?.addEventListener("input", () => {
|
||||
submitBtn.disabled = !titleInput.value.trim() || !selectedFile;
|
||||
});
|
||||
|
||||
submitBtn?.addEventListener("click", async () => {
|
||||
if (!selectedFile || !titleInput.value.trim()) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Uploading...";
|
||||
errorEl.hidden = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("pdf", selectedFile);
|
||||
formData.append("title", titleInput.value.trim());
|
||||
|
||||
const authorInput = this.shadowRoot!.querySelector('input[name="author"]') as HTMLInputElement;
|
||||
const descInput = this.shadowRoot!.querySelector('textarea[name="description"]') as HTMLTextAreaElement;
|
||||
const tagsInput = this.shadowRoot!.querySelector('input[name="tags"]') as HTMLInputElement;
|
||||
const licenseInput = this.shadowRoot!.querySelector('input[name="license"]') as HTMLInputElement;
|
||||
|
||||
if (authorInput.value) formData.append("author", authorInput.value);
|
||||
if (descInput.value) formData.append("description", descInput.value);
|
||||
if (tagsInput.value) formData.append("tags", tagsInput.value);
|
||||
if (licenseInput.value) formData.append("license", licenseInput.value);
|
||||
|
||||
// Get auth token
|
||||
const token = localStorage.getItem("encryptid_token");
|
||||
if (!token) {
|
||||
errorEl.textContent = "Please sign in first (use the identity button in the header)";
|
||||
errorEl.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Upload";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/${this._spaceSlug}/rbooks/api/books`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
|
||||
// Navigate to the new book
|
||||
window.location.href = `/${this._spaceSlug}/rbooks/read/${data.slug}`;
|
||||
} catch (e: any) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Upload";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateGrid() {
|
||||
// Re-render just the grid portion (lightweight update)
|
||||
this.render();
|
||||
}
|
||||
|
||||
private escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-book-shelf", FolkBookShelf);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
-- rBooks schema — community PDF library
|
||||
-- Runs inside the `rbooks` schema (set by migration runner)
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
pdf_path TEXT NOT NULL,
|
||||
pdf_size_bytes BIGINT DEFAULT 0,
|
||||
page_count INTEGER DEFAULT 0,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
license TEXT DEFAULT 'CC BY-SA 4.0',
|
||||
cover_color TEXT DEFAULT '#334155',
|
||||
contributor_id TEXT,
|
||||
contributor_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
featured BOOLEAN DEFAULT FALSE,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
download_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_books_status ON books (status) WHERE status = 'published';
|
||||
CREATE INDEX IF NOT EXISTS idx_books_slug ON books (slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_books_featured ON books (featured) WHERE featured = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_books_created ON books (created_at DESC);
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Books module — community PDF library with flipbook reader.
|
||||
*
|
||||
* Ported from rbooks-online (Next.js) to Hono routes.
|
||||
* Routes are relative to mount point (/:space/books in unified, / in standalone).
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { resolve } from "node:path";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import {
|
||||
verifyEncryptIDToken,
|
||||
extractToken,
|
||||
} from "@encryptid/sdk/server";
|
||||
|
||||
const BOOKS_DIR = process.env.BOOKS_DIR || "/data/books";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface BookRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
description: string | null;
|
||||
pdf_path: string;
|
||||
pdf_size_bytes: number;
|
||||
page_count: number;
|
||||
tags: string[];
|
||||
license: string;
|
||||
cover_color: string;
|
||||
contributor_id: string | null;
|
||||
contributor_name: string | null;
|
||||
status: string;
|
||||
featured: boolean;
|
||||
view_count: number;
|
||||
download_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
// ── Routes ──
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── API: List books ──
|
||||
routes.get("/api/books", async (c) => {
|
||||
const search = c.req.query("search");
|
||||
const tag = c.req.query("tag");
|
||||
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
|
||||
const offset = parseInt(c.req.query("offset") || "0");
|
||||
|
||||
let query = `SELECT id, slug, title, author, description, pdf_size_bytes,
|
||||
page_count, tags, cover_color, contributor_name, featured,
|
||||
view_count, created_at
|
||||
FROM rbooks.books WHERE status = 'published'`;
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
query += ` AND (title ILIKE $${params.length} OR author ILIKE $${params.length} OR description ILIKE $${params.length})`;
|
||||
}
|
||||
if (tag) {
|
||||
params.push(tag);
|
||||
query += ` AND $${params.length} = ANY(tags)`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY featured DESC, created_at DESC`;
|
||||
params.push(limit);
|
||||
query += ` LIMIT $${params.length}`;
|
||||
params.push(offset);
|
||||
query += ` OFFSET $${params.length}`;
|
||||
|
||||
const rows = await sql.unsafe(query, params);
|
||||
return c.json({ books: rows });
|
||||
});
|
||||
|
||||
// ── API: Upload book ──
|
||||
routes.post("/api/books", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
let claims;
|
||||
try {
|
||||
claims = await verifyEncryptIDToken(token);
|
||||
} catch {
|
||||
return c.json({ error: "Invalid token" }, 401);
|
||||
}
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get("pdf") as File | null;
|
||||
const title = (formData.get("title") as string || "").trim();
|
||||
const author = (formData.get("author") as string || "").trim() || null;
|
||||
const description = (formData.get("description") as string || "").trim() || null;
|
||||
const tagsRaw = (formData.get("tags") as string || "").trim();
|
||||
const license = (formData.get("license") as string || "CC BY-SA 4.0").trim();
|
||||
|
||||
if (!file || file.type !== "application/pdf") {
|
||||
return c.json({ error: "PDF file required" }, 400);
|
||||
}
|
||||
if (!title) {
|
||||
return c.json({ error: "Title required" }, 400);
|
||||
}
|
||||
|
||||
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
||||
const shortId = randomUUID().slice(0, 8);
|
||||
let slug = slugify(title);
|
||||
|
||||
// Check slug collision
|
||||
const existing = await sql.unsafe(
|
||||
`SELECT 1 FROM rbooks.books WHERE slug = $1`, [slug]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
slug = `${slug}-${shortId}`;
|
||||
}
|
||||
|
||||
// Save PDF to disk
|
||||
await mkdir(BOOKS_DIR, { recursive: true });
|
||||
const filename = `${slug}.pdf`;
|
||||
const filepath = resolve(BOOKS_DIR, filename);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await Bun.write(filepath, buffer);
|
||||
|
||||
// Insert into DB
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rbooks.books (slug, title, author, description, pdf_path, pdf_size_bytes, tags, license, contributor_id, contributor_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, slug, title, author, description, tags, created_at`,
|
||||
[slug, title, author, description, filename, buffer.length, tags, license, claims.sub, claims.username || null]
|
||||
);
|
||||
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
||||
// ── API: Get book details ──
|
||||
routes.get("/api/books/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) return c.json({ error: "Book not found" }, 404);
|
||||
|
||||
// Increment view count
|
||||
await sql.unsafe(
|
||||
`UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`,
|
||||
[rows[0].id]
|
||||
);
|
||||
|
||||
return c.json(rows[0]);
|
||||
});
|
||||
|
||||
// ── API: Serve PDF ──
|
||||
routes.get("/api/books/:id/pdf", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT id, slug, title, pdf_path FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) return c.json({ error: "Book not found" }, 404);
|
||||
|
||||
const book = rows[0];
|
||||
const filepath = resolve(BOOKS_DIR, book.pdf_path);
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
return c.json({ error: "PDF file not found" }, 404);
|
||||
}
|
||||
|
||||
// Increment download count
|
||||
await sql.unsafe(
|
||||
`UPDATE rbooks.books SET download_count = download_count + 1 WHERE id = $1`,
|
||||
[book.id]
|
||||
);
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${book.slug}.pdf"`,
|
||||
"Content-Length": String(file.size),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page: Library ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "personal";
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Library | rSpace`,
|
||||
moduleId: "rbooks",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
|
||||
scripts: `<script type="module" src="/modules/books/folk-book-shelf.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/books/books.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Page: Book reader ──
|
||||
routes.get("/read/:id", async (c) => {
|
||||
const spaceSlug = c.req.param("space") || "personal";
|
||||
const id = c.req.param("id");
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
const html = renderShell({
|
||||
title: "Book not found | rSpace",
|
||||
moduleId: "rbooks",
|
||||
spaceSlug,
|
||||
body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/${spaceSlug}/rbooks" style="color:#60a5fa;">Back to library</a></p></div>`,
|
||||
modules: getModuleInfoList(),
|
||||
});
|
||||
return c.html(html, 404);
|
||||
}
|
||||
|
||||
const book = rows[0];
|
||||
|
||||
// Increment view count
|
||||
await sql.unsafe(
|
||||
`UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`,
|
||||
[book.id]
|
||||
);
|
||||
|
||||
// Build the PDF URL relative to this module's mount point
|
||||
const pdfUrl = `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`;
|
||||
|
||||
const html = renderShell({
|
||||
title: `${book.title} | rSpace`,
|
||||
moduleId: "rbooks",
|
||||
spaceSlug,
|
||||
body: `
|
||||
<folk-book-reader
|
||||
id="reader"
|
||||
pdf-url="${pdfUrl}"
|
||||
book-id="${book.slug}"
|
||||
title="${escapeAttr(book.title)}"
|
||||
author="${escapeAttr(book.author || '')}"
|
||||
></folk-book-reader>
|
||||
`,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// ── Initialize DB schema ──
|
||||
async function initDB(): Promise<void> {
|
||||
try {
|
||||
const schemaPath = resolve(import.meta.dir, "db/schema.sql");
|
||||
const schemaSql = await readFile(schemaPath, "utf-8");
|
||||
await sql.unsafe(`SET search_path TO rbooks, public`);
|
||||
await sql.unsafe(schemaSql);
|
||||
await sql.unsafe(`SET search_path TO public`);
|
||||
console.log("[Books] Database schema initialized");
|
||||
} catch (e) {
|
||||
console.error("[Books] Schema init failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ── Module export ──
|
||||
|
||||
export const booksModule: RSpaceModule = {
|
||||
id: "rbooks",
|
||||
name: "rBooks",
|
||||
icon: "📚",
|
||||
description: "Community PDF library with flipbook reader",
|
||||
routes,
|
||||
standaloneDomain: "rbooks.online",
|
||||
|
||||
async onSpaceCreate(spaceSlug: string) {
|
||||
// Books are global, not space-scoped (for now). No-op.
|
||||
},
|
||||
};
|
||||
|
||||
// Run schema init on import
|
||||
initDB();
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Cal module — dark theme */
|
||||
folk-calendar-view {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* <folk-calendar-view> — temporal coordination calendar.
|
||||
*
|
||||
* Month grid view with event dots, lunar phase overlay,
|
||||
* event creation, and source filtering.
|
||||
*/
|
||||
|
||||
class FolkCalendarView extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private currentDate = new Date();
|
||||
private events: any[] = [];
|
||||
private sources: any[] = [];
|
||||
private lunarData: Record<string, { phase: string; illumination: number }> = {};
|
||||
private showLunar = true;
|
||||
private selectedDate = "";
|
||||
private selectedEvent: any = null;
|
||||
private error = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.loadMonth();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/cal/);
|
||||
return match ? `/${match[1]}/cal` : "";
|
||||
}
|
||||
|
||||
private async loadMonth() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const start = `${year}-${String(month + 1).padStart(2, "0")}-01`;
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
||||
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
|
||||
fetch(`${base}/api/events?start=${start}&end=${end}`),
|
||||
fetch(`${base}/api/sources`),
|
||||
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
|
||||
]);
|
||||
if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
|
||||
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
|
||||
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
|
||||
} catch { /* offline fallback */ }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private navigate(delta: number) {
|
||||
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
|
||||
this.loadMonth();
|
||||
}
|
||||
|
||||
private getMoonEmoji(phase: string): string {
|
||||
const map: Record<string, string> = {
|
||||
new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}",
|
||||
waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}",
|
||||
last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}",
|
||||
};
|
||||
return map[phase] || "";
|
||||
}
|
||||
|
||||
private render() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const monthName = this.currentDate.toLocaleString("default", { month: "long" });
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 16px; }
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
|
||||
.toggle-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; }
|
||||
.toggle-btn.active { border-color: #6366f1; color: #6366f1; }
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
|
||||
|
||||
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
|
||||
.weekday { text-align: center; font-size: 11px; color: #666; padding: 4px; font-weight: 600; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||
.day {
|
||||
background: #16161e; border: 1px solid #222; border-radius: 6px;
|
||||
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
|
||||
}
|
||||
.day:hover { border-color: #444; }
|
||||
.day.today { border-color: #6366f1; }
|
||||
.day.other-month { opacity: 0.3; }
|
||||
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
|
||||
.moon { font-size: 10px; opacity: 0.7; }
|
||||
.event-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
|
||||
.event-dots { display: flex; flex-wrap: wrap; gap: 1px; }
|
||||
.event-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; }
|
||||
|
||||
.event-modal {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.modal-content { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
|
||||
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
||||
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
|
||||
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
|
||||
|
||||
.sources { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.source-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" id="prev">\u2190</button>
|
||||
<span class="rapp-nav__title">${monthName} ${year}</span>
|
||||
<button class="toggle-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319} Lunar</button>
|
||||
<button class="rapp-nav__back" id="next">\u2192</button>
|
||||
</div>
|
||||
|
||||
${this.sources.length > 0 ? `<div class="sources">
|
||||
${this.sources.map(s => `<span class="source-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
|
||||
</div>` : ""}
|
||||
|
||||
<div class="weekdays">
|
||||
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="weekday">${d}</div>`).join("")}
|
||||
</div>
|
||||
<div class="grid">
|
||||
${this.renderDays(year, month)}
|
||||
</div>
|
||||
|
||||
${this.selectedEvent ? this.renderEventModal() : ""}
|
||||
`;
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private renderDays(year: number, month: number): string {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
let html = "";
|
||||
// Previous month padding
|
||||
const prevDays = new Date(year, month, 0).getDate();
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
html += `<div class="day other-month"><div class="day-num">${prevDays - i}</div></div>`;
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const isToday = dateStr === todayStr;
|
||||
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
|
||||
const lunar = this.lunarData[dateStr];
|
||||
|
||||
html += `<div class="day ${isToday ? "today" : ""}" data-date="${dateStr}">
|
||||
<div class="day-num">
|
||||
<span>${d}</span>
|
||||
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
|
||||
</div>
|
||||
${dayEvents.length > 0 ? `
|
||||
<div class="event-dots">
|
||||
${dayEvents.slice(0, 3).map(e => `<span class="event-dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
|
||||
${dayEvents.length > 3 ? `<span style="font-size:9px;color:#888">+${dayEvents.length - 3}</span>` : ""}
|
||||
</div>
|
||||
${dayEvents.slice(0, 2).map(e => `<div class="event-label" data-event='${JSON.stringify({ id: e.id })}'>${this.esc(e.title)}</div>`).join("")}
|
||||
` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const totalCells = firstDay + daysInMonth;
|
||||
const remaining = (7 - (totalCells % 7)) % 7;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
html += `<div class="day other-month"><div class="day-num">${i}</div></div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderEventModal(): string {
|
||||
const e = this.selectedEvent;
|
||||
return `
|
||||
<div class="event-modal" id="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="modal-close">\u2715</button>
|
||||
<div class="modal-title">${this.esc(e.title)}</div>
|
||||
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
|
||||
<div class="modal-field">When: ${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2014 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
|
||||
${e.location_name ? `<div class="modal-field">Where: ${this.esc(e.location_name)}</div>` : ""}
|
||||
${e.source_name ? `<div class="modal-field">Source: ${this.esc(e.source_name)}</div>` : ""}
|
||||
${e.is_virtual ? `<div class="modal-field">Virtual: ${this.esc(e.virtual_platform || "")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1));
|
||||
this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1));
|
||||
this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => {
|
||||
this.showLunar = !this.showLunar;
|
||||
this.render();
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-event]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const data = JSON.parse((el as HTMLElement).dataset.event!);
|
||||
this.selectedEvent = this.events.find(ev => ev.id === data.id);
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
|
||||
});
|
||||
this.shadow.getElementById("modal-close")?.addEventListener("click", () => {
|
||||
this.selectedEvent = null; this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-calendar-view", FolkCalendarView);
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
-- rCal module schema
|
||||
CREATE SCHEMA IF NOT EXISTS rcal;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcal.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
did TEXT UNIQUE NOT NULL,
|
||||
username TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcal.calendar_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('MANUAL','ICS','CALDAV','GOOGLE','OUTLOOK','APPLE','OBSIDIAN')),
|
||||
url TEXT,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_visible BOOLEAN DEFAULT TRUE,
|
||||
sync_interval_minutes INT DEFAULT 60,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
owner_id UUID REFERENCES rcal.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcal.locations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
granularity INT NOT NULL DEFAULT 5,
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
parent_id UUID REFERENCES rcal.locations(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcal.events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
all_day BOOLEAN DEFAULT FALSE,
|
||||
timezone TEXT DEFAULT 'UTC',
|
||||
rrule TEXT,
|
||||
status TEXT DEFAULT 'CONFIRMED' CHECK (status IN ('TENTATIVE','CONFIRMED','CANCELLED')),
|
||||
visibility TEXT DEFAULT 'DEFAULT' CHECK (visibility IN ('PUBLIC','PRIVATE','DEFAULT')),
|
||||
source_id UUID REFERENCES rcal.calendar_sources(id) ON DELETE SET NULL,
|
||||
location_id UUID REFERENCES rcal.locations(id) ON DELETE SET NULL,
|
||||
location_name TEXT,
|
||||
coordinates POINT,
|
||||
location_granularity INT,
|
||||
is_virtual BOOLEAN DEFAULT FALSE,
|
||||
virtual_url TEXT,
|
||||
virtual_platform TEXT,
|
||||
r_tool_source TEXT,
|
||||
r_tool_entity_id TEXT,
|
||||
attendees TEXT[] DEFAULT '{}',
|
||||
attendee_count INT DEFAULT 0,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rcal_events_time ON rcal.events(start_time, end_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_rcal_events_source ON rcal.events(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rcal_events_rtool ON rcal.events(r_tool_source, r_tool_entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rcal_locations_parent ON rcal.locations(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rcal_sources_owner ON rcal.calendar_sources(owner_id);
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* Cal module — temporal coordination calendar.
|
||||
*
|
||||
* Group calendars with lunar/solar/seasonal time systems,
|
||||
* location-aware events, and temporal-spatial zoom coupling.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── DB initialization ──
|
||||
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
|
||||
|
||||
async function initDB() {
|
||||
try {
|
||||
await sql.unsafe(SCHEMA_SQL);
|
||||
console.log("[Cal] DB schema initialized");
|
||||
} catch (e) {
|
||||
console.error("[Cal] DB init error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDemoIfEmpty() {
|
||||
try {
|
||||
const count = await sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events");
|
||||
if (parseInt(count[0].cnt) > 0) return;
|
||||
|
||||
// Create calendar sources
|
||||
const community = await sql.unsafe(
|
||||
`INSERT INTO rcal.calendar_sources (name, source_type, color, is_active, is_visible)
|
||||
VALUES ('Community Events', 'MANUAL', '#6366f1', true, true) RETURNING id`
|
||||
);
|
||||
const sprints = await sql.unsafe(
|
||||
`INSERT INTO rcal.calendar_sources (name, source_type, color, is_active, is_visible)
|
||||
VALUES ('Development Sprints', 'MANUAL', '#f59e0b', true, true) RETURNING id`
|
||||
);
|
||||
const communityId = community[0].id;
|
||||
const sprintsId = sprints[0].id;
|
||||
|
||||
// Create location hierarchy
|
||||
const world = await sql.unsafe(
|
||||
`INSERT INTO rcal.locations (name, granularity) VALUES ('Earth', 1) RETURNING id`
|
||||
);
|
||||
const europe = await sql.unsafe(
|
||||
`INSERT INTO rcal.locations (name, granularity, parent_id, lat, lng) VALUES ('Europe', 2, $1, 48.8566, 2.3522) RETURNING id`,
|
||||
[world[0].id]
|
||||
);
|
||||
const berlin = await sql.unsafe(
|
||||
`INSERT INTO rcal.locations (name, granularity, parent_id, lat, lng) VALUES ('Berlin', 4, $1, 52.52, 13.405) RETURNING id`,
|
||||
[europe[0].id]
|
||||
);
|
||||
|
||||
// Seed events — past, current week, and future
|
||||
const now = new Date();
|
||||
const events = [
|
||||
{
|
||||
title: "rSpace Launch Party",
|
||||
desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.",
|
||||
start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0),
|
||||
sourceId: communityId, locationName: "Radiant Hall, Pittsburgh",
|
||||
},
|
||||
{
|
||||
title: "Provider Onboarding Workshop",
|
||||
desc: "Hands-on session for print providers joining the cosmolocal network.",
|
||||
start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0),
|
||||
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi",
|
||||
},
|
||||
{
|
||||
title: "Weekly Community Standup",
|
||||
desc: "Open standup — share what you're working on, ask for help, coordinate.",
|
||||
start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45),
|
||||
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi",
|
||||
},
|
||||
{
|
||||
title: "Sprint: Module Seeding & Polish",
|
||||
desc: "Focus sprint on populating demo data and improving UX across all modules.",
|
||||
start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0),
|
||||
sourceId: sprintsId, allDay: true,
|
||||
},
|
||||
{
|
||||
title: "rFunds Budget Review",
|
||||
desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.",
|
||||
start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
|
||||
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rfunds-review", virtualPlatform: "Jitsi",
|
||||
},
|
||||
{
|
||||
title: "Cosmolocal Design Sprint",
|
||||
desc: "Two-day design sprint on the next generation of cosmolocal tooling.",
|
||||
start: daysFromNow(11, 9, 0), end: daysFromNow(12, 18, 0),
|
||||
sourceId: sprintsId, locationId: berlin[0].id, locationName: "Druckwerkstatt Berlin",
|
||||
},
|
||||
{
|
||||
title: "Q1 Retrospective",
|
||||
desc: "Looking back at what we built, what worked, and what to improve.",
|
||||
start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0),
|
||||
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi",
|
||||
},
|
||||
];
|
||||
|
||||
for (const e of events) {
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, source_id,
|
||||
location_id, location_name, is_virtual, virtual_url, virtual_platform)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[e.title, e.desc, e.start.toISOString(), e.end.toISOString(), e.allDay || false,
|
||||
e.sourceId, e.locationId || null, e.locationName || null,
|
||||
e.virtual || false, e.virtualUrl || null, e.virtualPlatform || null]
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Cal] Demo data seeded: 2 sources, 3 locations, 7 events");
|
||||
} catch (e) {
|
||||
console.error("[Cal] Seed error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function daysFromNow(days: number, hours: number, minutes: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
initDB().then(seedDemoIfEmpty);
|
||||
|
||||
// ── API: Events ──
|
||||
|
||||
// GET /api/events — query events with filters
|
||||
routes.get("/api/events", async (c) => {
|
||||
const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query();
|
||||
|
||||
let where = "WHERE 1=1";
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (start) { where += ` AND e.start_time >= $${idx}`; params.push(start); idx++; }
|
||||
if (end) { where += ` AND e.start_time <= ($${idx}::date + interval '1 day')`; params.push(end); idx++; }
|
||||
if (source) { where += ` AND e.source_id = $${idx}`; params.push(source); idx++; }
|
||||
if (search) { where += ` AND (e.title ILIKE $${idx} OR e.description ILIKE $${idx})`; params.push(`%${search}%`); idx++; }
|
||||
if (rTool) { where += ` AND e.r_tool_source = $${idx}`; params.push(rTool); idx++; }
|
||||
if (rEntityId) { where += ` AND e.r_tool_entity_id = $${idx}`; params.push(rEntityId); idx++; }
|
||||
if (upcoming) {
|
||||
where += ` AND e.start_time >= NOW() AND e.start_time <= NOW() + ($${idx} || ' days')::interval`;
|
||||
params.push(upcoming);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT e.*, cs.name as source_name, cs.color as source_color, l.name as location_label
|
||||
FROM rcal.events e
|
||||
LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id
|
||||
LEFT JOIN rcal.locations l ON l.id = e.location_id
|
||||
${where}
|
||||
ORDER BY e.start_time ASC LIMIT 500`,
|
||||
params
|
||||
);
|
||||
return c.json({ count: rows.length, results: rows });
|
||||
});
|
||||
|
||||
// POST /api/events — create event
|
||||
routes.post("/api/events", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
|
||||
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body;
|
||||
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id,
|
||||
location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||
[title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC",
|
||||
source_id || null, location_id || null, location_name || null, is_virtual || false,
|
||||
virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null, claims.sub]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
||||
// GET /api/events/:id
|
||||
routes.get("/api/events/:id", async (c) => {
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT e.*, cs.name as source_name, cs.color as source_color
|
||||
FROM rcal.events e LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id
|
||||
WHERE e.id = $1`,
|
||||
[c.req.param("id")]
|
||||
);
|
||||
if (rows.length === 0) return c.json({ error: "Event not found" }, 404);
|
||||
return c.json(rows[0]);
|
||||
});
|
||||
|
||||
// PATCH /api/events/:id
|
||||
routes.patch("/api/events/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
const allowed = ["title", "description", "start_time", "end_time", "all_day", "timezone",
|
||||
"status", "visibility", "location_name", "is_virtual", "virtual_url"];
|
||||
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) {
|
||||
fields.push(`${key} = $${idx}`);
|
||||
params.push(body[key]);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return c.json({ error: "No fields" }, 400);
|
||||
fields.push("updated_at = NOW()");
|
||||
params.push(id);
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`UPDATE rcal.events SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
if (rows.length === 0) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(rows[0]);
|
||||
});
|
||||
|
||||
// DELETE /api/events/:id
|
||||
routes.delete("/api/events/:id", async (c) => {
|
||||
const result = await sql.unsafe("DELETE FROM rcal.events WHERE id = $1 RETURNING id", [c.req.param("id")]);
|
||||
if (result.length === 0) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── API: Sources ──
|
||||
|
||||
routes.get("/api/sources", async (c) => {
|
||||
const { is_active, is_visible, source_type } = c.req.query();
|
||||
let where = "WHERE 1=1";
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (is_active !== undefined) { where += ` AND is_active = $${idx}`; params.push(is_active === "true"); idx++; }
|
||||
if (is_visible !== undefined) { where += ` AND is_visible = $${idx}`; params.push(is_visible === "true"); idx++; }
|
||||
if (source_type) { where += ` AND source_type = $${idx}`; params.push(source_type); idx++; }
|
||||
|
||||
const rows = await sql.unsafe(`SELECT * FROM rcal.calendar_sources ${where} ORDER BY name`, params);
|
||||
return c.json({ count: rows.length, results: rows });
|
||||
});
|
||||
|
||||
routes.post("/api/sources", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[body.name, body.source_type || "MANUAL", body.url || null, body.color || "#6366f1",
|
||||
body.is_active ?? true, body.is_visible ?? true]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
||||
// ── API: Locations ──
|
||||
|
||||
routes.get("/api/locations", async (c) => {
|
||||
const { granularity, parent, search, root } = c.req.query();
|
||||
let where = "WHERE 1=1";
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (root === "true") { where += " AND parent_id IS NULL"; }
|
||||
if (granularity) { where += ` AND granularity = $${idx}`; params.push(parseInt(granularity)); idx++; }
|
||||
if (parent) { where += ` AND parent_id = $${idx}`; params.push(parent); idx++; }
|
||||
if (search) { where += ` AND name ILIKE $${idx}`; params.push(`%${search}%`); idx++; }
|
||||
|
||||
const rows = await sql.unsafe(`SELECT * FROM rcal.locations ${where} ORDER BY name`, params);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
routes.get("/api/locations/tree", async (c) => {
|
||||
const rows = await sql.unsafe(
|
||||
`WITH RECURSIVE tree AS (
|
||||
SELECT id, name, granularity, parent_id, 0 as depth FROM rcal.locations WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT l.id, l.name, l.granularity, l.parent_id, t.depth + 1
|
||||
FROM rcal.locations l JOIN tree t ON l.parent_id = t.id
|
||||
)
|
||||
SELECT * FROM tree ORDER BY depth, name`
|
||||
);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// ── API: Lunar data (computed, not stored) ──
|
||||
|
||||
routes.get("/api/lunar", async (c) => {
|
||||
const { start, end } = c.req.query();
|
||||
if (!start || !end) return c.json({ error: "start and end required" }, 400);
|
||||
|
||||
// Simple lunar phase approximation based on synodic month
|
||||
const SYNODIC_MONTH = 29.53059;
|
||||
const KNOWN_NEW_MOON = new Date("2024-01-11T11:57:00Z").getTime();
|
||||
const phases: Record<string, { phase: string; illumination: number }> = {};
|
||||
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
const daysSinceNewMoon = (current.getTime() - KNOWN_NEW_MOON) / (1000 * 60 * 60 * 24);
|
||||
const lunation = ((daysSinceNewMoon % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH;
|
||||
const fraction = lunation / SYNODIC_MONTH;
|
||||
const illumination = 0.5 * (1 - Math.cos(2 * Math.PI * fraction));
|
||||
|
||||
let phase = "waxing_crescent";
|
||||
if (fraction < 0.0625) phase = "new_moon";
|
||||
else if (fraction < 0.1875) phase = "waxing_crescent";
|
||||
else if (fraction < 0.3125) phase = "first_quarter";
|
||||
else if (fraction < 0.4375) phase = "waxing_gibbous";
|
||||
else if (fraction < 0.5625) phase = "full_moon";
|
||||
else if (fraction < 0.6875) phase = "waning_gibbous";
|
||||
else if (fraction < 0.8125) phase = "last_quarter";
|
||||
else if (fraction < 0.9375) phase = "waning_crescent";
|
||||
else phase = "new_moon";
|
||||
|
||||
phases[current.toISOString().split("T")[0]] = { phase, illumination: Math.round(illumination * 100) / 100 };
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return c.json(phases);
|
||||
});
|
||||
|
||||
// ── API: Stats ──
|
||||
|
||||
routes.get("/api/stats", async (c) => {
|
||||
const [eventCount, sourceCount, locationCount] = await Promise.all([
|
||||
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events"),
|
||||
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.calendar_sources WHERE is_active = true"),
|
||||
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.locations"),
|
||||
]);
|
||||
return c.json({
|
||||
events: eventCount[0]?.cnt || 0,
|
||||
sources: sourceCount[0]?.cnt || 0,
|
||||
locations: locationCount[0]?.cnt || 0,
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: Context (r* tool bridge) ──
|
||||
|
||||
routes.get("/api/context/:tool", async (c) => {
|
||||
const tool = c.req.param("tool");
|
||||
const entityId = c.req.query("entityId");
|
||||
if (!entityId) return c.json({ error: "entityId required" }, 400);
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
"SELECT * FROM rcal.events WHERE r_tool_source = $1 AND r_tool_entity_id = $2 ORDER BY start_time",
|
||||
[tool, entityId]
|
||||
);
|
||||
return c.json({ count: rows.length, results: rows });
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Calendar | rSpace`,
|
||||
moduleId: "rcal",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
|
||||
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const calModule: RSpaceModule = {
|
||||
id: "rcal",
|
||||
name: "rCal",
|
||||
icon: "\u{1F4C5}",
|
||||
description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
|
||||
routes,
|
||||
standaloneDomain: "rcal.online",
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Canvas module — the collaborative infinite canvas.
|
||||
*
|
||||
* This is the original rSpace canvas restructured as an rSpace module.
|
||||
* Routes are relative to the mount point (/:space/canvas in unified mode,
|
||||
* / in standalone mode).
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { resolve } from "node:path";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const DIST_DIR = resolve(import.meta.dir, "../../dist");
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// GET / — serve the canvas page wrapped in shell
|
||||
routes.get("/", async (c) => {
|
||||
const spaceSlug = c.req.param("space") || c.req.query("space") || "demo";
|
||||
|
||||
// Read the canvas page template from dist
|
||||
const canvasFile = Bun.file(resolve(DIST_DIR, "canvas-module.html"));
|
||||
let canvasBody = "";
|
||||
if (await canvasFile.exists()) {
|
||||
canvasBody = await canvasFile.text();
|
||||
} else {
|
||||
// Fallback: serve full canvas.html directly if module template not built yet
|
||||
const fallbackFile = Bun.file(resolve(DIST_DIR, "canvas.html"));
|
||||
if (await fallbackFile.exists()) {
|
||||
return new Response(fallbackFile, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
canvasBody = `<div style="padding:2rem;text-align:center;color:#64748b;">Canvas loading...</div>`;
|
||||
}
|
||||
|
||||
const html = renderShell({
|
||||
title: `${spaceSlug} — Canvas | rSpace`,
|
||||
moduleId: "rspace",
|
||||
spaceSlug,
|
||||
body: canvasBody,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
scripts: `<script type="module" src="/canvas-module.js"></script>`,
|
||||
});
|
||||
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
export const canvasModule: RSpaceModule = {
|
||||
id: "rspace",
|
||||
name: "rSpace",
|
||||
icon: "🎨",
|
||||
description: "Real-time collaborative canvas",
|
||||
routes,
|
||||
feeds: [
|
||||
{
|
||||
id: "shapes",
|
||||
name: "Canvas Shapes",
|
||||
kind: "data",
|
||||
description: "All shapes on this canvas layer — notes, embeds, arrows, etc.",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
name: "Shape Connections",
|
||||
kind: "data",
|
||||
description: "Arrow connections between shapes — the canvas graph",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["economic", "trust", "data", "attention", "governance", "resource"],
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Cart module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* <folk-cart-shop> — browse catalog, view orders, trigger fulfillment.
|
||||
* Shows catalog items, order creation flow, and order status tracking.
|
||||
*/
|
||||
|
||||
class FolkCartShop extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private catalog: any[] = [];
|
||||
private orders: any[] = [];
|
||||
private view: "catalog" | "orders" = "catalog";
|
||||
private loading = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts.length >= 2 ? `/${parts[0]}/cart` : "/demo/cart";
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const [catRes, ordRes] = await Promise.all([
|
||||
fetch(`${this.getApiBase()}/api/catalog?limit=50`),
|
||||
fetch(`${this.getApiBase()}/api/orders?limit=20`),
|
||||
]);
|
||||
const catData = await catRes.json();
|
||||
const ordData = await ordRes.json();
|
||||
this.catalog = catData.entries || [];
|
||||
this.orders = ordData.orders || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load cart data:", e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; }
|
||||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.tabs { display: flex; gap: 0.5rem; }
|
||||
.tab { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; }
|
||||
.tab:hover { border-color: #475569; color: #f1f5f9; }
|
||||
.tab.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
|
||||
.card:hover { border-color: #475569; }
|
||||
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.5rem; }
|
||||
.card-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 0.5rem; }
|
||||
.tag { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; margin-right: 0.25rem; }
|
||||
.tag-type { background: rgba(99,102,241,0.1); color: #818cf8; }
|
||||
.tag-cap { background: rgba(34,197,94,0.1); color: #4ade80; }
|
||||
.dims { color: #64748b; font-size: 0.75rem; margin-top: 0.5rem; }
|
||||
.status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; }
|
||||
.status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
||||
.status-paid { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
.status-active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
.status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; }
|
||||
.status-cancelled { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||
.order-card { display: flex; justify-content: space-between; align-items: center; }
|
||||
.order-info { flex: 1; }
|
||||
.order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; }
|
||||
.empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; }
|
||||
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Shop</span>
|
||||
<div class="tabs">
|
||||
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">\u{1F4E6} Catalog (${this.catalog.length})</button>
|
||||
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">\u{1F4CB} Orders (${this.orders.length})</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.loading ? `<div class="loading">\u23F3 Loading...</div>` :
|
||||
this.view === "catalog" ? this.renderCatalog() : this.renderOrders()}
|
||||
`;
|
||||
|
||||
this.shadow.querySelectorAll(".tab").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.view = ((el as HTMLElement).dataset.view || "catalog") as "catalog" | "orders";
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private renderCatalog(): string {
|
||||
if (this.catalog.length === 0) {
|
||||
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
|
||||
}
|
||||
|
||||
return `<div class="grid">
|
||||
${this.catalog.map((entry) => `
|
||||
<div class="card">
|
||||
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
|
||||
<div class="card-meta">
|
||||
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
||||
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
|
||||
</div>
|
||||
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
|
||||
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
|
||||
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderOrders(): string {
|
||||
if (this.orders.length === 0) {
|
||||
return `<div class="empty">No orders yet.</div>`;
|
||||
}
|
||||
|
||||
return `<div class="grid">
|
||||
${this.orders.map((order) => `
|
||||
<div class="card">
|
||||
<div class="order-card">
|
||||
<div class="order-info">
|
||||
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
|
||||
<div class="card-meta">
|
||||
${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""}
|
||||
${order.quantity > 1 ? ` \u2022 Qty: ${order.quantity}` : ""}
|
||||
</div>
|
||||
<span class="status status-${order.status}">${order.status}</span>
|
||||
</div>
|
||||
<div class="order-price">$${parseFloat(order.total_price || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-cart-shop", FolkCartShop);
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
-- rCart schema — catalog entries, orders, payment splits
|
||||
-- Inside rSpace shared DB, schema: rcart
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcart.catalog_entries (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
artifact_id UUID NOT NULL UNIQUE,
|
||||
artifact JSONB NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
product_type TEXT,
|
||||
required_capabilities TEXT[] DEFAULT '{}',
|
||||
substrates TEXT[] DEFAULT '{}',
|
||||
creator_id TEXT,
|
||||
source_space TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'sold_out', 'removed')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_status ON rcart.catalog_entries (status) WHERE status = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_capabilities ON rcart.catalog_entries USING gin (required_capabilities);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_tags ON rcart.catalog_entries USING gin (tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_source_space ON rcart.catalog_entries (source_space);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_product_type ON rcart.catalog_entries (product_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcart.orders (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
catalog_entry_id UUID NOT NULL REFERENCES rcart.catalog_entries(id),
|
||||
artifact_id UUID NOT NULL,
|
||||
buyer_id TEXT,
|
||||
buyer_location JSONB,
|
||||
buyer_contact JSONB,
|
||||
provider_id UUID,
|
||||
provider_name TEXT,
|
||||
provider_distance_km DOUBLE PRECISION,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
production_cost NUMERIC(10,2),
|
||||
creator_payout NUMERIC(10,2),
|
||||
community_payout NUMERIC(10,2),
|
||||
total_price NUMERIC(10,2),
|
||||
currency TEXT DEFAULT 'USD',
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||
'pending', 'paid', 'accepted', 'in_production', 'ready', 'shipped', 'completed', 'cancelled'
|
||||
)),
|
||||
payment_method TEXT DEFAULT 'manual',
|
||||
payment_tx TEXT,
|
||||
payment_network TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
paid_at TIMESTAMPTZ,
|
||||
accepted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON rcart.orders (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_provider ON rcart.orders (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_buyer ON rcart.orders (buyer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_catalog ON rcart.orders (catalog_entry_id);
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Flow Service integration — deposits order revenue into the TBFF flow
|
||||
* for automatic threshold-based splits (provider / creator / community).
|
||||
*/
|
||||
|
||||
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
|
||||
const FLOW_ID = process.env.FLOW_ID || "";
|
||||
const FUNNEL_ID = process.env.FUNNEL_ID || "";
|
||||
|
||||
function toUsdcUnits(dollars: number | string): string {
|
||||
const d = typeof dollars === "string" ? parseFloat(dollars) : dollars;
|
||||
return Math.round(d * 1e6).toString();
|
||||
}
|
||||
|
||||
export async function depositOrderRevenue(
|
||||
totalPrice: number | string,
|
||||
orderId: string
|
||||
): Promise<void> {
|
||||
if (!FLOW_ID || !FUNNEL_ID) return;
|
||||
|
||||
const amount = toUsdcUnits(totalPrice);
|
||||
const url = `${FLOW_SERVICE_URL}/api/flows/${FLOW_ID}/deposit`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ funnelId: FUNNEL_ID, amount, source: "wallet" }),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json() as { transaction?: { id?: string } };
|
||||
console.log(`[Cart] Flow deposit OK: order=${orderId} amount=${amount} tx=${data.transaction?.id}`);
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
console.error(`[Cart] Flow deposit failed (${resp.status}): ${text}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Cart] Flow deposit error:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
/**
|
||||
* Cart module — cosmolocal print-on-demand shop.
|
||||
*
|
||||
* Ported from /opt/apps/rcart/ (Express → Hono).
|
||||
* Handles catalog (artifact listings), orders, fulfillment resolution.
|
||||
* Integrates with provider-registry for provider matching and flow-service for revenue splits.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { depositOrderRevenue } from "./flow";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── DB initialization ──
|
||||
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
|
||||
|
||||
async function initDB() {
|
||||
try {
|
||||
await sql.unsafe(SCHEMA_SQL);
|
||||
console.log("[Cart] DB schema initialized");
|
||||
} catch (e) {
|
||||
console.error("[Cart] DB init error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
initDB();
|
||||
|
||||
// Provider registry URL (for fulfillment resolution)
|
||||
const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || "";
|
||||
|
||||
function getProviderUrl(): string {
|
||||
// In unified mode, providers module is co-located — call its routes directly via internal URL
|
||||
// In standalone mode, use PROVIDER_REGISTRY_URL env
|
||||
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
|
||||
}
|
||||
|
||||
// ── CATALOG ROUTES ──
|
||||
|
||||
// POST /api/catalog/ingest — Add artifact to catalog
|
||||
routes.post("/api/catalog/ingest", async (c) => {
|
||||
const artifact = await c.req.json();
|
||||
|
||||
if (!artifact.id || !artifact.schema_version || !artifact.type) {
|
||||
return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400);
|
||||
}
|
||||
if (artifact.type !== "print-ready") {
|
||||
return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400);
|
||||
}
|
||||
if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) {
|
||||
return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400);
|
||||
}
|
||||
|
||||
const existing = await sql.unsafe("SELECT id FROM rcart.catalog_entries WHERE artifact_id = $1", [artifact.id]);
|
||||
if (existing.length > 0) {
|
||||
return c.json({ error: "Artifact already listed", catalog_entry_id: existing[0].id }, 409);
|
||||
}
|
||||
|
||||
const result = await sql.unsafe(
|
||||
`INSERT INTO rcart.catalog_entries (
|
||||
artifact_id, artifact, title, product_type,
|
||||
required_capabilities, substrates, creator_id,
|
||||
source_space, tags
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, artifact_id, title, product_type, status, created_at`,
|
||||
[
|
||||
artifact.id, JSON.stringify(artifact),
|
||||
artifact.payload?.title || "Untitled",
|
||||
artifact.spec?.product_type || null,
|
||||
artifact.spec?.required_capabilities || [],
|
||||
artifact.spec?.substrates || [],
|
||||
artifact.creator?.id || null,
|
||||
artifact.source_space || null,
|
||||
artifact.payload?.tags || [],
|
||||
]
|
||||
);
|
||||
|
||||
return c.json(result[0], 201);
|
||||
});
|
||||
|
||||
// GET /api/catalog — Browse catalog
|
||||
routes.get("/api/catalog", async (c) => {
|
||||
const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();
|
||||
|
||||
const conditions: string[] = ["status = 'active'"];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (product_type) {
|
||||
conditions.push(`product_type = $${paramIdx}`);
|
||||
params.push(product_type);
|
||||
paramIdx++;
|
||||
}
|
||||
if (capability) {
|
||||
conditions.push(`required_capabilities && $${paramIdx}`);
|
||||
params.push(capability.split(","));
|
||||
paramIdx++;
|
||||
}
|
||||
if (tag) {
|
||||
conditions.push(`$${paramIdx} = ANY(tags)`);
|
||||
params.push(tag);
|
||||
paramIdx++;
|
||||
}
|
||||
if (source_space) {
|
||||
conditions.push(`source_space = $${paramIdx}`);
|
||||
params.push(source_space);
|
||||
paramIdx++;
|
||||
}
|
||||
if (q) {
|
||||
conditions.push(`title ILIKE $${paramIdx}`);
|
||||
params.push(`%${q}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const where = conditions.join(" AND ");
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
||||
const offsetNum = parseInt(offset) || 0;
|
||||
|
||||
const [result, countResult] = await Promise.all([
|
||||
sql.unsafe(
|
||||
`SELECT id, artifact_id, title, product_type,
|
||||
required_capabilities, tags, source_space,
|
||||
artifact->'payload'->>'description' as description,
|
||||
artifact->'pricing' as pricing,
|
||||
artifact->'spec'->'dimensions' as dimensions,
|
||||
status, created_at
|
||||
FROM rcart.catalog_entries
|
||||
WHERE ${where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limitNum} OFFSET ${offsetNum}`,
|
||||
params
|
||||
),
|
||||
sql.unsafe(`SELECT count(*) FROM rcart.catalog_entries WHERE ${where}`, params),
|
||||
]);
|
||||
|
||||
return c.json({ entries: result, total: parseInt(countResult[0].count as string), limit: limitNum, offset: offsetNum });
|
||||
});
|
||||
|
||||
// GET /api/catalog/:id — Single catalog entry
|
||||
routes.get("/api/catalog/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const result = await sql.unsafe(
|
||||
"SELECT * FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
|
||||
[id]
|
||||
);
|
||||
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
|
||||
const row = result[0];
|
||||
return c.json({ id: row.id, artifact: row.artifact, status: row.status, created_at: row.created_at, updated_at: row.updated_at });
|
||||
});
|
||||
|
||||
// PATCH /api/catalog/:id — Update listing status
|
||||
routes.patch("/api/catalog/:id", async (c) => {
|
||||
const { status } = await c.req.json();
|
||||
const valid = ["active", "paused", "sold_out", "removed"];
|
||||
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
|
||||
|
||||
const result = await sql.unsafe(
|
||||
"UPDATE rcart.catalog_entries SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING id, status",
|
||||
[status, c.req.param("id")]
|
||||
);
|
||||
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// ── ORDER ROUTES ──
|
||||
|
||||
// POST /api/orders — Create an order
|
||||
routes.post("/api/orders", async (c) => {
|
||||
// Optional auth — set buyer_did from claims if authenticated
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let buyerDid: string | null = null;
|
||||
if (token) {
|
||||
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const {
|
||||
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
|
||||
provider_id, provider_name, provider_distance_km,
|
||||
quantity = 1, production_cost, creator_payout, community_payout,
|
||||
total_price, currency = "USD", payment_method = "manual",
|
||||
payment_tx, payment_network,
|
||||
} = body;
|
||||
|
||||
if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400);
|
||||
if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400);
|
||||
|
||||
const entryResult = await sql.unsafe(
|
||||
"SELECT id, artifact_id FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
|
||||
[catalog_entry_id || artifact_id]
|
||||
);
|
||||
if (entryResult.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
|
||||
|
||||
const entry = entryResult[0];
|
||||
|
||||
// x402 detection
|
||||
const x402Header = c.req.header("x-payment");
|
||||
const effectiveMethod = x402Header ? "x402" : payment_method;
|
||||
const initialStatus = x402Header ? "paid" : "pending";
|
||||
|
||||
const result = await sql.unsafe(
|
||||
`INSERT INTO rcart.orders (
|
||||
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
|
||||
provider_id, provider_name, provider_distance_km,
|
||||
quantity, production_cost, creator_payout, community_payout,
|
||||
total_price, currency, status, payment_method, payment_tx, payment_network
|
||||
${initialStatus === "paid" ? ", paid_at" : ""}
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18
|
||||
${initialStatus === "paid" ? ", NOW()" : ""})
|
||||
RETURNING *`,
|
||||
[
|
||||
entry.id, entry.artifact_id,
|
||||
buyerDid || buyer_id || null,
|
||||
buyer_location ? JSON.stringify(buyer_location) : null,
|
||||
buyer_contact ? JSON.stringify(buyer_contact) : null,
|
||||
provider_id, provider_name || null, provider_distance_km || null,
|
||||
quantity, production_cost || null, creator_payout || null, community_payout || null,
|
||||
total_price, currency, initialStatus, effectiveMethod,
|
||||
payment_tx || null, payment_network || null,
|
||||
]
|
||||
);
|
||||
|
||||
const order = result[0];
|
||||
if (initialStatus === "paid") {
|
||||
depositOrderRevenue(total_price, order.id);
|
||||
}
|
||||
|
||||
return c.json(order, 201);
|
||||
});
|
||||
|
||||
// GET /api/orders — List orders
|
||||
routes.get("/api/orders", async (c) => {
|
||||
// Optional auth — filter by buyer if authenticated
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let authedBuyer: string | null = null;
|
||||
if (token) {
|
||||
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
|
||||
}
|
||||
|
||||
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; }
|
||||
if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; }
|
||||
const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
|
||||
if (effectiveBuyerId) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(effectiveBuyerId); paramIdx++; }
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
||||
const offsetNum = parseInt(offset) || 0;
|
||||
|
||||
const result = await sql.unsafe(
|
||||
`SELECT o.*, c.title as artifact_title, c.product_type
|
||||
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
|
||||
${where} ORDER BY o.created_at DESC LIMIT ${limitNum} OFFSET ${offsetNum}`,
|
||||
params
|
||||
);
|
||||
|
||||
return c.json({ orders: result });
|
||||
});
|
||||
|
||||
// GET /api/orders/:id — Single order
|
||||
routes.get("/api/orders/:id", async (c) => {
|
||||
const result = await sql.unsafe(
|
||||
`SELECT o.*, c.artifact as artifact_envelope, c.title as artifact_title
|
||||
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
|
||||
WHERE o.id = $1`,
|
||||
[c.req.param("id")]
|
||||
);
|
||||
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// PATCH /api/orders/:id/status — Update order status
|
||||
routes.patch("/api/orders/:id/status", async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { status, payment_tx, payment_network } = body;
|
||||
const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"];
|
||||
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
|
||||
|
||||
const timestampField: Record<string, string> = { paid: "paid_at", accepted: "accepted_at", completed: "completed_at" };
|
||||
const extraSet = timestampField[status] ? `, ${timestampField[status]} = NOW()` : "";
|
||||
|
||||
// Use parameterized query for payment info
|
||||
let paymentSet = "";
|
||||
const params: any[] = [status, c.req.param("id")];
|
||||
if (status === "paid" && payment_tx) {
|
||||
paymentSet = `, payment_tx = $3, payment_network = $4`;
|
||||
params.push(payment_tx, payment_network || null);
|
||||
}
|
||||
|
||||
const result = await sql.unsafe(
|
||||
`UPDATE rcart.orders SET status = $1, updated_at = NOW()${extraSet}${paymentSet} WHERE id = $2 RETURNING *`,
|
||||
params
|
||||
);
|
||||
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
|
||||
|
||||
const updated = result[0];
|
||||
if (status === "paid" && updated.total_price) {
|
||||
depositOrderRevenue(updated.total_price, c.req.param("id"));
|
||||
}
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ── FULFILLMENT ROUTES ──
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
interface ProviderMatch {
|
||||
id: string; name: string; distance_km: number;
|
||||
location: { city: string; region: string; country: string };
|
||||
turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number };
|
||||
pricing: Record<string, { base_price: number; per_unit: number; per_unit_label: string; currency: string; volume_breaks?: { min_qty: number; per_unit: number }[] }>;
|
||||
wallet: string;
|
||||
}
|
||||
|
||||
function composeCost(artifact: Record<string, unknown>, provider: ProviderMatch, quantity: number) {
|
||||
const spec = artifact.spec as Record<string, unknown> | undefined;
|
||||
const capabilities = (spec?.required_capabilities as string[]) || [];
|
||||
const pages = (spec?.pages as number) || 1;
|
||||
const breakdown: { label: string; amount: number }[] = [];
|
||||
|
||||
for (const cap of capabilities) {
|
||||
const capPricing = provider.pricing?.[cap];
|
||||
if (!capPricing) continue;
|
||||
|
||||
const basePrice = capPricing.base_price || 0;
|
||||
let perUnit = capPricing.per_unit || 0;
|
||||
const unitLabel = capPricing.per_unit_label || "per unit";
|
||||
|
||||
if (capPricing.volume_breaks) {
|
||||
for (const vb of capPricing.volume_breaks) {
|
||||
if (quantity >= vb.min_qty) perUnit = vb.per_unit;
|
||||
}
|
||||
}
|
||||
|
||||
let units = quantity;
|
||||
if (unitLabel.includes("page")) units = pages * quantity;
|
||||
|
||||
if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) });
|
||||
breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) });
|
||||
}
|
||||
|
||||
if (breakdown.length === 0) {
|
||||
breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) });
|
||||
}
|
||||
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
// POST /api/fulfill/resolve — Find fulfillment options
|
||||
routes.post("/api/fulfill/resolve", async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body;
|
||||
|
||||
if (!buyer_location?.lat || !buyer_location?.lng) {
|
||||
return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400);
|
||||
}
|
||||
if (!artifact_id && !catalog_entry_id) {
|
||||
return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400);
|
||||
}
|
||||
|
||||
const entryResult = await sql.unsafe(
|
||||
"SELECT * FROM rcart.catalog_entries WHERE (artifact_id = $1 OR id = $1) AND status = 'active'",
|
||||
[artifact_id || catalog_entry_id]
|
||||
);
|
||||
if (entryResult.length === 0) return c.json({ error: "Artifact not found in catalog" }, 404);
|
||||
|
||||
const entry = entryResult[0];
|
||||
const artifact = entry.artifact;
|
||||
const capabilities = artifact.spec?.required_capabilities || [];
|
||||
const substrates = artifact.spec?.substrates || [];
|
||||
|
||||
if (capabilities.length === 0) {
|
||||
return c.json({ error: "Artifact has no required_capabilities" }, 400);
|
||||
}
|
||||
|
||||
// Query provider registry (internal module or external service)
|
||||
const providerUrl = getProviderUrl();
|
||||
const params = new URLSearchParams({
|
||||
capabilities: capabilities.join(","),
|
||||
lat: String(buyer_location.lat),
|
||||
lng: String(buyer_location.lng),
|
||||
});
|
||||
if (substrates.length > 0) params.set("substrates", substrates.join(","));
|
||||
|
||||
let providers: ProviderMatch[];
|
||||
try {
|
||||
const resp = await fetch(`${providerUrl}/api/providers/match?${params}`);
|
||||
if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`);
|
||||
const data = await resp.json() as { matches?: ProviderMatch[] };
|
||||
providers = data.matches || [];
|
||||
} catch (err) {
|
||||
return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502);
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id });
|
||||
}
|
||||
|
||||
const options = providers.map((provider) => {
|
||||
const costBreakdown = composeCost(artifact, provider, quantity);
|
||||
const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0);
|
||||
const pricing = artifact.pricing || {};
|
||||
const creatorPct = (pricing.creator_share_pct || 30) / 100;
|
||||
const communityPct = (pricing.community_share_pct || 0) / 100;
|
||||
const markupMultiplier = 1 / (1 - creatorPct - communityPct);
|
||||
const totalPrice = productionCost * markupMultiplier;
|
||||
const creatorPayout = totalPrice * creatorPct;
|
||||
const communityPayout = totalPrice * communityPct;
|
||||
|
||||
return {
|
||||
provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" },
|
||||
production_cost: round2(productionCost),
|
||||
creator_payout: round2(creatorPayout),
|
||||
community_payout: round2(communityPayout),
|
||||
total_price: round2(totalPrice),
|
||||
currency: "USD",
|
||||
turnaround_days: provider.turnaround?.standard_days || 5,
|
||||
cost_breakdown: costBreakdown,
|
||||
};
|
||||
});
|
||||
|
||||
options.sort((a, b) => a.total_price - b.total_price);
|
||||
|
||||
return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options });
|
||||
});
|
||||
|
||||
// ── Page route: shop ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `Shop | rSpace`,
|
||||
moduleId: "rcart",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`,
|
||||
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const cartModule: RSpaceModule = {
|
||||
id: "rcart",
|
||||
name: "rCart",
|
||||
icon: "\u{1F6D2}",
|
||||
description: "Cosmolocal print-on-demand shop",
|
||||
routes,
|
||||
standaloneDomain: "rcart.online",
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Choices module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* <folk-choices-dashboard> — lists choice shapes (polls, rankings, spider charts)
|
||||
* from the current space and links to the canvas to create/interact with them.
|
||||
*/
|
||||
|
||||
class FolkChoicesDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private choices: any[] = [];
|
||||
private loading = true;
|
||||
private space: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.loadChoices();
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices";
|
||||
}
|
||||
|
||||
private async loadChoices() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const res = await fetch(`${this.getApiBase()}/api/choices`);
|
||||
const data = await res.json();
|
||||
this.choices = data.choices || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load choices:", e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const typeIcons: Record<string, string> = {
|
||||
"folk-choice-vote": "\u2611",
|
||||
"folk-choice-rank": "\uD83D\uDCCA",
|
||||
"folk-choice-spider": "\uD83D\uDD78",
|
||||
};
|
||||
const typeLabels: Record<string, string> = {
|
||||
"folk-choice-vote": "Poll",
|
||||
"folk-choice-rank": "Ranking",
|
||||
"folk-choice-spider": "Spider Chart",
|
||||
};
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; }
|
||||
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
|
||||
.create-btns { display: flex; gap: 0.5rem; }
|
||||
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; text-decoration: none; }
|
||||
.create-btn:hover { border-color: #6366f1; color: #f1f5f9; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
|
||||
.card:hover { border-color: #6366f1; }
|
||||
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
|
||||
.card-type { color: #818cf8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
.card-meta { color: #94a3b8; font-size: 0.8125rem; }
|
||||
.stat { display: inline-block; margin-right: 1rem; }
|
||||
.empty { text-align: center; padding: 3rem; color: #64748b; }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.empty p { margin: 0.5rem 0; font-size: 0.875rem; }
|
||||
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||
.info { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; color: #a5b4fc; font-size: 0.8125rem; }
|
||||
</style>
|
||||
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Choices</span>
|
||||
<div class="create-btns">
|
||||
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">\u2795 New on Canvas</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas.
|
||||
Create them there and they'll appear here for quick access.
|
||||
</div>
|
||||
|
||||
${this.loading ? `<div class="loading">\u23F3 Loading choices...</div>` :
|
||||
this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmpty(): string {
|
||||
return `<div class="empty">
|
||||
<div class="empty-icon">\u2611</div>
|
||||
<p>No choices in this space yet.</p>
|
||||
<p>Open the <a href="/${this.space}/rspace" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
||||
return `<div class="grid">
|
||||
${this.choices.map((ch) => `
|
||||
<a class="card" href="/${this.space}/rspace">
|
||||
<div class="card-icon">${icons[ch.type] || "\u2611"}</div>
|
||||
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
||||
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
||||
<div class="card-meta">
|
||||
<span class="stat">${ch.optionCount} options</span>
|
||||
<span class="stat">${ch.voteCount} responses</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Choices module — voting, ranking, and multi-criteria scoring tools.
|
||||
*
|
||||
* The folk-choice-* web components live in lib/ (shared with canvas).
|
||||
* This module provides:
|
||||
* - A page listing choice shapes in the current space
|
||||
* - API to query choice shapes from the Automerge store
|
||||
* - Links to create new choices on the canvas
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { getDocumentData } from "../../server/community-store";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// GET /api/choices — list choice shapes in the current space
|
||||
routes.get("/api/choices", async (c) => {
|
||||
const space = c.req.param("space") || c.req.query("space") || "demo";
|
||||
const docData = getDocumentData(space);
|
||||
if (!docData?.shapes) {
|
||||
return c.json({ choices: [], total: 0 });
|
||||
}
|
||||
|
||||
const choiceTypes = ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"];
|
||||
const choices: any[] = [];
|
||||
|
||||
for (const [id, shape] of Object.entries(docData.shapes as Record<string, any>)) {
|
||||
if (shape.forgotten) continue;
|
||||
if (choiceTypes.includes(shape.type)) {
|
||||
choices.push({
|
||||
id,
|
||||
type: shape.type,
|
||||
title: shape.title || "Untitled",
|
||||
mode: shape.mode,
|
||||
optionCount: (shape.options || []).length,
|
||||
voteCount: (shape.votes || shape.rankings || shape.scores || []).length,
|
||||
createdAt: shape.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ choices, total: choices.length });
|
||||
});
|
||||
|
||||
// GET / — choices page
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Choices | rSpace`,
|
||||
moduleId: "rchoices",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
|
||||
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const choicesModule: RSpaceModule = {
|
||||
id: "rchoices",
|
||||
name: "rChoices",
|
||||
icon: "☑",
|
||||
description: "Polls, rankings, and multi-criteria scoring",
|
||||
routes,
|
||||
standaloneDomain: "rchoices.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "poll-results",
|
||||
name: "Poll Results",
|
||||
kind: "governance",
|
||||
description: "Live poll, ranking, and scoring outcomes",
|
||||
emits: ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"],
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "governance"],
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/* Data module — layout wrapper */
|
||||
folk-analytics-view {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* folk-analytics-view — Privacy-first analytics dashboard overview.
|
||||
*
|
||||
* Shows tracked apps, stats, and a link to the full Umami dashboard.
|
||||
*/
|
||||
|
||||
class FolkAnalyticsView extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "demo";
|
||||
private stats: any = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.loadStats();
|
||||
}
|
||||
|
||||
private async loadStats() {
|
||||
try {
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
const resp = await fetch(`${base}/api/stats`);
|
||||
if (resp.ok) {
|
||||
this.stats = await resp.json();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const stats = this.stats || { trackedApps: 17, cookiesSet: 0, scriptSize: "~2KB", selfHosted: true, apps: [], dashboardUrl: "https://analytics.rspace.online" };
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: #e2e8f0; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.desc { color: #94a3b8; font-size: 14px; line-height: 1.6; max-width: 600px; margin-bottom: 1.5rem; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
|
||||
.stat { text-align: center; background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; }
|
||||
.stat-value { font-size: 1.75rem; font-weight: 700; color: #22d3ee; }
|
||||
.stat-label { font-size: 0.75rem; color: #64748b; margin-top: 0.25rem; }
|
||||
.pillars { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem; }
|
||||
.pillar { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.5rem; }
|
||||
.pillar-icon { width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: 700; margin-bottom: 0.75rem; }
|
||||
.pillar-icon.zk { background: rgba(34,211,238,0.1); color: #22d3ee; }
|
||||
.pillar-icon.lf { background: rgba(129,140,248,0.1); color: #818cf8; }
|
||||
.pillar-icon.sh { background: rgba(52,211,153,0.1); color: #34d399; }
|
||||
.pillar h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
.pillar p { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; }
|
||||
.apps-section { margin-bottom: 2rem; }
|
||||
.apps-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.apps-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.app-chip { padding: 0.35rem 0.75rem; background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 20px; font-size: 0.8rem; color: #94a3b8; }
|
||||
.cta { padding: 1.5rem 0; border-top: 1px solid #1e293b; }
|
||||
.cta a { display: inline-block; padding: 0.6rem 1.5rem; background: #22d3ee; color: #0f172a; border-radius: 8px; font-weight: 600; text-decoration: none; font-size: 0.85rem; }
|
||||
.cta a:hover { opacity: 0.85; }
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pillars { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<p class="desc">Zero-knowledge, cookieless, self-hosted analytics for the r* ecosystem. Know how your tools are used without compromising anyone's privacy.</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.trackedApps}</div>
|
||||
<div class="stat-label">Apps Tracked</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.cookiesSet}</div>
|
||||
<div class="stat-label">Cookies Set</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.scriptSize}</div>
|
||||
<div class="stat-label">Script Size</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">100%</div>
|
||||
<div class="stat-label">Self-Hosted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pillars">
|
||||
<div class="pillar">
|
||||
<div class="pillar-icon zk">ZK</div>
|
||||
<h3>Zero-Knowledge Privacy</h3>
|
||||
<p>No cookies. No fingerprinting. No personal data. Each page view is anonymous. GDPR compliant by architecture.</p>
|
||||
</div>
|
||||
<div class="pillar">
|
||||
<div class="pillar-icon lf">LF</div>
|
||||
<h3>Local-First Data</h3>
|
||||
<p>Analytics data never leaves your infrastructure. No third-party servers, no cloud dependencies.</p>
|
||||
</div>
|
||||
<div class="pillar">
|
||||
<div class="pillar-icon sh">SH</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Full control over data retention, access, and lifecycle. Powered by Umami.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="apps-section">
|
||||
<div class="apps-title">Tracked Apps</div>
|
||||
<div class="apps-grid">
|
||||
${(stats.apps || []).map((a: string) => `<span class="app-chip">${a}</span>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta">
|
||||
<a href="${stats.dashboardUrl}" target="_blank" rel="noopener">Open Full Dashboard →</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-analytics-view", FolkAnalyticsView);
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Data module — privacy-first analytics dashboard.
|
||||
*
|
||||
* Lightweight module that shows analytics stats from the
|
||||
* self-hosted Umami instance. No database — proxies to Umami API.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
|
||||
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
|
||||
|
||||
const TRACKED_APPS = [
|
||||
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
|
||||
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
|
||||
"rTrips", "rTube", "rWork", "rNetwork", "rData",
|
||||
];
|
||||
|
||||
// ── API routes ──
|
||||
|
||||
// GET /api/info — module info
|
||||
routes.get("/api/info", (c) => {
|
||||
return c.json({
|
||||
module: "data",
|
||||
name: "rData",
|
||||
umamiUrl: UMAMI_URL,
|
||||
umamiConfigured: !!UMAMI_URL,
|
||||
features: ["privacy-first", "cookieless", "self-hosted"],
|
||||
trackedApps: TRACKED_APPS.length,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/health
|
||||
routes.get("/api/health", (c) => c.json({ ok: true }));
|
||||
|
||||
// GET /api/stats — proxy to Umami stats API
|
||||
routes.get("/api/stats", async (c) => {
|
||||
const startAt = c.req.query("startAt") || String(Date.now() - 24 * 3600_000);
|
||||
const endAt = c.req.query("endAt") || String(Date.now());
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/stats?startAt=${startAt}&endAt=${endAt}`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return c.json({
|
||||
...data as Record<string, unknown>,
|
||||
trackedApps: TRACKED_APPS.length,
|
||||
apps: TRACKED_APPS,
|
||||
selfHosted: true,
|
||||
dashboardUrl: UMAMI_URL,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback when Umami unreachable
|
||||
return c.json({
|
||||
trackedApps: TRACKED_APPS.length,
|
||||
cookiesSet: 0,
|
||||
scriptSize: "~2KB",
|
||||
selfHosted: true,
|
||||
dashboardUrl: UMAMI_URL,
|
||||
apps: TRACKED_APPS,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/active — proxy to Umami active visitors
|
||||
routes.get("/api/active", async (c) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/active`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
if (res.ok) return c.json(await res.json());
|
||||
} catch {}
|
||||
return c.json({ x: 0 });
|
||||
});
|
||||
|
||||
// GET /collect.js — proxy Umami tracker script
|
||||
routes.get("/collect.js", async (c) => {
|
||||
try {
|
||||
const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
const script = await res.text();
|
||||
return new Response(script, {
|
||||
headers: {
|
||||
"Content-Type": "application/javascript",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
return new Response("/* umami unavailable */", {
|
||||
headers: { "Content-Type": "application/javascript" },
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/collect — proxy Umami event collection
|
||||
routes.post("/api/collect", async (c) => {
|
||||
try {
|
||||
const body = await c.req.text();
|
||||
const res = await fetch(`${UMAMI_URL}/api/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) return c.json(await res.json());
|
||||
} catch {}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Data | rSpace`,
|
||||
moduleId: "rdata",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
|
||||
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const dataModule: RSpaceModule = {
|
||||
id: "rdata",
|
||||
name: "rData",
|
||||
icon: "\u{1F4CA}",
|
||||
description: "Privacy-first analytics for the r* ecosystem",
|
||||
routes,
|
||||
standaloneDomain: "rdata.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "analytics",
|
||||
name: "Analytics Stream",
|
||||
kind: "attention",
|
||||
description: "Page views, active visitors, and engagement metrics across rApps",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "active-users",
|
||||
name: "Active Users",
|
||||
kind: "attention",
|
||||
description: "Real-time active visitor counts",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "economic"],
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Files module — dark theme */
|
||||
folk-file-browser {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* <folk-file-browser> — file browsing, upload, share links, and memory cards.
|
||||
*
|
||||
* Attributes:
|
||||
* space="slug" — shared space to browse (default: "default")
|
||||
*/
|
||||
|
||||
class FolkFileBrowser extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "default";
|
||||
private files: any[] = [];
|
||||
private cards: any[] = [];
|
||||
private tab: "files" | "cards" = "files";
|
||||
private loading = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "default";
|
||||
this.render();
|
||||
this.loadFiles();
|
||||
this.loadCards();
|
||||
}
|
||||
|
||||
private async loadFiles() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/files?space=${encodeURIComponent(this.space)}`);
|
||||
const data = await res.json();
|
||||
this.files = data.files || [];
|
||||
} catch (e) {
|
||||
console.error("[FileBrowser] Error loading files:", e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadCards() {
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/cards?space=${encodeURIComponent(this.space)}`);
|
||||
const data = await res.json();
|
||||
this.cards = data.cards || [];
|
||||
} catch {
|
||||
this.cards = [];
|
||||
}
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/files/);
|
||||
return match ? `/${match[1]}/files` : "";
|
||||
}
|
||||
|
||||
private formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
private formatDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
private mimeIcon(mime: string): string {
|
||||
if (mime?.startsWith("image/")) return "\uD83D\uDDBC\uFE0F";
|
||||
if (mime?.startsWith("video/")) return "\uD83C\uDFA5";
|
||||
if (mime?.startsWith("audio/")) return "\uD83C\uDFB5";
|
||||
if (mime?.includes("pdf")) return "\uD83D\uDCC4";
|
||||
if (mime?.includes("zip") || mime?.includes("tar") || mime?.includes("gz")) return "\uD83D\uDCE6";
|
||||
if (mime?.includes("text") || mime?.includes("json") || mime?.includes("xml")) return "\uD83D\uDCDD";
|
||||
return "\uD83D\uDCC1";
|
||||
}
|
||||
|
||||
private cardTypeIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
note: "\uD83D\uDCDD",
|
||||
idea: "\uD83D\uDCA1",
|
||||
task: "\u2705",
|
||||
reference: "\uD83D\uDD17",
|
||||
quote: "\uD83D\uDCAC",
|
||||
};
|
||||
return icons[type] || "\uD83D\uDCDD";
|
||||
}
|
||||
|
||||
private async handleUpload(e: Event) {
|
||||
e.preventDefault();
|
||||
const form = this.shadow.querySelector("#upload-form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
const fileInput = form.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
if (!fileInput?.files?.length) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
formData.append("space", this.space);
|
||||
|
||||
const titleInput = form.querySelector('input[name="title"]') as HTMLInputElement;
|
||||
if (titleInput?.value) formData.append("title", titleInput.value);
|
||||
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/files`, { method: "POST", body: formData });
|
||||
if (res.ok) {
|
||||
form.reset();
|
||||
this.loadFiles();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Upload failed: ${err.error || "Unknown error"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Upload failed — network error");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDelete(fileId: string) {
|
||||
if (!confirm("Delete this file?")) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/files/${fileId}`, { method: "DELETE" });
|
||||
this.loadFiles();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async handleShare(fileId: string) {
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/files/${fileId}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expires_in_hours: 72 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.share?.url) {
|
||||
const fullUrl = `${window.location.origin}${this.getApiBase()}${data.share.url}`;
|
||||
await navigator.clipboard.writeText(fullUrl).catch(() => {});
|
||||
alert(`Share link copied!\n${fullUrl}\nExpires in 72 hours.`);
|
||||
}
|
||||
} catch {
|
||||
alert("Failed to create share link");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreateCard(e: Event) {
|
||||
e.preventDefault();
|
||||
const form = this.shadow.querySelector("#card-form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
const title = (form.querySelector('input[name="card-title"]') as HTMLInputElement)?.value;
|
||||
const body = (form.querySelector('textarea[name="card-body"]') as HTMLTextAreaElement)?.value;
|
||||
const cardType = (form.querySelector('select[name="card-type"]') as HTMLSelectElement)?.value;
|
||||
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/cards`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body, card_type: cardType, shared_space: this.space }),
|
||||
});
|
||||
if (res.ok) {
|
||||
form.reset();
|
||||
this.loadCards();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async handleDeleteCard(cardId: string) {
|
||||
if (!confirm("Delete this card?")) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/cards/${cardId}`, { method: "DELETE" });
|
||||
this.loadCards();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private render() {
|
||||
const filesActive = this.tab === "files";
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 20px; }
|
||||
.tab-btn {
|
||||
padding: 8px 20px; border: 1px solid #444; border-radius: 6px 6px 0 0;
|
||||
background: ${filesActive ? "#1a1a2e" : "#2a2a3e"}; color: #e0e0e0;
|
||||
cursor: pointer; font-size: 14px; border-bottom: ${filesActive ? "2px solid #64b5f6" : "none"};
|
||||
}
|
||||
.tab-btn:last-child {
|
||||
background: ${!filesActive ? "#1a1a2e" : "#2a2a3e"};
|
||||
border-bottom: ${!filesActive ? "2px solid #64b5f6" : "none"};
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: #1e1e2e; border: 1px dashed #555; border-radius: 8px;
|
||||
padding: 16px; margin-bottom: 20px;
|
||||
}
|
||||
.upload-section h3 { margin: 0 0 12px; font-size: 14px; color: #aaa; }
|
||||
.upload-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
input[type="file"] { color: #ccc; font-size: 13px; }
|
||||
input[type="text"], select, textarea {
|
||||
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
|
||||
padding: 6px 10px; border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
textarea { width: 100%; min-height: 60px; resize: vertical; }
|
||||
button {
|
||||
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
|
||||
background: #2a4a7a; color: #e0e0e0; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { background: #3a5a9a; }
|
||||
button.danger { background: #7a2a2a; }
|
||||
button.danger:hover { background: #9a3a3a; }
|
||||
|
||||
.file-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.file-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
||||
padding: 14px; transition: border-color 0.2s;
|
||||
}
|
||||
.file-card:hover { border-color: #64b5f6; }
|
||||
.file-icon { font-size: 28px; margin-bottom: 8px; }
|
||||
.file-name {
|
||||
font-size: 14px; font-weight: 500; margin-bottom: 4px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.file-meta { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
.file-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.file-actions button { padding: 3px 8px; font-size: 11px; }
|
||||
|
||||
.card-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.memory-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.memory-card:hover { border-color: #81c784; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px; }
|
||||
.card-title { font-size: 14px; font-weight: 500; }
|
||||
.card-type { font-size: 11px; background: #2a2a3e; padding: 2px 6px; border-radius: 3px; color: #aaa; }
|
||||
.card-body { font-size: 13px; color: #aaa; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px 20px; font-size: 14px; }
|
||||
.loading { text-align: center; color: #888; padding: 40px; }
|
||||
.card-form { margin-bottom: 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.card-form-row { display: flex; gap: 8px; }
|
||||
</style>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-btn" data-tab="files">\uD83D\uDCC1 Files</div>
|
||||
<div class="tab-btn" data-tab="cards">\uD83C\uDFB4 Memory Cards</div>
|
||||
</div>
|
||||
|
||||
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
|
||||
`;
|
||||
|
||||
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
const uploadForm = this.shadow.querySelector("#upload-form");
|
||||
if (uploadForm) uploadForm.addEventListener("submit", (e) => this.handleUpload(e));
|
||||
|
||||
const cardForm = this.shadow.querySelector("#card-form");
|
||||
if (cardForm) cardForm.addEventListener("submit", (e) => this.handleCreateCard(e));
|
||||
|
||||
this.shadow.querySelectorAll("[data-action]").forEach((btn) => {
|
||||
const action = (btn as HTMLElement).dataset.action!;
|
||||
const id = (btn as HTMLElement).dataset.id!;
|
||||
btn.addEventListener("click", () => {
|
||||
if (action === "delete") this.handleDelete(id);
|
||||
else if (action === "share") this.handleShare(id);
|
||||
else if (action === "delete-card") this.handleDeleteCard(id);
|
||||
else if (action === "download") {
|
||||
const base = this.getApiBase();
|
||||
window.open(`${base}/api/files/${id}/download`, "_blank");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private renderFilesTab(): string {
|
||||
return `
|
||||
<div class="upload-section">
|
||||
<h3>Upload File</h3>
|
||||
<form id="upload-form">
|
||||
<div class="upload-row">
|
||||
<input type="file" name="file" required>
|
||||
<input type="text" name="title" placeholder="Title (optional)" style="flex:1;min-width:120px">
|
||||
<button type="submit">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
${this.loading ? '<div class="loading">Loading files...</div>' : ""}
|
||||
${!this.loading && this.files.length === 0 ? '<div class="empty">No files yet. Upload one above.</div>' : ""}
|
||||
${
|
||||
!this.loading && this.files.length > 0
|
||||
? `<div class="file-grid">
|
||||
${this.files
|
||||
.map(
|
||||
(f) => `
|
||||
<div class="file-card">
|
||||
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
|
||||
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
|
||||
<div class="file-meta">${this.formatSize(f.file_size)} · ${this.formatDate(f.created_at)}</div>
|
||||
<div class="file-actions">
|
||||
<button data-action="download" data-id="${f.id}">Download</button>
|
||||
<button data-action="share" data-id="${f.id}">Share</button>
|
||||
<button class="danger" data-action="delete" data-id="${f.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCardsTab(): string {
|
||||
return `
|
||||
<div class="upload-section">
|
||||
<h3>New Memory Card</h3>
|
||||
<form id="card-form" class="card-form">
|
||||
<div class="card-form-row">
|
||||
<input type="text" name="card-title" placeholder="Title" required style="flex:1">
|
||||
<select name="card-type">
|
||||
<option value="note">Note</option>
|
||||
<option value="idea">Idea</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="reference">Reference</option>
|
||||
<option value="quote">Quote</option>
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
<textarea name="card-body" placeholder="Body (optional)"></textarea>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
${this.cards.length === 0 ? '<div class="empty">No memory cards yet.</div>' : ""}
|
||||
${
|
||||
this.cards.length > 0
|
||||
? `<div class="card-grid">
|
||||
${this.cards
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="memory-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
|
||||
<span class="card-type">${c.card_type}</span>
|
||||
</div>
|
||||
${c.body ? `<div class="card-body">${this.esc(c.body)}</div>` : ""}
|
||||
<div class="file-actions" style="margin-top:8px">
|
||||
<button class="danger" data-action="delete-card" data-id="${c.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-file-browser", FolkFileBrowser);
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
-- rFiles schema — file sharing, memory cards
|
||||
-- Inside rSpace shared DB, schema: rfiles
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rfiles.media_files (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
original_filename VARCHAR(500) NOT NULL,
|
||||
title VARCHAR(500),
|
||||
description TEXT,
|
||||
mime_type VARCHAR(200),
|
||||
file_size BIGINT DEFAULT 0,
|
||||
file_hash VARCHAR(64),
|
||||
storage_path TEXT NOT NULL,
|
||||
tags JSONB DEFAULT '[]',
|
||||
is_processed BOOLEAN DEFAULT FALSE,
|
||||
processing_error TEXT,
|
||||
uploaded_by TEXT,
|
||||
shared_space TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_hash ON rfiles.media_files (file_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_mime ON rfiles.media_files (mime_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_space ON rfiles.media_files (shared_space);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_created ON rfiles.media_files (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rfiles.public_shares (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
token VARCHAR(48) NOT NULL UNIQUE,
|
||||
media_file_id UUID NOT NULL REFERENCES rfiles.media_files(id) ON DELETE CASCADE,
|
||||
created_by TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_downloads INTEGER,
|
||||
download_count INTEGER DEFAULT 0,
|
||||
is_password_protected BOOLEAN DEFAULT FALSE,
|
||||
password_hash TEXT,
|
||||
note VARCHAR(500),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_token ON rfiles.public_shares (token);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_active ON rfiles.public_shares (is_active) WHERE is_active = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_expires ON rfiles.public_shares (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rfiles.memory_cards (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
shared_space TEXT NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
body TEXT,
|
||||
card_type VARCHAR(20) DEFAULT 'note' CHECK (card_type IN ('note', 'idea', 'task', 'reference', 'quote')),
|
||||
tags JSONB DEFAULT '[]',
|
||||
position INTEGER DEFAULT 0,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_space ON rfiles.memory_cards (shared_space);
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_type ON rfiles.memory_cards (card_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_position ON rfiles.memory_cards (position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rfiles.access_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
media_file_id UUID REFERENCES rfiles.media_files(id) ON DELETE CASCADE,
|
||||
share_id UUID REFERENCES rfiles.public_shares(id) ON DELETE SET NULL,
|
||||
accessed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
ip_address INET,
|
||||
user_agent VARCHAR(500),
|
||||
access_type VARCHAR(20) DEFAULT 'download' CHECK (access_type IN ('download', 'view', 'share_created', 'share_revoked'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_accessed ON rfiles.access_logs (accessed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_type ON rfiles.access_logs (access_type);
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
/**
|
||||
* Files module — file sharing, public share links, memory cards.
|
||||
* Ported from rfiles-online (Django → Bun/Hono).
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { mkdir, writeFile, unlink } from "node:fs/promises";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const FILES_DIR = process.env.FILES_DIR || "/data/files";
|
||||
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
|
||||
|
||||
// ── DB initialization ──
|
||||
async function initDB() {
|
||||
try {
|
||||
await sql.unsafe(SCHEMA_SQL);
|
||||
console.log("[Files] DB schema initialized");
|
||||
} catch (e: any) {
|
||||
console.error("[Files] DB init error:", e.message);
|
||||
}
|
||||
}
|
||||
initDB();
|
||||
|
||||
// ── Cleanup timers (replace Celery) ──
|
||||
// Deactivate expired shares every hour
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const result = await sql.unsafe(
|
||||
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE is_active = TRUE AND expires_at IS NOT NULL AND expires_at < NOW()"
|
||||
);
|
||||
if ((result as any).count > 0) console.log(`[Files] Deactivated ${(result as any).count} expired shares`);
|
||||
} catch (e: any) { console.error("[Files] Cleanup error:", e.message); }
|
||||
}, 3600_000);
|
||||
|
||||
// Delete access logs older than 90 days, daily
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await sql.unsafe("DELETE FROM rfiles.access_logs WHERE accessed_at < NOW() - INTERVAL '90 days'");
|
||||
} catch (e: any) { console.error("[Files] Log cleanup error:", e.message); }
|
||||
}, 86400_000);
|
||||
|
||||
// ── Helpers ──
|
||||
function generateToken(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
async function hashPassword(pw: string): Promise<string> {
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(pw + "rfiles-salt");
|
||||
return hasher.digest("hex");
|
||||
}
|
||||
|
||||
async function computeFileHash(buffer: ArrayBuffer): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(Buffer.from(buffer));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
// ── File upload ──
|
||||
routes.post("/api/files", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return c.json({ error: "file is required" }, 400);
|
||||
|
||||
const space = c.req.param("space") || formData.get("space")?.toString() || "default";
|
||||
const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, "");
|
||||
const description = formData.get("description")?.toString() || "";
|
||||
const tags = formData.get("tags")?.toString() || "[]";
|
||||
const uploadedBy = claims.sub;
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileHash = await computeFileHash(buffer);
|
||||
const now = new Date();
|
||||
const datePath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}`;
|
||||
const fileId = crypto.randomUUID();
|
||||
const storagePath = `uploads/${datePath}/${fileId}/${file.name}`;
|
||||
const fullPath = resolve(FILES_DIR, storagePath);
|
||||
|
||||
await mkdir(resolve(fullPath, ".."), { recursive: true });
|
||||
await writeFile(fullPath, Buffer.from(buffer));
|
||||
|
||||
const [row] = await sql.unsafe(
|
||||
`INSERT INTO rfiles.media_files (original_filename, title, description, mime_type, file_size, file_hash, storage_path, tags, uploaded_by, shared_space)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) RETURNING *`,
|
||||
[file.name, title, description, file.type || "application/octet-stream", file.size, fileHash, storagePath, tags, uploadedBy, space]
|
||||
);
|
||||
|
||||
return c.json({ file: row }, 201);
|
||||
});
|
||||
|
||||
// ── File listing ──
|
||||
routes.get("/api/files", async (c) => {
|
||||
const space = c.req.param("space") || c.req.query("space") || "default";
|
||||
const mimeType = c.req.query("mime_type");
|
||||
const limit = Math.min(Number(c.req.query("limit")) || 50, 200);
|
||||
const offset = Number(c.req.query("offset")) || 0;
|
||||
|
||||
let query = "SELECT * FROM rfiles.media_files WHERE shared_space = $1";
|
||||
const params: any[] = [space];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (mimeType) {
|
||||
query += ` AND mime_type LIKE $${paramIdx}`;
|
||||
params.push(`${mimeType}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const rows = await sql.unsafe(query, params);
|
||||
const [{ count }] = await sql.unsafe(
|
||||
"SELECT COUNT(*) as count FROM rfiles.media_files WHERE shared_space = $1",
|
||||
[space]
|
||||
);
|
||||
|
||||
return c.json({ files: rows, total: Number(count), limit, offset });
|
||||
});
|
||||
|
||||
// ── File download ──
|
||||
routes.get("/api/files/:id/download", async (c) => {
|
||||
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
|
||||
if (!file) return c.json({ error: "File not found" }, 404);
|
||||
|
||||
const fullPath = resolve(FILES_DIR, file.storage_path);
|
||||
const bunFile = Bun.file(fullPath);
|
||||
if (!await bunFile.exists()) return c.json({ error: "File missing from storage" }, 404);
|
||||
|
||||
return new Response(bunFile, {
|
||||
headers: {
|
||||
"Content-Type": file.mime_type || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${file.original_filename}"`,
|
||||
"Content-Length": String(file.file_size),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── File detail ──
|
||||
routes.get("/api/files/:id", async (c) => {
|
||||
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
|
||||
if (!file) return c.json({ error: "File not found" }, 404);
|
||||
return c.json({ file });
|
||||
});
|
||||
|
||||
// ── File delete ──
|
||||
routes.delete("/api/files/:id", async (c) => {
|
||||
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
|
||||
if (!file) return c.json({ error: "File not found" }, 404);
|
||||
|
||||
try { await unlink(resolve(FILES_DIR, file.storage_path)); } catch {}
|
||||
await sql.unsafe("DELETE FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
|
||||
return c.json({ message: "Deleted" });
|
||||
});
|
||||
|
||||
// ── Create share link ──
|
||||
routes.post("/api/files/:id/share", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
|
||||
if (!file) return c.json({ error: "File not found" }, 404);
|
||||
if (file.uploaded_by && file.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
|
||||
|
||||
const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>();
|
||||
const token = generateToken();
|
||||
const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null;
|
||||
const createdBy = claims.sub;
|
||||
|
||||
let passwordHash: string | null = null;
|
||||
let isPasswordProtected = false;
|
||||
if (body.password) {
|
||||
passwordHash = await hashPassword(body.password);
|
||||
isPasswordProtected = true;
|
||||
}
|
||||
|
||||
const [share] = await sql.unsafe(
|
||||
`INSERT INTO rfiles.public_shares (token, media_file_id, created_by, expires_at, max_downloads, is_password_protected, password_hash, note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[token, file.id, createdBy, expiresAt, body.max_downloads || null, isPasswordProtected, passwordHash, body.note || null]
|
||||
);
|
||||
|
||||
await sql.unsafe(
|
||||
"INSERT INTO rfiles.access_logs (media_file_id, share_id, access_type) VALUES ($1, $2, 'share_created')",
|
||||
[file.id, share.id]
|
||||
);
|
||||
|
||||
return c.json({ share: { ...share, url: `/s/${token}` } }, 201);
|
||||
});
|
||||
|
||||
// ── List shares for a file ──
|
||||
routes.get("/api/files/:id/shares", async (c) => {
|
||||
const rows = await sql.unsafe(
|
||||
"SELECT * FROM rfiles.public_shares WHERE media_file_id = $1 ORDER BY created_at DESC",
|
||||
[c.req.param("id")]
|
||||
);
|
||||
return c.json({ shares: rows });
|
||||
});
|
||||
|
||||
// ── Revoke share ──
|
||||
routes.post("/api/shares/:shareId/revoke", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const [share] = await sql.unsafe(
|
||||
"SELECT s.*, f.uploaded_by FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.id = $1",
|
||||
[c.req.param("shareId")]
|
||||
);
|
||||
if (!share) return c.json({ error: "Share not found" }, 404);
|
||||
if (share.uploaded_by && share.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
|
||||
|
||||
const [revoked] = await sql.unsafe(
|
||||
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *",
|
||||
[c.req.param("shareId")]
|
||||
);
|
||||
return c.json({ message: "Revoked", share: revoked });
|
||||
});
|
||||
|
||||
// ── Public share download ──
|
||||
routes.get("/s/:token", async (c) => {
|
||||
const [share] = await sql.unsafe(
|
||||
`SELECT s.*, f.storage_path, f.mime_type, f.original_filename, f.file_size
|
||||
FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id
|
||||
WHERE s.token = $1`,
|
||||
[c.req.param("token")]
|
||||
);
|
||||
if (!share) return c.json({ error: "Share not found" }, 404);
|
||||
if (!share.is_active) return c.json({ error: "Share has been revoked" }, 410);
|
||||
if (share.expires_at && new Date(share.expires_at) < new Date()) return c.json({ error: "Share has expired" }, 410);
|
||||
if (share.max_downloads && share.download_count >= share.max_downloads) return c.json({ error: "Download limit reached" }, 410);
|
||||
|
||||
if (share.is_password_protected) {
|
||||
const pw = c.req.query("password");
|
||||
if (!pw) return c.json({ error: "Password required", is_password_protected: true }, 401);
|
||||
const hash = await hashPassword(pw);
|
||||
if (hash !== share.password_hash) return c.json({ error: "Invalid password" }, 401);
|
||||
}
|
||||
|
||||
await sql.unsafe("UPDATE rfiles.public_shares SET download_count = download_count + 1 WHERE id = $1", [share.id]);
|
||||
const ip = c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() || c.req.header("X-Real-IP") || null;
|
||||
const ua = c.req.header("User-Agent") || "";
|
||||
await sql.unsafe(
|
||||
"INSERT INTO rfiles.access_logs (media_file_id, share_id, ip_address, user_agent, access_type) VALUES ($1, $2, $3, $4, 'download')",
|
||||
[share.media_file_id, share.id, ip, ua.slice(0, 500)]
|
||||
);
|
||||
|
||||
const fullPath = resolve(FILES_DIR, share.storage_path);
|
||||
const bunFile = Bun.file(fullPath);
|
||||
if (!await bunFile.exists()) return c.json({ error: "File missing" }, 404);
|
||||
|
||||
return new Response(bunFile, {
|
||||
headers: {
|
||||
"Content-Type": share.mime_type || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${share.original_filename}"`,
|
||||
"Content-Length": String(share.file_size),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── Share info (public) ──
|
||||
routes.get("/s/:token/info", async (c) => {
|
||||
const [share] = await sql.unsafe(
|
||||
`SELECT s.is_password_protected, s.is_active, s.expires_at, s.max_downloads, s.download_count, s.note,
|
||||
f.original_filename, f.mime_type, f.file_size
|
||||
FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id
|
||||
WHERE s.token = $1`,
|
||||
[c.req.param("token")]
|
||||
);
|
||||
if (!share) return c.json({ error: "Share not found" }, 404);
|
||||
|
||||
const isValid = share.is_active &&
|
||||
(!share.expires_at || new Date(share.expires_at) > new Date()) &&
|
||||
(!share.max_downloads || share.download_count < share.max_downloads);
|
||||
|
||||
return c.json({
|
||||
is_password_protected: share.is_password_protected,
|
||||
is_valid: isValid,
|
||||
expires_at: share.expires_at,
|
||||
downloads_remaining: share.max_downloads ? share.max_downloads - share.download_count : null,
|
||||
file_info: { filename: share.original_filename, mime_type: share.mime_type, size: share.file_size },
|
||||
note: share.note,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memory Cards CRUD ──
|
||||
routes.post("/api/cards", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>();
|
||||
const space = c.req.param("space") || body.shared_space || "default";
|
||||
const createdBy = claims.sub;
|
||||
|
||||
const [card] = await sql.unsafe(
|
||||
`INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`,
|
||||
[space, body.title, body.body || "", body.card_type || "note", JSON.stringify(body.tags || []), createdBy]
|
||||
);
|
||||
return c.json({ card }, 201);
|
||||
});
|
||||
|
||||
routes.get("/api/cards", async (c) => {
|
||||
const space = c.req.param("space") || c.req.query("space") || "default";
|
||||
const cardType = c.req.query("type");
|
||||
const limit = Math.min(Number(c.req.query("limit")) || 50, 200);
|
||||
|
||||
let query = "SELECT * FROM rfiles.memory_cards WHERE shared_space = $1";
|
||||
const params: any[] = [space];
|
||||
if (cardType) { query += " AND card_type = $2"; params.push(cardType); }
|
||||
query += " ORDER BY position, created_at DESC LIMIT $" + (params.length + 1);
|
||||
params.push(limit);
|
||||
|
||||
const rows = await sql.unsafe(query, params);
|
||||
return c.json({ cards: rows, total: rows.length });
|
||||
});
|
||||
|
||||
routes.patch("/api/cards/:id", async (c) => {
|
||||
const body = await c.req.json<{ title?: string; body?: string; card_type?: string; tags?: string[]; position?: number }>();
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (body.title !== undefined) { sets.push(`title = $${idx}`); params.push(body.title); idx++; }
|
||||
if (body.body !== undefined) { sets.push(`body = $${idx}`); params.push(body.body); idx++; }
|
||||
if (body.card_type !== undefined) { sets.push(`card_type = $${idx}`); params.push(body.card_type); idx++; }
|
||||
if (body.tags !== undefined) { sets.push(`tags = $${idx}::jsonb`); params.push(JSON.stringify(body.tags)); idx++; }
|
||||
if (body.position !== undefined) { sets.push(`position = $${idx}`); params.push(body.position); idx++; }
|
||||
|
||||
if (sets.length === 0) return c.json({ error: "No fields to update" }, 400);
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(c.req.param("id"));
|
||||
|
||||
const [card] = await sql.unsafe(
|
||||
`UPDATE rfiles.memory_cards SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
if (!card) return c.json({ error: "Card not found" }, 404);
|
||||
return c.json({ card });
|
||||
});
|
||||
|
||||
routes.delete("/api/cards/:id", async (c) => {
|
||||
const [card] = await sql.unsafe("DELETE FROM rfiles.memory_cards WHERE id = $1 RETURNING id", [c.req.param("id")]);
|
||||
if (!card) return c.json({ error: "Card not found" }, 404);
|
||||
return c.json({ message: "Deleted" });
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Files | rSpace`,
|
||||
moduleId: "rfiles",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
|
||||
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const filesModule: RSpaceModule = {
|
||||
id: "rfiles",
|
||||
name: "rFiles",
|
||||
icon: "\uD83D\uDCC1",
|
||||
description: "File sharing, share links, and memory cards",
|
||||
routes,
|
||||
standaloneDomain: "rfiles.online",
|
||||
};
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* <folk-forum-dashboard> — Discourse instance provisioner dashboard.
|
||||
*
|
||||
* Lists user's forum instances, shows provisioning status, and allows
|
||||
* creating new instances.
|
||||
*/
|
||||
|
||||
class FolkForumDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private instances: any[] = [];
|
||||
private selectedInstance: any = null;
|
||||
private selectedLogs: any[] = [];
|
||||
private view: "list" | "detail" | "create" = "list";
|
||||
private loading = false;
|
||||
private pollTimer: number | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.loadInstances();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/forum/);
|
||||
return match ? `/${match[1]}/forum` : "";
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("encryptid_session");
|
||||
if (token) {
|
||||
try {
|
||||
const parsed = JSON.parse(token);
|
||||
return { "X-User-DID": parsed.did || "" };
|
||||
} catch {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async loadInstances() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/instances`, { headers: this.getAuthHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.instances = data.instances || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ForumDashboard] Error:", e);
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadInstanceDetail(id: string) {
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.selectedInstance = data.instance;
|
||||
this.selectedLogs = data.logs || [];
|
||||
this.view = "detail";
|
||||
this.render();
|
||||
|
||||
// Poll if provisioning
|
||||
const active = ["pending", "provisioning", "installing", "configuring"];
|
||||
if (active.includes(this.selectedInstance.status)) {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.pollTimer = setInterval(() => this.loadInstanceDetail(id), 5000) as any;
|
||||
} else {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
const form = this.shadow.querySelector("#create-form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
const name = (form.querySelector('[name="name"]') as HTMLInputElement)?.value;
|
||||
const subdomain = (form.querySelector('[name="subdomain"]') as HTMLInputElement)?.value;
|
||||
const adminEmail = (form.querySelector('[name="admin_email"]') as HTMLInputElement)?.value;
|
||||
const region = (form.querySelector('[name="region"]') as HTMLSelectElement)?.value;
|
||||
const size = (form.querySelector('[name="size"]') as HTMLSelectElement)?.value;
|
||||
|
||||
if (!name || !subdomain || !adminEmail) return;
|
||||
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({ name, subdomain, admin_email: adminEmail, region, size }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.view = "detail";
|
||||
this.loadInstanceDetail(data.instance.id);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Failed to create instance");
|
||||
}
|
||||
} catch {
|
||||
alert("Network error");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDestroy(id: string) {
|
||||
if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return;
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/instances/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
this.view = "list";
|
||||
this.loadInstances();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private statusBadge(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
pending: "#ffa726",
|
||||
provisioning: "#42a5f5",
|
||||
installing: "#42a5f5",
|
||||
configuring: "#42a5f5",
|
||||
active: "#66bb6a",
|
||||
error: "#ef5350",
|
||||
destroying: "#ffa726",
|
||||
destroyed: "#888",
|
||||
};
|
||||
const color = colors[status] || "#888";
|
||||
const pulse = ["provisioning", "installing", "configuring"].includes(status)
|
||||
? "animation: pulse 1.5s ease-in-out infinite;"
|
||||
: "";
|
||||
return `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${color}22;color:${color};border:1px solid ${color}44;${pulse}">${status}</span>`;
|
||||
}
|
||||
|
||||
private logStepIcon(status: string): string {
|
||||
if (status === "success") return "\u2705";
|
||||
if (status === "error") return "\u274C";
|
||||
if (status === "running") return "\u23F3";
|
||||
return "\u23ED\uFE0F";
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
||||
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__btn:hover { background: #6366f1; }
|
||||
button {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);
|
||||
background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
||||
button.danger { background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.3); color: #ef4444; }
|
||||
button.danger:hover { background: rgba(239,68,68,0.25); }
|
||||
|
||||
input, select {
|
||||
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
|
||||
padding: 8px 12px; border-radius: 4px; font-size: 13px; width: 100%;
|
||||
}
|
||||
label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; }
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
.instance-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.instance-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
||||
padding: 16px; cursor: pointer; transition: border-color 0.2s;
|
||||
}
|
||||
.instance-card:hover { border-color: #64b5f6; }
|
||||
.instance-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.instance-name { font-size: 16px; font-weight: 600; }
|
||||
.instance-meta { font-size: 12px; color: #888; }
|
||||
|
||||
.detail-panel { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 20px; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; }
|
||||
.detail-title { font-size: 20px; font-weight: 600; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
|
||||
.detail-item label { font-size: 11px; color: #888; text-transform: uppercase; }
|
||||
.detail-item .value { font-size: 14px; margin-top: 2px; }
|
||||
|
||||
.logs-section h3 { font-size: 14px; color: #aaa; margin: 0 0 12px; }
|
||||
.log-entry { display: flex; gap: 10px; align-items: start; padding: 8px 0; border-bottom: 1px solid #2a2a3e; }
|
||||
.log-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.log-step { font-size: 13px; font-weight: 500; }
|
||||
.log-msg { font-size: 12px; color: #888; margin-top: 2px; }
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px 20px; }
|
||||
.loading { text-align: center; color: #888; padding: 40px; }
|
||||
|
||||
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.price-card {
|
||||
background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 14px;
|
||||
text-align: center; cursor: pointer; transition: border-color 0.2s;
|
||||
}
|
||||
.price-card:hover, .price-card.selected { border-color: #64b5f6; }
|
||||
.price-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
|
||||
.price-cost { font-size: 18px; color: #64b5f6; font-weight: 700; }
|
||||
.price-specs { font-size: 11px; color: #888; margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
${this.view === "list" ? this.renderList() : ""}
|
||||
${this.view === "detail" ? this.renderDetail() : ""}
|
||||
${this.view === "create" ? this.renderCreate() : ""}
|
||||
`;
|
||||
|
||||
this.attachEvents();
|
||||
}
|
||||
|
||||
private renderList(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Forum Instances</span>
|
||||
<button class="rapp-nav__btn" data-action="show-create">+ New Forum</button>
|
||||
</div>
|
||||
|
||||
${this.loading ? '<div class="loading">Loading...</div>' : ""}
|
||||
${!this.loading && this.instances.length === 0 ? '<div class="empty">No forum instances yet. Deploy your first Discourse forum!</div>' : ""}
|
||||
|
||||
<div class="instance-list">
|
||||
${this.instances.map((inst) => `
|
||||
<div class="instance-card" data-action="detail" data-id="${inst.id}">
|
||||
<div class="instance-header">
|
||||
<span class="instance-name">${this.esc(inst.name)}</span>
|
||||
${this.statusBadge(inst.status)}
|
||||
</div>
|
||||
<div class="instance-meta">
|
||||
${inst.domain} · ${inst.region} · ${inst.size}
|
||||
${inst.vps_ip ? ` · ${inst.vps_ip}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDetail(): string {
|
||||
const inst = this.selectedInstance;
|
||||
if (!inst) return "";
|
||||
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-action="back">\u2190 Forums</button>
|
||||
<span class="rapp-nav__title">${this.esc(inst.name)}</span>
|
||||
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="detail-panel">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-title">${this.esc(inst.name)}</div>
|
||||
<div style="margin-top:4px">${this.statusBadge(inst.status)}</div>
|
||||
</div>
|
||||
${inst.status === "active" ? `<a href="https://${inst.domain}" target="_blank" style="color:#64b5f6;font-size:13px">\u2197 Open Forum</a>` : ""}
|
||||
</div>
|
||||
|
||||
${inst.error_message ? `<div style="background:#7a2a2a33;border:1px solid #7a2a2a;padding:10px;border-radius:6px;margin-bottom:16px;font-size:13px;color:#ef5350">${this.esc(inst.error_message)}</div>` : ""}
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><label>Domain</label><div class="value">${inst.domain}</div></div>
|
||||
<div class="detail-item"><label>IP Address</label><div class="value">${inst.vps_ip || "—"}</div></div>
|
||||
<div class="detail-item"><label>Region</label><div class="value">${inst.region}</div></div>
|
||||
<div class="detail-item"><label>Server Size</label><div class="value">${inst.size}</div></div>
|
||||
<div class="detail-item"><label>Admin Email</label><div class="value">${inst.admin_email || "—"}</div></div>
|
||||
<div class="detail-item"><label>SSL</label><div class="value">${inst.ssl_provisioned ? "\u2705 Active" : "\u23F3 Pending"}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="logs-section">
|
||||
<h3>Provision Log</h3>
|
||||
${this.selectedLogs.length === 0 ? '<div style="color:#666;font-size:13px">No logs yet</div>' : ""}
|
||||
${this.selectedLogs.map((log) => `
|
||||
<div class="log-entry">
|
||||
<span class="log-icon">${this.logStepIcon(log.status)}</span>
|
||||
<div>
|
||||
<div class="log-step">${this.formatStep(log.step)}</div>
|
||||
<div class="log-msg">${this.esc(log.message || "")}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCreate(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-action="back">\u2190 Forums</button>
|
||||
<span class="rapp-nav__title">Deploy New Forum</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel">
|
||||
<h2 style="margin:0 0 16px;font-size:18px">Deploy New Forum</h2>
|
||||
|
||||
<div class="pricing">
|
||||
<div class="price-card selected" data-size="cx22">
|
||||
<div class="price-name">Starter</div>
|
||||
<div class="price-cost">\u20AC3.79/mo</div>
|
||||
<div class="price-specs">2 vCPU · 4 GB · ~500 users</div>
|
||||
</div>
|
||||
<div class="price-card" data-size="cx32">
|
||||
<div class="price-name">Standard</div>
|
||||
<div class="price-cost">\u20AC6.80/mo</div>
|
||||
<div class="price-specs">4 vCPU · 8 GB · ~2000 users</div>
|
||||
</div>
|
||||
<div class="price-card" data-size="cx42">
|
||||
<div class="price-name">Performance</div>
|
||||
<div class="price-cost">\u20AC13.80/mo</div>
|
||||
<div class="price-specs">8 vCPU · 16 GB · ~10k users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="create-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Forum Name</label>
|
||||
<input name="name" placeholder="My Community" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subdomain</label>
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
<input name="subdomain" placeholder="my-community" required style="flex:1">
|
||||
<span style="font-size:12px;color:#888;white-space:nowrap">.rforum.online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Admin Email</label>
|
||||
<input name="admin_email" type="email" placeholder="admin@example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Region</label>
|
||||
<select name="region">
|
||||
<option value="nbg1">Nuremberg (EU)</option>
|
||||
<option value="fsn1">Falkenstein (EU)</option>
|
||||
<option value="hel1">Helsinki (EU)</option>
|
||||
<option value="ash">Ashburn (US East)</option>
|
||||
<option value="hil">Hillsboro (US West)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="size" value="cx22">
|
||||
|
||||
<button type="submit" style="width:100%;padding:10px;font-size:14px;margin-top:8px">
|
||||
Deploy Forum
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachEvents() {
|
||||
this.shadow.querySelectorAll("[data-action]").forEach((el) => {
|
||||
const action = (el as HTMLElement).dataset.action!;
|
||||
const id = (el as HTMLElement).dataset.id;
|
||||
el.addEventListener("click", () => {
|
||||
if (action === "show-create") { this.view = "create"; this.render(); }
|
||||
else if (action === "back") {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.view = "list"; this.loadInstances();
|
||||
}
|
||||
else if (action === "detail" && id) { this.loadInstanceDetail(id); }
|
||||
else if (action === "destroy" && id) { this.handleDestroy(id); }
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll(".price-card").forEach((card) => {
|
||||
card.addEventListener("click", () => {
|
||||
this.shadow.querySelectorAll(".price-card").forEach((c) => c.classList.remove("selected"));
|
||||
card.classList.add("selected");
|
||||
const sizeInput = this.shadow.querySelector('[name="size"]') as HTMLInputElement;
|
||||
if (sizeInput) sizeInput.value = (card as HTMLElement).dataset.size || "cx22";
|
||||
});
|
||||
});
|
||||
|
||||
const form = this.shadow.querySelector("#create-form");
|
||||
if (form) form.addEventListener("submit", (e) => this.handleCreate(e));
|
||||
}
|
||||
|
||||
private formatStep(step: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
create_vps: "Create Server",
|
||||
wait_ready: "Wait for Boot",
|
||||
configure_dns: "Configure DNS",
|
||||
install_discourse: "Install Discourse",
|
||||
verify_live: "Verify Live",
|
||||
};
|
||||
return labels[step] || step;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-forum-dashboard", FolkForumDashboard);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Forum module — dark theme */
|
||||
folk-forum-dashboard {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
-- rForum schema — Discourse cloud provisioning
|
||||
-- Inside rSpace shared DB, schema: rforum
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rforum.users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
did TEXT UNIQUE,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rforum.instances (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES rforum.users(id),
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT UNIQUE NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','provisioning','installing','configuring','active','error','destroying','destroyed')),
|
||||
error_message TEXT,
|
||||
discourse_version TEXT DEFAULT 'stable',
|
||||
provider TEXT DEFAULT 'hetzner' CHECK (provider IN ('hetzner','digitalocean')),
|
||||
vps_id TEXT,
|
||||
vps_ip TEXT,
|
||||
region TEXT DEFAULT 'nbg1',
|
||||
size TEXT DEFAULT 'cx22',
|
||||
admin_email TEXT,
|
||||
smtp_config JSONB DEFAULT '{}',
|
||||
dns_record_id TEXT,
|
||||
ssl_provisioned BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
provisioned_at TIMESTAMPTZ,
|
||||
destroyed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_user ON rforum.instances (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_status ON rforum.instances (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_domain ON rforum.instances (domain);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rforum.provision_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
instance_id UUID NOT NULL REFERENCES rforum.instances(id) ON DELETE CASCADE,
|
||||
step TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running','success','error','skipped')),
|
||||
message TEXT,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_instance ON rforum.provision_logs (instance_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_step ON rforum.provision_logs (step);
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Cloud-init user data generator for Discourse instances.
|
||||
*/
|
||||
|
||||
export interface DiscourseConfig {
|
||||
hostname: string;
|
||||
adminEmail: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpPassword?: string;
|
||||
}
|
||||
|
||||
export function generateCloudInit(config: DiscourseConfig): string {
|
||||
const smtpHost = config.smtpHost || "mail.rmail.online";
|
||||
const smtpPort = config.smtpPort || 587;
|
||||
const smtpUser = config.smtpUser || `noreply@rforum.online`;
|
||||
const smtpPassword = config.smtpPassword || "";
|
||||
|
||||
return `#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Swap
|
||||
fallocate -l 2G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
|
||||
# Install Docker
|
||||
apt-get update
|
||||
apt-get install -y git docker.io docker-compose
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
# Clone Discourse
|
||||
git clone https://github.com/discourse/discourse_docker.git /var/discourse
|
||||
cd /var/discourse
|
||||
|
||||
# Write app.yml
|
||||
cat > containers/app.yml << 'APPYML'
|
||||
templates:
|
||||
- "templates/postgres.template.yml"
|
||||
- "templates/redis.template.yml"
|
||||
- "templates/web.template.yml"
|
||||
- "templates/web.ssl.template.yml"
|
||||
- "templates/web.letsencrypt.ssl.template.yml"
|
||||
|
||||
expose:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
params:
|
||||
db_default_text_search_config: "pg_catalog.english"
|
||||
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
DISCOURSE_DEFAULT_LOCALE: en
|
||||
DISCOURSE_HOSTNAME: '${config.hostname}'
|
||||
DISCOURSE_DEVELOPER_EMAILS: '${config.adminEmail}'
|
||||
DISCOURSE_SMTP_ADDRESS: '${smtpHost}'
|
||||
DISCOURSE_SMTP_PORT: ${smtpPort}
|
||||
DISCOURSE_SMTP_USER_NAME: '${smtpUser}'
|
||||
DISCOURSE_SMTP_PASSWORD: '${smtpPassword}'
|
||||
DISCOURSE_SMTP_ENABLE_START_TLS: true
|
||||
LETSENCRYPT_ACCOUNT_EMAIL: '${config.adminEmail}'
|
||||
|
||||
volumes:
|
||||
- volume:
|
||||
host: /var/discourse/shared/standalone
|
||||
guest: /shared
|
||||
- volume:
|
||||
host: /var/discourse/shared/standalone/log/var-log
|
||||
guest: /var/log
|
||||
APPYML
|
||||
|
||||
# Bootstrap and start
|
||||
./launcher bootstrap app
|
||||
./launcher start app
|
||||
`;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Cloudflare DNS management for forum subdomains.
|
||||
*/
|
||||
|
||||
const CF_API = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const token = process.env.CLOUDFLARE_API_TOKEN;
|
||||
if (!token) throw new Error("CLOUDFLARE_API_TOKEN not set");
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDNSRecord(
|
||||
subdomain: string,
|
||||
ip: string,
|
||||
): Promise<{ recordId: string } | null> {
|
||||
const zoneId = process.env.CLOUDFLARE_FORUM_ZONE_ID;
|
||||
if (!zoneId) throw new Error("CLOUDFLARE_FORUM_ZONE_ID not set");
|
||||
|
||||
const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
type: "A",
|
||||
name: `${subdomain}.rforum.online`,
|
||||
content: ip,
|
||||
ttl: 300,
|
||||
proxied: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("[Forum DNS] Failed to create record:", await res.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { recordId: data.result.id };
|
||||
}
|
||||
|
||||
export async function deleteDNSRecord(recordId: string): Promise<boolean> {
|
||||
const zoneId = process.env.CLOUDFLARE_FORUM_ZONE_ID;
|
||||
if (!zoneId) return false;
|
||||
|
||||
const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${recordId}`, {
|
||||
method: "DELETE",
|
||||
headers: headers(),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Hetzner Cloud API client for VPS provisioning.
|
||||
*/
|
||||
|
||||
const HETZNER_API = "https://api.hetzner.cloud/v1";
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const token = process.env.HETZNER_API_TOKEN;
|
||||
if (!token) throw new Error("HETZNER_API_TOKEN not set");
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
export interface HetznerServer {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
public_net: {
|
||||
ipv4: { ip: string };
|
||||
ipv6: { ip: string };
|
||||
};
|
||||
server_type: { name: string };
|
||||
datacenter: { name: string };
|
||||
}
|
||||
|
||||
export async function createServer(opts: {
|
||||
name: string;
|
||||
serverType: string;
|
||||
region: string;
|
||||
userData: string;
|
||||
}): Promise<{ serverId: string; ip: string }> {
|
||||
const res = await fetch(`${HETZNER_API}/servers`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
server_type: opts.serverType,
|
||||
location: opts.region,
|
||||
image: "ubuntu-22.04",
|
||||
user_data: opts.userData,
|
||||
start_after_create: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Hetzner create failed: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
serverId: String(data.server.id),
|
||||
ip: data.server.public_net.ipv4.ip,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getServer(serverId: string): Promise<HetznerServer | null> {
|
||||
const res = await fetch(`${HETZNER_API}/servers/${serverId}`, { headers: headers() });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.server;
|
||||
}
|
||||
|
||||
export async function deleteServer(serverId: string): Promise<boolean> {
|
||||
const res = await fetch(`${HETZNER_API}/servers/${serverId}`, {
|
||||
method: "DELETE",
|
||||
headers: headers(),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function serverAction(serverId: string, action: "poweron" | "poweroff" | "reboot"): Promise<boolean> {
|
||||
const res = await fetch(`${HETZNER_API}/servers/${serverId}/actions/${action}`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Forum instance provisioner — async pipeline that creates a VPS,
|
||||
* configures DNS, installs Discourse, and verifies it's live.
|
||||
*/
|
||||
|
||||
import { sql } from "../../../shared/db/pool";
|
||||
import { createServer, getServer, deleteServer } from "./hetzner";
|
||||
import { createDNSRecord, deleteDNSRecord } from "./dns";
|
||||
import { generateCloudInit, type DiscourseConfig } from "./cloud-init";
|
||||
|
||||
type StepStatus = "running" | "success" | "error" | "skipped";
|
||||
|
||||
async function logStep(
|
||||
instanceId: string,
|
||||
step: string,
|
||||
status: StepStatus,
|
||||
message: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
) {
|
||||
if (status === "running") {
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rforum.provision_logs (instance_id, step, status, message, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)`,
|
||||
[instanceId, step, status, message, JSON.stringify(metadata)],
|
||||
);
|
||||
} else {
|
||||
await sql.unsafe(
|
||||
`UPDATE rforum.provision_logs SET status = $1, message = $2, metadata = $3::jsonb, completed_at = NOW()
|
||||
WHERE instance_id = $4 AND step = $5 AND status = 'running'`,
|
||||
[status, message, JSON.stringify(metadata), instanceId, step],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateInstance(instanceId: string, fields: Record<string, unknown>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
sets.push(`${key} = $${idx}`);
|
||||
params.push(val);
|
||||
idx++;
|
||||
}
|
||||
sets.push("updated_at = NOW()");
|
||||
params.push(instanceId);
|
||||
await sql.unsafe(`UPDATE rforum.instances SET ${sets.join(", ")} WHERE id = $${idx}`, params);
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function provisionInstance(instanceId: string) {
|
||||
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]);
|
||||
if (!instance) throw new Error("Instance not found");
|
||||
|
||||
await updateInstance(instanceId, { status: "provisioning" });
|
||||
|
||||
try {
|
||||
// Step 1: Create VPS
|
||||
await logStep(instanceId, "create_vps", "running", "Creating VPS...");
|
||||
const config: DiscourseConfig = {
|
||||
hostname: instance.domain,
|
||||
adminEmail: instance.admin_email,
|
||||
...(instance.smtp_config?.host ? {
|
||||
smtpHost: instance.smtp_config.host,
|
||||
smtpPort: instance.smtp_config.port,
|
||||
smtpUser: instance.smtp_config.user,
|
||||
smtpPassword: instance.smtp_config.password,
|
||||
} : {}),
|
||||
};
|
||||
const userData = generateCloudInit(config);
|
||||
const { serverId, ip } = await createServer({
|
||||
name: `discourse-${instance.domain.replace(/\./g, "-")}`,
|
||||
serverType: instance.size,
|
||||
region: instance.region,
|
||||
userData,
|
||||
});
|
||||
await updateInstance(instanceId, { vps_id: serverId, vps_ip: ip });
|
||||
await logStep(instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip });
|
||||
|
||||
// Step 2: Wait for boot
|
||||
await logStep(instanceId, "wait_ready", "running", "Waiting for VPS to boot...");
|
||||
let booted = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(5000);
|
||||
const server = await getServer(serverId);
|
||||
if (server?.status === "running") {
|
||||
booted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!booted) {
|
||||
await logStep(instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes");
|
||||
await updateInstance(instanceId, { status: "error", error_message: "VPS boot timeout" });
|
||||
return;
|
||||
}
|
||||
await logStep(instanceId, "wait_ready", "success", "VPS is running");
|
||||
|
||||
// Step 3: Configure DNS
|
||||
await logStep(instanceId, "configure_dns", "running", "Configuring DNS...");
|
||||
const subdomain = instance.domain.replace(".rforum.online", "");
|
||||
const dns = await createDNSRecord(subdomain, ip);
|
||||
if (dns) {
|
||||
await updateInstance(instanceId, { dns_record_id: dns.recordId });
|
||||
await logStep(instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`);
|
||||
} else {
|
||||
await logStep(instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually");
|
||||
}
|
||||
|
||||
// Step 4: Wait for Discourse install
|
||||
await updateInstance(instanceId, { status: "installing" });
|
||||
await logStep(instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)...");
|
||||
let installed = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(15000);
|
||||
try {
|
||||
const res = await fetch(`http://${ip}`, { redirect: "manual" });
|
||||
if (res.status === 200 || res.status === 302) {
|
||||
installed = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!installed) {
|
||||
await logStep(instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes");
|
||||
await updateInstance(instanceId, { status: "error", error_message: "Discourse install timeout" });
|
||||
return;
|
||||
}
|
||||
await logStep(instanceId, "install_discourse", "success", "Discourse is responding");
|
||||
|
||||
// Step 5: Verify live
|
||||
await updateInstance(instanceId, { status: "configuring" });
|
||||
await logStep(instanceId, "verify_live", "running", "Verifying Discourse is live...");
|
||||
try {
|
||||
const res = await fetch(`https://${instance.domain}`, { redirect: "manual" });
|
||||
if (res.status === 200 || res.status === 302) {
|
||||
await updateInstance(instanceId, {
|
||||
status: "active",
|
||||
ssl_provisioned: true,
|
||||
provisioned_at: new Date().toISOString(),
|
||||
});
|
||||
await logStep(instanceId, "verify_live", "success", "Forum is live with SSL!");
|
||||
} else {
|
||||
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() });
|
||||
await logStep(instanceId, "verify_live", "success", "Forum is live (SSL pending)");
|
||||
}
|
||||
} catch {
|
||||
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() });
|
||||
await logStep(instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[Forum] Provisioning error:", e);
|
||||
await updateInstance(instanceId, { status: "error", error_message: e.message });
|
||||
await logStep(instanceId, "unknown", "error", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function destroyInstance(instanceId: string) {
|
||||
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]);
|
||||
if (!instance) return;
|
||||
|
||||
await updateInstance(instanceId, { status: "destroying" });
|
||||
|
||||
if (instance.vps_id) {
|
||||
await deleteServer(instance.vps_id);
|
||||
}
|
||||
if (instance.dns_record_id) {
|
||||
await deleteDNSRecord(instance.dns_record_id);
|
||||
}
|
||||
|
||||
await updateInstance(instanceId, { status: "destroyed", destroyed_at: new Date().toISOString() });
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Forum module — Discourse cloud provisioner.
|
||||
* Deploy self-hosted Discourse forums on Hetzner VPS with Cloudflare DNS.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { provisionInstance, destroyInstance } from "./lib/provisioner";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
|
||||
|
||||
// ── DB initialization ──
|
||||
async function initDB() {
|
||||
try {
|
||||
await sql.unsafe(SCHEMA_SQL);
|
||||
console.log("[Forum] DB schema initialized");
|
||||
} catch (e: any) {
|
||||
console.error("[Forum] DB init error:", e.message);
|
||||
}
|
||||
}
|
||||
initDB();
|
||||
|
||||
// ── Helpers ──
|
||||
async function getOrCreateUser(did: string): Promise<any> {
|
||||
const [existing] = await sql.unsafe("SELECT * FROM rforum.users WHERE did = $1", [did]);
|
||||
if (existing) return existing;
|
||||
const [user] = await sql.unsafe(
|
||||
"INSERT INTO rforum.users (did) VALUES ($1) RETURNING *",
|
||||
[did],
|
||||
);
|
||||
return user;
|
||||
}
|
||||
|
||||
// ── API: List instances ──
|
||||
routes.get("/api/instances", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const rows = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC",
|
||||
[user.id],
|
||||
);
|
||||
return c.json({ instances: rows });
|
||||
});
|
||||
|
||||
// ── API: Create instance ──
|
||||
routes.post("/api/instances", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const body = await c.req.json<{
|
||||
name: string;
|
||||
subdomain: string;
|
||||
region?: string;
|
||||
size?: string;
|
||||
admin_email: string;
|
||||
smtp_config?: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
if (!body.name || !body.subdomain || !body.admin_email) {
|
||||
return c.json({ error: "name, subdomain, and admin_email are required" }, 400);
|
||||
}
|
||||
|
||||
const domain = `${body.subdomain}.rforum.online`;
|
||||
|
||||
// Check uniqueness
|
||||
const [existing] = await sql.unsafe("SELECT id FROM rforum.instances WHERE domain = $1", [domain]);
|
||||
if (existing) return c.json({ error: "Domain already taken" }, 409);
|
||||
|
||||
const [instance] = await sql.unsafe(
|
||||
`INSERT INTO rforum.instances (user_id, name, domain, region, size, admin_email, smtp_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING *`,
|
||||
[user.id, body.name, domain, body.region || "nbg1", body.size || "cx22", body.admin_email, JSON.stringify(body.smtp_config || {})],
|
||||
);
|
||||
|
||||
// Start provisioning asynchronously
|
||||
provisionInstance(instance.id).catch((e) => {
|
||||
console.error("[Forum] Provision failed:", e);
|
||||
});
|
||||
|
||||
return c.json({ instance }, 201);
|
||||
});
|
||||
|
||||
// ── API: Get instance detail ──
|
||||
routes.get("/api/instances/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const [instance] = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
|
||||
[c.req.param("id"), user.id],
|
||||
);
|
||||
if (!instance) return c.json({ error: "Instance not found" }, 404);
|
||||
|
||||
const logs = await sql.unsafe(
|
||||
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
|
||||
[instance.id],
|
||||
);
|
||||
|
||||
return c.json({ instance, logs });
|
||||
});
|
||||
|
||||
// ── API: Destroy instance ──
|
||||
routes.delete("/api/instances/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const [instance] = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
|
||||
[c.req.param("id"), user.id],
|
||||
);
|
||||
if (!instance) return c.json({ error: "Instance not found" }, 404);
|
||||
if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400);
|
||||
|
||||
// Destroy asynchronously
|
||||
destroyInstance(instance.id).catch((e) => {
|
||||
console.error("[Forum] Destroy failed:", e);
|
||||
});
|
||||
|
||||
return c.json({ message: "Destroying instance...", instance: { ...instance, status: "destroying" } });
|
||||
});
|
||||
|
||||
// ── API: Get provision logs ──
|
||||
routes.get("/api/instances/:id/logs", async (c) => {
|
||||
const logs = await sql.unsafe(
|
||||
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
|
||||
[c.req.param("id")],
|
||||
);
|
||||
return c.json({ logs });
|
||||
});
|
||||
|
||||
// ── API: Health ──
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ status: "ok", service: "rforum" });
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
title: `${spaceSlug} — Forum | rSpace`,
|
||||
moduleId: "rforum",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
|
||||
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const forumModule: RSpaceModule = {
|
||||
id: "rforum",
|
||||
name: "rForum",
|
||||
icon: "\uD83D\uDCAC",
|
||||
description: "Deploy and manage Discourse forums",
|
||||
routes,
|
||||
standaloneDomain: "rforum.online",
|
||||
};
|
||||
|
|
@ -0,0 +1,524 @@
|
|||
/**
|
||||
* <folk-budget-river> — animated SVG sankey river visualization.
|
||||
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
|
||||
* Parent component (folk-funds-app) handles data fetching and mapping.
|
||||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
|
||||
import { computeSufficiencyState, computeSystemSufficiency, simulateTick, DEFAULT_CONFIG } from "../lib/simulation";
|
||||
import { demoNodes } from "../lib/presets";
|
||||
|
||||
// ─── Layout types ───────────────────────────────────────
|
||||
|
||||
interface RiverLayout {
|
||||
sources: SourceLayout[];
|
||||
funnels: FunnelLayout[];
|
||||
outcomes: OutcomeLayout[];
|
||||
sourceWaterfalls: WaterfallLayout[];
|
||||
overflowBranches: BranchLayout[];
|
||||
spendingWaterfalls: WaterfallLayout[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; }
|
||||
interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; riverWidth: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; }
|
||||
interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; }
|
||||
interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; }
|
||||
interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; }
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
const LAYER_HEIGHT = 160;
|
||||
const WATERFALL_HEIGHT = 120;
|
||||
const GAP = 40;
|
||||
const MIN_RIVER_WIDTH = 24;
|
||||
const MAX_RIVER_WIDTH = 100;
|
||||
const MIN_WATERFALL_WIDTH = 4;
|
||||
const SEGMENT_LENGTH = 200;
|
||||
const POOL_WIDTH = 100;
|
||||
const POOL_HEIGHT = 60;
|
||||
const SOURCE_HEIGHT = 40;
|
||||
|
||||
const COLORS = {
|
||||
sourceWaterfall: "#10b981",
|
||||
riverHealthy: ["#0ea5e9", "#06b6d4"],
|
||||
riverOverflow: ["#f59e0b", "#fbbf24"],
|
||||
riverCritical: ["#ef4444", "#f87171"],
|
||||
riverSufficient: ["#fbbf24", "#10b981"],
|
||||
overflowBranch: "#f59e0b",
|
||||
spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"],
|
||||
outcomePool: "#3b82f6",
|
||||
goldenGlow: "#fbbf24",
|
||||
bg: "#0f172a",
|
||||
text: "#e2e8f0",
|
||||
textMuted: "#94a3b8",
|
||||
};
|
||||
|
||||
function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] {
|
||||
const totalPct = percentages.reduce((s, p) => s + p, 0);
|
||||
if (totalPct === 0) return percentages.map(() => minWidth);
|
||||
let widths = percentages.map((p) => (p / totalPct) * totalAvailable);
|
||||
const belowMin = widths.filter((w) => w < minWidth);
|
||||
if (belowMin.length > 0 && belowMin.length < widths.length) {
|
||||
const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0);
|
||||
const aboveMinTotal = widths.filter((w) => w >= minWidth).reduce((s, w) => s + w, 0);
|
||||
widths = widths.map((w) => {
|
||||
if (w < minWidth) return minWidth;
|
||||
return Math.max(minWidth, w - (w / aboveMinTotal) * deficit);
|
||||
});
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
// ─── Layout engine (faithful port) ──────────────────────
|
||||
|
||||
function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||
const funnelNodes = nodes.filter((n) => n.type === "funnel");
|
||||
const outcomeNodes = nodes.filter((n) => n.type === "outcome");
|
||||
const sourceNodes = nodes.filter((n) => n.type === "source");
|
||||
|
||||
const overflowTargets = new Set<string>();
|
||||
const spendingTargets = new Set<string>();
|
||||
|
||||
funnelNodes.forEach((n) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
data.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId));
|
||||
data.spendingAllocations?.forEach((a) => spendingTargets.add(a.targetId));
|
||||
});
|
||||
|
||||
const rootFunnels = funnelNodes.filter((n) => !overflowTargets.has(n.id));
|
||||
|
||||
const funnelLayers = new Map<string, number>();
|
||||
rootFunnels.forEach((n) => funnelLayers.set(n.id, 0));
|
||||
|
||||
const queue = [...rootFunnels];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const data = current.data as FunnelNodeData;
|
||||
const parentLayer = funnelLayers.get(current.id) ?? 0;
|
||||
data.overflowAllocations?.forEach((a) => {
|
||||
const child = funnelNodes.find((n) => n.id === a.targetId);
|
||||
if (child && !funnelLayers.has(child.id)) {
|
||||
funnelLayers.set(child.id, parentLayer + 1);
|
||||
queue.push(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const layerGroups = new Map<number, FlowNode[]>();
|
||||
funnelNodes.forEach((n) => {
|
||||
const layer = funnelLayers.get(n.id) ?? 0;
|
||||
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
|
||||
layerGroups.get(layer)!.push(n);
|
||||
});
|
||||
|
||||
const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0);
|
||||
const sourceLayerY = GAP;
|
||||
const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP;
|
||||
|
||||
const funnelLayouts: FunnelLayout[] = [];
|
||||
|
||||
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||
const layerNodes = layerGroups.get(layer) || [];
|
||||
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
|
||||
const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2;
|
||||
|
||||
layerNodes.forEach((n, i) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1));
|
||||
const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
|
||||
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2);
|
||||
const status: "healthy" | "overflow" | "critical" =
|
||||
data.currentValue > data.maxThreshold ? "overflow" :
|
||||
data.currentValue < data.minThreshold ? "critical" : "healthy";
|
||||
|
||||
funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) });
|
||||
});
|
||||
}
|
||||
|
||||
// Source layouts
|
||||
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
|
||||
const data = n.data as SourceNodeData;
|
||||
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP;
|
||||
return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (120 + GAP), y: sourceLayerY, width: 120 };
|
||||
});
|
||||
|
||||
// Source waterfalls
|
||||
const inflowsByFunnel = new Map<string, { sourceNodeId: string; allocIndex: number; flowAmount: number; percentage: number }[]>();
|
||||
sourceNodes.forEach((sn) => {
|
||||
const data = sn.data as SourceNodeData;
|
||||
data.targetAllocations?.forEach((alloc, i) => {
|
||||
const flowAmount = (alloc.percentage / 100) * data.flowRate;
|
||||
if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []);
|
||||
inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage });
|
||||
});
|
||||
});
|
||||
|
||||
const sourceWaterfalls: WaterfallLayout[] = [];
|
||||
sourceNodes.forEach((sn) => {
|
||||
const data = sn.data as SourceNodeData;
|
||||
const sourceLayout = sourceLayouts.find((s) => s.id === sn.id);
|
||||
if (!sourceLayout) return;
|
||||
data.targetAllocations?.forEach((alloc, allocIdx) => {
|
||||
const targetLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
|
||||
if (!targetLayout) return;
|
||||
const flowAmount = (alloc.percentage / 100) * data.flowRate;
|
||||
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [];
|
||||
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0);
|
||||
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1;
|
||||
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth);
|
||||
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8);
|
||||
const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx);
|
||||
const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
|
||||
const startX = targetLayout.x + targetLayout.segmentLength * 0.15;
|
||||
let offsetX = 0;
|
||||
for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k];
|
||||
const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2;
|
||||
const sourceCenterX = sourceLayout.x + sourceLayout.width / 2;
|
||||
sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount });
|
||||
});
|
||||
});
|
||||
|
||||
// Implicit waterfalls for root funnels without source nodes
|
||||
if (sourceNodes.length === 0) {
|
||||
rootFunnels.forEach((rn) => {
|
||||
const data = rn.data as FunnelNodeData;
|
||||
if (data.inflowRate <= 0) return;
|
||||
const layout = funnelLayouts.find((f) => f.id === rn.id);
|
||||
if (!layout) return;
|
||||
sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, width: layout.riverWidth, riverEndWidth: layout.riverWidth, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate });
|
||||
});
|
||||
}
|
||||
|
||||
// Overflow branches
|
||||
const overflowBranches: BranchLayout[] = [];
|
||||
funnelNodes.forEach((n) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
|
||||
if (!parentLayout) return;
|
||||
data.overflowAllocations?.forEach((alloc) => {
|
||||
const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
|
||||
if (!childLayout) return;
|
||||
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth);
|
||||
overflowBranches.push({ sourceId: n.id, targetId: alloc.targetId, percentage: alloc.percentage, x1: parentLayout.x + parentLayout.segmentLength, y1: parentLayout.y + parentLayout.riverWidth / 2, x2: childLayout.x, y2: childLayout.y + childLayout.riverWidth / 2, width, color: alloc.color || COLORS.overflowBranch });
|
||||
});
|
||||
});
|
||||
|
||||
// Outcome layouts
|
||||
const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT;
|
||||
const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP;
|
||||
const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => {
|
||||
const data = n.data as OutcomeNodeData;
|
||||
const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0;
|
||||
return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent };
|
||||
});
|
||||
|
||||
// Spending waterfalls
|
||||
const spendingWaterfalls: WaterfallLayout[] = [];
|
||||
funnelNodes.forEach((n) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
|
||||
if (!parentLayout) return;
|
||||
const allocations = data.spendingAllocations || [];
|
||||
if (allocations.length === 0) return;
|
||||
const percentages = allocations.map((a) => a.percentage);
|
||||
const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
|
||||
const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH);
|
||||
const startX = parentLayout.x + parentLayout.segmentLength * 0.15;
|
||||
let offsetX = 0;
|
||||
allocations.forEach((alloc, i) => {
|
||||
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
|
||||
if (!outcomeLayout) return;
|
||||
const riverEndWidth = riverEndWidths[i];
|
||||
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6);
|
||||
const riverCenterX = startX + offsetX + slotWidths[i] / 2;
|
||||
offsetX += slotWidths[i];
|
||||
const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2;
|
||||
spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "outflow", color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1) });
|
||||
});
|
||||
});
|
||||
|
||||
// Compute bounds and normalize
|
||||
const allX = [...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.segmentLength), ...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth), ...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width)];
|
||||
const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY];
|
||||
|
||||
const minX = Math.min(...allX, -100);
|
||||
const maxX = Math.max(...allX, 100);
|
||||
const maxY = Math.max(...allY, 400);
|
||||
const padding = 80;
|
||||
|
||||
const offsetXGlobal = -minX + padding;
|
||||
const offsetYGlobal = padding;
|
||||
|
||||
funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; });
|
||||
outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; });
|
||||
sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; });
|
||||
sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
|
||||
overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; });
|
||||
spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
|
||||
|
||||
return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding };
|
||||
}
|
||||
|
||||
// ─── SVG Rendering ──────────────────────────────────────
|
||||
|
||||
function renderWaterfall(wf: WaterfallLayout): string {
|
||||
const isInflow = wf.direction === "inflow";
|
||||
const height = wf.yEnd - wf.yStart;
|
||||
if (height <= 0) return "";
|
||||
|
||||
const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth;
|
||||
const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth;
|
||||
const topCx = isInflow ? wf.xSource : wf.x;
|
||||
const bottomCx = isInflow ? wf.x : wf.xSource;
|
||||
|
||||
const cpFrac1 = isInflow ? 0.55 : 0.2;
|
||||
const cpFrac2 = isInflow ? 0.75 : 0.45;
|
||||
const cpY1 = wf.yStart + height * cpFrac1;
|
||||
const cpY2 = wf.yStart + height * cpFrac2;
|
||||
|
||||
const tl = topCx - topWidth / 2;
|
||||
const tr = topCx + topWidth / 2;
|
||||
const bl = bottomCx - bottomWidth / 2;
|
||||
const br = bottomCx + bottomWidth / 2;
|
||||
|
||||
const shapePath = `M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart} Z`;
|
||||
const clipId = `sankey-clip-${wf.id}`;
|
||||
const gradId = `sankey-grad-${wf.id}`;
|
||||
const pathMinX = Math.min(tl, bl) - 5;
|
||||
const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
|
||||
|
||||
return `
|
||||
<defs>
|
||||
<clipPath id="${clipId}"><path d="${shapePath}"/></clipPath>
|
||||
<linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.85 : 0.5}"/>
|
||||
<stop offset="50%" stop-color="${wf.color}" stop-opacity="0.65"/>
|
||||
<stop offset="100%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.35 : 0.85}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="${shapePath}" fill="${wf.color}" opacity="0.08"/>
|
||||
<path d="${shapePath}" fill="url(#${gradId})"/>
|
||||
<g clip-path="url(#${clipId})">
|
||||
${[0, 1, 2].map((i) => `<rect x="${pathMinX}" y="${wf.yStart - height}" width="${pathMaxW}" height="${height}" fill="${wf.color}" opacity="0.12" style="animation:waterFlow ${1.4 + i * 0.3}s linear infinite;animation-delay:${i * -0.4}s"/>`).join("")}
|
||||
</g>
|
||||
<path d="M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1" opacity="0.3" stroke-dasharray="4 6" style="animation:riverCurrent 1s linear infinite"/>`;
|
||||
}
|
||||
|
||||
function renderBranch(b: BranchLayout): string {
|
||||
const dx = b.x2 - b.x1;
|
||||
const dy = b.y2 - b.y1;
|
||||
const cpx = b.x1 + dx * 0.5;
|
||||
const halfW = b.width / 2;
|
||||
|
||||
return `
|
||||
<path d="M ${b.x1} ${b.y1 - halfW} C ${cpx} ${b.y1 - halfW}, ${cpx} ${b.y2 - halfW}, ${b.x2} ${b.y2 - halfW} L ${b.x2} ${b.y2 + halfW} C ${cpx} ${b.y2 + halfW}, ${cpx} ${b.y1 + halfW}, ${b.x1} ${b.y1 + halfW} Z" fill="${b.color}" opacity="0.35"/>
|
||||
<text x="${(b.x1 + b.x2) / 2}" y="${(b.y1 + b.y2) / 2 - 8}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`;
|
||||
}
|
||||
|
||||
function renderSource(s: SourceLayout): string {
|
||||
return `
|
||||
<rect x="${s.x}" y="${s.y}" width="${s.width}" height="${SOURCE_HEIGHT}" rx="8" fill="#1e293b" stroke="#334155"/>
|
||||
<text x="${s.x + s.width / 2}" y="${s.y + 16}" text-anchor="middle" fill="${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text>
|
||||
<text x="${s.x + s.width / 2}" y="${s.y + 30}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">$${s.flowRate.toLocaleString()}/mo</text>`;
|
||||
}
|
||||
|
||||
function renderFunnel(f: FunnelLayout): string {
|
||||
const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy;
|
||||
const gradId = `river-grad-${f.id}`;
|
||||
const fillRatio = f.data.currentValue / (f.data.maxCapacity || 1);
|
||||
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
|
||||
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
|
||||
|
||||
return `
|
||||
<defs>
|
||||
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
|
||||
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
${isSufficient ? `<rect x="${f.x - 4}" y="${f.y - 4}" width="${f.segmentLength + 8}" height="${f.riverWidth + 8}" rx="6" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="2" opacity="0.6" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
|
||||
<rect x="${f.x}" y="${f.y}" width="${f.segmentLength}" height="${f.riverWidth}" rx="4" fill="url(#${gradId})"/>
|
||||
${[0, 1, 2].map((i) => `<rect x="${f.x}" y="${f.y + (f.riverWidth / 4) * i}" width="${f.segmentLength}" height="${f.riverWidth / 4}" fill="${colors[0]}" opacity="0.08" style="animation:waterFlow ${2 + i * 0.5}s linear infinite;animation-delay:${i * -0.6}s"/>`).join("")}
|
||||
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
|
||||
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 2}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">$${Math.floor(f.data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${isSufficient ? "\u2728" : ""}</text>
|
||||
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" fill="#334155"/>
|
||||
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength * fillRatio}" height="3" rx="1.5" fill="${colors[0]}"/>`;
|
||||
}
|
||||
|
||||
function renderOutcome(o: OutcomeLayout): string {
|
||||
const filled = (o.fillPercent / 100) * POOL_HEIGHT;
|
||||
const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6";
|
||||
|
||||
return `
|
||||
<rect x="${o.x}" y="${o.y}" width="${o.poolWidth}" height="${POOL_HEIGHT}" rx="8" fill="#1e293b" stroke="#334155"/>
|
||||
<rect x="${o.x + 2}" y="${o.y + POOL_HEIGHT - filled}" width="${o.poolWidth - 4}" height="${filled}" rx="6" fill="${color}" opacity="0.4"/>
|
||||
${filled > 5 ? `<rect x="${o.x + 2}" y="${o.y + POOL_HEIGHT - filled}" width="${o.poolWidth - 4}" height="3" rx="1.5" fill="${color}" opacity="0.6" style="animation:waveFloat 2s ease-in-out infinite"/>` : ""}
|
||||
<text x="${o.x + o.poolWidth / 2}" y="${o.y + POOL_HEIGHT + 14}" text-anchor="middle" fill="${COLORS.text}" font-size="10" font-weight="500">${esc(o.label)}</text>
|
||||
<text x="${o.x + o.poolWidth / 2}" y="${o.y + POOL_HEIGHT + 26}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">${Math.round(o.fillPercent)}%</text>`;
|
||||
}
|
||||
|
||||
function renderSufficiencyBadge(score: number, x: number, y: number): string {
|
||||
const pct = Math.round(score * 100);
|
||||
const color = pct >= 90 ? COLORS.goldenGlow : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444";
|
||||
const circumference = 2 * Math.PI * 18;
|
||||
const dashoffset = circumference * (1 - score);
|
||||
|
||||
return `
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<circle cx="24" cy="24" r="22" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
|
||||
<circle cx="24" cy="24" r="18" fill="none" stroke="#334155" stroke-width="3"/>
|
||||
<circle cx="24" cy="24" r="18" fill="none" stroke="${color}" stroke-width="3" stroke-dasharray="${circumference}" stroke-dashoffset="${dashoffset}" transform="rotate(-90 24 24)" stroke-linecap="round"/>
|
||||
<text x="24" y="22" text-anchor="middle" fill="${color}" font-size="11" font-weight="700">${pct}%</text>
|
||||
<text x="24" y="34" text-anchor="middle" fill="${COLORS.textMuted}" font-size="7">ENOUGH</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ─── Web Component ──────────────────────────────────────
|
||||
|
||||
class FolkBudgetRiver extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private nodes: FlowNode[] = [];
|
||||
private simulating = false;
|
||||
private simTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private dragging = false;
|
||||
private dragStartX = 0;
|
||||
private dragStartY = 0;
|
||||
private scrollStartX = 0;
|
||||
private scrollStartY = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
static get observedAttributes() { return ["simulate"]; }
|
||||
|
||||
connectedCallback() {
|
||||
this.simulating = this.getAttribute("simulate") === "true";
|
||||
if (this.nodes.length === 0) {
|
||||
this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))];
|
||||
}
|
||||
this.render();
|
||||
if (this.simulating) this.startSimulation();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopSimulation();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _: string, newVal: string) {
|
||||
if (name === "simulate") {
|
||||
this.simulating = newVal === "true";
|
||||
if (this.simulating) this.startSimulation();
|
||||
else this.stopSimulation();
|
||||
}
|
||||
}
|
||||
|
||||
setNodes(nodes: FlowNode[]) {
|
||||
this.nodes = nodes;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private startSimulation() {
|
||||
if (this.simTimer) return;
|
||||
this.simTimer = setInterval(() => {
|
||||
this.nodes = simulateTick(this.nodes, DEFAULT_CONFIG);
|
||||
this.render();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopSimulation() {
|
||||
if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; }
|
||||
}
|
||||
|
||||
private render() {
|
||||
const layout = computeLayout(this.nodes);
|
||||
const score = computeSystemSufficiency(this.nodes);
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.container { position: relative; overflow: auto; background: ${COLORS.bg}; border-radius: 12px; border: 1px solid #334155; max-height: 85vh; cursor: grab; }
|
||||
.container.dragging { cursor: grabbing; user-select: none; }
|
||||
svg { display: block; }
|
||||
.controls { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; }
|
||||
.controls button { padding: 6px 12px; border-radius: 6px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 12px; }
|
||||
.controls button:hover { border-color: #6366f1; color: #f1f5f9; }
|
||||
.controls button.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
|
||||
.legend { position: absolute; bottom: 12px; left: 12px; background: rgba(15,23,42,0.9); border: 1px solid #334155; border-radius: 8px; padding: 8px 12px; font-size: 10px; color: #94a3b8; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
|
||||
@keyframes waterFlow { 0% { transform: translateY(0); } 100% { transform: translateY(100%); } }
|
||||
@keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } }
|
||||
@keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
|
||||
@keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
||||
</style>
|
||||
<div class="container">
|
||||
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
|
||||
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
|
||||
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
|
||||
${layout.overflowBranches.map(renderBranch).join("")}
|
||||
${layout.sources.map(renderSource).join("")}
|
||||
${layout.funnels.map(renderFunnel).join("")}
|
||||
${layout.outcomes.map(renderOutcome).join("")}
|
||||
${renderSufficiencyBadge(score, layout.width - 70, 10)}
|
||||
</svg>
|
||||
<div class="controls">
|
||||
<button class="${this.simulating ? "active" : ""}" data-action="toggle-sim">${this.simulating ? "\u23F8 Pause" : "\u25B6 Simulate"}</button>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#10b981"></div> Inflow</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#0ea5e9"></div> Healthy</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div> Overflow</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div> Critical</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#8b5cf6"></div> Spending</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Sufficient</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
|
||||
this.simulating = !this.simulating;
|
||||
if (this.simulating) this.startSimulation();
|
||||
else this.stopSimulation();
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Drag-to-pan
|
||||
const container = this.shadow.querySelector(".container") as HTMLElement;
|
||||
if (container) {
|
||||
container.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
this.dragging = true;
|
||||
this.dragStartX = e.clientX;
|
||||
this.dragStartY = e.clientY;
|
||||
this.scrollStartX = container.scrollLeft;
|
||||
this.scrollStartY = container.scrollTop;
|
||||
container.classList.add("dragging");
|
||||
container.setPointerCapture(e.pointerId);
|
||||
});
|
||||
container.addEventListener("pointermove", (e: PointerEvent) => {
|
||||
if (!this.dragging) return;
|
||||
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
|
||||
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
|
||||
});
|
||||
container.addEventListener("pointerup", (e: PointerEvent) => {
|
||||
this.dragging = false;
|
||||
container.classList.remove("dragging");
|
||||
container.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
// Auto-center on initial render
|
||||
container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
|
||||
container.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-budget-river", FolkBudgetRiver);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue