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}
+
+
+
${this.#escapeHtml(this.#postType)}
+
+ ${this.#escapeHtml(this.#content) || "No content yet..."}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // 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 = `
`;
+ } 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