feat: add rChats.online to ecosystem links and EncryptID allowed origins

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 14:15:42 -07:00
parent ea8f1b3f95
commit a3572f7a5f
8 changed files with 1482 additions and 6 deletions

View File

@ -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;
}
}
/**

891
lib/folk-social-post.ts Normal file
View File

@ -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`
<span
class="step-number"
style="display: ${this.#stepNumber > 0 ? "flex" : "none"}"
>${this.#stepNumber}</span
>
<div class="header" style="background: ${config.color}">
<div class="header-left">
<span class="platform-icon">${config.icon}</span>
<span class="platform-name">${config.label}</span>
</div>
<div class="header-actions">
<button class="edit-btn" title="Edit">\u270F</button>
<button class="close-btn" title="Remove">\u00D7</button>
</div>
</div>
<div class="body">
<span class="post-type">${this.#escapeHtml(this.#postType)}</span>
<div class="content-preview">
${this.#escapeHtml(this.#content) || "No content yet..."}
</div>
<div class="media-preview">
<div class="media-placeholder">
<span class="icon">\ud83d\uddbc</span>
<span>No media</span>
</div>
</div>
<div class="hashtags"></div>
</div>
<div class="footer">
<div class="schedule">
<span class="schedule-icon">\ud83d\udcc5</span>
<span class="schedule-time"
>${this.#formatSchedule(this.#scheduledAt)}</span
>
</div>
<span class="status-badge ${this.#status}">${this.#status}</span>
</div>
<div class="edit-overlay">
<div class="edit-header">
<span>Edit Post</span>
</div>
<div class="edit-body">
<div class="edit-field">
<label>Platform</label>
<select class="edit-platform">
<option value="x">X (Twitter)</option>
<option value="linkedin">LinkedIn</option>
<option value="instagram">Instagram</option>
<option value="youtube">YouTube</option>
<option value="threads">Threads</option>
<option value="bluesky">Bluesky</option>
<option value="tiktok">TikTok</option>
<option value="facebook">Facebook</option>
</select>
</div>
<div class="edit-field">
<label>Post Type</label>
<select class="edit-post-type">
<option value="text">Text</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="carousel">Carousel</option>
<option value="story">Story</option>
<option value="reel">Reel</option>
<option value="thread">Thread</option>
<option value="article">Article</option>
<option value="short">Short</option>
</select>
</div>
<div class="edit-field">
<label>Content</label>
<textarea
class="edit-content"
placeholder="Write your post..."
></textarea>
</div>
<div class="edit-field">
<label>Media URL</label>
<input
type="text"
class="edit-media-url"
placeholder="https://..."
/>
</div>
<div class="edit-field">
<label>Scheduled At</label>
<input type="datetime-local" class="edit-scheduled" />
</div>
<div class="edit-field">
<label>Hashtags (comma-separated)</label>
<input
type="text"
class="edit-hashtags"
placeholder="#launch, #product"
/>
</div>
<div class="edit-field">
<label>Status</label>
<select class="edit-status">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="posted">Posted</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
<div class="edit-actions">
<button class="cancel-btn">Cancel</button>
<button class="save-btn">Save</button>
</div>
</div>
`;
// 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 = `<img src="${this.#escapeHtml(this.#mediaUrl)}" alt="Post media" onerror="this.parentElement.innerHTML='<div class=\\'media-placeholder\\'><span class=\\'icon\\'>⚠️</span><span>Failed to load</span></div>'" />`;
} else {
const mediaIcons: Record<string, string> = {
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 = `<div class="media-placeholder"><span class="icon">${icon}</span><span>${label}</span></div>`;
}
}
#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 `<span class="hashtag">${this.#escapeHtml(t)}</span>`;
})
.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,
};
}
}

View File

@ -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";

View File

@ -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<Response> {
}
}
// 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<Response> {
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}`);

495
server/seed-campaign.ts Normal file
View File

@ -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<string, unknown>[] = [
// ────────────────────────────────────────────────────────────
// 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<void> {
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`,
);
}
}

View File

@ -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',

View File

@ -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 @@
<button id="add-choice-vote" title="Live Poll">☑ Poll</button>
<button id="add-choice-rank" title="Rank Choices">📊 Rank</button>
<button id="add-choice-spider" title="Score Matrix">🕸 Spider</button>
<button id="add-social-post" title="Social Media Post">📱 Post</button>
<button id="add-arrow" title="Connect Shapes">↗️ Connect</button>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out">-</button>
@ -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;

View File

@ -555,6 +555,7 @@
<a href="https://rnetwork.online" class="ecosystem-app">🕸 rNetwork</a>
<a href="https://rcart.online" class="ecosystem-app">🛒 rCart</a>
<a href="https://rtube.online" class="ecosystem-app">🎬 rTube</a>
<a href="https://rchats.online" class="ecosystem-app">💬 rChats</a>
<a href="https://rforum.online" class="ecosystem-app">💬 rForum</a>
<a href="https://rswag.online" class="ecosystem-app">👕 rSwag</a>
<a href="https://rdata.online" class="ecosystem-app">📊 rData</a>