From a3572f7a5f7f8cb456780f0104b865c1dcf6690b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 14:15:42 -0700 Subject: [PATCH] feat: add rChats.online to ecosystem links and EncryptID allowed origins Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 13 + lib/folk-social-post.ts | 891 ++++++++++++++++++++++++++++++++++++++++ lib/index.ts | 3 + server/index.ts | 42 +- server/seed-campaign.ts | 495 ++++++++++++++++++++++ src/encryptid/server.ts | 1 + website/canvas.html | 42 +- website/index.html | 1 + 8 files changed, 1482 insertions(+), 6 deletions(-) create mode 100644 lib/folk-social-post.ts create mode 100644 server/seed-campaign.ts diff --git a/lib/community-sync.ts b/lib/community-sync.ts index a101b95..a04fe95 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -625,6 +625,19 @@ export class CommunitySync extends EventTarget { if (data.criteria !== undefined) spider.criteria = data.criteria; if (data.scores !== undefined) spider.scores = data.scores; } + + // Update social-post properties + if (data.type === "folk-social-post") { + const post = shape as any; + if (data.platform !== undefined && post.platform !== data.platform) post.platform = data.platform; + if (data.postType !== undefined && post.postType !== data.postType) post.postType = data.postType; + if (data.mediaUrl !== undefined && post.mediaUrl !== data.mediaUrl) post.mediaUrl = data.mediaUrl; + if (data.mediaType !== undefined && post.mediaType !== data.mediaType) post.mediaType = data.mediaType; + if (data.scheduledAt !== undefined && post.scheduledAt !== data.scheduledAt) post.scheduledAt = data.scheduledAt; + if (data.status !== undefined && post.status !== data.status) post.status = data.status; + if (data.hashtags !== undefined) post.hashtags = data.hashtags; + if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber; + } } /** diff --git a/lib/folk-social-post.ts b/lib/folk-social-post.ts new file mode 100644 index 0000000..d0f7a0a --- /dev/null +++ b/lib/folk-social-post.ts @@ -0,0 +1,891 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 12px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + min-width: 280px; + min-height: 140px; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + :host(:hover) { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); + } + + :host([data-status="posted"]) { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.4), 0 2px 12px rgba(0, 0, 0, 0.08); + } + + :host([data-status="scheduled"]) { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4), 0 2px 12px rgba(0, 0, 0, 0.08); + } + + :host([data-status="failed"]) { + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.4), 0 2px 12px rgba(0, 0, 0, 0.08); + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: move; + color: white; + border-radius: 12px 12px 0 0; + } + + .header-left { + display: flex; + align-items: center; + gap: 8px; + } + + .platform-icon { + font-size: 18px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; + } + + .platform-name { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.3px; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .header-actions button { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.3); + } + + .body { + padding: 12px 14px; + } + + .post-type { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 10px; + background: #f1f5f9; + color: #64748b; + margin-bottom: 8px; + } + + .content-preview { + font-size: 13px; + line-height: 1.5; + color: #1e293b; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 8px; + } + + .media-preview { + width: 100%; + height: 100px; + border-radius: 8px; + background: #f1f5f9; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; + } + + .media-preview img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .media-placeholder { + color: #94a3b8; + font-size: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + + .media-placeholder .icon { + font-size: 24px; + } + + .hashtags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; + } + + .hashtag { + font-size: 11px; + color: #3b82f6; + background: #eff6ff; + padding: 2px 6px; + border-radius: 4px; + } + + .footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + border-radius: 0 0 12px 12px; + } + + .schedule { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #64748b; + } + + .schedule-icon { + font-size: 13px; + } + + .schedule-time { + font-weight: 500; + color: #475569; + } + + .status-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: 10px; + } + + .status-badge.draft { + background: #f1f5f9; + color: #64748b; + } + + .status-badge.scheduled { + background: #dbeafe; + color: #2563eb; + } + + .status-badge.posted { + background: #dcfce7; + color: #16a34a; + } + + .status-badge.failed { + background: #fee2e2; + color: #dc2626; + } + + .step-number { + position: absolute; + top: -8px; + left: -8px; + width: 24px; + height: 24px; + border-radius: 50%; + background: #1e293b; + color: white; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + } + + .edit-overlay { + display: none; + position: absolute; + inset: 0; + background: white; + z-index: 10; + border-radius: 12px; + flex-direction: column; + } + + .edit-overlay.active { + display: flex; + } + + .edit-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + } + + .edit-header span { + font-size: 13px; + font-weight: 600; + color: #1e293b; + } + + .edit-body { + flex: 1; + padding: 12px 14px; + overflow-y: auto; + } + + .edit-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + } + + .edit-field label { + font-size: 11px; + font-weight: 500; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .edit-field input, + .edit-field textarea, + .edit-field select { + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 13px; + outline: none; + font-family: inherit; + } + + .edit-field input:focus, + .edit-field textarea:focus, + .edit-field select:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + + .edit-field textarea { + min-height: 80px; + resize: vertical; + } + + .edit-actions { + display: flex; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + } + + .edit-actions button { + flex: 1; + padding: 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: 1px solid #e2e8f0; + } + + .edit-actions .save-btn { + background: #3b82f6; + color: white; + border-color: #3b82f6; + } + + .edit-actions .save-btn:hover { + background: #2563eb; + } + + .edit-actions .cancel-btn { + background: white; + color: #64748b; + } + + .edit-actions .cancel-btn:hover { + background: #f1f5f9; + } +`; + +export type SocialPlatform = + | "x" + | "linkedin" + | "instagram" + | "youtube" + | "threads" + | "bluesky" + | "tiktok" + | "facebook"; + +export type PostStatus = "draft" | "scheduled" | "posted" | "failed"; + +export type PostType = + | "text" + | "image" + | "video" + | "carousel" + | "story" + | "reel" + | "thread" + | "article" + | "short"; + +const PLATFORM_CONFIG: Record< + SocialPlatform, + { icon: string; label: string; color: string } +> = { + x: { icon: "\ud835\udd4f", label: "X", color: "#000000" }, + linkedin: { icon: "in", label: "LinkedIn", color: "#0A66C2" }, + instagram: { icon: "\ud83d\udcf7", label: "Instagram", color: "#E4405F" }, + youtube: { icon: "\u25b6", label: "YouTube", color: "#FF0000" }, + threads: { icon: "@", label: "Threads", color: "#000000" }, + bluesky: { icon: "\ud83e\ude77", label: "Bluesky", color: "#0085FF" }, + tiktok: { icon: "\u266b", label: "TikTok", color: "#010101" }, + facebook: { icon: "f", label: "Facebook", color: "#1877F2" }, +}; + +declare global { + interface HTMLElementTagNameMap { + "folk-social-post": FolkSocialPost; + } +} + +export class FolkSocialPost extends FolkShape { + static override tagName = "folk-social-post"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #platform: SocialPlatform = "x"; + #postType: PostType = "text"; + #content = ""; + #mediaUrl = ""; + #mediaType = ""; // "image", "video", "carousel" + #scheduledAt = ""; + #status: PostStatus = "draft"; + #hashtags: string[] = []; + #stepNumber = 0; + #isEditing = false; + + // DOM references + #contentPreviewEl: HTMLElement | null = null; + #statusBadgeEl: HTMLElement | null = null; + #scheduleTimeEl: HTMLElement | null = null; + #editOverlay: HTMLElement | null = null; + #mediaPreviewEl: HTMLElement | null = null; + #hashtagsEl: HTMLElement | null = null; + #postTypeEl: HTMLElement | null = null; + #stepNumberEl: HTMLElement | null = null; + + get platform() { + return this.#platform; + } + set platform(value: SocialPlatform) { + this.#platform = value; + this.requestUpdate("platform"); + this.#dispatchChange(); + } + + get postType() { + return this.#postType; + } + set postType(value: PostType) { + this.#postType = value; + if (this.#postTypeEl) this.#postTypeEl.textContent = value; + this.requestUpdate("postType"); + this.#dispatchChange(); + } + + get content() { + return this.#content; + } + set content(value: string) { + this.#content = value; + if (this.#contentPreviewEl) this.#contentPreviewEl.textContent = value; + this.requestUpdate("content"); + this.#dispatchChange(); + } + + get mediaUrl() { + return this.#mediaUrl; + } + set mediaUrl(value: string) { + this.#mediaUrl = value; + this.#renderMedia(); + this.requestUpdate("mediaUrl"); + this.#dispatchChange(); + } + + get mediaType() { + return this.#mediaType; + } + set mediaType(value: string) { + this.#mediaType = value; + this.#renderMedia(); + this.requestUpdate("mediaType"); + this.#dispatchChange(); + } + + get scheduledAt() { + return this.#scheduledAt; + } + set scheduledAt(value: string) { + this.#scheduledAt = value; + if (this.#scheduleTimeEl) + this.#scheduleTimeEl.textContent = this.#formatSchedule(value); + this.requestUpdate("scheduledAt"); + this.#dispatchChange(); + } + + get status(): PostStatus { + return this.#status; + } + set status(value: PostStatus) { + this.#status = value; + this.setAttribute("data-status", value); + if (this.#statusBadgeEl) { + this.#statusBadgeEl.className = `status-badge ${value}`; + this.#statusBadgeEl.textContent = value; + } + this.requestUpdate("status"); + this.#dispatchChange(); + } + + get hashtags(): string[] { + return this.#hashtags; + } + set hashtags(value: string[]) { + this.#hashtags = value; + this.#renderHashtags(); + this.requestUpdate("hashtags"); + this.#dispatchChange(); + } + + get stepNumber(): number { + return this.#stepNumber; + } + set stepNumber(value: number) { + this.#stepNumber = value; + if (this.#stepNumberEl) { + this.#stepNumberEl.textContent = String(value); + this.#stepNumberEl.style.display = value > 0 ? "flex" : "none"; + } + this.requestUpdate("stepNumber"); + this.#dispatchChange(); + } + + #dispatchChange() { + this.dispatchEvent( + new CustomEvent("content-change", { + detail: this.toJSON(), + }), + ); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + // Parse attributes + const platformAttr = this.getAttribute("platform") as SocialPlatform; + if (platformAttr && platformAttr in PLATFORM_CONFIG) + this.#platform = platformAttr; + const postTypeAttr = this.getAttribute("post-type") as PostType; + if (postTypeAttr) this.#postType = postTypeAttr; + const contentAttr = this.getAttribute("content"); + if (contentAttr) this.#content = contentAttr; + const statusAttr = this.getAttribute("status") as PostStatus; + if (statusAttr) this.#status = statusAttr; + const stepAttr = this.getAttribute("step"); + if (stepAttr) this.#stepNumber = parseInt(stepAttr, 10); + + const config = PLATFORM_CONFIG[this.#platform]; + + const wrapper = document.createElement("div"); + wrapper.style.position = "relative"; + wrapper.style.height = "100%"; + + wrapper.innerHTML = html` + ${this.#stepNumber} +
+
+ ${config.icon} + ${config.label} +
+
+ + +
+
+
+ ${this.#escapeHtml(this.#postType)} +
+ ${this.#escapeHtml(this.#content) || "No content yet..."} +
+
+
+ \ud83d\uddbc + No media +
+
+
+
+ +
+
+ Edit Post +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + // Replace the container div (slot's parent) with our wrapper + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + // Cache DOM refs + this.#contentPreviewEl = wrapper.querySelector(".content-preview"); + this.#statusBadgeEl = wrapper.querySelector(".status-badge"); + this.#scheduleTimeEl = wrapper.querySelector(".schedule-time"); + this.#editOverlay = wrapper.querySelector(".edit-overlay"); + this.#mediaPreviewEl = wrapper.querySelector(".media-preview"); + this.#hashtagsEl = wrapper.querySelector(".hashtags"); + this.#postTypeEl = wrapper.querySelector(".post-type"); + this.#stepNumberEl = wrapper.querySelector(".step-number"); + + // Set initial attribute state + this.setAttribute("data-status", this.#status); + + // Edit button + const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement; + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#openEditor(); + }); + + // Close button + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Save button + const saveBtn = wrapper.querySelector(".save-btn") as HTMLButtonElement; + saveBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#saveEdit(wrapper); + }); + + // Cancel button + const cancelBtn = wrapper.querySelector( + ".cancel-btn", + ) as HTMLButtonElement; + cancelBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#closeEditor(); + }); + + // Render media and hashtags + this.#renderMedia(); + this.#renderHashtags(); + + return root; + } + + #openEditor() { + if (!this.#editOverlay) return; + this.#isEditing = true; + this.#editOverlay.classList.add("active"); + + const root = this.#editOverlay.closest("div") as HTMLElement; + const platformSel = root.querySelector( + ".edit-platform", + ) as HTMLSelectElement; + const postTypeSel = root.querySelector( + ".edit-post-type", + ) as HTMLSelectElement; + const contentArea = root.querySelector( + ".edit-content", + ) as HTMLTextAreaElement; + const mediaInput = root.querySelector( + ".edit-media-url", + ) as HTMLInputElement; + const scheduledInput = root.querySelector( + ".edit-scheduled", + ) as HTMLInputElement; + const hashtagsInput = root.querySelector( + ".edit-hashtags", + ) as HTMLInputElement; + const statusSel = root.querySelector( + ".edit-status", + ) as HTMLSelectElement; + + if (platformSel) platformSel.value = this.#platform; + if (postTypeSel) postTypeSel.value = this.#postType; + if (contentArea) contentArea.value = this.#content; + if (mediaInput) mediaInput.value = this.#mediaUrl; + if (scheduledInput) scheduledInput.value = this.#scheduledAt; + if (hashtagsInput) hashtagsInput.value = this.#hashtags.join(", "); + if (statusSel) statusSel.value = this.#status; + } + + #closeEditor() { + if (!this.#editOverlay) return; + this.#isEditing = false; + this.#editOverlay.classList.remove("active"); + } + + #saveEdit(wrapper: HTMLElement) { + const platformSel = wrapper.querySelector( + ".edit-platform", + ) as HTMLSelectElement; + const postTypeSel = wrapper.querySelector( + ".edit-post-type", + ) as HTMLSelectElement; + const contentArea = wrapper.querySelector( + ".edit-content", + ) as HTMLTextAreaElement; + const mediaInput = wrapper.querySelector( + ".edit-media-url", + ) as HTMLInputElement; + const scheduledInput = wrapper.querySelector( + ".edit-scheduled", + ) as HTMLInputElement; + const hashtagsInput = wrapper.querySelector( + ".edit-hashtags", + ) as HTMLInputElement; + const statusSel = wrapper.querySelector( + ".edit-status", + ) as HTMLSelectElement; + + if (platformSel) + this.#platform = platformSel.value as SocialPlatform; + if (postTypeSel) this.postType = postTypeSel.value as PostType; + if (contentArea) this.content = contentArea.value; + if (mediaInput) this.mediaUrl = mediaInput.value; + if (scheduledInput) this.scheduledAt = scheduledInput.value; + if (hashtagsInput) + this.hashtags = hashtagsInput.value + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + if (statusSel) this.status = statusSel.value as PostStatus; + + // Update header color for new platform + const header = wrapper.querySelector(".header") as HTMLElement; + const config = PLATFORM_CONFIG[this.#platform]; + if (header && config) { + header.style.background = config.color; + const iconEl = header.querySelector(".platform-icon"); + const nameEl = header.querySelector(".platform-name"); + if (iconEl) iconEl.textContent = config.icon; + if (nameEl) nameEl.textContent = config.label; + } + + this.#closeEditor(); + } + + #renderMedia() { + if (!this.#mediaPreviewEl) return; + + if (this.#mediaUrl) { + this.#mediaPreviewEl.innerHTML = `Post media`; + } else { + const mediaIcons: Record = { + image: "\ud83d\uddbc", + video: "\ud83c\udfac", + carousel: "\ud83d\udcf8", + reel: "\ud83c\udfac", + short: "\ud83c\udfac", + story: "\ud83d\udcf1", + }; + const icon = mediaIcons[this.#postType] || "\ud83d\uddbc"; + const label = + this.#postType === "text" ? "No media" : `${this.#postType} media`; + this.#mediaPreviewEl.innerHTML = `
${icon}${label}
`; + } + } + + #renderHashtags() { + if (!this.#hashtagsEl) return; + + if (this.#hashtags.length === 0) { + this.#hashtagsEl.innerHTML = ""; + return; + } + + this.#hashtagsEl.innerHTML = this.#hashtags + .map((tag) => { + const t = tag.startsWith("#") ? tag : `#${tag}`; + return `${this.#escapeHtml(t)}`; + }) + .join(""); + } + + #formatSchedule(dateStr: string): string { + if (!dateStr) return "Not scheduled"; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return dateStr; + const opts: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }; + return date.toLocaleDateString("en-US", opts); + } catch { + return dateStr; + } + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-social-post", + platform: this.#platform, + postType: this.#postType, + content: this.#content, + mediaUrl: this.#mediaUrl, + mediaType: this.#mediaType, + scheduledAt: this.#scheduledAt, + status: this.#status, + hashtags: this.#hashtags, + stepNumber: this.#stepNumber, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 2e2b8b7..90c6aba 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -55,6 +55,9 @@ export * from "./folk-booking"; export * from "./folk-token-mint"; export * from "./folk-token-ledger"; +// Social Media / Campaign Shapes +export * from "./folk-social-post"; + // Decision/Choice Shapes export * from "./folk-choice-vote"; export * from "./folk-choice-rank"; diff --git a/server/index.ts b/server/index.ts index ff1ab97..87107ae 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,7 @@ import { removeMember, } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; +import { ensureCampaignDemo } from "./seed-campaign"; import type { SpaceVisibility } from "./community-store"; import { verifyEncryptIDToken, @@ -553,6 +554,39 @@ async function handleAPI(req: Request, url: URL): Promise { } } + // POST /api/communities/campaign-demo/reset - Reset campaign demo + if (url.pathname === "/api/communities/campaign-demo/reset" && req.method === "POST") { + const now = Date.now(); + if (now - lastDemoReset < DEMO_RESET_COOLDOWN) { + const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000); + return Response.json( + { error: `Reset on cooldown. Try again in ${remaining}s` }, + { status: 429, headers: corsHeaders } + ); + } + + try { + lastDemoReset = now; + await loadCommunity("campaign-demo"); + clearShapes("campaign-demo"); + await ensureCampaignDemo(); + + broadcastAutomergeSync("campaign-demo"); + broadcastJsonSnapshot("campaign-demo"); + + return Response.json( + { ok: true, message: "Campaign demo reset to seed data" }, + { headers: corsHeaders } + ); + } catch (e) { + console.error("Failed to reset campaign demo:", e); + return Response.json( + { error: "Failed to reset campaign demo" }, + { status: 500, headers: corsHeaders } + ); + } + } + // GET /api/communities/:slug - Get community info (respects visibility) if (url.pathname.startsWith("/api/communities/") && req.method === "GET") { const slug = url.pathname.split("/")[3]; @@ -717,11 +751,17 @@ async function handleAPI(req: Request, url: URL): Promise { return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders }); } -// Ensure demo community exists on startup +// Ensure demo communities exist on startup ensureDemoCommunity().then(() => { console.log("[Demo] Demo community ready"); }).catch((e) => { console.error("[Demo] Failed to initialize demo community:", e); }); +ensureCampaignDemo().then(() => { + console.log("[Campaign] Campaign demo community ready"); +}).catch((e) => { + console.error("[Campaign] Failed to initialize campaign demo:", e); +}); + console.log(`rSpace server running on http://localhost:${PORT}`); diff --git a/server/seed-campaign.ts b/server/seed-campaign.ts new file mode 100644 index 0000000..a64359b --- /dev/null +++ b/server/seed-campaign.ts @@ -0,0 +1,495 @@ +/** + * Social Media Campaign seed — "MycoFi Earth Launch" campaign + * + * Demonstrates the folk-social-post component with a realistic + * multi-platform product launch campaign connected by folk-arrows + * in an n8n-style "this then that" flow. + * + * Flow layout (left-to-right, with parallel branches): + * + * [Trigger] → [X Teaser] → [LinkedIn Thought] → [IG Carousel] ──┐ + * ├→ [YT Video] → [X Launch] → [LinkedIn Ann.] ──┐ + * │ ├→ [IG Reel] → [Threads] → [Bluesky] + * └───────────────────────────────────────────────┘ + */ + +import { + addShapes, + communityExists, + createCommunity, + getDocumentData, + loadCommunity, +} from "./community-store"; + +// ── Layout constants ──────────────────────────────────────────── +const COL_WIDTH = 320; +const COL_GAP = 100; +const ROW_HEIGHT = 420; +const ROW_GAP = 60; +const START_X = 60; +const START_Y = 60; +const NODE_W = 300; +const NODE_H = 380; + +function col(n: number): number { + return START_X + n * (COL_WIDTH + COL_GAP); +} +function row(n: number): number { + return START_Y + n * (ROW_HEIGHT + ROW_GAP); +} + +// ── Campaign seed data ────────────────────────────────────────── + +const CAMPAIGN_SHAPES: Record[] = [ + // ──────────────────────────────────────────────────────────── + // TRIGGER NODE (campaign start) + // ──────────────────────────────────────────────────────────── + { + id: "campaign-trigger", + type: "folk-workflow-block", + x: col(0), + y: row(0) + 100, + width: 220, + height: 140, + rotation: 0, + blockType: "trigger", + label: "Campaign Start", + inputs: [], + outputs: [{ name: "launch", type: "trigger" }], + }, + + // ──────────────────────────────────────────────────────────── + // PHASE 1: Pre-Launch Hype (Days -3 to -1) + // ──────────────────────────────────────────────────────────── + { + id: "post-x-teaser", + type: "folk-social-post", + x: col(1), + y: row(0), + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "x", + postType: "thread", + stepNumber: 1, + content: + "Something is growing in the mycelium... \ud83c\udf44\n\nFor the past 2 years, we've been building the infrastructure for a regenerative economy.\n\nOn Feb 24, we reveal everything.\n\nA thread on why the old financial system is composting itself \ud83e\uddf5\ud83d\udc47", + mediaUrl: "", + mediaType: "", + scheduledAt: "2026-02-21T09:00:00", + status: "scheduled", + hashtags: ["MycoFi", "RegenFinance", "Web3", "ComingSoon"], + }, + + { + id: "post-linkedin-thought", + type: "folk-social-post", + x: col(2), + y: row(0), + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "linkedin", + postType: "article", + stepNumber: 2, + content: + "The regenerative finance movement isn't just about returns \u2014 it's about redesigning incentive structures from the ground up.\n\nIn this article, I break down why mycelial network theory offers the best model for decentralized economic coordination.\n\n3 key insights from 2 years of building MycoFi Earth...", + mediaUrl: "", + mediaType: "image", + scheduledAt: "2026-02-22T11:00:00", + status: "scheduled", + hashtags: ["RegenerativeFinance", "DeFi", "SystemsThinking", "Leadership"], + }, + + { + id: "post-ig-carousel", + type: "folk-social-post", + x: col(3), + y: row(0), + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "instagram", + postType: "carousel", + stepNumber: 3, + content: + "5 Ways Mycelium Networks Mirror the Future of Finance \ud83c\udf0d\ud83c\udf44\n\nSlide 1: The problem with extractive finance\nSlide 2: How mycelium redistributes nutrients\nSlide 3: Token-weighted funding circles\nSlide 4: Community governance that actually works\nSlide 5: Join the launch \u2014 Feb 24", + mediaUrl: "", + mediaType: "carousel", + scheduledAt: "2026-02-23T14:00:00", + status: "scheduled", + hashtags: [ + "MycoFi", + "RegenerativeEconomy", + "Infographic", + "Web3Education", + ], + }, + + // ──────────────────────────────────────────────────────────── + // PHASE 2: Launch Day (Day 0) + // ──────────────────────────────────────────────────────────── + { + id: "post-yt-launch", + type: "folk-social-post", + x: col(4), + y: row(0) - 30, + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "youtube", + postType: "video", + stepNumber: 4, + content: + "MycoFi Earth \u2014 Official Launch Video\n\nThe regenerative economy starts here. Watch how mycelial intelligence is reshaping finance, governance, and community coordination.\n\nFeaturing interviews with 12 builders from the ecosystem.\n\n[18:42]", + mediaUrl: "", + mediaType: "video", + scheduledAt: "2026-02-24T10:00:00", + status: "draft", + hashtags: [ + "MycoFiLaunch", + "RegenerativeFinance", + "Documentary", + "Web3", + ], + }, + + { + id: "post-x-launch", + type: "folk-social-post", + x: col(5), + y: row(0) - 60, + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "x", + postType: "thread", + stepNumber: 5, + content: + "\ud83c\udf44 MycoFi Earth is LIVE \ud83c\udf44\n\nAfter 2 years of building, the regenerative finance platform is here.\n\nWhat is it?\n\u2022 Token-weighted funding circles\n\u2022 Mycelial governance (no whales)\n\u2022 Composting mechanism for failed proposals\n\u2022 100% on-chain, 100% community-owned\n\n\ud83d\udc47 Full breakdown thread", + mediaUrl: "", + mediaType: "image", + scheduledAt: "2026-02-24T10:15:00", + status: "draft", + hashtags: [ + "MycoFi", + "Launch", + "RegenFinance", + "DeFi", + "DAO", + ], + }, + + { + id: "post-linkedin-launch", + type: "folk-social-post", + x: col(6), + y: row(0) - 30, + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "linkedin", + postType: "text", + stepNumber: 6, + content: + "Today we're launching MycoFi Earth \u2014 a regenerative finance platform modeled on mycelial networks.\n\nWhy it matters for the future of organizational design:\n\n1. Composting mechanism: Failed proposals return resources to the network\n2. Nutrient routing: Funds flow to where they're needed most\n3. No single point of failure: True decentralization\n\nFull video + docs in comments \u2193", + mediaUrl: "", + mediaType: "image", + scheduledAt: "2026-02-24T11:00:00", + status: "draft", + hashtags: [ + "Launch", + "RegenerativeFinance", + "Innovation", + "FutureOfWork", + ], + }, + + // ──────────────────────────────────────────────────────────── + // PHASE 3: Post-Launch Amplification (Day +1) + // ──────────────────────────────────────────────────────────── + { + id: "post-ig-reel", + type: "folk-social-post", + x: col(7), + y: row(0) - 30, + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "instagram", + postType: "reel", + stepNumber: 7, + content: + "60-second explainer: How MycoFi Earth works \ud83c\udf44\u2728\n\nVisual breakdown of the token flow from contributor \u2192 funding circle \u2192 project \u2192 compost.\n\nSet to lo-fi beats with mycelium time-lapse footage.\n\nCTA: Link in bio to join the first funding circle.", + mediaUrl: "", + mediaType: "video", + scheduledAt: "2026-02-25T12:00:00", + status: "draft", + hashtags: [ + "MycoFi", + "RegenFinance", + "Explainer", + "Web3", + "Reels", + ], + }, + + { + id: "post-threads-xpost", + type: "folk-social-post", + x: col(8), + y: row(0) - 60, + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "threads", + postType: "text", + stepNumber: 8, + content: + "We just launched MycoFi Earth and the response has been incredible \ud83c\udf1f\n\nThe idea is simple: what if finance worked like mycelium?\n\nMycelium doesn't hoard \u2014 it redistributes. MycoFi applies that logic to community funding.\n\nEarly access is open. Come grow with us \ud83c\udf31", + mediaUrl: "", + mediaType: "", + scheduledAt: "2026-02-25T14:00:00", + status: "draft", + hashtags: ["MycoFi", "RegenEconomy", "Community"], + }, + + { + id: "post-bluesky-launch", + type: "folk-social-post", + x: col(9), + y: row(0), + width: NODE_W, + height: NODE_H, + rotation: 0, + platform: "bluesky", + postType: "text", + stepNumber: 9, + content: + "MycoFi Earth just went live \ud83c\udf44\n\nIt's a regenerative finance platform where funding flows like nutrients through a mycelial network.\n\nNo VCs. No whales. Just communities funding what matters.\n\nmycofi.earth", + mediaUrl: "", + mediaType: "", + scheduledAt: "2026-02-25T15:00:00", + status: "draft", + hashtags: ["MycoFi", "RegenFinance", "Bluesky"], + }, + + // ──────────────────────────────────────────────────────────── + // CAMPAIGN SUMMARY NODE + // ──────────────────────────────────────────────────────────── + { + id: "campaign-summary", + type: "folk-markdown", + x: col(0), + y: row(0) - 200, + width: 500, + height: 160, + rotation: 0, + content: + "# \ud83c\udf44 MycoFi Earth Launch Campaign\n\n**Duration:** Feb 21\u201325, 2026 (5 days)\n**Platforms:** X, LinkedIn, Instagram, YouTube, Threads, Bluesky\n**Posts:** 9 total across 3 phases\n\n| Phase | Days | Posts |\n|-------|------|-------|\n| Pre-Launch Hype | Day -3 to -1 | 3 posts |\n| Launch Day | Day 0 | 3 posts |\n| Amplification | Day +1 | 3 posts |", + }, + + // ──────────────────────────────────────────────────────────── + // PHASE LABELS (markdown notes as section headers) + // ──────────────────────────────────────────────────────────── + { + id: "label-phase1", + type: "folk-markdown", + x: col(1), + y: row(0) - 55, + width: COL_WIDTH * 3 + COL_GAP * 2, + height: 36, + rotation: 0, + content: + "### \ud83d\udce3 Phase 1: Pre-Launch Hype (Feb 21\u201323)", + }, + { + id: "label-phase2", + type: "folk-markdown", + x: col(4), + y: row(0) - 85, + width: COL_WIDTH * 3 + COL_GAP * 2, + height: 36, + rotation: 0, + content: "### \ud83d\ude80 Phase 2: Launch Day (Feb 24)", + }, + { + id: "label-phase3", + type: "folk-markdown", + x: col(7), + y: row(0) - 85, + width: COL_WIDTH * 3 + COL_GAP * 2, + height: 36, + rotation: 0, + content: + "### \ud83d\udce1 Phase 3: Amplification (Feb 25)", + }, + + // ──────────────────────────────────────────────────────────── + // ARROWS (flow connections) + // ──────────────────────────────────────────────────────────── + + // Trigger → X Teaser + { + id: "arrow-trigger-to-teaser", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "campaign-trigger", + targetId: "post-x-teaser", + color: "#64748b", + }, + + // X Teaser → LinkedIn Thought Leadership + { + id: "arrow-teaser-to-linkedin", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-x-teaser", + targetId: "post-linkedin-thought", + color: "#0A66C2", + }, + + // LinkedIn Thought → IG Carousel + { + id: "arrow-linkedin-to-ig", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-linkedin-thought", + targetId: "post-ig-carousel", + color: "#E4405F", + }, + + // IG Carousel → YouTube Launch (phase transition) + { + id: "arrow-ig-to-yt", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-ig-carousel", + targetId: "post-yt-launch", + color: "#FF0000", + }, + + // YouTube → X Launch Thread + { + id: "arrow-yt-to-x-launch", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-yt-launch", + targetId: "post-x-launch", + color: "#000000", + }, + + // X Launch → LinkedIn Announcement + { + id: "arrow-x-to-linkedin-launch", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-x-launch", + targetId: "post-linkedin-launch", + color: "#0A66C2", + }, + + // LinkedIn Announcement → IG Reel (phase transition) + { + id: "arrow-linkedin-to-reel", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-linkedin-launch", + targetId: "post-ig-reel", + color: "#E4405F", + }, + + // IG Reel → Threads + { + id: "arrow-reel-to-threads", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-ig-reel", + targetId: "post-threads-xpost", + color: "#000000", + }, + + // Threads → Bluesky + { + id: "arrow-threads-to-bluesky", + type: "folk-arrow", + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + sourceId: "post-threads-xpost", + targetId: "post-bluesky-launch", + color: "#0085FF", + }, +]; + +/** + * Ensure the campaign demo community exists and is seeded. + * Called on server startup alongside the main demo. + */ +export async function ensureCampaignDemo(): Promise { + const slug = "campaign-demo"; + const exists = await communityExists(slug); + + if (!exists) { + await createCommunity( + "Social Media Campaign Demo", + slug, + null, + "public", + ); + console.log( + "[Campaign] Created campaign-demo community with visibility: public", + ); + } else { + await loadCommunity(slug); + } + + // Check if already seeded + const data = getDocumentData(slug); + const shapeCount = data ? Object.keys(data.shapes || {}).length : 0; + + if (shapeCount === 0) { + addShapes(slug, CAMPAIGN_SHAPES); + console.log( + `[Campaign] Seeded ${CAMPAIGN_SHAPES.length} shapes into campaign-demo`, + ); + } else { + console.log( + `[Campaign] campaign-demo already has ${shapeCount} shapes`, + ); + } +} diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index f33a33e..b32e747 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -80,6 +80,7 @@ const CONFIG = { 'https://rnetwork.online', 'https://rcart.online', 'https://rtube.online', + 'https://rchats.online', 'https://rstack.online', 'https://rpubs.online', 'https://rauctions.online', diff --git a/website/canvas.html b/website/canvas.html index 4b93b62..02151c9 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -181,7 +181,8 @@ folk-token-ledger, folk-choice-vote, folk-choice-rank, - folk-choice-spider { + folk-choice-spider, + folk-social-post { position: absolute; } @@ -191,7 +192,8 @@ folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, - folk-choice-vote, folk-choice-rank, folk-choice-spider) { + folk-choice-vote, folk-choice-rank, folk-choice-spider, + folk-social-post) { cursor: crosshair; } @@ -201,7 +203,8 @@ folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, - folk-choice-vote, folk-choice-rank, folk-choice-spider):hover { + folk-choice-vote, folk-choice-rank, folk-choice-spider, + folk-social-post):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -244,6 +247,7 @@ + @@ -287,6 +291,7 @@ FolkChoiceVote, FolkChoiceRank, FolkChoiceSpider, + FolkSocialPost, CommunitySync, PresenceManager, generatePeerId, @@ -335,12 +340,14 @@ FolkChoiceVote.define(); FolkChoiceRank.define(); FolkChoiceSpider.define(); + FolkSocialPost.define(); // Get community info from URL const hostname = window.location.hostname; const subdomain = hostname.split(".")[0]; const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1"; - const communitySlug = isLocalhost ? "demo" : subdomain; + const urlParams = new URLSearchParams(window.location.search); + const communitySlug = urlParams.get("space") || (isLocalhost ? "demo" : subdomain); // Update UI document.getElementById("community-name").textContent = communitySlug; @@ -360,7 +367,8 @@ "folk-workflow-block", "folk-itinerary", "folk-destination", "folk-budget", "folk-packing-list", "folk-booking", "folk-token-mint", "folk-token-ledger", - "folk-choice-vote", "folk-choice-rank", "folk-choice-spider" + "folk-choice-vote", "folk-choice-rank", "folk-choice-spider", + "folk-social-post" ].join(", "); // Initialize offline store and CommunitySync @@ -647,6 +655,18 @@ if (data.criteria) shape.criteria = data.criteria; if (data.scores) shape.scores = data.scores; break; + case "folk-social-post": + shape = document.createElement("folk-social-post"); + if (data.platform) shape.platform = data.platform; + if (data.postType) shape.postType = data.postType; + if (data.content) shape.content = data.content; + if (data.mediaUrl) shape.mediaUrl = data.mediaUrl; + if (data.mediaType) shape.mediaType = data.mediaType; + if (data.scheduledAt) shape.scheduledAt = data.scheduledAt; + if (data.status) shape.status = data.status; + if (data.hashtags) shape.hashtags = data.hashtags; + if (data.stepNumber) shape.stepNumber = data.stepNumber; + break; case "folk-markdown": default: shape = document.createElement("folk-markdown"); @@ -715,6 +735,7 @@ "folk-choice-vote": { width: 360, height: 400 }, "folk-choice-rank": { width: 380, height: 480 }, "folk-choice-spider": { width: 440, height: 540 }, + "folk-social-post": { width: 300, height: 380 }, }; // Get the center of the current viewport in canvas coordinates @@ -892,6 +913,17 @@ }); }); + // Social media post + document.getElementById("add-social-post").addEventListener("click", () => { + createAndAddShape("folk-social-post", { + platform: "x", + postType: "text", + content: "Write your post content here...", + status: "draft", + hashtags: [], + }); + }); + // Arrow connection mode let connectMode = false; let connectSource = null; diff --git a/website/index.html b/website/index.html index 7278c64..e11904c 100644 --- a/website/index.html +++ b/website/index.html @@ -555,6 +555,7 @@ 🕸 rNetwork 🛒 rCart 🎬 rTube + 💬 rChats 💬 rForum 👕 rSwag 📊 rData