From a54661d4d448a83f4b6fd058bd036f2577444325 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 13:34:17 -0800 Subject: [PATCH 1/4] fix: use correct /rX prefix in all module getApiBase() methods All module components were matching URL paths without the 'r' prefix (e.g., /notes instead of /rnotes), causing API calls to fail when modules are mounted at /:space/:moduleId. Also adds space attribute fallback for canvas-embedded usage. Co-Authored-By: Claude Opus 4.6 --- modules/rbooks/mod.ts | 5 +- modules/rcal/mod.ts | 1 + modules/rcart/components/folk-cart-shop.ts | 7 +- modules/rcart/mod.ts | 1 + .../components/folk-choices-dashboard.ts | 7 +- modules/rchoices/mod.ts | 1 + modules/rdata/mod.ts | 1 + modules/rdesign/mod.ts | 1 + modules/rdocs/mod.ts | 1 + .../rfiles/components/folk-file-browser.ts | 7 +- modules/rfiles/mod.ts | 1 + .../rforum/components/folk-forum-dashboard.ts | 7 +- modules/rforum/mod.ts | 1 + modules/rfunds/components/folk-funds-app.ts | 7 +- modules/rfunds/mod.ts | 1 + modules/rinbox/mod.ts | 1 + modules/rmaps/components/folk-map-viewer.ts | 7 +- modules/rmaps/mod.ts | 1 + .../rnetwork/components/folk-graph-viewer.ts | 7 +- modules/rnetwork/mod.ts | 1 + modules/rnotes/components/folk-notes-app.ts | 7 +- modules/rnotes/mod.ts | 1 + modules/rphotos/mod.ts | 1 + modules/rpubs/mod.ts | 1 + modules/rsocials/mod.ts | 1 + modules/rspace/mod.ts | 1 + modules/rsplat/mod.ts | 5 +- .../rswag/components/folk-swag-designer.ts | 7 +- modules/rswag/mod.ts | 1 + .../rtrips/components/folk-route-planner.ts | 10 +- .../rtrips/components/folk-trips-planner.ts | 7 +- modules/rtrips/mod.ts | 1 + modules/rtube/mod.ts | 1 + .../rvote/components/folk-vote-dashboard.ts | 7 +- modules/rvote/mod.ts | 1 + .../rwallet/components/folk-wallet-viewer.ts | 7 +- modules/rwallet/mod.ts | 1 + modules/rwork/components/folk-work-board.ts | 7 +- modules/rwork/mod.ts | 1 + server/community-store.ts | 13 +- server/index.ts | 127 ++++++----- server/spaces.ts | 209 +++++++++++++++--- shared/module.ts | 61 ++++- 43 files changed, 394 insertions(+), 150 deletions(-) diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index ca9e624..623b23d 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -12,7 +12,7 @@ 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 type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { renderLanding } from "./landing"; import { verifyEncryptIDToken, @@ -300,6 +300,7 @@ export const booksModule: RSpaceModule = { name: "rBooks", icon: "๐Ÿ“š", description: "Community PDF library with flipbook reader", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, standaloneDomain: "rbooks.online", landingPage: renderLanding, @@ -324,7 +325,7 @@ export const booksModule: RSpaceModule = { { path: "collections", name: "Collections", icon: "๐Ÿ“‘", description: "Curated book collections" }, ], - async onSpaceCreate(spaceSlug: string) { + async onSpaceCreate(ctx: SpaceLifecycleContext) { // Books are global, not space-scoped (for now). No-op. }, }; diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 326652d..93b6557 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -394,6 +394,7 @@ export const calModule: RSpaceModule = { name: "rCal", icon: "๐Ÿ“…", description: "Temporal coordination calendar with lunar, solar, and seasonal systems", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, standaloneDomain: "rcal.online", landingPage: renderLanding, diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index d1ff6ac..1907b6c 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -183,9 +183,10 @@ class FolkCartShop extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const parts = path.split("/").filter(Boolean); - return parts.length >= 2 ? `/${parts[0]}/cart` : "/demo/cart"; + const match = window.location.pathname.match(/^\/([^/]+)\/rcart/); + if (match) return `/${match[1]}/rcart`; + if (this.space) return `/${this.space}/rcart`; + return "/demo/rcart"; } private async loadData() { diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 822850d..f08b0a5 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -459,6 +459,7 @@ export const cartModule: RSpaceModule = { name: "rCart", icon: "๐Ÿ›’", description: "Cosmolocal print-on-demand shop", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, standaloneDomain: "rcart.online", landingPage: renderLanding, diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index fe3e757..038f01f 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -42,9 +42,10 @@ class FolkChoicesDashboard extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const parts = path.split("/").filter(Boolean); - return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices"; + const match = window.location.pathname.match(/^\/([^/]+)\/rchoices/); + if (match) return `/${match[1]}/rchoices`; + if (this.space) return `/${this.space}/rchoices`; + return "/demo/rchoices"; } private async loadChoices() { diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index 3bf430b..acb4563 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -66,6 +66,7 @@ export const choicesModule: RSpaceModule = { name: "rChoices", icon: "โ˜‘", description: "Polls, rankings, and multi-criteria scoring", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, standaloneDomain: "rchoices.online", landingPage: renderLanding, diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index af080d0..3595fdd 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -138,6 +138,7 @@ export const dataModule: RSpaceModule = { name: "rData", icon: "๐Ÿ“Š", description: "Privacy-first analytics for the r* ecosystem", + scoping: { defaultScope: 'global', userConfigurable: false }, routes, standaloneDomain: "rdata.online", landingPage: renderLanding, diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index c08faf2..d560a06 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -54,6 +54,7 @@ export const designModule: RSpaceModule = { name: "rDesign", icon: "๐ŸŽฏ", description: "Collaborative design workspace with whiteboard and docs", + scoping: { defaultScope: 'global', userConfigurable: false }, routes, standaloneDomain: "rdesign.online", externalApp: { url: AFFINE_URL, name: "Affine" }, diff --git a/modules/rdocs/mod.ts b/modules/rdocs/mod.ts index 2d2345d..0e90ab9 100644 --- a/modules/rdocs/mod.ts +++ b/modules/rdocs/mod.ts @@ -54,6 +54,7 @@ export const docsModule: RSpaceModule = { name: "rDocs", icon: "๐Ÿ“", description: "Collaborative documentation and knowledge base", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, standaloneDomain: "rdocs.online", externalApp: { url: DOCMOST_URL, name: "Docmost" }, diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts index 7fe4235..545349c 100644 --- a/modules/rfiles/components/folk-file-browser.ts +++ b/modules/rfiles/components/folk-file-browser.ts @@ -160,9 +160,10 @@ class FolkFileBrowser extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/files/); - return match ? `/${match[1]}/files` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rfiles/); + if (match) return `/${match[1]}/rfiles`; + if (this.space) return `/${this.space}/rfiles`; + return ""; } private formatSize(bytes: number): string { diff --git a/modules/rfiles/mod.ts b/modules/rfiles/mod.ts index 6306a06..b389eb7 100644 --- a/modules/rfiles/mod.ts +++ b/modules/rfiles/mod.ts @@ -399,6 +399,7 @@ export const filesModule: RSpaceModule = { name: "rFiles", icon: "๐Ÿ“", description: "File sharing, share links, and memory cards", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rfiles.online", diff --git a/modules/rforum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts index b9d0da3..85bccae 100644 --- a/modules/rforum/components/folk-forum-dashboard.ts +++ b/modules/rforum/components/folk-forum-dashboard.ts @@ -42,9 +42,10 @@ class FolkForumDashboard extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/forum/); - return match ? `/${match[1]}/forum` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rforum/); + if (match) return `/${match[1]}/rforum`; + if (this.space) return `/${this.space}/rforum`; + return ""; } private getAuthHeaders(): Record { diff --git a/modules/rforum/mod.ts b/modules/rforum/mod.ts index dfc7c76..d1891b4 100644 --- a/modules/rforum/mod.ts +++ b/modules/rforum/mod.ts @@ -190,6 +190,7 @@ export const forumModule: RSpaceModule = { name: "rForum", icon: "๐Ÿ’ฌ", description: "Deploy and manage Discourse forums", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, landingPage: renderLanding, standaloneDomain: "rforum.online", diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index 109cfec..208d828 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -122,9 +122,10 @@ class FolkFundsApp extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/funds/); - return match ? `/${match[1]}/funds` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rfunds/); + if (match) return `/${match[1]}/rfunds`; + if (this.space) return `/${this.space}/rfunds`; + return ""; } private async loadFlows() { diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index d2a136a..093faba 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -246,6 +246,7 @@ export const fundsModule: RSpaceModule = { name: "rFunds", icon: "๐ŸŒŠ", description: "Budget flows, river visualization, and treasury management", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rfunds.online", diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index 043fd7e..e8a870f 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -599,6 +599,7 @@ export const inboxModule: RSpaceModule = { name: "rInbox", icon: "๐Ÿ“จ", description: "Collaborative email with multisig approval", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rinbox.online", diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 8df7fc9..66a7711 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -773,9 +773,10 @@ class FolkMapViewer extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/maps/); - return match ? `/${match[1]}/maps` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rmaps/); + if (match) return `/${match[1]}/rmaps`; + if (this.space) return `/${this.space}/rmaps`; + return ""; } private async checkSyncHealth() { diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index 86321c7..fca53a2 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -167,6 +167,7 @@ export const mapsModule: RSpaceModule = { name: "rMaps", icon: "๐Ÿ—บ", description: "Real-time collaborative location sharing and indoor/outdoor maps", + scoping: { defaultScope: 'global', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rmaps.online", diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 2931a6c..c2a3ea5 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -106,9 +106,10 @@ class FolkGraphViewer extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/network/); - return match ? `/${match[1]}/network` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rnetwork/); + if (match) return `/${match[1]}/rnetwork`; + if (this.space) return `/${this.space}/rnetwork`; + return ""; } private async loadData() { diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 1c66ed5..edde330 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -249,6 +249,7 @@ export const networkModule: RSpaceModule = { name: "rNetwork", icon: "๐ŸŒ", description: "Community relationship graph visualization with CRM sync", + scoping: { defaultScope: 'global', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rnetwork.online", diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 516023f..11709e4 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -880,9 +880,10 @@ We are using a simple majority vote for group activities. For expenses over EUR // โ”€โ”€ REST (notebook list + search) โ”€โ”€ private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/notes/); - return match ? `/${match[1]}/notes` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rnotes/); + if (match) return `/${match[1]}/rnotes`; + if (this.space) return `/${this.space}/rnotes`; + return ""; } private async loadNotebooks() { diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 908c9c2..838eab4 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -379,6 +379,7 @@ export const notesModule: RSpaceModule = { name: "rNotes", icon: "๐Ÿ“", description: "Notebooks with rich-text notes, voice transcription, and collaboration", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, landingPage: renderLanding, standaloneDomain: "rnotes.online", diff --git a/modules/rphotos/mod.ts b/modules/rphotos/mod.ts index 22c8ea4..d95495f 100644 --- a/modules/rphotos/mod.ts +++ b/modules/rphotos/mod.ts @@ -141,6 +141,7 @@ export const photosModule: RSpaceModule = { name: "rPhotos", icon: "๐Ÿ“ธ", description: "Community photo commons", + scoping: { defaultScope: 'global', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rphotos.online", diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index 71ebc5c..b03c2dd 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -341,6 +341,7 @@ export const pubsModule: RSpaceModule = { name: "rPubs", icon: "๐Ÿ“–", description: "Drop in a document, get a pocket book", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, standaloneDomain: "rpubs.online", landingPage: renderLanding, diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 9a3acce..f8b1c84 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -450,6 +450,7 @@ export const socialsModule: RSpaceModule = { name: "rSocials", icon: "๐Ÿ“ข", description: "Federated social feed aggregator for communities", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, standaloneDomain: "rsocials.online", landingPage: renderLanding, diff --git a/modules/rspace/mod.ts b/modules/rspace/mod.ts index 47d4b4f..5e33444 100644 --- a/modules/rspace/mod.ts +++ b/modules/rspace/mod.ts @@ -110,6 +110,7 @@ export const canvasModule: RSpaceModule = { name: "rSpace", icon: "๐ŸŽจ", description: "Real-time collaborative canvas", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, feeds: [ { diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index 7d42e79..a46fe97 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -12,7 +12,7 @@ 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 type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { renderLanding } from "./landing"; import { verifyEncryptIDToken, @@ -539,6 +539,7 @@ export const splatModule: RSpaceModule = { name: "rSplat", icon: "๐Ÿ”ฎ", description: "3D Gaussian splat viewer", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, landingPage: renderLanding, standaloneDomain: "rsplat.online", @@ -547,7 +548,7 @@ export const splatModule: RSpaceModule = { { path: "drawings", name: "Drawings", icon: "๐Ÿ”ฎ", description: "3D Gaussian splat drawings" }, ], - async onSpaceCreate(_spaceSlug: string) { + async onSpaceCreate(ctx: SpaceLifecycleContext) { // Splats are scoped by space_slug column. No per-space setup needed. }, }; diff --git a/modules/rswag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts index 00b058f..8c26b9e 100644 --- a/modules/rswag/components/folk-swag-designer.ts +++ b/modules/rswag/components/folk-swag-designer.ts @@ -183,9 +183,10 @@ class FolkSwagDesigner extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const parts = path.split("/").filter(Boolean); - return parts.length >= 2 ? `/${parts[0]}/swag` : "/demo/swag"; + const match = window.location.pathname.match(/^\/([^/]+)\/rswag/); + if (match) return `/${match[1]}/rswag`; + if (this.space) return `/${this.space}/rswag`; + return "/demo/rswag"; } private getDemoProduct(): DemoProduct { diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts index 5f4ac01..0d08039 100644 --- a/modules/rswag/mod.ts +++ b/modules/rswag/mod.ts @@ -247,6 +247,7 @@ export const swagModule: RSpaceModule = { name: "rSwag", icon: "๐ŸŽจ", description: "Design print-ready swag: stickers, posters, tees", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, landingPage: renderLanding, standaloneDomain: "rswag.online", diff --git a/modules/rtrips/components/folk-route-planner.ts b/modules/rtrips/components/folk-route-planner.ts index 83d30d8..7a5ae19 100644 --- a/modules/rtrips/components/folk-route-planner.ts +++ b/modules/rtrips/components/folk-route-planner.ts @@ -41,12 +41,10 @@ class FolkRoutePlanner extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const parts = path.split("/").filter(Boolean); - if (parts.length >= 2 && parts[1] === "trips") { - return `/${parts[0]}/trips`; - } - return "/demo/trips"; + const match = window.location.pathname.match(/^\/([^/]+)\/rtrips/); + if (match) return `/${match[1]}/rtrips`; + if (this.space) return `/${this.space}/rtrips`; + return "/demo/rtrips"; } private async fetchRoute(input: RouteInput): Promise { diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts index 44df68f..820c1d8 100644 --- a/modules/rtrips/components/folk-trips-planner.ts +++ b/modules/rtrips/components/folk-trips-planner.ts @@ -291,9 +291,10 @@ class FolkTripsPlanner extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/trips/); - return match ? `/${match[1]}/trips` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rtrips/); + if (match) return `/${match[1]}/rtrips`; + if (this.space) return `/${this.space}/rtrips`; + return ""; } private async loadTrips() { diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 60aa538..9b058ba 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -271,6 +271,7 @@ export const tripsModule: RSpaceModule = { name: "rTrips", icon: "โœˆ๏ธ", description: "Collaborative trip planner with itinerary, bookings, and expense splitting", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, landingPage: renderLanding, standaloneDomain: "rtrips.online", diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts index 557de0f..7900cfd 100644 --- a/modules/rtube/mod.ts +++ b/modules/rtube/mod.ts @@ -209,6 +209,7 @@ export const tubeModule: RSpaceModule = { name: "rTube", icon: "๐ŸŽฌ", description: "Community video hosting & live streaming", + scoping: { defaultScope: 'global', userConfigurable: true }, routes, landingPage: renderLanding, standaloneDomain: "rtube.online", diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts index 95207bf..1e58e4c 100644 --- a/modules/rvote/components/folk-vote-dashboard.ts +++ b/modules/rvote/components/folk-vote-dashboard.ts @@ -132,9 +132,10 @@ class FolkVoteDashboard extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/vote/); - return match ? `/${match[1]}/vote` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rvote/); + if (match) return `/${match[1]}/rvote`; + if (this.space) return `/${this.space}/rvote`; + return ""; } private async loadSpaces() { diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index 44d1caf..be6d53a 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -345,6 +345,7 @@ export const voteModule: RSpaceModule = { name: "rVote", icon: "๐Ÿ—ณ", description: "Conviction voting engine for collaborative governance", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, standaloneDomain: "rvote.online", landingPage: renderLanding, diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 6ab9a94..0981ce3 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -85,9 +85,10 @@ class FolkWalletViewer extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/wallet/); - return match ? `/${match[1]}/wallet` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rwallet/); + if (match) return `/${match[1]}/rwallet`; + if (this.space) return `/${this.space}/rwallet`; + return ""; } private async detectChains() { diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 7f38807..ef7654a 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -291,6 +291,7 @@ export const walletModule: RSpaceModule = { name: "rWallet", icon: "๐Ÿ’ฐ", description: "Multichain Safe wallet visualization and treasury management", + scoping: { defaultScope: 'global', userConfigurable: false }, routes, standaloneDomain: "rwallet.online", landingPage: renderLanding, diff --git a/modules/rwork/components/folk-work-board.ts b/modules/rwork/components/folk-work-board.ts index 477cdf1..c685db4 100644 --- a/modules/rwork/components/folk-work-board.ts +++ b/modules/rwork/components/folk-work-board.ts @@ -55,9 +55,10 @@ class FolkWorkBoard extends HTMLElement { } private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/work/); - return match ? `/${match[1]}/work` : ""; + const match = window.location.pathname.match(/^\/([^/]+)\/rwork/); + if (match) return `/${match[1]}/rwork`; + if (this.space) return `/${this.space}/rwork`; + return ""; } private async loadWorkspaces() { diff --git a/modules/rwork/mod.ts b/modules/rwork/mod.ts index d586a1d..441fd33 100644 --- a/modules/rwork/mod.ts +++ b/modules/rwork/mod.ts @@ -235,6 +235,7 @@ export const workModule: RSpaceModule = { name: "rWork", icon: "๐Ÿ“‹", description: "Kanban workspace boards for collaborative task management", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, standaloneDomain: "rwork.online", landingPage: renderLanding, diff --git a/server/community-store.ts b/server/community-store.ts index 1003096..075205d 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -122,7 +122,8 @@ export interface CommunityMeta { createdAt: string; visibility: SpaceVisibility; ownerDID: string | null; - enabledModules?: string[]; + enabledModules?: string[]; // null = all enabled + moduleScopeOverrides?: Record; description?: string; avatar?: string; nestPolicy?: NestPolicy; @@ -399,7 +400,13 @@ export async function deleteCommunity(slug: string): Promise { */ export function updateSpaceMeta( slug: string, - fields: { name?: string; visibility?: SpaceVisibility; description?: string }, + fields: { + name?: string; + visibility?: SpaceVisibility; + description?: string; + enabledModules?: string[]; + moduleScopeOverrides?: Record; + }, ): boolean { const doc = communities.get(slug); if (!doc) return false; @@ -408,6 +415,8 @@ export function updateSpaceMeta( if (fields.name !== undefined) d.meta.name = fields.name; if (fields.visibility !== undefined) d.meta.visibility = fields.visibility; if (fields.description !== undefined) d.meta.description = fields.description; + if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules; + if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides; }); communities.set(slug, newDoc); saveCommunity(slug); diff --git a/server/index.ts b/server/index.ts index b0a50f1..0954bd4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -67,7 +67,7 @@ import { photosModule } from "../modules/rphotos/mod"; import { socialsModule } from "../modules/rsocials/mod"; import { docsModule } from "../modules/rdocs/mod"; import { designModule } from "../modules/rdesign/mod"; -import { spaces } from "./spaces"; +import { spaces, createSpace } from "./spaces"; import { renderShell, renderModuleLanding } from "./shell"; import { renderOutputListPage } from "./output-list"; import { renderMainLanding, renderSpaceDashboard } from "./landing"; @@ -318,7 +318,7 @@ async function getSpaceConfig(slug: string): Promise { let lastDemoReset = 0; const DEMO_RESET_COOLDOWN = 5 * 60 * 1000; -// POST /api/communities โ€” create community +// POST /api/communities โ€” create community (deprecated, use POST /api/spaces) app.post("/api/communities", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required to create a community" }, 401); @@ -333,23 +333,17 @@ app.post("/api/communities", async (c) => { const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>(); const { name, slug, visibility = "public_read" } = body; - if (!name || !slug) return c.json({ error: "Name and slug are required" }, 400); - if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400); - const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"]; - if (!validVisibilities.includes(visibility)) return c.json({ error: `Invalid visibility` }, 400); - if (await communityExists(slug)) return c.json({ error: "Community already exists" }, 409); + if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400); - await createCommunity(name, slug, claims.sub, visibility); + const result = await createSpace({ + name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api', + }); + if (!result.ok) return c.json({ error: result.error }, result.status); - // Notify modules - for (const mod of getAllModules()) { - if (mod.onSpaceCreate) { - try { await mod.onSpaceCreate(slug); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); } - } - } - - return c.json({ url: `https://${slug}.rspace.online`, slug, name, visibility, ownerDID: claims.sub }, 201); + c.header("Deprecation", "true"); + c.header("Link", "; rel=\"successor-version\""); + return c.json({ url: `https://${result.slug}.rspace.online`, slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID }, 201); }); // POST /api/internal/provision โ€” auth-free, called by rSpace Registry @@ -362,19 +356,14 @@ app.post("/api/internal/provision", async (c) => { return c.json({ status: "exists", slug: space }); } - const visibility: SpaceVisibility = body.public ? "public" : "public_read"; - await createCommunity( - space.charAt(0).toUpperCase() + space.slice(1), - space, - `did:system:${space}`, - visibility, - ); - - for (const mod of getAllModules()) { - if (mod.onSpaceCreate) { - try { await mod.onSpaceCreate(space); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); } - } - } + const result = await createSpace({ + name: space.charAt(0).toUpperCase() + space.slice(1), + slug: space, + ownerDID: `did:system:${space}`, + visibility: body.public ? "public" : "public_read", + source: 'internal', + }); + if (!result.ok) return c.json({ error: result.error }, result.status); return c.json({ status: "created", slug: space }, 201); }); @@ -789,24 +778,15 @@ app.post("/api/spaces/auto-provision", async (c) => { return c.json({ status: "exists", slug: username }); } - await createCommunity( - `${claims.username}'s Space`, - username, - claims.sub, - "members_only", - ); + const result = await createSpace({ + name: `${claims.username}'s Space`, + slug: username, + ownerDID: claims.sub, + visibility: "members_only", + source: 'auto-provision', + }); + if (!result.ok) return c.json({ error: result.error }, result.status); - for (const mod of getAllModules()) { - if (mod.onSpaceCreate) { - try { - await mod.onSpaceCreate(username); - } catch (e) { - console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e); - } - } - } - - console.log(`[AutoProvision] Created personal space: ${username}`); return c.json({ status: "created", slug: username }, 201); }); @@ -834,7 +814,18 @@ app.use("/:space/*", async (c, next) => { }); // โ”€โ”€ Mount module routes under /:space/:moduleId โ”€โ”€ +// Enforce enabledModules: if a space has an explicit list, only those modules route. +// The 'rspace' (canvas) module is always allowed as the core module. for (const mod of getAllModules()) { + app.use(`/:space/${mod.id}/*`, async (c, next) => { + if (mod.id === "rspace") return next(); + const space = c.req.param("space"); + if (!space || space === "api" || space.includes(".")) return next(); + const doc = getDocumentData(space); + if (!doc?.meta?.enabledModules) return next(); // null = all enabled + if (doc.meta.enabledModules.includes(mod.id)) return next(); + return c.json({ error: "Module not enabled for this space" }, 404); + }); app.route(`/:space/${mod.id}`, mod.routes); // Auto-mount browsable output list pages if (mod.outputPaths) { @@ -976,9 +967,15 @@ app.post("/admin-action", async (c) => { const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); + const deleteCtx = { + spaceSlug: slug, + ownerDID: data.meta?.ownerDID ?? null, + enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id), + syncServer, + }; for (const mod of getAllModules()) { if (mod.onSpaceDelete) { - try { await mod.onSpaceDelete(slug); } catch (e) { + try { await mod.onSpaceDelete(deleteCtx); } catch (e) { console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e); } } @@ -1301,20 +1298,13 @@ const server = Bun.serve({ const claims = await verifyEncryptIDToken(token); const username = claims.username?.toLowerCase(); if (username === subdomain && !(await communityExists(subdomain))) { - await createCommunity( - `${claims.username}'s Space`, - subdomain, - claims.sub, - "members_only", - ); - for (const mod of getAllModules()) { - if (mod.onSpaceCreate) { - try { await mod.onSpaceCreate(subdomain); } catch (e) { - console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e); - } - } - } - console.log(`[AutoProvision] Created personal space on visit: ${subdomain}`); + await createSpace({ + name: `${claims.username}'s Space`, + slug: subdomain, + ownerDID: claims.sub, + visibility: "members_only", + source: 'subdomain', + }); } } catch (e) { console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e); @@ -1616,6 +1606,21 @@ const server = Bun.serve({ }); // โ”€โ”€ Startup โ”€โ”€ + +// Call onInit for each module that defines it (schema registration, DB init, etc.) +(async () => { + for (const mod of getAllModules()) { + if (mod.onInit) { + try { + await mod.onInit({ syncServer }); + console.log(`[Init] ${mod.name} initialized`); + } catch (e) { + console.error(`[Init] ${mod.name} failed:`, e); + } + } + } +})(); + ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e)); diff --git a/server/spaces.ts b/server/spaces.ts index 007e22a..66adb78 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -42,7 +42,74 @@ import { extractToken, } from "@encryptid/sdk/server"; import type { EncryptIDClaims } from "@encryptid/sdk/server"; -import { getAllModules } from "../shared/module"; +import { getAllModules, getModule } from "../shared/module"; +import type { SpaceLifecycleContext } from "../shared/module"; +import { syncServer } from "./sync-instance"; + +// โ”€โ”€ Unified space creation โ”€โ”€ + +export interface CreateSpaceOpts { + name: string; + slug: string; + ownerDID: string; + visibility?: SpaceVisibility; + enabledModules?: string[]; + source?: 'api' | 'auto-provision' | 'subdomain' | 'internal'; +} + +export type CreateSpaceResult = + | { ok: true; slug: string; name: string; visibility: SpaceVisibility; ownerDID: string } + | { ok: false; error: string; status: 400 | 409 }; + +/** + * Unified space creation: validate โ†’ create โ†’ notify modules. + * All creation endpoints should call this instead of duplicating logic. + */ +export async function createSpace(opts: CreateSpaceOpts): Promise { + const { name, slug, ownerDID, visibility = 'public_read', enabledModules, source = 'api' } = opts; + + if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 }; + if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 }; + if (await communityExists(slug)) return { ok: false, error: "Space already exists", status: 409 }; + + await createCommunity(name, slug, ownerDID, visibility); + + // If enabledModules specified, update the community doc + if (enabledModules) { + updateSpaceMeta(slug, { enabledModules }); + } + + // Build lifecycle context and notify all modules + const ctx: SpaceLifecycleContext = { + spaceSlug: slug, + ownerDID, + enabledModules: enabledModules || getAllModules().map(m => m.id), + syncServer, + }; + + for (const mod of getAllModules()) { + if (mod.onSpaceCreate) { + try { + await mod.onSpaceCreate(ctx); + } catch (e) { + console.error(`[createSpace:${source}] Module ${mod.id} onSpaceCreate failed:`, e); + } + } + } + + console.log(`[createSpace:${source}] Created space: ${slug}`); + return { ok: true, slug, name, visibility, ownerDID }; +} + +/** Build lifecycle context for an existing space. */ +function buildLifecycleContext(slug: string, doc: ReturnType): SpaceLifecycleContext { + return { + spaceSlug: slug, + ownerDID: doc?.meta?.ownerDID ?? null, + enabledModules: doc?.meta?.enabledModules || getAllModules().map(m => m.id), + syncServer, + }; +} // โ”€โ”€ In-memory pending nest requests (move to DB later) โ”€โ”€ const nestRequests = new Map(); @@ -144,9 +211,7 @@ spaces.get("/", async (c) => { spaces.post("/", async (c) => { const token = extractToken(c.req.raw.headers); - if (!token) { - return c.json({ error: "Authentication required" }, 401); - } + if (!token) return c.json({ error: "Authentication required" }, 401); let claims: EncryptIDClaims; try { @@ -159,47 +224,123 @@ spaces.post("/", async (c) => { name?: string; slug?: string; visibility?: SpaceVisibility; + enabledModules?: string[]; }>(); - const { name, slug, visibility = "public_read" } = body; - - if (!name || !slug) { - return c.json({ error: "Name and slug are required" }, 400); - } - - if (!/^[a-z0-9-]+$/.test(slug)) { - return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400); - } + const { name, slug, visibility = "public_read", enabledModules } = body; const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"]; - if (!validVisibilities.includes(visibility)) { + if (visibility && !validVisibilities.includes(visibility)) { return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400); } - if (await communityExists(slug)) { - return c.json({ error: "Space already exists" }, 409); + const result = await createSpace({ + name: name || "", + slug: slug || "", + ownerDID: claims.sub, + visibility, + enabledModules, + source: 'api', + }); + + if (!result.ok) return c.json({ error: result.error }, result.status); + + return c.json({ + slug: result.slug, + name: result.name, + visibility: result.visibility, + ownerDID: result.ownerDID, + url: `/${result.slug}/canvas`, + }, 201); +}); + +// โ”€โ”€ Module configuration per space โ”€โ”€ + +spaces.get("/:slug/modules", async (c) => { + const slug = c.req.param("slug"); + await loadCommunity(slug); + const doc = getDocumentData(slug); + if (!doc?.meta) return c.json({ error: "Space not found" }, 404); + + const allModules = getAllModules(); + const enabled = doc.meta.enabledModules; // null = all + const overrides = doc.meta.moduleScopeOverrides || {}; + + const modules = allModules.map(mod => ({ + id: mod.id, + name: mod.name, + icon: mod.icon, + enabled: enabled ? enabled.includes(mod.id) : true, + scoping: { + defaultScope: mod.scoping.defaultScope, + userConfigurable: mod.scoping.userConfigurable, + currentScope: overrides[mod.id] || mod.scoping.defaultScope, + }, + })); + + return c.json({ modules, enabledModules: enabled }); +}); + +spaces.patch("/:slug/modules", async (c) => { + const slug = c.req.param("slug"); + + // Auth: only space owner or admin can configure modules + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const doc = getDocumentData(slug); + if (!doc?.meta) return c.json({ error: "Space not found" }, 404); + if (doc.meta.ownerDID && doc.meta.ownerDID !== claims.sub) { + return c.json({ error: "Only the space owner can configure modules" }, 403); } - await createCommunity(name, slug, claims.sub, visibility); + const body = await c.req.json<{ + enabledModules?: string[] | null; + scopeOverrides?: Record; + }>(); - // Notify all modules about the new space - for (const mod of getAllModules()) { - if (mod.onSpaceCreate) { - try { - await mod.onSpaceCreate(slug); - } catch (e) { - console.error(`[Spaces] Module ${mod.id} onSpaceCreate failed:`, e); - } + const updates: Partial = {}; + + // Validate and set enabledModules + if (body.enabledModules !== undefined) { + if (body.enabledModules === null) { + updates.enabledModules = undefined; // reset to "all enabled" + } else { + const validIds = new Set(getAllModules().map(m => m.id)); + const invalid = body.enabledModules.filter(id => !validIds.has(id)); + if (invalid.length) return c.json({ error: `Unknown modules: ${invalid.join(", ")}` }, 400); + // Always include rspace (core module) + const enabled = new Set(body.enabledModules); + enabled.add("rspace"); + updates.enabledModules = Array.from(enabled); } } - return c.json({ - slug, - name, - visibility, - ownerDID: claims.sub, - url: `/${slug}/canvas`, - }, 201); + // Validate and set scope overrides + if (body.scopeOverrides) { + const newOverrides: Record = { ...(doc.meta.moduleScopeOverrides || {}) }; + for (const [modId, scope] of Object.entries(body.scopeOverrides)) { + const mod = getModule(modId); + if (!mod) return c.json({ error: `Unknown module: ${modId}` }, 400); + if (!mod.scoping.userConfigurable) { + return c.json({ error: `Module ${modId} does not support scope configuration` }, 400); + } + if (scope !== 'space' && scope !== 'global') { + return c.json({ error: `Invalid scope '${scope}' for ${modId}` }, 400); + } + newOverrides[modId] = scope; + } + updates.moduleScopeOverrides = newOverrides; + } + + if (Object.keys(updates).length > 0) { + updateSpaceMeta(slug, updates); + } + + return c.json({ ok: true }); }); // โ”€โ”€ Static routes must be defined BEFORE /:slug to avoid matching as a slug โ”€โ”€ @@ -292,7 +433,7 @@ spaces.delete("/admin/:slug", async (c) => { // Notify modules for (const mod of getAllModules()) { if (mod.onSpaceDelete) { - try { await mod.onSpaceDelete(slug); } catch (e) { + try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) { console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e); } } @@ -395,7 +536,7 @@ spaces.delete("/:slug", async (c) => { // Notify all modules about deletion for (const mod of getAllModules()) { if (mod.onSpaceDelete) { - try { await mod.onSpaceDelete(slug); } catch (e) { + try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) { console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e); } } diff --git a/shared/module.ts b/shared/module.ts index b74e976..ed12f28 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -1,6 +1,38 @@ import { Hono } from "hono"; import type { FlowKind, FeedDefinition } from "../lib/layer-types"; export type { FeedDefinition } from "../lib/layer-types"; +import type { SyncServer } from "../server/local-first/sync-server"; + +// โ”€โ”€ Module Scoping โ”€โ”€ + +export type ModuleScope = 'space' | 'global'; + +export interface ModuleScoping { + /** Whether the module's data lives per-space or globally by default */ + defaultScope: ModuleScope; + /** Whether space owners can override the default scope */ + userConfigurable: boolean; +} + +// โ”€โ”€ Lifecycle Context โ”€โ”€ + +export interface SpaceLifecycleContext { + spaceSlug: string; + ownerDID: string | null; + enabledModules: string[]; + syncServer: SyncServer; +} + +// โ”€โ”€ Doc Schema (for Automerge document types a module manages) โ”€โ”€ + +export interface DocSchema { + /** Document ID pattern, e.g. '{space}:notes:notebooks:{notebookId}' */ + pattern: string; + /** Human-readable description */ + description: string; + /** Factory to create a fresh empty document */ + init: () => T; +} /** A browsable content type that a module produces. */ export interface OutputPath { @@ -33,16 +65,35 @@ export interface RSpaceModule { description: string; /** Mountable Hono sub-app. Routes are relative to the mount point. */ routes: Hono; + + // โ”€โ”€ Scoping & Schema โ”€โ”€ + + /** How this module's data is scoped (space vs global) */ + scoping: ModuleScoping; + /** Automerge document schemas this module manages */ + docSchemas?: DocSchema[]; + + // โ”€โ”€ Lifecycle hooks โ”€โ”€ + + /** Called once at server startup (register schemas, init DB, etc.) */ + onInit?: (ctx: { syncServer: SyncServer }) => Promise; + /** Called when a new space is created */ + onSpaceCreate?: (ctx: SpaceLifecycleContext) => Promise; + /** Called when a space is deleted */ + onSpaceDelete?: (ctx: SpaceLifecycleContext) => Promise; + /** Called when this module is enabled for a space */ + onSpaceEnable?: (ctx: SpaceLifecycleContext) => Promise; + /** Called when this module is disabled for a space */ + onSpaceDisable?: (ctx: SpaceLifecycleContext) => Promise; + + // โ”€โ”€ Display & routing โ”€โ”€ + /** Optional: standalone domain for this module (e.g. 'rbooks.online') */ standaloneDomain?: string; /** Feeds this module exposes to other layers */ feeds?: FeedDefinition[]; /** Feed kinds this module can consume from other layers */ acceptsFeeds?: FlowKind[]; - /** Called when a new space is created (e.g. to initialize module-specific data) */ - onSpaceCreate?: (spaceSlug: string) => Promise; - /** Called when a space is deleted (e.g. to clean up module-specific data) */ - onSpaceDelete?: (spaceSlug: string) => Promise; /** If true, module is hidden from app switcher (still has routes) */ hidden?: boolean; /** Browsable content types this module produces */ @@ -77,6 +128,7 @@ export interface ModuleInfo { name: string; icon: string; description: string; + scoping: ModuleScoping; standaloneDomain?: string; feeds?: FeedDefinition[]; acceptsFeeds?: FlowKind[]; @@ -97,6 +149,7 @@ export function getModuleInfoList(): ModuleInfo[] { name: m.name, icon: m.icon, description: m.description, + scoping: m.scoping, ...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}), ...(m.feeds ? { feeds: m.feeds } : {}), ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}), From bbe19c520634ebdb93f0ab2a3588dbd26eb939c3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 11:13:54 -0800 Subject: [PATCH 2/4] fix: deep-clone shape data to prevent Automerge proxy re-assignment error Break Automerge proxy chain in #shapeToData() and #updateShapeInDoc() to fix "Cannot create a reference to an existing document object" on canvas load and WS sync. Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 25f6d88..31460af 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -492,7 +492,7 @@ export class CommunitySync extends EventTarget { this.#doc = Automerge.change(this.#doc, `Update shape ${shape.id}`, (doc) => { if (!doc.shapes) doc.shapes = {}; - doc.shapes[shape.id] = shapeData; + doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData)); }); this.#scheduleSave(); @@ -518,7 +518,9 @@ export class CommunitySync extends EventTarget { // Merge all extra properties from toJSON for (const [key, value] of Object.entries(json)) { if (!(key in data)) { - data[key] = value; + data[key] = typeof value === 'object' && value !== null + ? JSON.parse(JSON.stringify(value)) + : value; } } From cbe9fb51032a58711763e6f704576482e24fc688 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 4 Mar 2026 19:16:46 -0800 Subject: [PATCH 3/4] feat: add interactive canvas to rNetwork + rSocials canvas view rNetwork: upgrade folk-graph-viewer with pan/zoom/drag, fitView, touch pinch-zoom, zoom controls, persisted node positions, and incremental drag updates. rSocials: new folk-socials-canvas component with campaign and thread card nodes, Postiz slide-out panel, and canvas interactions. Default / route now renders canvas view; old feed moved to ?view=feed. Co-Authored-By: Claude Opus 4.6 --- .../rnetwork/components/folk-graph-viewer.ts | 387 +++++++++++- modules/rnetwork/components/network.css | 9 +- .../components/folk-socials-canvas.ts | 590 ++++++++++++++++++ .../rsocials/components/socials-canvas.css | 215 +++++++ modules/rsocials/mod.ts | 50 +- 5 files changed, 1199 insertions(+), 52 deletions(-) create mode 100644 modules/rsocials/components/folk-socials-canvas.ts create mode 100644 modules/rsocials/components/socials-canvas.css diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 5686448..ffa5f7b 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -3,6 +3,7 @@ * * Displays network nodes (people, companies, opportunities) * and edges in a force-directed layout with search and filtering. + * Interactive canvas with pan/zoom/drag (rFlows-style). */ interface GraphNode { @@ -34,6 +35,26 @@ class FolkGraphViewer extends HTMLElement { private error = ""; private selectedNode: GraphNode | null = null; + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private isTouchPanning = false; + private lastTouchCenter: { x: number; y: number } | null = null; + private lastTouchDist: number | null = null; + private nodePositions: Record = {}; + private layoutDirty = true; // recompute layout when true + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -64,12 +85,12 @@ class FolkGraphViewer extends HTMLElement { // People โ€” Commons DAO { id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" }, { id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" }, - { id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "S\u00e3o Paulo" }, + { id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "Sรฃo Paulo" }, { id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" }, // People โ€” Mycelial Lab { id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" }, - { id: "p-6", name: "Frank M\u00fcller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" }, + { id: "p-6", name: "Frank Mรผller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" }, { id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" }, // People โ€” Regenerative Fund @@ -97,12 +118,14 @@ class FolkGraphViewer extends HTMLElement { { source: "p-10", target: "org-3", type: "work_at" }, // Cross-org point_of_contact edges - { source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice \u2194 Frank" }, - { source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob \u2194 Carol" }, - { source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave \u2194 Grace" }, + { source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice โ†” Frank" }, + { source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob โ†” Carol" }, + { source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave โ†” Grace" }, ]; + this.layoutDirty = true; this.render(); + requestAnimationFrame(() => this.fitView()); } private getApiBase(): string { @@ -126,7 +149,9 @@ class FolkGraphViewer extends HTMLElement { this.importGraph(graph); } } catch { /* offline */ } + this.layoutDirty = true; this.render(); + requestAnimationFrame(() => this.fitView()); } /** Map server /api/graph response to client GraphNode/GraphEdge format */ @@ -201,6 +226,13 @@ class FolkGraphViewer extends HTMLElement { return filtered; } + private ensureLayout() { + if (!this.layoutDirty && Object.keys(this.nodePositions).length > 0) return; + const W = 800, H = 600; + this.nodePositions = this.computeForceLayout(this.nodes, this.edges, W, H); + this.layoutDirty = false; + } + private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record { const pos: Record = {}; @@ -292,8 +324,6 @@ class FolkGraphViewer extends HTMLElement { for (const id of allIds) { pos[id].x += force[id].fx * damping; pos[id].y += force[id].fy * damping; - pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x)); - pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y)); } } return pos; @@ -336,7 +366,116 @@ class FolkGraphViewer extends HTMLElement { `; } - private renderGraphNodes(): string { + // โ”€โ”€ Canvas transform helpers โ”€โ”€ + + private updateCanvasTransform() { + const g = this.shadow.getElementById("canvas-transform"); + if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + } + + private fitView() { + const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; + if (!svg) return; + this.ensureLayout(); + const filtered = this.getFilteredNodes(); + const positions = filtered.map(n => this.nodePositions[n.id]).filter(Boolean); + if (positions.length === 0) return; + + const pad = 60; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of positions) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + // Include cluster radius for org nodes + minX -= 40; minY -= 40; maxX += 40; maxY += 40; + + const contentW = maxX - minX; + const contentH = maxY - minY; + const rect = svg.getBoundingClientRect(); + const svgW = rect.width || 800; + const svgH = rect.height || 600; + + const zoom = Math.min((svgW - pad * 2) / Math.max(contentW, 1), (svgH - pad * 2) / Math.max(contentH, 1), 2); + this.canvasZoom = Math.max(0.1, Math.min(zoom, 4)); + this.canvasPanX = (svgW / 2) - ((minX + maxX) / 2) * this.canvasZoom; + this.canvasPanY = (svgH / 2) - ((minY + maxY) / 2) * this.canvasZoom; + this.updateCanvasTransform(); + this.updateZoomDisplay(); + } + + private updateZoomDisplay() { + const el = this.shadow.getElementById("zoom-level"); + if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; + } + + // โ”€โ”€ Incremental node update (drag) โ”€โ”€ + + private updateNodePosition(nodeId: string) { + const pos = this.nodePositions[nodeId]; + if (!pos) return; + const g = this.shadow.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; + if (!g) return; + + // Update all circles and texts that use absolute coordinates + const node = this.nodes.find(n => n.id === nodeId); + if (!node) return; + const isOrg = node.type === "company"; + const radius = isOrg ? 22 : 12; + + // Update circles + g.querySelectorAll("circle").forEach(circle => { + const rAttr = circle.getAttribute("r"); + const r = parseFloat(rAttr || "0"); + circle.setAttribute("cx", String(pos.x)); + circle.setAttribute("cy", String(pos.y)); + }); + + // Update text elements by relative offset from center + const texts = g.querySelectorAll("text"); + if (isOrg) { + // [0] inner label, [1] name below, [2] description, [3+] trust badge + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + 4)); } + if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 13)); } + if (texts[2]) { texts[2].setAttribute("x", String(pos.x)); texts[2].setAttribute("y", String(pos.y + radius + 26)); } + } else { + // [0] name below, [1] role/location, [2] trust text + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + radius + 13)); } + if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 24)); } + if (texts[2]) { texts[2].setAttribute("x", String(pos.x + radius - 2)); texts[2].setAttribute("y", String(pos.y - radius + 5.5)); } + } + + // Update connected edges + for (const edge of this.edges) { + if (edge.source !== nodeId && edge.target !== nodeId) continue; + const sp = this.nodePositions[edge.source]; + const tp = this.nodePositions[edge.target]; + if (!sp || !tp) continue; + + // Find the edge line element + const lines = this.shadow.querySelectorAll(`#edge-layer line`); + for (const line of lines) { + const x1 = parseFloat(line.getAttribute("x1") || "0"); + const y1 = parseFloat(line.getAttribute("y1") || "0"); + const x2 = parseFloat(line.getAttribute("x2") || "0"); + const y2 = parseFloat(line.getAttribute("y2") || "0"); + // Match by checking if this line connects these nodes (approximate) + if (edge.source === nodeId) { + // Check if endpoint matches old position pattern โ€” just update all matching edges + line.setAttribute("x1", String(sp.x)); + line.setAttribute("y1", String(sp.y)); + } + if (edge.target === nodeId) { + line.setAttribute("x2", String(tp.x)); + line.setAttribute("y2", String(tp.y)); + } + } + } + } + + private renderGraphSVG(): string { const filtered = this.getFilteredNodes(); if (filtered.length === 0 && this.nodes.length > 0) { return `

No nodes match current filter.

`; @@ -352,13 +491,10 @@ class FolkGraphViewer extends HTMLElement { `; } - const W = 700; - const H = 500; + this.ensureLayout(); + const positions = this.nodePositions; const filteredIds = new Set(filtered.map(n => n.id)); - // Force-directed layout - const positions = this.computeForceLayout(this.nodes, this.edges, W, H); - // Assign colors to companies dynamically const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"]; const companies = this.nodes.filter(n => n.type === "company"); @@ -368,7 +504,7 @@ class FolkGraphViewer extends HTMLElement { // Cluster backgrounds based on computed positions const clustersSvg = companies.map(org => { const pos = positions[org.id]; - if (!pos) return ""; + if (!pos || !filteredIds.has(org.id)) return ""; const color = orgColors[org.id] || "#333"; return ``; }).join(""); @@ -393,7 +529,6 @@ class FolkGraphViewer extends HTMLElement { } else if (edge.type === "collaborates") { edgesSvg.push(``); } else { - // Fallback for any unknown edge type edgesSvg.push(``); } } @@ -412,7 +547,7 @@ class FolkGraphViewer extends HTMLElement { if (isOrg && node.description) { sublabel = `${this.esc(node.description)}`; } else if (!isOrg && node.role) { - sublabel = `${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}`; + sublabel = `${this.esc(node.role)}${node.location ? " ยท " + this.esc(node.location) : ""}`; } // Trust score badge for people @@ -434,19 +569,33 @@ class FolkGraphViewer extends HTMLElement { `; }).join(""); - return `${clustersSvg}${edgesSvg.join("")}${nodesSvg}`; + return ` + + + ${clustersSvg} + ${edgesSvg.join("")} + ${nodesSvg} + + +
+ + ${Math.round(this.canvasZoom * 100)}% + + +
+ `; } private render() { this.shadow.innerHTML = ` `; - - const styles = isDemo - ? demoFeedStyles - : ``; - - return c.html( - renderShell({ - title: `${space} โ€” Socials | rSpace`, + const styles = isDemo ? demoFeedStyles : ``; + return c.html(renderShell({ + title: `${space} โ€” Socials Feed | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body, styles, - }), - ); + })); + } + + if (view === "landing") { + return c.html(renderShell({ + title: `${space} โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderLanding(), + styles: ``, + })); + } + + // Default: canvas view + return c.html(renderShell({ + title: `${space} โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + body: ``, + scripts: ``, + styles: ``, + theme: "dark", + })); }); export const socialsModule: RSpaceModule = { From 91d414fc881e8db227f65e7d0d99d3c28fb4794d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 4 Mar 2026 20:42:18 -0800 Subject: [PATCH 4/4] feat: refactor rSocials from monolith to full rApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose the 2,116-line mod.ts into a canonical rApp matching the rFlows/rBooks pattern with Automerge sync, web components, and extracted CSS. New files: - schemas.ts: SocialsDoc, ThreadData, Campaign types + Automerge schema - local-first-client.ts: browser-side sync client - lib/types.ts: shared types, DEMO_FEED, PLATFORM_LIMITS - lib/image-gen.ts: server-only fal.ai + file upload helpers - components/folk-thread-builder.ts: compose/preview/readonly component - components/folk-thread-gallery.ts: thread listing grid component - components/folk-campaign-manager.ts: campaign viewer with import modal - components/socials.css: all extracted CSS (~550 lines) mod.ts slimmed to ~616 lines: ensureDoc, image APIs, page routes injecting web components, fileโ†’Automerge migration, seed template, and module export. Thread/campaign CRUD moved from REST to Automerge local-first sync. Co-Authored-By: Claude Opus 4.6 --- .../components/folk-campaign-manager.ts | 314 +++ .../components/folk-thread-builder.ts | 1172 +++++++++ .../components/folk-thread-gallery.ts | 175 ++ modules/rsocials/components/socials.css | 368 +++ modules/rsocials/lib/image-gen.ts | 97 + modules/rsocials/lib/types.ts | 98 + modules/rsocials/local-first-client.ts | 143 ++ modules/rsocials/mod.ts | 2238 +++-------------- modules/rsocials/schemas.ts | 90 + vite.config.ts | 54 +- 10 files changed, 2865 insertions(+), 1884 deletions(-) create mode 100644 modules/rsocials/components/folk-campaign-manager.ts create mode 100644 modules/rsocials/components/folk-thread-builder.ts create mode 100644 modules/rsocials/components/folk-thread-gallery.ts create mode 100644 modules/rsocials/components/socials.css create mode 100644 modules/rsocials/lib/image-gen.ts create mode 100644 modules/rsocials/lib/types.ts create mode 100644 modules/rsocials/local-first-client.ts create mode 100644 modules/rsocials/schemas.ts diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts new file mode 100644 index 0000000..5ef0806 --- /dev/null +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -0,0 +1,314 @@ +/** + * โ€” Campaign viewer/editor with import modal. + * + * Subscribes to Automerge doc for campaign data. Falls back to MYCOFI_CAMPAIGN + * demo data when no campaigns exist or space=demo. + */ + +import { socialsSchema, socialsDocId } from '../schemas'; +import type { SocialsDoc, Campaign, CampaignPost } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; +import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; + +export class FolkCampaignManager extends HTMLElement { + private _space = 'demo'; + private _campaigns: Campaign[] = []; + private _offlineUnsub: (() => void) | null = null; + + static get observedAttributes() { return ['space']; } + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + // Start with demo campaign + this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }]; + this.render(); + if (this._space !== 'demo') { + this.subscribeOffline(); + } + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = socialsDocId(this._space) as DocumentId; + const doc = await runtime.subscribe(docId, socialsSchema); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + // Runtime unavailable โ€” use demo data + } + } + + private renderFromDoc(doc: SocialsDoc) { + if (!doc?.campaigns || Object.keys(doc.campaigns).length === 0) return; + this._campaigns = Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt); + this.render(); + } + + private saveCampaignToDoc(campaign: Campaign) { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + const docId = socialsDocId(this._space) as DocumentId; + runtime.change(docId, `Save campaign ${campaign.title}`, (d: SocialsDoc) => { + if (!d.campaigns) d.campaigns = {} as any; + campaign.updatedAt = Date.now(); + d.campaigns[campaign.id] = campaign; + }); + } + + private esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + private renderCampaign(c: Campaign): string { + const phases = [1, 2, 3]; + const phaseIcons = ['๐Ÿ“ฃ', '๐Ÿš€', '๐Ÿ“ก']; + + const phaseHTML = phases.map((phaseNum, i) => { + const phasePosts = c.posts.filter(p => p.phase === phaseNum); + if (!phasePosts.length && !c.phases[i]) return ''; + const phaseInfo = c.phases[i] || { label: `Phase ${phaseNum}`, days: '' }; + + const postsHTML = phasePosts.map(post => { + const icon = PLATFORM_ICONS[post.platform] || post.platform; + const color = PLATFORM_COLORS[post.platform] || '#64748b'; + const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft'; + const date = new Date(post.scheduledAt); + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + const contentPreview = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content); + const tags = post.hashtags.map(h => `#${this.esc(h)}`).join(' '); + + return ` +
+
+ ${icon} + + ${this.esc(post.status)} +
+
Step ${post.stepNumber}
+

${contentPreview.replace(/\n/g, '
')}

+ +
`; + }).join(''); + + return ` +
+

${phaseIcons[i] || '๐Ÿ“‹'} Phase ${phaseNum}: ${this.esc(phaseInfo.label)} ${this.esc(phaseInfo.days)}

+
${postsHTML}
+
`; + }).join(''); + + return ` +
+ ๐Ÿ„ +
+

${this.esc(c.title)}

+

${this.esc(c.description)}

+
+ ๐Ÿ“… ${this.esc(c.duration)} + ๐Ÿ“ฑ ${c.platforms.join(', ')} + ๐Ÿ“ ${c.posts.length} posts across ${c.phases.length} phases +
+
+
+
+ Open Thread Builder + +
+ ${phaseHTML} +
`; + } + + private render() { + if (!this.shadowRoot) return; + + const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }; + const campaignHTML = this.renderCampaign(c); + + this.shadowRoot.innerHTML = ` + +
+ ${campaignHTML} +
+ + `; + + this.bindEvents(); + } + + private bindEvents() { + if (!this.shadowRoot) return; + + const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement; + const openBtn = this.shadowRoot.getElementById('import-md-btn'); + const closeBtn = this.shadowRoot.getElementById('import-modal-close'); + const parseBtn = this.shadowRoot.getElementById('import-parse-btn'); + const mdInput = this.shadowRoot.getElementById('import-md-textarea') as HTMLTextAreaElement; + const platformSel = this.shadowRoot.getElementById('import-platform') as HTMLSelectElement; + const importedEl = this.shadowRoot.getElementById('imported-posts'); + + openBtn?.addEventListener('click', () => { modal.hidden = false; }); + closeBtn?.addEventListener('click', () => { modal.hidden = true; }); + modal?.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; }); + + parseBtn?.addEventListener('click', () => { + const raw = mdInput.value; + const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + if (!tweets.length || !importedEl) return; + const platform = platformSel.value; + const total = tweets.length; + + // Build imported posts as campaign posts and save to Automerge + const posts: CampaignPost[] = tweets.map((text, i) => ({ + id: `imported-${Date.now()}-${i}`, + platform, + postType: 'text', + stepNumber: i + 1, + content: text, + scheduledAt: new Date().toISOString(), + status: 'imported', + hashtags: [], + phase: 1, + phaseLabel: 'Imported', + })); + + // Render imported posts inline + let html = `

๐Ÿ“ฅ Imported Posts (${total})

`; + html += '
'; + tweets.forEach((text, i) => { + const preview = text.length > 180 ? this.esc(text.substring(0, 180)) + '...' : this.esc(text); + html += `
+
+ ${this.esc(platform.charAt(0).toUpperCase())} + + imported +
+
Tweet ${i + 1}/${total}
+

${preview.replace(/\n/g, '
')}

+
`; + }); + html += '
'; + importedEl.innerHTML = html; + modal.hidden = true; + + // Save to Automerge if runtime available + if (this._space !== 'demo') { + const c = this._campaigns[0]; + if (c) { + c.posts = [...c.posts, ...posts]; + this.saveCampaignToDoc(c); + } + } + }); + } +} + +customElements.define('folk-campaign-manager', FolkCampaignManager); diff --git a/modules/rsocials/components/folk-thread-builder.ts b/modules/rsocials/components/folk-thread-builder.ts new file mode 100644 index 0000000..65a0a55 --- /dev/null +++ b/modules/rsocials/components/folk-thread-builder.ts @@ -0,0 +1,1172 @@ +/** + * โ€” Thread compose/preview/readonly web component. + * + * Attributes: + * space โ€” space slug + * thread-id โ€” existing thread ID (for edit/readonly) + * mode โ€” "new" | "edit" | "readonly" + * + * In new/edit mode: compose pane + live preview, auto-save to Automerge, + * draft management, image ops, export to multiple platforms. + * + * In readonly mode: display thread with share/copy/export actions. + */ + +import { socialsSchema, socialsDocId } from '../schemas'; +import type { SocialsDoc, ThreadData } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; +import { PLATFORM_LIMITS } from '../lib/types'; + +function generateThreadId(): string { + const random = Math.random().toString(36).substring(2, 8); + return `t-${Date.now()}-${random}`; +} + +export class FolkThreadBuilder extends HTMLElement { + private _space = 'demo'; + private _threadId: string | null = null; + private _mode: 'new' | 'edit' | 'readonly' = 'new'; + private _thread: ThreadData | null = null; + private _tweetImages: Record = {}; + private _autoSaveTimer: ReturnType | null = null; + private _offlineUnsub: (() => void) | null = null; + private _tweetImageUploadIdx: string | null = null; + + // SVG icons + private svgReply = ''; + private svgRetweet = ''; + private svgHeart = ''; + private svgShare = ''; + private svgUpload = ''; + private svgSparkle = ''; + private svgCamera = ''; + + static get observedAttributes() { return ['space', 'thread-id', 'mode']; } + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + this._threadId = this.getAttribute('thread-id') || null; + this._mode = (this.getAttribute('mode') as any) || 'new'; + + // Check for server-hydrated data + if ((window as any).__THREAD_DATA__) { + const data = (window as any).__THREAD_DATA__; + this._thread = data; + this._threadId = data.id; + this._tweetImages = data.tweetImages || {}; + } + + this.render(); + + if (this._space !== 'demo') { + this.subscribeOffline(); + } + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + else if (name === 'thread-id') this._threadId = val; + else if (name === 'mode') this._mode = val as any; + } + + private get basePath() { + return `/${this._space}/rsocials/`; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = socialsDocId(this._space) as DocumentId; + const doc = await runtime.subscribe(docId, socialsSchema); + + if (this._threadId && doc?.threads?.[this._threadId] && !this._thread) { + this._thread = doc.threads[this._threadId]; + this._tweetImages = this._thread?.tweetImages || {}; + this.render(); + } + + this._offlineUnsub = runtime.onChange(docId, (updated: SocialsDoc) => { + if (this._threadId && updated?.threads?.[this._threadId]) { + this._thread = updated.threads[this._threadId]; + } + }); + } catch { + // Working without offline runtime + } + } + + private esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + private getRuntime() { + return (window as any).__rspaceOfflineRuntime; + } + + private saveToAutomerge(thread: ThreadData) { + const runtime = this.getRuntime(); + if (!runtime?.isInitialized) return; + + const docId = socialsDocId(this._space) as DocumentId; + runtime.change(docId, `Save thread ${thread.title || thread.id}`, (d: SocialsDoc) => { + if (!d.threads) d.threads = {} as any; + thread.updatedAt = Date.now(); + d.threads[thread.id] = thread; + }); + } + + private deleteFromAutomerge(id: string) { + const runtime = this.getRuntime(); + if (!runtime?.isInitialized) return; + + const docId = socialsDocId(this._space) as DocumentId; + runtime.change(docId, `Delete thread ${id}`, (d: SocialsDoc) => { + if (d.threads?.[id]) delete d.threads[id]; + }); + } + + private getDoc(): SocialsDoc | undefined { + const runtime = this.getRuntime(); + if (!runtime?.isInitialized) return undefined; + const docId = socialsDocId(this._space) as DocumentId; + return runtime.getDoc(docId); + } + + private listThreads(): ThreadData[] { + const doc = this.getDoc(); + if (!doc?.threads) return []; + return Object.values(doc.threads).sort((a: ThreadData, b: ThreadData) => b.updatedAt - a.updatedAt); + } + + // โ”€โ”€ Rendering โ”€โ”€ + + private render() { + if (!this.shadowRoot) return; + + if (this._mode === 'readonly') { + this.renderReadonly(); + } else { + this.renderEditor(); + } + } + + private renderReadonly() { + if (!this.shadowRoot || !this._thread) return; + const t = this._thread; + const name = this.esc(t.name || 'Anonymous'); + const handle = this.esc(t.handle || '@anonymous'); + const initial = name.charAt(0).toUpperCase(); + const total = t.tweets.length; + const dateStr = new Date(t.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + + const tweetCards = t.tweets.map((text, i) => { + const len = text.length; + const connector = i > 0 ? '
' : ''; + const tweetImgUrl = t.tweetImages?.[String(i)]; + const tweetImgHtml = tweetImgUrl + ? `
Tweet image
` + : ''; + return `
+ ${connector} +
+
${this.esc(initial)}
+ ${name} + ${handle} + · + ${this.esc(dateStr)} +
+

${this.esc(text)}

+ ${tweetImgHtml} + +
`; + }).join('\n'); + + const imageHTML = t.imageUrl + ? `
Thread preview
` + : ''; + + this.shadowRoot.innerHTML = ` + +
+
+
+
${this.esc(initial)}
+
+
${name}
+
${handle}
+
+
+
+ ${total} tweet${total === 1 ? '' : 's'} + · + ${this.esc(dateStr)} +
+
+ ${t.title ? `

${this.esc(t.title)}

` : ''} + ${imageHTML} +
${tweetCards}
+
+ Edit Thread + + +
+ + +
+
+ +
+ + `; + + this.bindReadonlyEvents(); + } + + private renderEditor() { + if (!this.shadowRoot) return; + const t = this._thread; + + this.shadowRoot.innerHTML = ` + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + + +
+ Preview +
+
+
+
+
Your tweet thread preview will appear here
+
+ +
+ `; + + if (t) { + this._tweetImages = t.tweetImages || {}; + } + + this.bindEditorEvents(); + if (t) this.renderPreview(); + } + + // โ”€โ”€ Preview rendering โ”€โ”€ + + private renderPreview() { + const sr = this.shadowRoot; + if (!sr) return; + const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; + const preview = sr.getElementById('thread-preview'); + const nameInput = sr.getElementById('thread-name') as HTMLInputElement; + const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; + if (!textarea || !preview || !nameInput || !handleInput) return; + + const raw = textarea.value; + const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + const name = nameInput.value || 'Your Name'; + const handle = handleInput.value || '@yourhandle'; + const initial = name.charAt(0).toUpperCase(); + const total = tweets.length; + + if (!total) { + preview.innerHTML = '
Your tweet thread preview will appear here
'; + return; + } + + preview.innerHTML = tweets.map((text, i) => { + const len = text.length; + const overClass = len > 280 ? ' tc-chars--over' : ''; + const connector = i > 0 ? '
' : ''; + const imgUrl = this._tweetImages[String(i)]; + const imgHtml = imgUrl + ? `
+ Tweet image + +
` + : ''; + const photoBtn = !imgUrl + ? ` + ` + : ''; + return `
+ ${connector} + ${photoBtn} +
+
${this.esc(initial)}
+ ${this.esc(name)} + ${this.esc(handle)} + · + now +
+

${this.esc(text)}

+ ${imgHtml} + +
`; + }).join(''); + } + + // โ”€โ”€ Draft management (Automerge) โ”€โ”€ + + private saveDraft() { + const sr = this.shadowRoot; + if (!sr) return; + const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; + const nameInput = sr.getElementById('thread-name') as HTMLInputElement; + const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; + const titleInput = sr.getElementById('thread-title') as HTMLInputElement; + const saveBtn = sr.getElementById('thread-save') as HTMLButtonElement; + + const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + if (!tweets.length) return; + + if (!this._threadId) { + this._threadId = generateThreadId(); + } + + const thread: ThreadData = { + id: this._threadId, + name: nameInput.value || 'Your Name', + handle: handleInput.value || '@yourhandle', + title: titleInput.value || tweets[0].substring(0, 60), + tweets, + tweetImages: Object.keys(this._tweetImages).length ? { ...this._tweetImages } : undefined, + imageUrl: this._thread?.imageUrl, + createdAt: this._thread?.createdAt || Date.now(), + updatedAt: Date.now(), + }; + + this._thread = thread; + + // Save via Automerge + this.saveToAutomerge(thread); + + // Update URL + history.replaceState(null, '', this.basePath + 'thread/' + this._threadId + '/edit'); + + if (saveBtn) { + saveBtn.textContent = 'Saved!'; + setTimeout(() => { saveBtn.textContent = 'Save Draft'; }, 2000); + } + + this.loadDraftList(); + } + + private loadDraft(id: string) { + const doc = this.getDoc(); + const thread = doc?.threads?.[id]; + if (!thread) return; + + this._threadId = thread.id; + this._thread = thread; + this._tweetImages = thread.tweetImages || {}; + + const sr = this.shadowRoot; + if (!sr) return; + const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; + const nameInput = sr.getElementById('thread-name') as HTMLInputElement; + const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; + const titleInput = sr.getElementById('thread-title') as HTMLInputElement; + const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; + const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement; + const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; + const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; + + textarea.value = thread.tweets.join('\n---\n'); + nameInput.value = thread.name || ''; + handleInput.value = thread.handle || ''; + titleInput.value = thread.title || ''; + + if (thread.imageUrl) { + imageThumb.src = thread.imageUrl; + imagePreview.hidden = false; + genBtn.textContent = 'Replace with AI'; + uploadBtn.textContent = 'Replace Image'; + } else { + imagePreview.hidden = true; + genBtn.textContent = 'Generate with AI'; + uploadBtn.textContent = 'Upload Image'; + } + + history.replaceState(null, '', this.basePath + 'thread/' + thread.id + '/edit'); + this.renderPreview(); + this.loadDraftList(); + } + + private async deleteDraft(id: string) { + if (!confirm('Delete this draft?')) return; + + // Delete images on server + try { + await fetch(this.basePath + 'api/threads/' + id + '/images', { method: 'DELETE' }); + } catch { /* ignore */ } + + // Remove from Automerge + this.deleteFromAutomerge(id); + + if (this._threadId === id) { + this._threadId = null; + this._thread = null; + this._tweetImages = {}; + history.replaceState(null, '', this.basePath + 'thread'); + + const sr = this.shadowRoot; + if (sr) { + const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; + const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; + const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; + const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement; + if (imagePreview) imagePreview.hidden = true; + if (genBtn) genBtn.textContent = 'Generate with AI'; + if (uploadBtn) uploadBtn.textContent = 'Upload Image'; + if (shareLinkArea) shareLinkArea.innerHTML = ''; + } + } + + this.loadDraftList(); + } + + private loadDraftList() { + const sr = this.shadowRoot; + if (!sr) return; + const draftsList = sr.getElementById('drafts-list'); + if (!draftsList) return; + + const threads = this.listThreads(); + + if (!threads.length) { + draftsList.innerHTML = '
No saved drafts
'; + return; + } + + draftsList.innerHTML = threads.map(t => { + const date = new Date(t.updatedAt); + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const active = t.id === this._threadId ? ' draft-item--active' : ''; + return `
+
+ ${this.esc(t.title || 'Untitled')} + ${t.tweets.length} tweets · ${dateStr} +
+ +
`; + }).join(''); + + // Attach events + draftsList.querySelectorAll('[data-load-id]').forEach(el => { + el.addEventListener('click', () => this.loadDraft((el as HTMLElement).dataset.loadId!)); + }); + draftsList.querySelectorAll('[data-delete-id]').forEach(el => { + el.addEventListener('click', (e) => { e.stopPropagation(); this.deleteDraft((el as HTMLElement).dataset.deleteId!); }); + }); + } + + // โ”€โ”€ Image operations (server API) โ”€โ”€ + + private async generateImage() { + if (!this._threadId) { + this.saveDraft(); + if (!this._threadId) return; + } + + const sr = this.shadowRoot; + if (!sr) return; + const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; + const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; + const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement; + const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; + + genBtn.textContent = 'Generating...'; + genBtn.disabled = true; + + try { + const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/image', { method: 'POST' }); + const data = await res.json(); + + if (data.imageUrl) { + imageThumb.src = data.imageUrl; + imagePreview.hidden = false; + genBtn.textContent = 'Replace with AI'; + uploadBtn.textContent = 'Replace Image'; + // Update Automerge with new image URL + if (this._thread) { + this._thread.imageUrl = data.imageUrl; + this.saveToAutomerge(this._thread); + } + } else { + genBtn.textContent = 'Generation Failed'; + setTimeout(() => { genBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000); + } + } catch { + genBtn.textContent = 'Generation Failed'; + setTimeout(() => { genBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000); + } finally { + genBtn.disabled = false; + } + } + + private async uploadImage(file: File) { + if (!this._threadId) { + this.saveDraft(); + if (!this._threadId) return; + } + + const sr = this.shadowRoot; + if (!sr) return; + const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement; + const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; + const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement; + const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement; + + uploadBtn.textContent = 'Uploading...'; + uploadBtn.disabled = true; + + try { + const form = new FormData(); + form.append('file', file); + const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/upload-image', { method: 'POST', body: form }); + const data = await res.json(); + + if (data.imageUrl) { + imageThumb.src = data.imageUrl; + imagePreview.hidden = false; + uploadBtn.textContent = 'Replace Image'; + genBtn.textContent = 'Replace with AI'; + if (this._thread) { + this._thread.imageUrl = data.imageUrl; + this.saveToAutomerge(this._thread); + } + } else { + uploadBtn.textContent = data.error || 'Upload Failed'; + setTimeout(() => { uploadBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000); + } + } catch { + uploadBtn.textContent = 'Upload Failed'; + setTimeout(() => { uploadBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000); + } finally { + uploadBtn.disabled = false; + } + } + + private async uploadTweetImage(index: string, file: File) { + if (!this._threadId) { + this.saveDraft(); + if (!this._threadId) return; + } + + try { + const form = new FormData(); + form.append('file', file); + const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/tweet/' + index + '/upload-image', { method: 'POST', body: form }); + const data = await res.json(); + if (data.imageUrl) { + this._tweetImages[index] = data.imageUrl; + if (this._thread) { + this._thread.tweetImages = { ...this._tweetImages }; + this.saveToAutomerge(this._thread); + } + this.renderPreview(); + } + } catch (e) { console.error('Tweet image upload failed:', e); } + } + + private async generateTweetImage(index: string) { + if (!this._threadId) { + this.saveDraft(); + if (!this._threadId) return; + } + + const sr = this.shadowRoot; + const btn = sr?.querySelector(`[data-generate-idx="${index}"]`) as HTMLButtonElement; + if (btn) { btn.textContent = 'Generating...'; btn.disabled = true; } + + try { + const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/tweet/' + index + '/image', { method: 'POST' }); + const data = await res.json(); + if (data.imageUrl) { + this._tweetImages[index] = data.imageUrl; + if (this._thread) { + this._thread.tweetImages = { ...this._tweetImages }; + this.saveToAutomerge(this._thread); + } + this.renderPreview(); + } else if (btn) { + btn.textContent = 'Failed'; + setTimeout(() => this.renderPreview(), 2000); + } + } catch { + if (btn) { btn.textContent = 'Failed'; setTimeout(() => this.renderPreview(), 2000); } + } + } + + private async removeTweetImage(index: string) { + if (!this._threadId) return; + try { + await fetch(this.basePath + 'api/threads/' + this._threadId + '/tweet/' + index + '/image', { method: 'DELETE' }); + delete this._tweetImages[index]; + if (this._thread) { + this._thread.tweetImages = Object.keys(this._tweetImages).length ? { ...this._tweetImages } : undefined; + this.saveToAutomerge(this._thread); + } + this.renderPreview(); + } catch (e) { console.error('Tweet image removal failed:', e); } + } + + // โ”€โ”€ Export โ”€โ”€ + + private formatForPlatform(platform: string): { text: string; warnings: string[] } { + const sr = this.shadowRoot; + if (!sr) return { text: '', warnings: [] }; + const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; + const titleInput = sr.getElementById('thread-title') as HTMLInputElement; + + const limit = PLATFORM_LIMITS[platform] || 280; + const tweets = (this._mode === 'readonly' && this._thread) + ? this._thread.tweets + : textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + const total = tweets.length; + const title = (this._mode === 'readonly' && this._thread) ? this._thread.title : titleInput?.value || ''; + const warnings: string[] = []; + + if (platform === 'linkedin') { + let text = ''; + if (title) text += title + '\n\n'; + text += tweets.join('\n\n'); + text += '\n\n---\nOriginally composed as a ' + total + '-tweet thread.'; + return { text, warnings: text.length > limit ? ['Content exceeds LinkedIn\'s ' + limit + ' char limit (' + text.length + ' chars)'] : [] }; + } + + const parts = tweets.map((t, i) => { + const prefix = total > 1 ? (i + 1) + '/' + total + ' ' : ''; + const full = prefix + t; + if (full.length > limit) warnings.push('Tweet ' + (i + 1) + ' exceeds ' + limit + ' chars (' + full.length + ')'); + return full; + }); + + return { text: parts.join('\n\n'), warnings }; + } + + // โ”€โ”€ Auto-save โ”€โ”€ + + private scheduleAutoSave() { + if (!this._threadId) return; + if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); + this._autoSaveTimer = setTimeout(() => this.saveDraft(), 1500); + } + + // โ”€โ”€ Event binding โ”€โ”€ + + private bindEditorEvents() { + const sr = this.shadowRoot; + if (!sr) return; + + const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement; + const nameInput = sr.getElementById('thread-name') as HTMLInputElement; + const handleInput = sr.getElementById('thread-handle') as HTMLInputElement; + const titleInput = sr.getElementById('thread-title') as HTMLInputElement; + const saveBtn = sr.getElementById('thread-save'); + const shareBtn = sr.getElementById('thread-share'); + const copyBtn = sr.getElementById('thread-copy'); + const genImageBtn = sr.getElementById('gen-image-btn'); + const uploadImageBtn = sr.getElementById('upload-image-btn'); + const uploadImageInput = sr.getElementById('upload-image-input') as HTMLInputElement; + const toggleDraftsBtn = sr.getElementById('toggle-drafts'); + const draftsList = sr.getElementById('drafts-list') as HTMLElement; + const preview = sr.getElementById('thread-preview') as HTMLElement; + const tweetImageInput = sr.getElementById('tweet-image-input') as HTMLInputElement; + + // Preview updates + textarea?.addEventListener('input', () => this.renderPreview()); + nameInput?.addEventListener('input', () => this.renderPreview()); + handleInput?.addEventListener('input', () => this.renderPreview()); + + // Auto-save on blur + textarea?.addEventListener('blur', () => this.scheduleAutoSave()); + nameInput?.addEventListener('blur', () => this.scheduleAutoSave()); + handleInput?.addEventListener('blur', () => this.scheduleAutoSave()); + titleInput?.addEventListener('blur', () => this.scheduleAutoSave()); + + // Buttons + saveBtn?.addEventListener('click', () => this.saveDraft()); + shareBtn?.addEventListener('click', () => this.shareThread()); + genImageBtn?.addEventListener('click', () => this.generateImage()); + uploadImageBtn?.addEventListener('click', () => uploadImageInput?.click()); + uploadImageInput?.addEventListener('change', () => { + const file = uploadImageInput.files?.[0]; + if (file) this.uploadImage(file); + uploadImageInput.value = ''; + }); + + // Drafts toggle + toggleDraftsBtn?.addEventListener('click', () => { + draftsList.hidden = !draftsList.hidden; + toggleDraftsBtn.innerHTML = draftsList.hidden ? 'Saved Drafts ▾' : 'Saved Drafts ▴'; + }); + + // Copy thread + copyBtn?.addEventListener('click', async () => { + const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + if (!tweets.length) return; + const total = tweets.length; + const text = tweets.map((t, i) => (i + 1) + '/' + total + '\n' + t).join('\n\n'); + try { + await navigator.clipboard.writeText(text); + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); + } catch { + copyBtn.textContent = 'Failed'; + setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); + } + }); + + // Export dropdown + const exportBtn = sr.getElementById('thread-export-btn'); + const exportMenu = sr.getElementById('thread-export-menu'); + exportBtn?.addEventListener('click', () => { if (exportMenu) exportMenu.hidden = !exportMenu.hidden; }); + sr.addEventListener('click', (e) => { + if (!exportBtn?.contains(e.target as Node) && !exportMenu?.contains(e.target as Node)) { + if (exportMenu) exportMenu.hidden = true; + } + }); + + exportMenu?.querySelectorAll('button[data-platform]').forEach(btn => { + btn.addEventListener('click', async () => { + const platform = (btn as HTMLElement).dataset.platform!; + const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean); + if (!tweets.length) return; + const { text, warnings } = this.formatForPlatform(platform); + try { + await navigator.clipboard.writeText(text); + const label = warnings.length + ? 'Copied with warnings: ' + warnings.join('; ') + : 'Copied for ' + platform + '!'; + if (copyBtn) { + copyBtn.textContent = label; + setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 3000); + } + } catch { + if (copyBtn) { copyBtn.textContent = 'Failed'; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); } + } + if (exportMenu) exportMenu.hidden = true; + }); + }); + + // Per-tweet image operations (event delegation) + preview?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + const photoBtn = target.closest('[data-photo-idx]') as HTMLElement; + if (photoBtn) { + const idx = photoBtn.dataset.photoIdx!; + const menu = preview.querySelector(`[data-menu-idx="${idx}"]`) as HTMLElement; + if (menu) { + const wasHidden = menu.hidden; + preview.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); + menu.hidden = !wasHidden; + } + return; + } + + const uploadTweetBtn = target.closest('[data-upload-idx]') as HTMLElement; + if (uploadTweetBtn) { + preview.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); + this._tweetImageUploadIdx = uploadTweetBtn.dataset.uploadIdx!; + tweetImageInput?.click(); + return; + } + + const genTweetBtn = target.closest('[data-generate-idx]') as HTMLElement; + if (genTweetBtn) { + preview.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); + this.generateTweetImage(genTweetBtn.dataset.generateIdx!); + return; + } + + const removeBtn = target.closest('[data-remove-idx]') as HTMLElement; + if (removeBtn) { + this.removeTweetImage(removeBtn.dataset.removeIdx!); + return; + } + }); + + // Close photo menus on outside click + sr.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.closest('.photo-btn') && !target.closest('.photo-menu')) { + preview?.querySelectorAll('.photo-menu').forEach(m => (m as HTMLElement).hidden = true); + } + }); + + // Tweet image file input + tweetImageInput?.addEventListener('change', () => { + const file = tweetImageInput.files?.[0]; + if (file && this._tweetImageUploadIdx !== null) { + this.uploadTweetImage(this._tweetImageUploadIdx, file); + } + tweetImageInput.value = ''; + this._tweetImageUploadIdx = null; + }); + + // Load initial draft list + this.loadDraftList(); + } + + private async shareThread() { + const sr = this.shadowRoot; + if (!sr) return; + const shareBtn = sr.getElementById('thread-share') as HTMLButtonElement; + const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement; + const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement; + + shareBtn.textContent = 'Saving...'; + shareBtn.disabled = true; + + try { + this.saveDraft(); + if (!this._threadId) { shareBtn.textContent = 'Share'; shareBtn.disabled = false; return; } + + if (imagePreview?.hidden) { + shareBtn.textContent = 'Generating image...'; + await this.generateImage(); + } + + const url = window.location.origin + this.basePath + 'thread/' + this._threadId; + try { + await navigator.clipboard.writeText(url); + shareBtn.textContent = 'Link Copied!'; + } catch { + shareBtn.textContent = 'Shared!'; + } + + shareLinkArea.innerHTML = ``; + sr.getElementById('copy-share-link')?.addEventListener('click', () => { + navigator.clipboard.writeText(url); + }); + } catch { + shareBtn.textContent = 'Error'; + } + setTimeout(() => { shareBtn.textContent = 'Share'; shareBtn.disabled = false; }, 3000); + } + + private bindReadonlyEvents() { + const sr = this.shadowRoot; + if (!sr || !this._thread) return; + + const t = this._thread; + + sr.getElementById('ro-copy-thread')?.addEventListener('click', async () => { + const text = t.tweets.map((tw, i) => (i + 1) + '/' + t.tweets.length + '\n' + tw).join('\n\n'); + try { await navigator.clipboard.writeText(text); this.showToast('Thread copied!'); } + catch { this.showToast('Failed to copy'); } + }); + + sr.getElementById('ro-copy-link')?.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(window.location.href); this.showToast('Link copied!'); } + catch { this.showToast('Failed to copy'); } + }); + + const exportBtn = sr.getElementById('ro-export-btn'); + const exportMenu = sr.getElementById('ro-export-menu'); + exportBtn?.addEventListener('click', () => { if (exportMenu) exportMenu.hidden = !exportMenu.hidden; }); + sr.addEventListener('click', (e) => { + if (!exportBtn?.contains(e.target as Node) && !exportMenu?.contains(e.target as Node)) { + if (exportMenu) exportMenu.hidden = true; + } + }); + + exportMenu?.querySelectorAll('button[data-platform]').forEach(btn => { + btn.addEventListener('click', async () => { + const platform = (btn as HTMLElement).dataset.platform!; + const { text, warnings } = this.formatForPlatform(platform); + try { + await navigator.clipboard.writeText(text); + this.showToast(warnings.length ? 'Copied with warnings: ' + warnings.join('; ') : 'Copied for ' + platform + '!'); + } catch { this.showToast('Failed to copy'); } + if (exportMenu) exportMenu.hidden = true; + }); + }); + } + + private showToast(msg: string) { + const toast = this.shadowRoot?.getElementById('export-toast'); + if (!toast) return; + toast.textContent = msg; + toast.hidden = false; + setTimeout(() => { toast.hidden = true; }, 2500); + } + + // โ”€โ”€ Styles โ”€โ”€ + + private getBaseStyles(): string { + return ` + :host { display: block; } + .btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; } + .btn--primary { background: #6366f1; color: white; } + .btn--primary:hover { background: #818cf8; } + .btn--outline { background: transparent; color: var(--rs-text-secondary, #94a3b8); border: 1px solid var(--rs-input-border, #334155); } + .btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } + .btn--success { background: #10b981; color: white; } + .btn--success:hover { background: #34d399; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .tweet-card { + position: relative; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem; + padding: 1rem; margin-bottom: 0; + } + .tweet-card + .tweet-card { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: -1px; } + .tweet-card:has(+ .tweet-card) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } + .connector { position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; background: var(--rs-input-border, #334155); z-index: 1; } + .tc-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } + .tc-avatar { width: 40px; height: 40px; border-radius: 50%; background: #6366f1; display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 1rem; flex-shrink: 0; } + .tc-name { font-weight: 700; color: var(--rs-text-primary, #f1f5f9); font-size: 0.9rem; } + .tc-handle { color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } + .tc-dot { color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } + .tc-time { color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } + .tc-content { color: var(--rs-text-primary, #f1f5f9); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } + .tc-footer { display: flex; align-items: center; justify-content: space-between; } + .tc-actions { display: flex; gap: 1.25rem; } + .tc-action { display: flex; align-items: center; gap: 0.3rem; color: var(--rs-text-muted, #64748b); font-size: 0.8rem; cursor: default; } + .tc-action svg { width: 16px; height: 16px; } + .tc-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted, #64748b); } + .tc-chars { font-variant-numeric: tabular-nums; } + .tc-chars--over { color: #ef4444; font-weight: 600; } + .tc-thread-num { color: #6366f1; font-weight: 600; } + .attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); } + .attached-image img { display: block; width: 100%; height: auto; } + .image-remove { + position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; + border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none; + font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; + justify-content: center; line-height: 1; transition: background 0.15s; + } + .image-remove:hover { background: #ef4444; } + + .export-dropdown { position: relative; } + .export-menu { + position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; + background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; + min-width: 180px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg, rgba(0,0,0,0.3)); + } + .export-menu[hidden] { display: none; } + .export-menu button { + display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; + background: transparent; color: var(--rs-text-primary, #f1f5f9); font-size: 0.85rem; + text-align: left; cursor: pointer; transition: background 0.1s; + } + .export-menu button:hover { background: rgba(99,102,241,0.15); } + .export-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); } + + .preview { display: flex; flex-direction: column; gap: 0; } + .preview-empty { color: var(--rs-text-muted, #64748b); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } + `; + } + + private getEditorStyles(): string { + return ` + .thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } + .page-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } + .page-header h1 { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary, #f1f5f9); background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } + .page-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } + .compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } + .compose-textarea { + width: 100%; min-height: 320px; background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #f1f5f9); border: 1px solid var(--rs-input-border, #334155); + border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; + line-height: 1.6; box-sizing: border-box; + } + .compose-textarea:focus { outline: none; border-color: #6366f1; } + .compose-textarea::placeholder { color: var(--rs-text-muted, #64748b); } + .compose-fields { display: flex; gap: 0.75rem; } + .compose-input { + flex: 1; background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #f1f5f9); border: 1px solid var(--rs-input-border, #334155); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; + } + .compose-input:focus { outline: none; border-color: #6366f1; } + .compose-input::placeholder { color: var(--rs-text-muted, #64748b); } + .compose-title { + width: 100%; background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #f1f5f9); border: 1px solid var(--rs-input-border, #334155); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; + } + .compose-title:focus { outline: none; border-color: #6366f1; } + .compose-title::placeholder { color: var(--rs-text-muted, #64748b); } + + .drafts-area { grid-column: 1 / -1; } + .drafts-toggle { cursor: pointer; user-select: none; } + .drafts-list { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; + margin-top: 0.75rem; + } + .drafts-list[hidden] { display: none; } + .drafts-empty { color: var(--rs-text-muted, #64748b); font-size: 0.8rem; padding: 0.5rem 0; } + .draft-item { + display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; + background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; + transition: border-color 0.15s; cursor: pointer; + } + .draft-item:hover { border-color: #6366f1; } + .draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } + .draft-item__info { flex: 1; min-width: 0; } + .draft-item__info strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary, #f1f5f9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } + .draft-item__delete { + background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1.2rem; cursor: pointer; + padding: 0 4px; line-height: 1; flex-shrink: 0; + } + .draft-item__delete:hover { color: #ef4444; } + + .image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } + .image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); } + .image-preview[hidden] { display: none; } + .image-preview img { display: block; max-width: 200px; height: auto; } + + #share-link-area { grid-column: 1 / -1; } + .share-link { + display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; + background: rgba(99,102,241,0.1); border: 1px solid #6366f1; border-radius: 8px; + font-size: 0.8rem; color: #c4b5fd; + } + .share-link code { font-size: 0.75rem; color: #7dd3fc; } + .share-link button { + background: none; border: none; color: var(--rs-text-secondary, #94a3b8); cursor: pointer; font-size: 0.75rem; padding: 2px 6px; + } + .share-link button:hover { color: var(--rs-text-primary, #f1f5f9); } + + .photo-btn { + position: absolute; top: 8px; right: 8px; z-index: 5; + width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--rs-input-border, #334155); + background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-muted, #64748b); cursor: pointer; + display: flex; align-items: center; justify-content: center; + opacity: 0; transition: opacity 0.15s, border-color 0.15s, color 0.15s; + } + .tweet-card:hover .photo-btn { opacity: 1; } + .photo-btn:hover { border-color: #6366f1; color: #c4b5fd; } + .photo-btn svg { width: 14px; height: 14px; } + .photo-plus { + position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700; + color: #6366f1; line-height: 1; + } + .photo-menu { + position: absolute; top: 38px; right: 8px; z-index: 10; + background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; + min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg, rgba(0,0,0,0.3)); + } + .photo-menu[hidden] { display: none; } + .photo-menu button { + display: flex; align-items: center; gap: 0.4rem; width: 100%; + padding: 0.5rem 0.7rem; border: none; background: transparent; + color: var(--rs-text-primary, #f1f5f9); font-size: 0.8rem; cursor: pointer; transition: background 0.1s; + } + .photo-menu button:hover { background: rgba(99,102,241,0.15); } + .photo-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); } + .photo-menu button svg { width: 14px; height: 14px; } + + @media (max-width: 700px) { + .thread-page { grid-template-columns: 1fr; } + .compose { position: static; } + } + `; + } + + private getReadonlyStyles(): string { + return ` + .thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } + .ro-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } + .ro-author { display: flex; align-items: center; gap: 0.75rem; } + .ro-name { font-weight: 700; color: var(--rs-text-primary, #f1f5f9); font-size: 1.1rem; } + .ro-handle { color: var(--rs-text-muted, #64748b); font-size: 0.9rem; } + .ro-meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted, #64748b); font-size: 0.85rem; } + .ro-title { font-size: 1.4rem; color: var(--rs-text-primary, #f1f5f9); margin: 0 0 1.5rem; line-height: 1.3; } + .ro-image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); } + .ro-image img { display: block; width: 100%; height: auto; } + .ro-cards { margin-bottom: 1.5rem; } + .ro-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border, #334155); } + .ro-cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } + .toast { + position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); + background: var(--rs-bg-surface, #1e293b); border: 1px solid #6366f1; color: #c4b5fd; + padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem; + box-shadow: 0 4px 16px var(--rs-shadow-lg, rgba(0,0,0,0.3)); z-index: 1000; + } + .toast[hidden] { display: none; } + `; + } +} + +customElements.define('folk-thread-builder', FolkThreadBuilder); diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts new file mode 100644 index 0000000..38c9789 --- /dev/null +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -0,0 +1,175 @@ +/** + * โ€” Thread listing grid with cards. + * + * Subscribes to Automerge doc and renders all threads sorted by updatedAt. + * Falls back to demo data when space=demo. + */ + +import { socialsSchema, socialsDocId } from '../schemas'; +import type { SocialsDoc, ThreadData } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; +import { DEMO_FEED } from '../lib/types'; + +export class FolkThreadGallery extends HTMLElement { + private _space = 'demo'; + private _threads: ThreadData[] = []; + private _offlineUnsub: (() => void) | null = null; + + static get observedAttributes() { return ['space']; } + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + this.render(); + if (this._space === 'demo') { + this.loadDemoData(); + } else { + this.subscribeOffline(); + } + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = socialsDocId(this._space) as DocumentId; + const doc = await runtime.subscribe(docId, socialsSchema); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + // Runtime unavailable + } + } + + private renderFromDoc(doc: SocialsDoc) { + if (!doc?.threads) return; + this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt); + this.render(); + } + + private loadDemoData() { + this._threads = [ + { + id: 'demo-1', name: 'Alice', handle: '@alice', + title: 'Building local-first apps with rSpace', + tweets: ['Just deployed the new rFlows river view! The enoughness score is such a powerful concept.', 'The key insight: local-first means your data is always available, even offline.', 'And with Automerge, real-time sync just works. No conflict resolution needed.'], + createdAt: Date.now() - 86400000, updatedAt: Date.now() - 3600000, + }, + { + id: 'demo-2', name: 'Bob', handle: '@bob', + title: 'Why cosmolocal production matters', + tweets: ['The cosmolocal print network now has 6 providers across 4 countries.', 'Design global, manufacture local โ€” this is the future of sustainable production.'], + createdAt: Date.now() - 172800000, updatedAt: Date.now() - 86400000, + }, + { + id: 'demo-3', name: 'Carol', handle: '@carol', + title: 'Governance lessons from Elinor Ostrom', + tweets: ['Reading "Governing the Commons" โ€” so many parallels to what we\'re building.', 'Ostrom\'s 8 principles for managing commons map perfectly to DAO governance.', 'The key: graduated sanctions and local monitoring. Not one-size-fits-all.'], + createdAt: Date.now() - 259200000, updatedAt: Date.now() - 172800000, + }, + ]; + this.render(); + } + + private esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + private render() { + if (!this.shadowRoot) return; + const space = this._space; + const threads = this._threads; + + const cardsHTML = threads.length === 0 + ? `
+

No threads yet. Create your first thread!

+ Create Thread +
` + : `
+ ${threads.map(t => { + const initial = (t.name || '?').charAt(0).toUpperCase(); + const preview = this.esc((t.tweets[0] || '').substring(0, 200)); + const dateStr = new Date(t.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const imageTag = t.imageUrl + ? `
` + : ''; + return ` + ${imageTag} +

${this.esc(t.title || 'Untitled Thread')}

+

${preview}

+
+
+
${this.esc(initial)}
+ ${this.esc(t.handle || t.name || 'Anonymous')} +
+ ${t.tweets.length} tweet${t.tweets.length === 1 ? '' : 's'} + ${dateStr} +
+
`; + }).join('')} +
`; + + this.shadowRoot.innerHTML = ` + + + `; + } +} + +customElements.define('folk-thread-gallery', FolkThreadGallery); diff --git a/modules/rsocials/components/socials.css b/modules/rsocials/components/socials.css new file mode 100644 index 0000000..ede225d --- /dev/null +++ b/modules/rsocials/components/socials.css @@ -0,0 +1,368 @@ +/** + * rSocials โ€” all extracted CSS. + * + * Combined from inline CSS blocks previously in mod.ts: + * - Campaign page styles + * - Thread builder styles + * - Thread read-only styles + * - Thread gallery styles + * - Demo feed styles + */ + +/* โ”€โ”€ Campaign page โ”€โ”€ */ + +.campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } +.campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--rs-input-border); } +.campaign-page__icon { font-size: 3rem; } +.campaign-page__title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); } +.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary); font-size: 0.9rem; line-height: 1.5; } +.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); } +.campaign-phase { margin-bottom: 2rem; } +.campaign-phase__title { font-size: 1.15rem; color: var(--rs-text-primary); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; } +.campaign-phase__days { font-size: 0.8rem; color: var(--rs-text-muted); font-weight: 400; } +.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } +.campaign-post { + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; padding: 1rem; + transition: border-color 0.15s; +} +.campaign-post:hover { border-color: #6366f1; } +.campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.campaign-post__platform { + width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; + color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; +} +.campaign-post__meta { flex: 1; min-width: 0; } +.campaign-post__meta strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); text-transform: capitalize; } +.campaign-post__date { font-size: 0.7rem; color: var(--rs-text-muted); } +.campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } +.campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; } +.campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; } +.campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; } +.campaign-post__content { font-size: 0.8rem; color: var(--rs-text-secondary); line-height: 1.5; margin: 0 0 0.5rem; } +.campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; } +.campaign-tag { font-size: 0.65rem; color: #7dd3fc; } +.campaign-page__actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } +.campaign-action-btn { + padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600; + cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; +} +.campaign-action-btn--primary { background: #6366f1; color: white; border: none; } +.campaign-action-btn--primary:hover { background: #818cf8; } +.campaign-action-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } +.campaign-action-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } +.campaign-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; + align-items: center; justify-content: center; z-index: 1000; +} +.campaign-modal-overlay[hidden] { display: none; } +.campaign-modal { + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; + padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem; +} +.campaign-modal__header { display: flex; align-items: center; justify-content: space-between; } +.campaign-modal__header h3 { margin: 0; font-size: 1.1rem; color: var(--rs-text-primary); } +.campaign-modal__close { + background: none; border: none; color: var(--rs-text-muted); font-size: 1.5rem; cursor: pointer; + line-height: 1; padding: 0; +} +.campaign-modal__close:hover { color: var(--rs-text-primary); } +.campaign-modal__textarea { + width: 100%; min-height: 200px; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical; + line-height: 1.5; box-sizing: border-box; +} +.campaign-modal__textarea:focus { outline: none; border-color: #6366f1; } +.campaign-modal__textarea::placeholder { color: var(--rs-text-muted); } +.campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; } +.campaign-modal__select { + flex: 1; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; +} +.campaign-modal__select:focus { outline: none; border-color: #6366f1; } + +/* โ”€โ”€ Thread builder โ”€โ”€ */ + +.thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } +.thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } +.thread-page__header h1 { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +.thread-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.thread-btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } +.thread-btn--primary { background: #6366f1; color: white; } +.thread-btn--primary:hover { background: #818cf8; } +.thread-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } +.thread-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } +.thread-btn--success { background: #10b981; color: white; } +.thread-btn--success:hover { background: #34d399; } +.thread-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.thread-compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } +.thread-compose__textarea { + width: 100%; min-height: 320px; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); + border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; + line-height: 1.6; box-sizing: border-box; +} +.thread-compose__textarea:focus { outline: none; border-color: #6366f1; } +.thread-compose__textarea::placeholder { color: var(--rs-text-muted); } +.thread-compose__fields { display: flex; gap: 0.75rem; } +.thread-compose__input { + flex: 1; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; +} +.thread-compose__input:focus { outline: none; border-color: #6366f1; } +.thread-compose__input::placeholder { color: var(--rs-text-muted); } +.thread-compose__title { + width: 100%; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); + border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; +} +.thread-compose__title:focus { outline: none; border-color: #6366f1; } +.thread-compose__title::placeholder { color: var(--rs-text-muted); } +.thread-drafts { grid-column: 1 / -1; } +.thread-drafts__toggle { cursor: pointer; user-select: none; } +.thread-drafts__list { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; + margin-top: 0.75rem; +} +.thread-drafts__list[hidden] { display: none; } +.thread-drafts__empty { color: var(--rs-text-muted); font-size: 0.8rem; padding: 0.5rem 0; } +.thread-draft-item { + display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; + transition: border-color 0.15s; cursor: pointer; +} +.thread-draft-item:hover { border-color: #6366f1; } +.thread-draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } +.thread-draft-item__info { flex: 1; min-width: 0; } +.thread-draft-item__info strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.thread-draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted); } +.thread-draft-item__delete { + background: none; border: none; color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer; + padding: 0 4px; line-height: 1; flex-shrink: 0; +} +.thread-draft-item__delete:hover { color: #ef4444; } +.thread-image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.thread-image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.thread-image-preview[hidden] { display: none; } +.thread-image-preview img { display: block; max-width: 200px; height: auto; } +#share-link-area { grid-column: 1 / -1; } +.thread-share-link { + display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; + background: rgba(99,102,241,0.1); border: 1px solid #6366f1; border-radius: 8px; + font-size: 0.8rem; color: #c4b5fd; +} +.thread-share-link code { font-size: 0.75rem; color: #7dd3fc; } +.thread-share-link button { + background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.75rem; padding: 2px 6px; +} +.thread-share-link button:hover { color: var(--rs-text-primary); } +.thread-preview { display: flex; flex-direction: column; gap: 0; } +.thread-preview__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } +.tweet-card { + position: relative; background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; + padding: 1rem; margin-bottom: 0; +} +.tweet-card + .tweet-card { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: -1px; } +.tweet-card:has(+ .tweet-card) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.tweet-card__connector { + position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; + background: var(--rs-input-border); z-index: 1; +} +.tweet-card__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.tweet-card__avatar { + width: 40px; height: 40px; border-radius: 50%; background: #6366f1; + display: flex; align-items: center; justify-content: center; color: white; + font-weight: 700; font-size: 1rem; flex-shrink: 0; +} +.tweet-card__name { font-weight: 700; color: var(--rs-text-primary); font-size: 0.9rem; } +.tweet-card__handle { color: var(--rs-text-muted); font-size: 0.85rem; } +.tweet-card__dot { color: var(--rs-text-muted); font-size: 0.85rem; } +.tweet-card__time { color: var(--rs-text-muted); font-size: 0.85rem; } +.tweet-card__content { color: var(--rs-text-primary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } +.tweet-card__footer { display: flex; align-items: center; justify-content: space-between; } +.tweet-card__actions { display: flex; gap: 1.25rem; } +.tweet-card__action { display: flex; align-items: center; gap: 0.3rem; color: var(--rs-text-muted); font-size: 0.8rem; cursor: default; } +.tweet-card__action svg { width: 16px; height: 16px; } +.tweet-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); } +.tweet-card__chars { font-variant-numeric: tabular-nums; } +.tweet-card__chars--over { color: #ef4444; font-weight: 600; } +.tweet-card__thread-num { color: #6366f1; font-weight: 600; } +@media (max-width: 700px) { + .thread-page { grid-template-columns: 1fr; } + .thread-compose { position: static; } +} +.thread-export-dropdown { position: relative; } +.thread-export-menu { + position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; + min-width: 180px; overflow: hidden; + box-shadow: 0 8px 24px var(--rs-shadow-lg); +} +.thread-export-menu[hidden] { display: none; } +.thread-export-menu button { + display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; + background: transparent; color: var(--rs-text-primary); font-size: 0.85rem; + text-align: left; cursor: pointer; transition: background 0.1s; +} +.thread-export-menu button:hover { background: rgba(99,102,241,0.15); } +.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } +.tweet-card__photo-btn { + position: absolute; top: 8px; right: 8px; z-index: 5; + width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--rs-input-border); + background: var(--rs-bg-surface); color: var(--rs-text-muted); cursor: pointer; + display: flex; align-items: center; justify-content: center; + opacity: 0; transition: opacity 0.15s, border-color 0.15s, color 0.15s; +} +.tweet-card:hover .tweet-card__photo-btn { opacity: 1; } +.tweet-card__photo-btn:hover { border-color: #6366f1; color: #c4b5fd; } +.tweet-card__photo-btn svg { width: 14px; height: 14px; } +.tweet-card__photo-btn .photo-btn-plus { + position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700; + color: #6366f1; line-height: 1; +} +.tweet-card__photo-menu { + position: absolute; top: 38px; right: 8px; z-index: 10; + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; + min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg); +} +.tweet-card__photo-menu[hidden] { display: none; } +.tweet-card__photo-menu button { + display: flex; align-items: center; gap: 0.4rem; width: 100%; + padding: 0.5rem 0.7rem; border: none; background: transparent; + color: var(--rs-text-primary); font-size: 0.8rem; cursor: pointer; transition: background 0.1s; +} +.tweet-card__photo-menu button:hover { background: rgba(99,102,241,0.15); } +.tweet-card__photo-menu button + button { border-top: 1px solid var(--rs-bg-hover); } +.tweet-card__photo-menu button svg { width: 14px; height: 14px; } +.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.tweet-card__attached-image img { display: block; width: 100%; height: auto; } +.tweet-card__image-remove { + position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; + border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none; + font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; + justify-content: center; line-height: 1; transition: background 0.15s; +} +.tweet-card__image-remove:hover { background: #ef4444; } + +/* โ”€โ”€ Thread read-only โ”€โ”€ */ + +.thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } +.thread-ro__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } +.thread-ro__author { display: flex; align-items: center; gap: 0.75rem; } +.thread-ro__name { font-weight: 700; color: var(--rs-text-primary); font-size: 1.1rem; } +.thread-ro__handle { color: var(--rs-text-muted); font-size: 0.9rem; } +.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted); font-size: 0.85rem; } +.thread-ro__title { font-size: 1.4rem; color: var(--rs-text-primary); margin: 0 0 1.5rem; line-height: 1.3; } +.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border); } +.thread-ro__image img { display: block; width: 100%; height: auto; } +.thread-ro__cards { margin-bottom: 1.5rem; } +.thread-ro__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border); } +.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } +.thread-export-toast { + position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); + background: var(--rs-bg-surface); border: 1px solid #6366f1; color: #c4b5fd; + padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem; + box-shadow: 0 4px 16px var(--rs-shadow-lg); z-index: 1000; + transition: opacity 0.2s; +} +.thread-export-toast[hidden] { display: none; } + +/* โ”€โ”€ Thread gallery โ”€โ”€ */ + +.threads-gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } +.threads-gallery__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; } +.threads-gallery__header h1 { + margin: 0; font-size: 1.5rem; + background: linear-gradient(135deg, #7dd3fc, #c4b5fd); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.threads-gallery__grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; +} +.threads-gallery__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } +.thread-card { + background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; + padding: 1.25rem; transition: border-color 0.15s, transform 0.15s; + display: flex; flex-direction: column; gap: 0.75rem; + text-decoration: none; color: inherit; +} +.thread-card:hover { border-color: #6366f1; transform: translateY(-2px); } +.thread-card__title { font-size: 1rem; font-weight: 700; color: var(--rs-text-primary); margin: 0; line-height: 1.3; } +.thread-card__preview { + font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; +} +.thread-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: auto; } +.thread-card__author { display: flex; align-items: center; gap: 0.4rem; } +.thread-card__avatar-sm { + width: 20px; height: 20px; border-radius: 50%; background: #6366f1; + display: flex; align-items: center; justify-content: center; + color: white; font-weight: 700; font-size: 0.55rem; flex-shrink: 0; +} +.thread-card__image { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); margin-bottom: 0.25rem; } +.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; } + +/* โ”€โ”€ Demo feed โ”€โ”€ */ + +.rsocials-app { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } +.rsocials-header { margin-bottom: 1.5rem; } +.rsocials-header h2 { + font-size: 1.5rem; margin: 0 0 0.25rem; display: flex; align-items: center; gap: 0.75rem; + background: linear-gradient(135deg, #7dd3fc, #c4b5fd); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.rsocials-demo-badge { + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; + background: #6366f1; color: white; + -webkit-text-fill-color: white; + padding: 2px 8px; border-radius: 4px; + text-transform: uppercase; line-height: 1.6; +} +.rsocials-subtitle { color: var(--rs-text-muted); font-size: 0.85rem; margin: 0; } +.rsocials-feed { display: flex; flex-direction: column; gap: 1px; } +.rsocials-loading { color: var(--rs-text-muted); padding: 2rem 0; text-align: center; } +.rsocials-empty { color: var(--rs-text-muted); padding: 2rem 0; text-align: center; } +.rsocials-item { + padding: 1rem; border-radius: 8px; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); + margin-bottom: 0.5rem; + transition: border-color 0.2s; +} +.rsocials-item:hover { border-color: rgba(99,102,241,0.3); } +.rsocials-item-header { + display: flex; align-items: center; gap: 0.75rem; + font-size: 0.85rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; +} +.rsocials-avatar { + width: 36px; height: 36px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + color: white; font-weight: 700; font-size: 0.85rem; + flex-shrink: 0; +} +.rsocials-meta { + display: flex; flex-direction: column; gap: 1px; +} +.rsocials-meta strong { color: var(--rs-text-primary); font-size: 0.9rem; } +.rsocials-meta time { font-size: 0.75rem; color: var(--rs-text-muted); } +.rsocials-item-header strong { color: var(--rs-text-primary); } +.rsocials-item-header time { margin-left: auto; font-size: 0.75rem; } +.rsocials-source { + font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; + background: rgba(124,58,237,0.15); color: #c4b5fd; + text-transform: uppercase; letter-spacing: 0.05em; +} +.rsocials-item-content { margin: 0 0 0.75rem; color: var(--rs-text-primary); line-height: 1.6; font-size: 0.9rem; } +.rsocials-item-link { + display: block; font-size: 0.8rem; color: #7dd3fc; + text-decoration: none; margin-bottom: 0.5rem; word-break: break-all; +} +.rsocials-item-link:hover { text-decoration: underline; } +.rsocials-item-actions { + display: flex; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); +} +.rsocials-action { + display: flex; align-items: center; gap: 0.35rem; + cursor: default; +} +.rsocials-action svg { opacity: 0.7; } +.rsocials-demo-notice { + text-align: center; font-size: 0.75rem; color: var(--rs-text-muted); + padding: 1rem 0; border-top: 1px solid var(--rs-border-subtle); margin-top: 0.5rem; +} diff --git a/modules/rsocials/lib/image-gen.ts b/modules/rsocials/lib/image-gen.ts new file mode 100644 index 0000000..7a2ae82 --- /dev/null +++ b/modules/rsocials/lib/image-gen.ts @@ -0,0 +1,97 @@ +/** + * Server-side image generation and file upload helpers. + * + * These functions require filesystem access and FAL_KEY, + * so they stay server-only (not bundled into web components). + */ + +import { resolve } from "node:path"; +import { mkdir, writeFile, unlink } from "node:fs/promises"; + +const GEN_DIR = resolve(process.env.FILES_DIR || "./data/files", "generated"); + +async function ensureGenDir(): Promise { + await mkdir(GEN_DIR, { recursive: true }); + return GEN_DIR; +} + +// โ”€โ”€ fal.ai image generation โ”€โ”€ + +export async function generateImageFromPrompt(prompt: string): Promise { + const FAL_KEY = process.env.FAL_KEY || ""; + if (!FAL_KEY) return null; + + const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { + method: "POST", + headers: { + Authorization: `Key ${FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt, + image_size: "landscape_4_3", + num_images: 1, + safety_tolerance: "2", + }), + }); + + if (!falRes.ok) { + console.error("[image-gen] fal.ai error:", await falRes.text()); + return null; + } + + const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; + return falData.images?.[0]?.url || falData.output?.url || null; +} + +export async function downloadAndSaveImage(cdnUrl: string, filename: string): Promise { + const imgRes = await fetch(cdnUrl); + if (!imgRes.ok) return null; + + const imgBuffer = await imgRes.arrayBuffer(); + const dir = await ensureGenDir(); + await writeFile(resolve(dir, filename), Buffer.from(imgBuffer)); + return `/data/files/generated/${filename}`; +} + +// โ”€โ”€ File upload handling โ”€โ”€ + +const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; +const MAX_SIZE = 5 * 1024 * 1024; // 5MB + +export function validateImageFile(file: File): string | null { + if (!ALLOWED_TYPES.includes(file.type)) { + return "Invalid file type. Allowed: png, jpg, webp, gif"; + } + if (file.size > MAX_SIZE) { + return "File too large. Maximum 5MB"; + } + return null; +} + +export function safeExtension(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || "png"; + return ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png"; +} + +export async function saveUploadedFile(buffer: Buffer, filename: string): Promise { + const dir = await ensureGenDir(); + await writeFile(resolve(dir, filename), buffer); + return `/data/files/generated/${filename}`; +} + +// โ”€โ”€ Cleanup helpers โ”€โ”€ + +export async function deleteImageFile(imageUrl: string): Promise { + const fname = imageUrl.split("/").pop(); + if (!fname) return; + try { await unlink(resolve(GEN_DIR, fname)); } catch { /* ignore */ } +} + +export async function deleteOldImage(oldUrl: string | undefined, newFilename: string): Promise { + if (!oldUrl) return; + const oldFilename = oldUrl.split("/").pop(); + if (oldFilename && oldFilename !== newFilename) { + try { await unlink(resolve(GEN_DIR, oldFilename)); } catch { /* ignore */ } + } +} diff --git a/modules/rsocials/lib/types.ts b/modules/rsocials/lib/types.ts new file mode 100644 index 0000000..5d6cd86 --- /dev/null +++ b/modules/rsocials/lib/types.ts @@ -0,0 +1,98 @@ +/** + * rSocials shared types and constants. + * + * Used by both server (mod.ts) and client (web components). + */ + +// โ”€โ”€ Feed types โ”€โ”€ + +export interface FeedItem { + id: string; + type: "post" | "link"; + author: string; + content: string; + url?: string; + source: string; + timestamp: string; + likes: number; + replies: number; +} + +// โ”€โ”€ Demo feed data โ”€โ”€ + +export interface DemoFeedPost { + username: string; + initial: string; + color: string; + content: string; + timeAgo: string; + likes: number; + replies: number; +} + +export const DEMO_FEED: DemoFeedPost[] = [ + { + username: "@alice", + initial: "A", + color: "#6366f1", + content: "Just deployed the new rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}", + timeAgo: "2 hours ago", + likes: 5, + replies: 2, + }, + { + username: "@bob", + initial: "B", + color: "#f59e0b", + content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!", + timeAgo: "5 hours ago", + likes: 8, + replies: 4, + }, + { + username: "@carol", + initial: "C", + color: "#10b981", + content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}", + timeAgo: "1 day ago", + likes: 12, + replies: 3, + }, + { + username: "@diana", + initial: "D", + color: "#ec4899", + content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.", + timeAgo: "1 day ago", + likes: 7, + replies: 5, + }, + { + username: "@eve", + initial: "E", + color: "#14b8a6", + content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!", + timeAgo: "2 days ago", + likes: 3, + replies: 1, + }, + { + username: "@frank", + initial: "F", + color: "#8b5cf6", + content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}", + timeAgo: "3 days ago", + likes: 15, + replies: 6, + }, +]; + +// โ”€โ”€ Platform character limits โ”€โ”€ + +export const PLATFORM_LIMITS: Record = { + twitter: 280, + bluesky: 300, + mastodon: 500, + linkedin: 3000, + plain: Infinity, +}; diff --git a/modules/rsocials/local-first-client.ts b/modules/rsocials/local-first-client.ts new file mode 100644 index 0000000..807aa44 --- /dev/null +++ b/modules/rsocials/local-first-client.ts @@ -0,0 +1,143 @@ +/** + * rSocials Local-First Client + * + * Wraps the shared local-first stack for thread and campaign data. + * One Automerge doc per space stores all threads and campaigns. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { socialsSchema, socialsDocId } from './schemas'; +import type { SocialsDoc, ThreadData, Campaign } from './schemas'; + +export class SocialsLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(socialsSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('socials', 'data'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, socialsSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[SocialsClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = socialsDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, socialsSchema, binary) + : this.#documents.open(docId, socialsSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + // โ”€โ”€ Reads โ”€โ”€ + + getDoc(): SocialsDoc | undefined { + return this.#documents.get(socialsDocId(this.#space) as DocumentId); + } + + listThreads(): ThreadData[] { + const doc = this.getDoc(); + if (!doc?.threads) return []; + return Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt); + } + + getThread(id: string): ThreadData | undefined { + const doc = this.getDoc(); + return doc?.threads?.[id]; + } + + listCampaigns(): Campaign[] { + const doc = this.getDoc(); + if (!doc?.campaigns) return []; + return Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt); + } + + getCampaign(id: string): Campaign | undefined { + const doc = this.getDoc(); + return doc?.campaigns?.[id]; + } + + // โ”€โ”€ Thread writes โ”€โ”€ + + saveThread(thread: ThreadData): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save thread ${thread.title || thread.id}`, (d) => { + if (!d.threads) d.threads = {} as any; + thread.updatedAt = Date.now(); + if (!thread.createdAt) thread.createdAt = Date.now(); + d.threads[thread.id] = thread; + }); + } + + deleteThread(id: string): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete thread ${id}`, (d) => { + if (d.threads?.[id]) delete d.threads[id]; + }); + } + + // โ”€โ”€ Campaign writes โ”€โ”€ + + saveCampaign(campaign: Campaign): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save campaign ${campaign.title || campaign.id}`, (d) => { + if (!d.campaigns) d.campaigns = {} as any; + campaign.updatedAt = Date.now(); + if (!campaign.createdAt) campaign.createdAt = Date.now(); + d.campaigns[campaign.id] = campaign; + }); + } + + deleteCampaign(id: string): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete campaign ${id}`, (d) => { + if (d.campaigns?.[id]) delete d.campaigns[id]; + }); + } + + // โ”€โ”€ Events โ”€โ”€ + + onChange(cb: (doc: SocialsDoc) => void): () => void { + return this.#sync.onChange(socialsDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 7a7f0e6..99ff106 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -1,420 +1,285 @@ /** * Socials module โ€” federated social feed aggregator. * - * Aggregates and displays social media activity across community members. - * Supports ActivityPub, RSS, and manual link sharing. + * Slim mod.ts: Automerge doc management, image API routes, + * page routes (injecting web components), seed template, module export. + * + * All UI moved to web components in components/. + * Thread/campaign CRUD handled by Automerge (no REST CRUD). + * File-based threads migrated to Automerge on first access. */ import { resolve } from "node:path"; -import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell, escapeHtml, RICH_LANDING_CSS } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; -import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from "./campaign-data"; +import { MYCOFI_CAMPAIGN } from "./campaign-data"; +import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData } from "./schemas"; +import { + generateImageFromPrompt, + downloadAndSaveImage, + validateImageFile, + safeExtension, + saveUploadedFile, + deleteImageFile, + deleteOldImage, +} from "./lib/image-gen"; +import { DEMO_FEED } from "./lib/types"; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); -// โ”€โ”€ Thread storage helpers โ”€โ”€ -const THREADS_BASE = resolve(process.env.FILES_DIR || "./data/files", "threads"); +// โ”€โ”€ Automerge doc management โ”€โ”€ -interface ThreadData { - id: string; - name: string; - handle: string; - title: string; - tweets: string[]; - imageUrl?: string; - tweetImages?: Record; - createdAt: number; - updatedAt: number; -} - -function generateThreadId(): string { - const random = Math.random().toString(36).substring(2, 8); - return `t-${Date.now()}-${random}`; -} - -async function ensureThreadsDir(): Promise { - await mkdir(THREADS_BASE, { recursive: true }); - return THREADS_BASE; -} - -async function loadThread(id: string): Promise { - try { - const dir = await ensureThreadsDir(); - const raw = await readFile(resolve(dir, `${id}.json`), "utf-8"); - return JSON.parse(raw); - } catch { - return null; +function ensureDoc(space: string): SocialsDoc { + const docId = socialsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), "init", (d) => { + const init = socialsSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.threads = {}; + d.campaigns = {}; + }); + _syncServer!.setDoc(docId, doc); } + return doc; } -async function saveThread(data: ThreadData): Promise { - const dir = await ensureThreadsDir(); - await writeFile(resolve(dir, `${data.id}.json`), JSON.stringify(data, null, 2)); +function getThreadFromDoc(space: string, id: string): ThreadData | undefined { + const doc = ensureDoc(space); + return doc.threads?.[id]; } -async function deleteThreadFile(id: string): Promise { +// โ”€โ”€ Migration: file-based threads โ†’ Automerge โ”€โ”€ + +async function migrateFileThreadsToAutomerge(space: string): Promise { + const doc = ensureDoc(space); + if (Object.keys(doc.threads || {}).length > 0) return; // Already has threads + + const threadsDir = resolve(process.env.FILES_DIR || "./data/files", "threads"); + let files: string[]; try { - const dir = await ensureThreadsDir(); - await unlink(resolve(dir, `${id}.json`)); - return true; + files = await readdir(threadsDir); } catch { - return false; + return; // No threads directory } -} -async function listThreads(): Promise> { - const dir = await ensureThreadsDir(); - const files = await readdir(dir); - const threads: Array<{ id: string; title: string; tweetCount: number; updatedAt: number }> = []; + let count = 0; + const docId = socialsDocId(space); for (const f of files) { if (!f.endsWith(".json")) continue; try { - const raw = await readFile(resolve(dir, f), "utf-8"); - const data: ThreadData = JSON.parse(raw); - threads.push({ id: data.id, title: data.title, tweetCount: data.tweets.length, updatedAt: data.updatedAt }); + const raw = await readFile(resolve(threadsDir, f), "utf-8"); + const thread: ThreadData = JSON.parse(raw); + _syncServer!.changeDoc(docId, `migrate thread ${thread.id}`, (d) => { + if (!d.threads) d.threads = {} as any; + d.threads[thread.id] = thread; + }); + count++; } catch { /* skip corrupt files */ } } - threads.sort((a, b) => b.updatedAt - a.updatedAt); - return threads; + + if (count > 0) { + console.log(`[rSocials] Migrated ${count} file-based threads to Automerge for space "${space}"`); + } } -// โ”€โ”€ API: Health โ”€โ”€ -routes.get("/api/health", (c) => { - return c.json({ ok: true, module: "rsocials" }); -}); +// โ”€โ”€ Seed template โ”€โ”€ -// โ”€โ”€ API: Info โ”€โ”€ -routes.get("/api/info", (c) => { - return c.json({ +function seedTemplateSocials(space: string): void { + if (!_syncServer) return; + const doc = ensureDoc(space); + + // Seed MYCOFI_CAMPAIGN if no campaigns exist + if (Object.keys(doc.campaigns || {}).length === 0) { + const docId = socialsDocId(space); + const now = Date.now(); + _syncServer.changeDoc(docId, "seed campaign", (d) => { + if (!d.campaigns) d.campaigns = {} as any; + d.campaigns[MYCOFI_CAMPAIGN.id] = { + ...MYCOFI_CAMPAIGN, + createdAt: now, + updatedAt: now, + }; + }); + } + + // Seed a sample thread if empty + if (Object.keys(doc.threads || {}).length === 0) { + const docId = socialsDocId(space); + const now = Date.now(); + const threadId = `t-${now}-seed`; + _syncServer.changeDoc(docId, "seed thread", (d) => { + if (!d.threads) d.threads = {} as any; + d.threads[threadId] = { + id: threadId, + name: "rSocials", + handle: "@rsocials", + title: "Welcome to Thread Builder", + tweets: [ + "Welcome to the rSocials Thread Builder! Write your thread content here, separated by --- between tweets.", + "Each section becomes a separate tweet card with live character counts and thread numbering.", + "When you're ready, export to Twitter, Bluesky, Mastodon, or LinkedIn. All locally stored, no third-party data mining.", + ], + createdAt: now, + updatedAt: now, + }; + }); + console.log(`[rSocials] Template seeded for "${space}": campaign + sample thread`); + } +} + +// โ”€โ”€ API: Health & Info โ”€โ”€ + +routes.get("/api/health", (c) => c.json({ ok: true, module: "rsocials" })); + +routes.get("/api/info", (c) => + c.json({ module: "rsocials", description: "Federated social feed aggregator for communities", - features: [ - "ActivityPub integration", - "RSS feed aggregation", - "Link sharing", - "Community timeline", - ], - }); -}); + features: ["ActivityPub integration", "RSS feed aggregation", "Link sharing", "Community timeline"], + }), +); -// โ”€โ”€ API: Feed โ€” community social timeline โ”€โ”€ -routes.get("/api/feed", (c) => { - // Demo feed items - return c.json({ +// โ”€โ”€ API: Demo feed โ”€โ”€ + +routes.get("/api/feed", (c) => + c.json({ items: [ - { - id: "demo-1", - type: "post", - author: "Alice", - content: "Just published our community governance proposal!", - source: "fediverse", - timestamp: new Date(Date.now() - 3600_000).toISOString(), - likes: 12, - replies: 3, - }, - { - id: "demo-2", - type: "link", - author: "Bob", - content: "Great article on local-first collaboration", - url: "https://example.com/local-first", - source: "shared", - timestamp: new Date(Date.now() - 7200_000).toISOString(), - likes: 8, - replies: 1, - }, - { - id: "demo-3", - type: "post", - author: "Carol", - content: "Welcome new members! Check out rSpace's tools in the app switcher above.", - source: "local", - timestamp: new Date(Date.now() - 14400_000).toISOString(), - likes: 24, - replies: 7, - }, + { id: "demo-1", type: "post", author: "Alice", content: "Just published our community governance proposal!", source: "fediverse", timestamp: new Date(Date.now() - 3600_000).toISOString(), likes: 12, replies: 3 }, + { id: "demo-2", type: "link", author: "Bob", content: "Great article on local-first collaboration", url: "https://example.com/local-first", source: "shared", timestamp: new Date(Date.now() - 7200_000).toISOString(), likes: 8, replies: 1 }, + { id: "demo-3", type: "post", author: "Carol", content: "Welcome new members! Check out rSpace's tools in the app switcher above.", source: "local", timestamp: new Date(Date.now() - 14400_000).toISOString(), likes: 24, replies: 7 }, ], demo: true, - }); -}); + }), +); -// โ”€โ”€ API: Campaigns list โ”€โ”€ -routes.get("/api/campaigns", (c) => { - const space = c.req.param("space") || "demo"; - const campaign = MYCOFI_CAMPAIGN; - return c.json({ - campaigns: [ - { - id: campaign.id, - title: campaign.title, - description: campaign.description, - duration: campaign.duration, - platforms: campaign.platforms, - postCount: campaign.posts.length, - updated_at: "2026-02-21T09:00:00Z", - url: `/${space}/rsocials/campaign`, - }, - ], - }); -}); - -// โ”€โ”€ API: Thread CRUD โ”€โ”€ -routes.get("/api/threads", async (c) => { - const threads = await listThreads(); - return c.json({ threads }); -}); - -routes.post("/api/threads", async (c) => { - const { name, handle, title, tweets } = await c.req.json(); - if (!tweets?.length) return c.json({ error: "tweets required" }, 400); - - const id = generateThreadId(); - const now = Date.now(); - const thread: ThreadData = { - id, - name: name || "Your Name", - handle: handle || "@yourhandle", - title: title || (tweets[0] || "").substring(0, 60), - tweets, - createdAt: now, - updatedAt: now, - }; - await saveThread(thread); - return c.json({ id }); -}); - -routes.get("/api/threads/:id", async (c) => { - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = await loadThread(id); - if (!thread) return c.json({ error: "Thread not found" }, 404); - return c.json(thread); -}); - -routes.put("/api/threads/:id", async (c) => { - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const existing = await loadThread(id); - if (!existing) return c.json({ error: "Thread not found" }, 404); - - const { name, handle, title, tweets, tweetImages } = await c.req.json(); - if (name !== undefined) existing.name = name; - if (handle !== undefined) existing.handle = handle; - if (title !== undefined) existing.title = title; - if (tweets?.length) existing.tweets = tweets; - if (tweetImages !== undefined) existing.tweetImages = tweetImages; - existing.updatedAt = Date.now(); - - await saveThread(existing); - return c.json({ id }); -}); - -routes.delete("/api/threads/:id", async (c) => { - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - - // Try to delete associated images - const thread = await loadThread(id); - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - if (thread?.imageUrl) { - const filename = thread.imageUrl.split("/").pop(); - if (filename) { - try { await unlink(resolve(genDir, filename)); } catch {} - } - } - // Delete per-tweet images - if (thread?.tweetImages) { - for (const url of Object.values(thread.tweetImages)) { - const fname = url.split("/").pop(); - if (fname) { - try { await unlink(resolve(genDir, fname)); } catch {} - } - } - } - - const ok = await deleteThreadFile(id); - if (!ok) return c.json({ error: "Thread not found" }, 404); - return c.json({ ok: true }); -}); +// โ”€โ”€ Image API routes (server-side, need filesystem + FAL_KEY) โ”€โ”€ routes.post("/api/threads/:id/image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); - const FAL_KEY = process.env.FAL_KEY || ""; - if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); - // Build prompt from first 2-3 tweets const summary = thread.tweets.slice(0, 3).join(" ").substring(0, 200); const prompt = `Social media thread preview card about: ${summary}. Dark themed, modern, minimal style with abstract shapes.`; - const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt, - image_size: "landscape_4_3", - num_images: 1, - safety_tolerance: "2", - }), - }); + const cdnUrl = await generateImageFromPrompt(prompt); + if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); - if (!falRes.ok) { - console.error("[thread-image] fal.ai error:", await falRes.text()); - return c.json({ error: "Image generation failed" }, 502); - } - - const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; - const cdnUrl = falData.images?.[0]?.url || falData.output?.url; - if (!cdnUrl) return c.json({ error: "No image returned" }, 502); - - // Download and save locally - const imgRes = await fetch(cdnUrl); - if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502); - - const imgBuffer = await imgRes.arrayBuffer(); - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); const filename = `thread-${id}.png`; - await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); + const imageUrl = await downloadAndSaveImage(cdnUrl, filename); + if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); - const imageUrl = `/data/files/generated/${filename}`; - thread.imageUrl = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + // Update Automerge doc with image URL + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "set thread image", (d) => { + if (d.threads?.[id]) { + d.threads[id].imageUrl = imageUrl; + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/upload-image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; - try { - formData = await c.req.formData(); - } catch { - return c.json({ error: "Invalid form data" }, 400); - } + try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); } const file = formData.get("file"); if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400); - const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; - if (!ALLOWED_TYPES.includes(file.type)) { - return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400); - } + const err = validateImageFile(file); + if (err) return c.json({ error: err }, 400); - const MAX_SIZE = 5 * 1024 * 1024; // 5MB - if (file.size > MAX_SIZE) { - return c.json({ error: "File too large. Maximum 5MB" }, 400); - } + const ext = safeExtension(file.name); + const filename = `thread-${id}.${ext}`; - const ext = file.name.split(".").pop()?.toLowerCase() || "png"; - const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png"; - const filename = `thread-${id}.${safeExt}`; - - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); - - // Delete old image if it exists with a different extension - if (thread.imageUrl) { - const oldFilename = thread.imageUrl.split("/").pop(); - if (oldFilename && oldFilename !== filename) { - try { await unlink(resolve(genDir, oldFilename)); } catch {} - } - } + await deleteOldImage(thread.imageUrl, filename); const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(resolve(genDir, filename), buffer); + const imageUrl = await saveUploadedFile(buffer, filename); - const imageUrl = `/data/files/generated/${filename}`; - thread.imageUrl = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "upload thread image", (d) => { + if (d.threads?.[id]) { + d.threads[id].imageUrl = imageUrl; + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ imageUrl }); }); -// โ”€โ”€ Per-tweet image endpoints โ”€โ”€ - routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; - try { - formData = await c.req.formData(); - } catch { - return c.json({ error: "Invalid form data" }, 400); - } + try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); } const file = formData.get("file"); if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400); - const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; - if (!ALLOWED_TYPES.includes(file.type)) { - return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400); - } + const err = validateImageFile(file); + if (err) return c.json({ error: err }, 400); - const MAX_SIZE = 5 * 1024 * 1024; - if (file.size > MAX_SIZE) { - return c.json({ error: "File too large. Maximum 5MB" }, 400); - } + const ext = safeExtension(file.name); + const filename = `thread-${id}-tweet-${index}.${ext}`; - const ext = file.name.split(".").pop()?.toLowerCase() || "png"; - const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png"; - const filename = `thread-${id}-tweet-${index}.${safeExt}`; - - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); - - // Delete old image at this index if replacing - if (!thread.tweetImages) thread.tweetImages = {}; - const oldUrl = thread.tweetImages[index]; - if (oldUrl) { - const oldFilename = oldUrl.split("/").pop(); - if (oldFilename && oldFilename !== filename) { - try { await unlink(resolve(genDir, oldFilename)); } catch {} - } - } + const oldUrl = thread.tweetImages?.[index]; + if (oldUrl) await deleteOldImage(oldUrl, filename); const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(resolve(genDir, filename), buffer); + const imageUrl = await saveUploadedFile(buffer, filename); - const imageUrl = `/data/files/generated/${filename}`; - thread.tweetImages[index] = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "upload tweet image", (d) => { + if (d.threads?.[id]) { + if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; + d.threads[id].tweetImages![index] = imageUrl; + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ imageUrl }); }); routes.post("/api/threads/:id/tweet/:index/image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); const tweetIndex = parseInt(index, 10); @@ -422,146 +287,197 @@ routes.post("/api/threads/:id/tweet/:index/image", async (c) => { return c.json({ error: "Tweet index out of range" }, 400); } - const FAL_KEY = process.env.FAL_KEY || ""; - if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const tweetText = thread.tweets[tweetIndex].substring(0, 200); const prompt = `Social media post image about: ${tweetText}. Dark themed, modern, minimal style with abstract shapes.`; - const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt, - image_size: "landscape_4_3", - num_images: 1, - safety_tolerance: "2", - }), - }); + const cdnUrl = await generateImageFromPrompt(prompt); + if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502); - if (!falRes.ok) { - console.error("[tweet-image] fal.ai error:", await falRes.text()); - return c.json({ error: "Image generation failed" }, 502); - } - - const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } }; - const cdnUrl = falData.images?.[0]?.url || falData.output?.url; - if (!cdnUrl) return c.json({ error: "No image returned" }, 502); - - const imgRes = await fetch(cdnUrl); - if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502); - - const imgBuffer = await imgRes.arrayBuffer(); - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await mkdir(genDir, { recursive: true }); const filename = `thread-${id}-tweet-${index}.png`; - // Delete old image at this index if replacing - if (!thread.tweetImages) thread.tweetImages = {}; - const oldUrl = thread.tweetImages[index]; - if (oldUrl) { - const oldFilename = oldUrl.split("/").pop(); - if (oldFilename && oldFilename !== filename) { - try { await unlink(resolve(genDir, oldFilename)); } catch {} + const oldUrl = thread.tweetImages?.[index]; + if (oldUrl) await deleteOldImage(oldUrl, filename); + + const imageUrl = await downloadAndSaveImage(cdnUrl, filename); + if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); + + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "generate tweet image", (d) => { + if (d.threads?.[id]) { + if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; + d.threads[id].tweetImages![index] = imageUrl; + d.threads[id].updatedAt = Date.now(); } - } - - await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer)); - - const imageUrl = `/data/files/generated/${filename}`; - thread.tweetImages[index] = imageUrl; - thread.updatedAt = Date.now(); - await saveThread(thread); + }); return c.json({ imageUrl }); }); routes.delete("/api/threads/:id/tweet/:index/image", async (c) => { + const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = await loadThread(id); + const thread = getThreadFromDoc(space, id); if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread.tweetImages?.[index]) return c.json({ ok: true }); - const url = thread.tweetImages[index]; - const fname = url.split("/").pop(); - if (fname) { - const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - try { await unlink(resolve(genDir, fname)); } catch {} - } + await deleteImageFile(thread.tweetImages[index]); - delete thread.tweetImages[index]; - if (Object.keys(thread.tweetImages).length === 0) delete thread.tweetImages; - thread.updatedAt = Date.now(); - await saveThread(thread); + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "remove tweet image", (d) => { + if (d.threads?.[id]?.tweetImages?.[index]) { + delete d.threads[id].tweetImages![index]; + if (Object.keys(d.threads[id].tweetImages || {}).length === 0) { + delete d.threads[id].tweetImages; + } + d.threads[id].updatedAt = Date.now(); + } + }); return c.json({ ok: true }); }); -// โ”€โ”€ Demo feed data (server-rendered, no API calls) โ”€โ”€ -const DEMO_FEED = [ - { - username: "@alice", - initial: "A", - color: "#6366f1", - content: "Just deployed the new rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}", - timeAgo: "2 hours ago", - likes: 5, - replies: 2, - }, - { - username: "@bob", - initial: "B", - color: "#f59e0b", - content: "Workshop recording is up on rTube: 'Introduction to Local-First Data'. Check it out!", - timeAgo: "5 hours ago", - likes: 8, - replies: 4, - }, - { - username: "@carol", - initial: "C", - color: "#10b981", - content: "The cosmolocal print network now has 6 providers across 4 countries. Design global, manufacture local! \u{1F30D}", - timeAgo: "1 day ago", - likes: 12, - replies: 3, - }, - { - username: "@diana", - initial: "D", - color: "#ec4899", - content: "Reading Elinor Ostrom's 'Governing the Commons' \u2014 so many parallels to what we're building with rSpace governance.", - timeAgo: "1 day ago", - likes: 7, - replies: 5, - }, - { - username: "@eve", - initial: "E", - color: "#14b8a6", - content: "New community garden plot assignments are up on rChoices. Vote for your preferred plot by Friday!", - timeAgo: "2 days ago", - likes: 3, - replies: 1, - }, - { - username: "@frank", - initial: "F", - color: "#8b5cf6", - content: "Mesh network node #42 is online! Coverage now extends to the community center. \u{1F4E1}", - timeAgo: "3 days ago", - likes: 15, - replies: 6, - }, -]; +routes.delete("/api/threads/:id/images", async (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); + + const thread = getThreadFromDoc(space, id); + if (!thread) return c.json({ ok: true }); // Thread already gone + + // Clean up header image + if (thread.imageUrl) await deleteImageFile(thread.imageUrl); + + // Clean up per-tweet images + if (thread.tweetImages) { + for (const url of Object.values(thread.tweetImages)) { + await deleteImageFile(url); + } + } + + return c.json({ ok: true }); +}); + +// โ”€โ”€ Page routes (inject web components) โ”€โ”€ + +routes.get("/campaign", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Campaign โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/thread/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); + + const thread = getThreadFromDoc(space, id); + if (!thread) return c.text("Thread not found", 404); + + // OG tags for social crawlers (SSR) + const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200)); + const titleText = escapeHtml(`Thread by ${thread.handle}`); + const origin = "https://rspace.online"; + + let ogHead = ` + + + + + + `; + if (thread.imageUrl) { + ogHead += ` + + `; + } + + // Hydrate thread data for the component + const dataScript = ``; + + return c.html(renderShell({ + title: `${thread.title || "Thread"} โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: `${dataScript}`, + styles: ``, + scripts: ``, + head: ogHead, + })); +}); + +routes.get("/thread/:id/edit", async (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); + + const thread = getThreadFromDoc(space, id); + if (!thread) return c.text("Thread not found", 404); + + const dataScript = ``; + + return c.html(renderShell({ + title: `Edit: ${thread.title || "Thread"} โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: `${dataScript}`, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/thread", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Thread Builder โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/threads", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `Threads โ€” rSocials | rSpace`, + moduleId: "rsocials", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + styles: ``, + scripts: ``, + })); +}); + +routes.get("/campaigns", (c) => { + const space = c.req.param("space") || "demo"; + return c.redirect(`/${space}/rsocials/campaign`); +}); + +// โ”€โ”€ Demo feed rendering (server-rendered, no web component needed) โ”€โ”€ function renderDemoFeedHTML(): string { const cards = DEMO_FEED.map( @@ -591,1364 +507,13 @@ function renderDemoFeedHTML(): string {

A preview of your community's social timeline

-
- ${cards} -
+
${cards}

This is demo data. Connect ActivityPub or RSS feeds in your own space.

`; } -// โ”€โ”€ Campaign page route โ”€โ”€ -function renderCampaignPage(space: string): string { - const c = MYCOFI_CAMPAIGN; - const phases = [1, 2, 3]; - const phaseIcons = ["๐Ÿ“ฃ", "๐Ÿš€", "๐Ÿ“ก"]; +// โ”€โ”€ Main page route โ”€โ”€ - const phaseHTML = phases.map((phaseNum, i) => { - const phasePosts = c.posts.filter((p) => p.phase === phaseNum); - const phaseInfo = c.phases[i]; - const postsHTML = phasePosts.map((post) => { - const icon = PLATFORM_ICONS[post.platform] || post.platform; - const color = PLATFORM_COLORS[post.platform] || "#64748b"; - const statusClass = post.status === "scheduled" ? "campaign-status--scheduled" : "campaign-status--draft"; - const date = new Date(post.scheduledAt); - const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); - const contentPreview = escapeHtml(post.content.length > 180 ? post.content.substring(0, 180) + "..." : post.content); - const tags = post.hashtags.map((h) => `#${escapeHtml(h)}`).join(" "); - - return ` -
-
- ${icon} - - ${escapeHtml(post.status)} -
-
Step ${post.stepNumber}
-

${contentPreview.replace(/\n/g, "
")}

- -
`; - }).join("\n"); - - return ` -
-

${phaseIcons[i]} Phase ${phaseNum}: ${escapeHtml(phaseInfo.label)} ${escapeHtml(phaseInfo.days)}

-
${postsHTML}
-
`; - }).join("\n"); - - return ` -
-
- ๐Ÿ„ -
-

${escapeHtml(c.title)}

-

${escapeHtml(c.description)}

-
- ๐Ÿ“… ${escapeHtml(c.duration)} - ๐Ÿ“ฑ ${c.platforms.join(", ")} - ๐Ÿ“ ${c.posts.length} posts across ${c.phases.length} phases -
-
-
-
- Open Thread Builder - -
- ${phaseHTML} -
-
- - `; -} - -const CAMPAIGN_CSS = ` -.campaign-page { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } -.campaign-page__header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--rs-input-border); } -.campaign-page__icon { font-size: 3rem; } -.campaign-page__title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); } -.campaign-page__desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary); font-size: 0.9rem; line-height: 1.5; } -.campaign-page__stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); } -.campaign-phase { margin-bottom: 2rem; } -.campaign-phase__title { font-size: 1.15rem; color: var(--rs-text-primary); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; } -.campaign-phase__days { font-size: 0.8rem; color: var(--rs-text-muted); font-weight: 400; } -.campaign-phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } -.campaign-post { - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; padding: 1rem; - transition: border-color 0.15s; -} -.campaign-post:hover { border-color: #6366f1; } -.campaign-post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.campaign-post__platform { - width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; - color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; -} -.campaign-post__meta { flex: 1; min-width: 0; } -.campaign-post__meta strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); text-transform: capitalize; } -.campaign-post__date { font-size: 0.7rem; color: var(--rs-text-muted); } -.campaign-post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } -.campaign-status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; } -.campaign-status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; } -.campaign-status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; } -.campaign-post__content { font-size: 0.8rem; color: var(--rs-text-secondary); line-height: 1.5; margin: 0 0 0.5rem; } -.campaign-post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; } -.campaign-tag { font-size: 0.65rem; color: #7dd3fc; } -.campaign-page__actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } -.campaign-action-btn { - padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600; - cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; -} -.campaign-action-btn--primary { background: #6366f1; color: white; border: none; } -.campaign-action-btn--primary:hover { background: #818cf8; } -.campaign-action-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } -.campaign-action-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } -.campaign-modal-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; - align-items: center; justify-content: center; z-index: 1000; -} -.campaign-modal-overlay[hidden] { display: none; } -.campaign-modal { - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; - padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem; -} -.campaign-modal__header { display: flex; align-items: center; justify-content: space-between; } -.campaign-modal__header h3 { margin: 0; font-size: 1.1rem; color: var(--rs-text-primary); } -.campaign-modal__close { - background: none; border: none; color: var(--rs-text-muted); font-size: 1.5rem; cursor: pointer; - line-height: 1; padding: 0; -} -.campaign-modal__close:hover { color: var(--rs-text-primary); } -.campaign-modal__textarea { - width: 100%; min-height: 200px; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical; - line-height: 1.5; box-sizing: border-box; -} -.campaign-modal__textarea:focus { outline: none; border-color: #6366f1; } -.campaign-modal__textarea::placeholder { color: var(--rs-text-muted); } -.campaign-modal__row { display: flex; gap: 0.75rem; align-items: center; } -.campaign-modal__select { - flex: 1; background: var(--rs-input-bg); color: var(--rs-input-text); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; -} -.campaign-modal__select:focus { outline: none; border-color: #6366f1; } -`; - -routes.get("/campaign", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Campaign โ€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderCampaignPage(space), - styles: ``, - })); -}); - -// โ”€โ”€ Thread Builder โ”€โ”€ -const THREAD_CSS = ` -.thread-page { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; min-height: 80vh; } -.thread-page__header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } -.thread-page__header h1 { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary); background: linear-gradient(135deg, #7dd3fc, #c4b5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } -.thread-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } -.thread-btn { padding: 0.5rem 1rem; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } -.thread-btn--primary { background: #6366f1; color: white; } -.thread-btn--primary:hover { background: #818cf8; } -.thread-btn--outline { background: transparent; color: var(--rs-text-secondary); border: 1px solid var(--rs-input-border); } -.thread-btn--outline:hover { border-color: #6366f1; color: #c4b5fd; } -.thread-btn--success { background: #10b981; color: white; } -.thread-btn--success:hover { background: #34d399; } -.thread-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.thread-compose { position: sticky; top: 1rem; align-self: start; display: flex; flex-direction: column; gap: 1rem; } -.thread-compose__textarea { - width: 100%; min-height: 320px; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); - border-radius: 0.75rem; padding: 1rem; font-family: inherit; font-size: 0.9rem; resize: vertical; - line-height: 1.6; box-sizing: border-box; -} -.thread-compose__textarea:focus { outline: none; border-color: #6366f1; } -.thread-compose__textarea::placeholder { color: var(--rs-text-muted); } -.thread-compose__fields { display: flex; gap: 0.75rem; } -.thread-compose__input { - flex: 1; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; -} -.thread-compose__input:focus { outline: none; border-color: #6366f1; } -.thread-compose__input::placeholder { color: var(--rs-text-muted); } -.thread-compose__title { - width: 100%; background: var(--rs-bg-surface); color: var(--rs-text-primary); border: 1px solid var(--rs-input-border); - border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; box-sizing: border-box; -} -.thread-compose__title:focus { outline: none; border-color: #6366f1; } -.thread-compose__title::placeholder { color: var(--rs-text-muted); } -.thread-drafts { grid-column: 1 / -1; } -.thread-drafts__toggle { cursor: pointer; user-select: none; } -.thread-drafts__list { - display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; - margin-top: 0.75rem; -} -.thread-drafts__list[hidden] { display: none; } -.thread-drafts__empty { color: var(--rs-text-muted); font-size: 0.8rem; padding: 0.5rem 0; } -.thread-draft-item { - display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - transition: border-color 0.15s; cursor: pointer; -} -.thread-draft-item:hover { border-color: #6366f1; } -.thread-draft-item--active { border-color: #6366f1; background: rgba(99,102,241,0.1); } -.thread-draft-item__info { flex: 1; min-width: 0; } -.thread-draft-item__info strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.thread-draft-item__info span { font-size: 0.7rem; color: var(--rs-text-muted); } -.thread-draft-item__delete { - background: none; border: none; color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer; - padding: 0 4px; line-height: 1; flex-shrink: 0; -} -.thread-draft-item__delete:hover { color: #ef4444; } -.thread-image-section { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } -.thread-image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } -.thread-image-preview[hidden] { display: none; } -.thread-image-preview img { display: block; max-width: 200px; height: auto; } -#share-link-area { grid-column: 1 / -1; } -.thread-share-link { - display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; - background: rgba(99,102,241,0.1); border: 1px solid #6366f1; border-radius: 8px; - font-size: 0.8rem; color: #c4b5fd; -} -.thread-share-link code { font-size: 0.75rem; color: #7dd3fc; } -.thread-share-link button { - background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.75rem; padding: 2px 6px; -} -.thread-share-link button:hover { color: var(--rs-text-primary); } -.thread-preview { display: flex; flex-direction: column; gap: 0; } -.thread-preview__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } -.tweet-card { - position: relative; background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; - padding: 1rem; margin-bottom: 0; -} -.tweet-card + .tweet-card { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: -1px; } -.tweet-card:has(+ .tweet-card) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } -.tweet-card__connector { - position: absolute; left: 29px; top: -1px; width: 2px; height: 1rem; - background: var(--rs-input-border); z-index: 1; -} -.tweet-card__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.tweet-card__avatar { - width: 40px; height: 40px; border-radius: 50%; background: #6366f1; - display: flex; align-items: center; justify-content: center; color: white; - font-weight: 700; font-size: 1rem; flex-shrink: 0; -} -.tweet-card__name { font-weight: 700; color: var(--rs-text-primary); font-size: 0.9rem; } -.tweet-card__handle { color: var(--rs-text-muted); font-size: 0.85rem; } -.tweet-card__dot { color: var(--rs-text-muted); font-size: 0.85rem; } -.tweet-card__time { color: var(--rs-text-muted); font-size: 0.85rem; } -.tweet-card__content { color: var(--rs-text-primary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.75rem; white-space: pre-wrap; word-break: break-word; } -.tweet-card__footer { display: flex; align-items: center; justify-content: space-between; } -.tweet-card__actions { display: flex; gap: 1.25rem; } -.tweet-card__action { display: flex; align-items: center; gap: 0.3rem; color: var(--rs-text-muted); font-size: 0.8rem; cursor: default; } -.tweet-card__action svg { width: 16px; height: 16px; } -.tweet-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); } -.tweet-card__chars { font-variant-numeric: tabular-nums; } -.tweet-card__chars--over { color: #ef4444; font-weight: 600; } -.tweet-card__thread-num { color: #6366f1; font-weight: 600; } -@media (max-width: 700px) { - .thread-page { grid-template-columns: 1fr; } - .thread-compose { position: static; } -} -.thread-export-dropdown { position: relative; } -.thread-export-menu { - position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - min-width: 180px; overflow: hidden; - box-shadow: 0 8px 24px var(--rs-shadow-lg); -} -.thread-export-menu[hidden] { display: none; } -.thread-export-menu button { - display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; - background: transparent; color: var(--rs-text-primary); font-size: 0.85rem; - text-align: left; cursor: pointer; transition: background 0.1s; -} -.thread-export-menu button:hover { background: rgba(99,102,241,0.15); } -.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } -.tweet-card__photo-btn { - position: absolute; top: 8px; right: 8px; z-index: 5; - width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--rs-input-border); - background: var(--rs-bg-surface); color: var(--rs-text-muted); cursor: pointer; - display: flex; align-items: center; justify-content: center; - opacity: 0; transition: opacity 0.15s, border-color 0.15s, color 0.15s; -} -.tweet-card:hover .tweet-card__photo-btn { opacity: 1; } -.tweet-card__photo-btn:hover { border-color: #6366f1; color: #c4b5fd; } -.tweet-card__photo-btn svg { width: 14px; height: 14px; } -.tweet-card__photo-btn .photo-btn-plus { - position: absolute; bottom: -1px; right: -3px; font-size: 10px; font-weight: 700; - color: #6366f1; line-height: 1; -} -.tweet-card__photo-menu { - position: absolute; top: 38px; right: 8px; z-index: 10; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - min-width: 160px; overflow: hidden; box-shadow: 0 8px 24px var(--rs-shadow-lg); -} -.tweet-card__photo-menu[hidden] { display: none; } -.tweet-card__photo-menu button { - display: flex; align-items: center; gap: 0.4rem; width: 100%; - padding: 0.5rem 0.7rem; border: none; background: transparent; - color: var(--rs-text-primary); font-size: 0.8rem; cursor: pointer; transition: background 0.1s; -} -.tweet-card__photo-menu button:hover { background: rgba(99,102,241,0.15); } -.tweet-card__photo-menu button + button { border-top: 1px solid var(--rs-bg-hover); } -.tweet-card__photo-menu button svg { width: 14px; height: 14px; } -.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); } -.tweet-card__attached-image img { display: block; width: 100%; height: auto; } -.tweet-card__image-remove { - position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; - border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none; - font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; - justify-content: center; line-height: 1; transition: background 0.15s; -} -.tweet-card__image-remove:hover { background: #ef4444; } -`; - -function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string { - const dataScript = threadData - ? `` - : ""; - const basePath = `/${space}/rsocials/`; - - return ` - ${dataScript} - -
-
-

Thread Builder

-
- - - -
- - -
-
-
-
- - -
- -
- -
- - -
- -
- - - - -
-
-
-
Your tweet thread preview will appear here
-
- -
- `; -} - -// โ”€โ”€ Thread read-only view (shareable permalink) โ”€โ”€ -function renderThreadReadOnly(space: string, thread: ThreadData): string { - const name = escapeHtml(thread.name || "Anonymous"); - const handle = escapeHtml(thread.handle || "@anonymous"); - const initial = name.charAt(0).toUpperCase(); - const total = thread.tweets.length; - const dateStr = new Date(thread.createdAt).toLocaleDateString("en-US", { - month: "long", day: "numeric", year: "numeric", - }); - - const tweetCards = thread.tweets.map((text, i) => { - const len = text.length; - const connector = i > 0 ? '
' : ""; - const tweetImgUrl = thread.tweetImages?.[String(i)]; - const tweetImgHtml = tweetImgUrl - ? `
Tweet image
` - : ""; - return `
- ${connector} -
-
${escapeHtml(initial)}
- ${name} - ${handle} - · - ${escapeHtml(dateStr)} -
-

${escapeHtml(text)}

- ${tweetImgHtml} - -
`; - }).join("\n"); - - const imageHTML = thread.imageUrl - ? `
Thread preview
` - : ""; - - return ` -
-
-
-
${escapeHtml(initial)}
-
-
${name}
-
${handle}
-
-
-
- ${total} tweet${total === 1 ? "" : "s"} - · - ${escapeHtml(dateStr)} -
-
- ${thread.title ? `

${escapeHtml(thread.title)}

` : ""} - ${imageHTML} -
- ${tweetCards} -
-
- Edit Thread - - -
- - -
-
- -
- - `; -} - -const THREAD_RO_CSS = ` -.thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; } -.thread-ro__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } -.thread-ro__author { display: flex; align-items: center; gap: 0.75rem; } -.thread-ro__name { font-weight: 700; color: var(--rs-text-primary); font-size: 1.1rem; } -.thread-ro__handle { color: var(--rs-text-muted); font-size: 0.9rem; } -.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted); font-size: 0.85rem; } -.thread-ro__title { font-size: 1.4rem; color: var(--rs-text-primary); margin: 0 0 1.5rem; line-height: 1.3; } -.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border); } -.thread-ro__image img { display: block; width: 100%; height: auto; } -.thread-ro__cards { margin-bottom: 1.5rem; } -.thread-ro__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border); } -.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } -.thread-export-dropdown { position: relative; } -.thread-export-menu { - position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 8px; - min-width: 180px; overflow: hidden; - box-shadow: 0 8px 24px var(--rs-shadow-lg); -} -.thread-export-menu[hidden] { display: none; } -.thread-export-menu button { - display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; - background: transparent; color: var(--rs-text-primary); font-size: 0.85rem; - text-align: left; cursor: pointer; transition: background 0.1s; -} -.thread-export-menu button:hover { background: rgba(99,102,241,0.15); } -.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); } -.thread-export-toast { - position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); - background: var(--rs-bg-surface); border: 1px solid #6366f1; color: #c4b5fd; - padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem; - box-shadow: 0 4px 16px var(--rs-shadow-lg); z-index: 1000; - transition: opacity 0.2s; -} -.thread-export-toast[hidden] { display: none; } -`; - -// โ”€โ”€ Thread read-only permalink with OG tags โ”€โ”€ -routes.get("/thread/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); - - const thread = await loadThread(id); - if (!thread) return c.text("Thread not found", 404); - - const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200)); - const titleText = escapeHtml(`Thread by ${thread.handle}`); - const origin = "https://rspace.online"; - - let ogHead = ` - - - - - - `; - - if (thread.imageUrl) { - ogHead += ` - - `; - } - - return c.html(renderShell({ - title: `${thread.title || "Thread"} โ€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderThreadReadOnly(space, thread), - styles: ``, - head: ogHead, - })); -}); - -// โ”€โ”€ Thread editor (edit existing) โ”€โ”€ -routes.get("/thread/:id/edit", async (c) => { - const space = c.req.param("space") || "demo"; - const id = c.req.param("id"); - if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); - - const thread = await loadThread(id); - if (!thread) return c.text("Thread not found", 404); - - return c.html(renderShell({ - title: `Edit: ${thread.title || "Thread"} โ€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderThreadBuilderPage(space, thread), - styles: ``, - })); -}); - -// โ”€โ”€ Thread builder (new) โ”€โ”€ -routes.get("/thread", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Thread Builder โ€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderThreadBuilderPage(space), - styles: ``, - })); -}); - -// โ”€โ”€ Thread listing / gallery โ”€โ”€ -const THREADS_LIST_CSS = ` -.threads-gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } -.threads-gallery__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; } -.threads-gallery__header h1 { - margin: 0; font-size: 1.5rem; - background: linear-gradient(135deg, #7dd3fc, #c4b5fd); - -webkit-background-clip: text; -webkit-text-fill-color: transparent; -} -.threads-gallery__grid { - display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; -} -.threads-gallery__empty { color: var(--rs-text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; } -.thread-card { - background: var(--rs-bg-surface); border: 1px solid var(--rs-input-border); border-radius: 0.75rem; - padding: 1.25rem; transition: border-color 0.15s, transform 0.15s; - display: flex; flex-direction: column; gap: 0.75rem; - text-decoration: none; color: inherit; -} -.thread-card:hover { border-color: #6366f1; transform: translateY(-2px); } -.thread-card__title { font-size: 1rem; font-weight: 700; color: var(--rs-text-primary); margin: 0; line-height: 1.3; } -.thread-card__preview { - font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5; - display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; -} -.thread-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: auto; } -.thread-card__author { display: flex; align-items: center; gap: 0.4rem; } -.thread-card__avatar-sm { - width: 20px; height: 20px; border-radius: 50%; background: #6366f1; - display: flex; align-items: center; justify-content: center; - color: white; font-weight: 700; font-size: 0.55rem; flex-shrink: 0; -} -.thread-card__image { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); margin-bottom: 0.25rem; } -.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; } -`; - -async function renderThreadsGallery(space: string): Promise { - const dir = await ensureThreadsDir(); - const files = await readdir(dir); - const threads: ThreadData[] = []; - - for (const f of files) { - if (!f.endsWith(".json")) continue; - try { - const raw = await readFile(resolve(dir, f), "utf-8"); - threads.push(JSON.parse(raw)); - } catch { /* skip corrupt */ } - } - threads.sort((a, b) => b.updatedAt - a.updatedAt); - - if (!threads.length) { - return ` - `; - } - - const cards = threads.map((t) => { - const initial = (t.name || "?").charAt(0).toUpperCase(); - const preview = escapeHtml((t.tweets[0] || "").substring(0, 200)); - const dateStr = new Date(t.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" }); - const imageTag = t.imageUrl - ? `
` - : ""; - - return ` - ${imageTag} -

${escapeHtml(t.title || "Untitled Thread")}

-

${preview}

-
-
-
${escapeHtml(initial)}
- ${escapeHtml(t.handle || t.name || "Anonymous")} -
- ${t.tweets.length} tweet${t.tweets.length === 1 ? "" : "s"} - ${dateStr} -
-
`; - }).join("\n"); - - return ` - `; -} - -routes.get("/threads", async (c) => { - const space = c.req.param("space") || "demo"; - const body = await renderThreadsGallery(space); - return c.html(renderShell({ - title: `Threads โ€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body, - styles: ``, - })); -}); - -// โ”€โ”€ Campaigns redirect (plural โ†’ singular) โ”€โ”€ -routes.get("/campaigns", (c) => { - const space = c.req.param("space") || "demo"; - return c.redirect(`/${space}/rsocials/campaign`); -}); - -// โ”€โ”€ Page route โ”€โ”€ routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const view = c.req.query("view"); @@ -1965,123 +530,42 @@ routes.get("/", (c) => { })); } - if (view === "feed") { - const isDemo = space === "demo"; - const body = isDemo ? renderDemoFeedHTML() : renderLanding(); - const demoFeedStyles = ``; - const styles = isDemo ? demoFeedStyles : ``; - return c.html(renderShell({ - title: `${space} โ€” Socials Feed | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body, - styles, - })); - } + const isDemo = space === "demo"; + const body = isDemo ? renderDemoFeedHTML() : renderLanding(); + const styles = isDemo + ? `` + : ``; - if (view === "landing") { - return c.html(renderShell({ - title: `${space} โ€” rSocials | rSpace`, - moduleId: "rsocials", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: renderLanding(), - styles: ``, - })); - } - - // Default: canvas view return c.html(renderShell({ - title: `${space} โ€” rSocials | rSpace`, + title: `${space} โ€” Socials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), - body: ``, - scripts: ``, - styles: ``, theme: "dark", + body, + styles, })); }); +// โ”€โ”€ Module export โ”€โ”€ + export const socialsModule: RSpaceModule = { id: "rsocials", name: "rSocials", icon: "๐Ÿ“ข", description: "Federated social feed aggregator for communities", - scoping: { defaultScope: 'global', userConfigurable: true }, + scoping: { defaultScope: "global", userConfigurable: true }, + docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }], routes, publicWrite: true, standaloneDomain: "rsocials.online", landingPage: renderLanding, + seedTemplate: seedTemplateSocials, + async onInit(ctx) { + _syncServer = ctx.syncServer; + // Run migration for any existing file-based threads + try { await migrateFileThreadsToAutomerge("demo"); } catch { /* ignore */ } + }, externalApp: { url: "https://social.jeffemmett.com", name: "Postiz" }, feeds: [ { diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts new file mode 100644 index 0000000..7f0b190 --- /dev/null +++ b/modules/rsocials/schemas.ts @@ -0,0 +1,90 @@ +/** + * rSocials Automerge document schemas. + * + * Granularity: one Automerge document per space. + * DocId format: {space}:socials:data + * + * Images stay on filesystem, referenced by URL strings in the doc. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// โ”€โ”€ Thread types โ”€โ”€ + +export interface ThreadData { + id: string; + name: string; + handle: string; + title: string; + tweets: string[]; + imageUrl?: string; + tweetImages?: Record; + createdAt: number; + updatedAt: number; +} + +// โ”€โ”€ Campaign types โ”€โ”€ + +export interface CampaignPost { + id: string; + platform: string; + postType: string; + stepNumber: number; + content: string; + scheduledAt: string; + status: string; + hashtags: string[]; + phase: number; + phaseLabel: string; +} + +export interface Campaign { + id: string; + title: string; + description: string; + duration: string; + platforms: string[]; + phases: { name: string; label: string; days: string }[]; + posts: CampaignPost[]; + createdAt: number; + updatedAt: number; +} + +// โ”€โ”€ Document root โ”€โ”€ + +export interface SocialsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + threads: Record; + campaigns: Record; +} + +// โ”€โ”€ Schema registration โ”€โ”€ + +export const socialsSchema: DocSchema = { + module: 'socials', + collection: 'data', + version: 1, + init: (): SocialsDoc => ({ + meta: { + module: 'socials', + collection: 'data', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + threads: {}, + campaigns: {}, + }), +}; + +// โ”€โ”€ Helpers โ”€โ”€ + +export function socialsDocId(space: string) { + return `${space}:socials:data` as const; +} diff --git a/vite.config.ts b/vite.config.ts index 2c7b004..9752e19 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -568,7 +568,7 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rnetwork/network.css"), ); - // Build socials canvas component + // Build socials thread builder component await build({ configFile: false, root: resolve(__dirname, "modules/rsocials/components"), @@ -576,23 +576,63 @@ export default defineConfig({ emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/rsocials"), lib: { - entry: resolve(__dirname, "modules/rsocials/components/folk-socials-canvas.ts"), + entry: resolve(__dirname, "modules/rsocials/components/folk-thread-builder.ts"), formats: ["es"], - fileName: () => "folk-socials-canvas.js", + fileName: () => "folk-thread-builder.js", }, rollupOptions: { output: { - entryFileNames: "folk-socials-canvas.js", + entryFileNames: "folk-thread-builder.js", }, }, }, }); - // Copy socials canvas CSS + // Build socials thread gallery component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-thread-gallery.ts"), + formats: ["es"], + fileName: () => "folk-thread-gallery.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-thread-gallery.js", + }, + }, + }, + }); + + // Build socials campaign manager component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-campaign-manager.ts"), + formats: ["es"], + fileName: () => "folk-campaign-manager.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-campaign-manager.js", + }, + }, + }, + }); + + // Copy socials CSS mkdirSync(resolve(__dirname, "dist/modules/rsocials"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/rsocials/components/socials-canvas.css"), - resolve(__dirname, "dist/modules/rsocials/socials-canvas.css"), + resolve(__dirname, "modules/rsocials/components/socials.css"), + resolve(__dirname, "dist/modules/rsocials/socials.css"), ); // Build tube module component