Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-25 20:04:22 -07:00
commit 943c8ec084
15 changed files with 1666 additions and 125 deletions

View File

@ -281,6 +281,113 @@ const registry: CanvasToolDefinition[] = [
},
];
// ── Social Media / Campaign Tools ──
registry.push(
{
declaration: {
name: "create_social_post",
description: "Create a social media post card for scheduling across platforms.",
parameters: {
type: "object",
properties: {
platform: { type: "string", description: "Target platform", enum: ["x", "linkedin", "instagram", "youtube", "threads", "bluesky", "tiktok", "facebook"] },
content: { type: "string", description: "Post text content" },
postType: { type: "string", description: "Format", enum: ["text", "image", "video", "carousel", "thread", "article"] },
scheduledAt: { type: "string", description: "ISO datetime to schedule" },
hashtags: { type: "string", description: "Comma-separated hashtags" },
},
required: ["platform", "content"],
},
},
tagName: "folk-social-post",
buildProps: (args) => ({
platform: args.platform || "x",
content: args.content,
postType: args.postType || "text",
scheduledAt: args.scheduledAt || "",
hashtags: args.hashtags ? args.hashtags.split(",").map((t: string) => t.trim()).filter(Boolean) : [],
status: "draft",
}),
actionLabel: (args) => `Created ${args.platform || "social"} post`,
},
{
declaration: {
name: "create_social_thread",
description: "Create a tweet thread card on the canvas. Use when the user wants to draft a multi-tweet thread.",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "Thread title" },
platform: { type: "string", description: "Target platform", enum: ["x", "bluesky", "threads"] },
tweetsJson: { type: "string", description: "JSON array of tweet strings" },
status: { type: "string", description: "Thread status", enum: ["draft", "ready", "published"] },
},
required: ["title"],
},
},
tagName: "folk-social-thread",
buildProps: (args) => {
let tweets: string[] = [];
try { tweets = JSON.parse(args.tweetsJson || "[]"); } catch { tweets = []; }
return {
title: args.title,
platform: args.platform || "x",
tweets,
status: args.status || "draft",
};
},
actionLabel: (args) => `Created thread: ${args.title}`,
},
{
declaration: {
name: "create_campaign_card",
description: "Create a campaign dashboard card on the canvas. Use when the user wants to plan or track a social media campaign.",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "Campaign title" },
description: { type: "string", description: "Campaign description" },
platforms: { type: "string", description: "Comma-separated platform names" },
duration: { type: "string", description: "Campaign duration (e.g. '4 weeks')" },
},
required: ["title"],
},
},
tagName: "folk-social-campaign",
buildProps: (args) => ({
title: args.title,
description: args.description || "",
platforms: args.platforms ? args.platforms.split(",").map((p: string) => p.trim().toLowerCase()).filter(Boolean) : [],
duration: args.duration || "",
}),
actionLabel: (args) => `Created campaign: ${args.title}`,
},
{
declaration: {
name: "create_newsletter_card",
description: "Create a newsletter/email campaign card on the canvas. Use when the user wants to draft or schedule an email newsletter.",
parameters: {
type: "object",
properties: {
subject: { type: "string", description: "Email subject line" },
listName: { type: "string", description: "Mailing list name" },
status: { type: "string", description: "Newsletter status", enum: ["draft", "scheduled", "sent"] },
scheduledAt: { type: "string", description: "ISO datetime to schedule" },
},
required: ["subject"],
},
},
tagName: "folk-social-newsletter",
buildProps: (args) => ({
subject: args.subject,
listName: args.listName || "",
status: args.status || "draft",
scheduledAt: args.scheduledAt || "",
}),
actionLabel: (args) => `Created newsletter: ${args.subject}`,
},
);
// ── Design Agent Tool ──
registry.push({
declaration: {

395
lib/folk-social-campaign.ts Normal file
View File

@ -0,0 +1,395 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const PLATFORM_ICONS: Record<string, string> = {
x: "𝕏", linkedin: "in", instagram: "📷", youtube: "▶",
threads: "@", bluesky: "🩷", tiktok: "♫", facebook: "f",
};
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 12px;
box-shadow: var(--rs-shadow-sm);
min-width: 280px;
min-height: 140px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:host(:hover) {
box-shadow: var(--rs-shadow-md);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: move;
color: white;
border-radius: 12px 12px 0 0;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-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;
}
.header-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
.description {
font-size: 13px;
line-height: 1.4;
color: var(--rs-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 10px;
}
.platform-row {
display: flex;
gap: 6px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.platform-chip {
font-size: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--rs-bg-surface-raised);
border-radius: 6px;
border: 1px solid var(--rs-border);
}
.progress-section {
margin-bottom: 10px;
}
.progress-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--rs-text-muted);
margin-bottom: 4px;
}
.progress-bar {
height: 6px;
background: var(--rs-bg-surface-raised);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 3px;
transition: width 0.3s ease;
}
.stats-row {
display: flex;
gap: 12px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--rs-text-muted);
}
.stat-count {
font-weight: 600;
color: var(--rs-text-primary);
}
.stat-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.stat-dot.draft { background: #94a3b8; }
.stat-dot.scheduled { background: #3b82f6; }
.stat-dot.published { background: #22c55e; }
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--rs-bg-surface-raised);
border-top: 1px solid var(--rs-border);
border-radius: 0 0 12px 12px;
}
.duration-label {
font-size: 11px;
color: var(--rs-text-muted);
font-weight: 500;
}
.open-link {
font-size: 11px;
color: var(--rs-primary);
cursor: pointer;
text-decoration: none;
font-weight: 500;
}
.open-link:hover {
text-decoration: underline;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-social-campaign": FolkSocialCampaign;
}
}
export class FolkSocialCampaign extends FolkShape {
static override tagName = "folk-social-campaign";
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;
}
#campaignId = "";
#title = "Untitled Campaign";
#description = "";
#platforms: string[] = [];
#phases: { name: string; label: string; days: string }[] = [];
#postCount = 0;
#draftCount = 0;
#scheduledCount = 0;
#publishedCount = 0;
#duration = "";
#spaceSlug = "";
get campaignId() { return this.#campaignId; }
set campaignId(v: string) { this.#campaignId = v; this.#dispatchChange(); }
get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); this.#dispatchChange(); }
get description() { return this.#description; }
set description(v: string) { this.#description = v; this.requestUpdate("description"); this.#dispatchChange(); }
get platforms(): string[] { return this.#platforms; }
set platforms(v: string[]) { this.#platforms = v; this.requestUpdate("platforms"); this.#dispatchChange(); }
get phases() { return this.#phases; }
set phases(v: { name: string; label: string; days: string }[]) { this.#phases = v; this.requestUpdate("phases"); this.#dispatchChange(); }
get postCount() { return this.#postCount; }
set postCount(v: number) { this.#postCount = v; this.requestUpdate("postCount"); this.#dispatchChange(); }
get draftCount() { return this.#draftCount; }
set draftCount(v: number) { this.#draftCount = v; this.requestUpdate("draftCount"); this.#dispatchChange(); }
get scheduledCount() { return this.#scheduledCount; }
set scheduledCount(v: number) { this.#scheduledCount = v; this.requestUpdate("scheduledCount"); this.#dispatchChange(); }
get publishedCount() { return this.#publishedCount; }
set publishedCount(v: number) { this.#publishedCount = v; this.requestUpdate("publishedCount"); this.#dispatchChange(); }
get duration() { return this.#duration; }
set duration(v: string) { this.#duration = v; this.requestUpdate("duration"); this.#dispatchChange(); }
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) { this.#spaceSlug = v; this.#dispatchChange(); }
#dispatchChange() {
this.dispatchEvent(new CustomEvent("content-change", { detail: this.toJSON() }));
}
override createRenderRoot() {
const root = super.createRenderRoot();
const titleAttr = this.getAttribute("title");
if (titleAttr) this.#title = titleAttr;
const total = this.#postCount || 1;
const publishedPct = Math.round((this.#publishedCount / total) * 100);
const escTitle = this.#escapeHtml(this.#title);
const escDesc = this.#escapeHtml(this.#description || "No description");
const platformChips = this.#platforms
.map((p) => `<span class="platform-chip" title="${this.#escapeHtml(p)}">${PLATFORM_ICONS[p] || p[0]?.toUpperCase() || "?"}</span>`)
.join("");
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.height = "100%";
wrapper.innerHTML = html`
<div class="header">
<div class="header-left">
<span class="header-icon">📢</span>
<span class="header-title">${escTitle}</span>
</div>
<div class="header-actions">
<button class="close-btn" title="Remove">×</button>
</div>
</div>
<div class="body">
<div class="description">${escDesc}</div>
<div class="platform-row">${platformChips || '<span style="font-size:11px;color:var(--rs-text-muted)">No platforms</span>'}</div>
<div class="progress-section">
<div class="progress-label">Phase Progress</div>
<div class="progress-bar"><div class="progress-fill" style="width: ${publishedPct}%"></div></div>
</div>
<div class="stats-row">
<div class="stat"><span class="stat-dot draft"></span><span class="stat-count">${this.#draftCount}</span> draft</div>
<div class="stat"><span class="stat-dot scheduled"></span><span class="stat-count">${this.#scheduledCount}</span> sched</div>
<div class="stat"><span class="stat-dot published"></span><span class="stat-count">${this.#publishedCount}</span> pub</div>
</div>
</div>
<div class="footer">
<span class="duration-label">${this.#escapeHtml(this.#duration || "No duration set")}</span>
<span class="open-link">Open Campaign </span>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
const openLink = wrapper.querySelector(".open-link") as HTMLElement;
openLink.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("navigate-to-module", {
bubbles: true, composed: true,
detail: { path: `/${this.#spaceSlug}/rsocials/campaigns?id=${this.#campaignId}` },
}));
});
return root;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialCampaign {
const shape = FolkShape.fromData(data) as FolkSocialCampaign;
if (data.campaignId) shape.campaignId = data.campaignId;
if (data.title) shape.title = data.title;
if (data.description !== undefined) shape.description = data.description;
if (Array.isArray(data.platforms)) shape.platforms = data.platforms;
if (Array.isArray(data.phases)) shape.phases = data.phases;
if (typeof data.postCount === "number") shape.postCount = data.postCount;
if (typeof data.draftCount === "number") shape.draftCount = data.draftCount;
if (typeof data.scheduledCount === "number") shape.scheduledCount = data.scheduledCount;
if (typeof data.publishedCount === "number") shape.publishedCount = data.publishedCount;
if (data.duration !== undefined) shape.duration = data.duration;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.campaignId !== undefined && data.campaignId !== this.campaignId) this.campaignId = data.campaignId;
if (data.title !== undefined && data.title !== this.title) this.title = data.title;
if (data.description !== undefined && data.description !== this.description) this.description = data.description;
if (Array.isArray(data.platforms) && JSON.stringify(data.platforms) !== JSON.stringify(this.platforms)) this.platforms = data.platforms;
if (Array.isArray(data.phases) && JSON.stringify(data.phases) !== JSON.stringify(this.phases)) this.phases = data.phases;
if (typeof data.postCount === "number" && data.postCount !== this.postCount) this.postCount = data.postCount;
if (typeof data.draftCount === "number" && data.draftCount !== this.draftCount) this.draftCount = data.draftCount;
if (typeof data.scheduledCount === "number" && data.scheduledCount !== this.scheduledCount) this.scheduledCount = data.scheduledCount;
if (typeof data.publishedCount === "number" && data.publishedCount !== this.publishedCount) this.publishedCount = data.publishedCount;
if (data.duration !== undefined && data.duration !== this.duration) this.duration = data.duration;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-campaign",
campaignId: this.#campaignId,
title: this.#title,
description: this.#description,
platforms: this.#platforms,
phases: this.#phases,
postCount: this.#postCount,
draftCount: this.#draftCount,
scheduledCount: this.#scheduledCount,
publishedCount: this.#publishedCount,
duration: this.#duration,
spaceSlug: this.#spaceSlug,
};
}
}

View File

@ -0,0 +1,354 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 12px;
box-shadow: var(--rs-shadow-sm);
min-width: 260px;
min-height: 120px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:host(:hover) {
box-shadow: var(--rs-shadow-md);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: move;
color: white;
border-radius: 12px 12px 0 0;
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-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;
}
.header-label {
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;
}
.subject-line {
font-size: 14px;
font-weight: 600;
color: var(--rs-text-primary);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.list-name {
font-size: 12px;
color: var(--rs-text-secondary);
}
.subscriber-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
.body-preview {
font-size: 13px;
line-height: 1.4;
color: var(--rs-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--rs-bg-surface-raised);
border-top: 1px solid var(--rs-border);
border-radius: 0 0 12px 12px;
}
.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: var(--rs-bg-surface-raised);
color: var(--rs-text-muted);
}
.status-badge.scheduled {
background: rgba(59, 130, 246, 0.15);
color: #2563eb;
}
.status-badge.sent {
background: rgba(34, 197, 94, 0.15);
color: var(--rs-success);
}
.schedule-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--rs-text-muted);
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-social-newsletter": FolkSocialNewsletter;
}
}
export class FolkSocialNewsletter extends FolkShape {
static override tagName = "folk-social-newsletter";
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;
}
#newsletterId = "";
#subject = "Untitled Newsletter";
#listName = "";
#subscriberCount = 0;
#status: "draft" | "scheduled" | "sent" = "draft";
#scheduledAt = "";
#bodyPreview = "";
#spaceSlug = "";
#subjectEl: HTMLElement | null = null;
#statusBadgeEl: HTMLElement | null = null;
#scheduleInfoEl: HTMLElement | null = null;
get newsletterId() { return this.#newsletterId; }
set newsletterId(v: string) { this.#newsletterId = v; this.#dispatchChange(); }
get subject() { return this.#subject; }
set subject(v: string) {
this.#subject = v;
if (this.#subjectEl) this.#subjectEl.textContent = v;
this.requestUpdate("subject");
this.#dispatchChange();
}
get listName() { return this.#listName; }
set listName(v: string) { this.#listName = v; this.requestUpdate("listName"); this.#dispatchChange(); }
get subscriberCount() { return this.#subscriberCount; }
set subscriberCount(v: number) { this.#subscriberCount = v; this.requestUpdate("subscriberCount"); this.#dispatchChange(); }
get status() { return this.#status; }
set status(v: "draft" | "scheduled" | "sent") {
this.#status = v;
if (this.#statusBadgeEl) {
this.#statusBadgeEl.className = `status-badge ${v}`;
this.#statusBadgeEl.textContent = v;
}
this.requestUpdate("status");
this.#dispatchChange();
}
get scheduledAt() { return this.#scheduledAt; }
set scheduledAt(v: string) {
this.#scheduledAt = v;
if (this.#scheduleInfoEl) this.#scheduleInfoEl.textContent = this.#formatSchedule(v);
this.requestUpdate("scheduledAt");
this.#dispatchChange();
}
get bodyPreview() { return this.#bodyPreview; }
set bodyPreview(v: string) { this.#bodyPreview = v; this.requestUpdate("bodyPreview"); this.#dispatchChange(); }
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) { this.#spaceSlug = v; this.#dispatchChange(); }
#dispatchChange() {
this.dispatchEvent(new CustomEvent("content-change", { detail: this.toJSON() }));
}
override createRenderRoot() {
const root = super.createRenderRoot();
const subjectAttr = this.getAttribute("subject");
if (subjectAttr) this.#subject = subjectAttr;
const statusAttr = this.getAttribute("status") as any;
if (statusAttr) this.#status = statusAttr;
const escSubject = this.#escapeHtml(this.#subject);
const escListName = this.#escapeHtml(this.#listName || "No list");
const escPreview = this.#escapeHtml(this.#bodyPreview || "No content preview");
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.height = "100%";
wrapper.innerHTML = html`
<div class="header">
<div class="header-left">
<span class="header-icon">📧</span>
<span class="header-label">Newsletter</span>
</div>
<div class="header-actions">
<button class="close-btn" title="Remove">×</button>
</div>
</div>
<div class="body">
<div class="subject-line">${escSubject}</div>
<div class="list-row">
<span class="list-name">${escListName}</span>
<span class="subscriber-badge">${this.#subscriberCount} subscribers</span>
</div>
<div class="body-preview">${escPreview}</div>
</div>
<div class="footer">
<span class="status-badge ${this.#status}">${this.#status}</span>
<span class="schedule-info">${this.#formatSchedule(this.#scheduledAt)}</span>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#subjectEl = wrapper.querySelector(".subject-line");
this.#statusBadgeEl = wrapper.querySelector(".status-badge");
this.#scheduleInfoEl = wrapper.querySelector(".schedule-info");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#formatSchedule(dateStr: string): string {
if (!dateStr) return "Not scheduled";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch {
return dateStr;
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialNewsletter {
const shape = FolkShape.fromData(data) as FolkSocialNewsletter;
if (data.newsletterId) shape.newsletterId = data.newsletterId;
if (data.subject) shape.subject = data.subject;
if (data.listName !== undefined) shape.listName = data.listName;
if (typeof data.subscriberCount === "number") shape.subscriberCount = data.subscriberCount;
if (data.status) shape.status = data.status;
if (data.scheduledAt !== undefined) shape.scheduledAt = data.scheduledAt;
if (data.bodyPreview !== undefined) shape.bodyPreview = data.bodyPreview;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.newsletterId !== undefined && data.newsletterId !== this.newsletterId) this.newsletterId = data.newsletterId;
if (data.subject !== undefined && data.subject !== this.subject) this.subject = data.subject;
if (data.listName !== undefined && data.listName !== this.listName) this.listName = data.listName;
if (typeof data.subscriberCount === "number" && data.subscriberCount !== this.subscriberCount) this.subscriberCount = data.subscriberCount;
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
if (data.scheduledAt !== undefined && data.scheduledAt !== this.scheduledAt) this.scheduledAt = data.scheduledAt;
if (data.bodyPreview !== undefined && data.bodyPreview !== this.bodyPreview) this.bodyPreview = data.bodyPreview;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-newsletter",
newsletterId: this.#newsletterId,
subject: this.#subject,
listName: this.#listName,
subscriberCount: this.#subscriberCount,
status: this.#status,
scheduledAt: this.#scheduledAt,
bodyPreview: this.#bodyPreview,
spaceSlug: this.#spaceSlug,
};
}
}

394
lib/folk-social-thread.ts Normal file
View File

@ -0,0 +1,394 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import type { SocialPlatform } from "./folk-social-post";
const PLATFORM_COLORS: Record<string, string> = {
x: "#000000",
linkedin: "#0A66C2",
instagram: "#E4405F",
youtube: "#FF0000",
threads: "#000000",
bluesky: "#0085FF",
tiktok: "#010101",
facebook: "#1877F2",
};
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 12px;
box-shadow: var(--rs-shadow-sm);
min-width: 260px;
min-height: 120px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:host(:hover) {
box-shadow: var(--rs-shadow-md);
}
.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;
}
.header-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;
}
.header-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
.tweet-count {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 10px;
background: var(--rs-bg-surface-raised);
color: var(--rs-text-muted);
margin-bottom: 8px;
}
.tweet-preview {
font-size: 13px;
line-height: 1.5;
color: var(--rs-text-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.cover-image {
width: 100%;
height: 80px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 8px;
border: 1px solid var(--rs-border);
}
.cover-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--rs-bg-surface-raised);
border-top: 1px solid var(--rs-border);
border-radius: 0 0 12px 12px;
}
.open-link {
font-size: 11px;
color: var(--rs-primary);
cursor: pointer;
text-decoration: none;
font-weight: 500;
}
.open-link:hover {
text-decoration: underline;
}
.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: var(--rs-bg-surface-raised);
color: var(--rs-text-muted);
}
.status-badge.ready {
background: rgba(59, 130, 246, 0.15);
color: #2563eb;
}
.status-badge.published {
background: rgba(34, 197, 94, 0.15);
color: var(--rs-success);
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-social-thread": FolkSocialThread;
}
}
export class FolkSocialThread extends FolkShape {
static override tagName = "folk-social-thread";
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;
}
#threadId = "";
#title = "Untitled Thread";
#tweets: string[] = [];
#platform: SocialPlatform = "x";
#status: "draft" | "ready" | "published" = "draft";
#imageUrl = "";
#spaceSlug = "";
#tweetCountEl: HTMLElement | null = null;
#tweetPreviewEl: HTMLElement | null = null;
#statusBadgeEl: HTMLElement | null = null;
#coverImageEl: HTMLElement | null = null;
get threadId() { return this.#threadId; }
set threadId(v: string) { this.#threadId = v; this.#dispatchChange(); }
get title() { return this.#title; }
set title(v: string) {
this.#title = v;
this.requestUpdate("title");
this.#dispatchChange();
}
get tweets(): string[] { return this.#tweets; }
set tweets(v: string[]) {
this.#tweets = v;
if (this.#tweetCountEl) this.#tweetCountEl.textContent = `${v.length} tweet${v.length !== 1 ? "s" : ""}`;
if (this.#tweetPreviewEl) this.#tweetPreviewEl.textContent = v[0] || "No tweets yet...";
this.requestUpdate("tweets");
this.#dispatchChange();
}
get platform(): SocialPlatform { return this.#platform; }
set platform(v: SocialPlatform) {
this.#platform = v;
this.requestUpdate("platform");
this.#dispatchChange();
}
get status() { return this.#status; }
set status(v: "draft" | "ready" | "published") {
this.#status = v;
if (this.#statusBadgeEl) {
this.#statusBadgeEl.className = `status-badge ${v}`;
this.#statusBadgeEl.textContent = v;
}
this.requestUpdate("status");
this.#dispatchChange();
}
get imageUrl() { return this.#imageUrl; }
set imageUrl(v: string) {
this.#imageUrl = v;
this.#renderCoverImage();
this.requestUpdate("imageUrl");
this.#dispatchChange();
}
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) { this.#spaceSlug = v; this.#dispatchChange(); }
#dispatchChange() {
this.dispatchEvent(new CustomEvent("content-change", { detail: this.toJSON() }));
}
override createRenderRoot() {
const root = super.createRenderRoot();
const threadIdAttr = this.getAttribute("thread-id");
if (threadIdAttr) this.#threadId = threadIdAttr;
const titleAttr = this.getAttribute("title");
if (titleAttr) this.#title = titleAttr;
const platformAttr = this.getAttribute("platform") as SocialPlatform;
if (platformAttr && platformAttr in PLATFORM_COLORS) this.#platform = platformAttr;
const statusAttr = this.getAttribute("status") as any;
if (statusAttr) this.#status = statusAttr;
const color = PLATFORM_COLORS[this.#platform] || "#000";
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.height = "100%";
const escTitle = this.#escapeHtml(this.#title);
const tweetCount = this.#tweets.length;
const firstTweet = this.#escapeHtml(this.#tweets[0] || "No tweets yet...");
wrapper.innerHTML = html`
<div class="header" style="background: ${color}">
<div class="header-left">
<span class="header-icon">🧵</span>
<span class="header-title">${escTitle}</span>
</div>
<div class="header-actions">
<button class="close-btn" title="Remove">×</button>
</div>
</div>
<div class="body">
<span class="tweet-count">${tweetCount} tweet${tweetCount !== 1 ? "s" : ""}</span>
<div class="tweet-preview">${firstTweet}</div>
<div class="cover-image" style="display: ${this.#imageUrl ? "block" : "none"}">
${this.#imageUrl ? `<img src="${this.#escapeHtml(this.#imageUrl)}" alt="Thread cover" />` : ""}
</div>
</div>
<div class="footer">
<span class="status-badge ${this.#status}">${this.#status}</span>
<span class="open-link">Open Editor </span>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#tweetCountEl = wrapper.querySelector(".tweet-count");
this.#tweetPreviewEl = wrapper.querySelector(".tweet-preview");
this.#statusBadgeEl = wrapper.querySelector(".status-badge");
this.#coverImageEl = wrapper.querySelector(".cover-image");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
const openLink = wrapper.querySelector(".open-link") as HTMLElement;
openLink.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("navigate-to-module", {
bubbles: true,
composed: true,
detail: { path: `/${this.#spaceSlug}/rsocials/thread-editor?id=${this.#threadId}` },
}));
});
return root;
}
#renderCoverImage() {
if (!this.#coverImageEl) return;
if (this.#imageUrl) {
this.#coverImageEl.style.display = "block";
this.#coverImageEl.innerHTML = `<img src="${this.#escapeHtml(this.#imageUrl)}" alt="Thread cover" />`;
} else {
this.#coverImageEl.style.display = "none";
this.#coverImageEl.innerHTML = "";
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialThread {
const shape = FolkShape.fromData(data) as FolkSocialThread;
if (data.threadId) shape.threadId = data.threadId;
if (data.title) shape.title = data.title;
if (Array.isArray(data.tweets)) shape.tweets = data.tweets;
if (data.platform) shape.platform = data.platform;
if (data.status) shape.status = data.status;
if (data.imageUrl !== undefined) shape.imageUrl = data.imageUrl;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.threadId !== undefined && data.threadId !== this.threadId) this.threadId = data.threadId;
if (data.title !== undefined && data.title !== this.title) this.title = data.title;
if (Array.isArray(data.tweets) && JSON.stringify(data.tweets) !== JSON.stringify(this.tweets)) this.tweets = data.tweets;
if (data.platform !== undefined && data.platform !== this.platform) this.platform = data.platform;
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
if (data.imageUrl !== undefined && data.imageUrl !== this.imageUrl) this.imageUrl = data.imageUrl;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-thread",
threadId: this.#threadId,
title: this.#title,
tweets: this.#tweets,
platform: this.#platform,
status: this.#status,
imageUrl: this.#imageUrl,
spaceSlug: this.#spaceSlug,
};
}
}

View File

@ -72,6 +72,9 @@ export * from "./folk-transaction-builder";
// Social Media / Campaign Shapes
export * from "./folk-social-post";
export * from "./folk-social-thread";
export * from "./folk-social-campaign";
export * from "./folk-social-newsletter";
// Decision/Choice Shapes
export * from "./folk-choice-vote";

113
lib/mi-suggestions.ts Normal file
View File

@ -0,0 +1,113 @@
/**
* MI Context-Aware Suggestions client-side suggestion registry.
*
* Maps module IDs to suggested prompts. When the MI panel opens,
* it reads the current module context and returns matching chips.
* No server call needed for static suggestions.
*/
export interface MiSuggestion {
label: string;
icon: string;
prompt: string;
autoSend?: boolean;
}
const MODULE_SUGGESTIONS: Record<string, MiSuggestion[]> = {
rspace: [
{ label: "Add a note", icon: "📝", prompt: "Add a markdown note to the canvas", autoSend: true },
{ label: "Generate an image", icon: "🎨", prompt: "Generate an image", autoSend: false },
{ label: "Arrange my shapes", icon: "📐", prompt: "Arrange all shapes on the canvas into a grid", autoSend: true },
{ label: "What's on this canvas?", icon: "🔍", prompt: "Summarize what's on this canvas", autoSend: true },
],
rnotes: [
{ label: "Create a notebook", icon: "📓", prompt: "Create a new notebook", autoSend: true },
{ label: "Summarize my notes", icon: "📋", prompt: "Summarize my recent notes", autoSend: true },
{ label: "Import from Obsidian", icon: "⬇", prompt: "How do I import notes from Obsidian?", autoSend: true },
],
rcal: [
{ label: "Create an event", icon: "📅", prompt: "Create a calendar event", autoSend: false },
{ label: "What's this week?", icon: "🗓", prompt: "What's on my calendar this week?", autoSend: true },
{ label: "Import a calendar", icon: "⬇", prompt: "How do I import an ICS calendar?", autoSend: true },
],
rtasks: [
{ label: "Create a task", icon: "✅", prompt: "Create a task", autoSend: false },
{ label: "Show open tasks", icon: "📋", prompt: "Show my open tasks", autoSend: true },
{ label: "Set up a project", icon: "🏗", prompt: "Help me set up a project board", autoSend: true },
],
rflows: [
{ label: "Create a flow", icon: "💧", prompt: "Create a new funding flow", autoSend: false },
{ label: "How do flows work?", icon: "❓", prompt: "How do community funding flows work?", autoSend: true },
],
rvote: [
{ label: "Create a proposal", icon: "🗳", prompt: "Create a governance proposal", autoSend: false },
{ label: "Start a vote", icon: "✋", prompt: "Start a governance vote", autoSend: false },
],
rchat: [
{ label: "Start a discussion", icon: "💬", prompt: "Start a new discussion thread", autoSend: false },
{ label: "Create a channel", icon: "📢", prompt: "Create a new chat channel", autoSend: false },
],
rcrm: [
{ label: "Add a contact", icon: "👤", prompt: "Add a new contact", autoSend: false },
{ label: "Show my pipeline", icon: "📊", prompt: "Show my CRM pipeline", autoSend: true },
],
rsocials: [
{ label: "Create a campaign", icon: "📣", prompt: "Create a social media campaign", autoSend: false },
{ label: "Draft a post", icon: "✏", prompt: "Draft a social media post", autoSend: false },
],
rwallet: [
{ label: "Check my balance", icon: "💰", prompt: "Check my wallet balance", autoSend: true },
{ label: "How do tokens work?", icon: "🪙", prompt: "How do CRDT tokens work?", autoSend: true },
],
rmaps: [
{ label: "Share my location", icon: "📍", prompt: "How do I share my location?", autoSend: true },
{ label: "Find nearby places", icon: "🔍", prompt: "Find interesting places nearby", autoSend: true },
],
rphotos: [
{ label: "Upload photos", icon: "📷", prompt: "How do I upload photos?", autoSend: true },
{ label: "Create an album", icon: "🖼", prompt: "Create a new photo album", autoSend: false },
],
rfiles: [
{ label: "Upload a file", icon: "📁", prompt: "How do I upload files?", autoSend: true },
{ label: "Browse files", icon: "🗂", prompt: "Show my recent files", autoSend: true },
],
};
const GENERIC_SUGGESTIONS: MiSuggestion[] = [
{ label: "What can I do here?", icon: "✨", prompt: "What can I do in this space?", autoSend: true },
{ label: "Set up this space", icon: "🏗", prompt: "Help me set up this space", autoSend: true },
{ label: "Show me around", icon: "🧭", prompt: "Give me a tour of the available rApps", autoSend: true },
];
const CANVAS_SUGGESTIONS: MiSuggestion[] = [
{ label: "Summarize selected", icon: "📋", prompt: "Summarize the selected shapes", autoSend: true },
{ label: "Connect these", icon: "🔗", prompt: "Connect the selected shapes", autoSend: true },
{ label: "Arrange in grid", icon: "📐", prompt: "Arrange the selected shapes in a grid", autoSend: true },
];
export function getContextSuggestions(ctx: {
module: string;
hasShapes: boolean;
hasSelectedShapes: boolean;
}): MiSuggestion[] {
const suggestions: MiSuggestion[] = [];
// Canvas selection suggestions take priority
if (ctx.hasSelectedShapes) {
suggestions.push(...CANVAS_SUGGESTIONS);
}
// Module-specific suggestions
const moduleSuggestions = MODULE_SUGGESTIONS[ctx.module];
if (moduleSuggestions) {
suggestions.push(...moduleSuggestions);
}
// Always add generic suggestions if we have room
if (suggestions.length < 6) {
const remaining = 6 - suggestions.length;
suggestions.push(...GENERIC_SUGGESTIONS.slice(0, remaining));
}
return suggestions;
}

View File

@ -117,14 +117,20 @@ export class FolkCampaignManager extends HTMLElement {
const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft';
const date = new Date(post.scheduledAt);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const contentPreview = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content);
const isLong = post.content.length > 180;
const contentPreview = isLong ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content);
const fullContent = this.esc(post.content);
const tags = post.hashtags.map(h => `<span class="tag">#${this.esc(h)}</span>`).join(' ');
// Thread badge
// Thread badge — links to thread editor if threadId exists
let threadBadge = '';
if (post.threadPosts && post.threadPosts.length > 0) {
if (post.threadId) {
threadBadge = `<a href="${this.basePath}thread-editor/${this.esc(post.threadId)}/edit" class="thread-badge thread-badge--link" data-post-id="${this.esc(post.id)}">🧵 ${post.threadPosts.length}-post thread — Edit</a>`;
} else {
threadBadge = `<span class="thread-badge" data-post-id="${this.esc(post.id)}">🧵 ${post.threadPosts.length}-post thread</span>`;
}
}
// Email subject for newsletter
let emailLine = '';
@ -141,8 +147,14 @@ export class FolkCampaignManager extends HTMLElement {
threadExpansion = `<div class="thread-expansion" id="thread-${this.esc(post.id)}" hidden>${threadItems}</div>`;
}
// "Open Thread" link for posts with created threads
let threadLink = '';
if (post.threadId && !(post.threadPosts && post.threadPosts.length > 0)) {
threadLink = `<a href="${this.basePath}thread-editor/${this.esc(post.threadId)}/edit" class="post__thread-link">Open Thread</a>`;
}
return `
<div class="post" data-platform="${this.esc(post.platform)}">
<div class="post" data-platform="${this.esc(post.platform)}" data-post-id="${this.esc(post.id)}">
<div class="post__header">
<span class="post__platform" style="background:${color}">${icon}</span>
<div class="post__meta">
@ -153,9 +165,10 @@ export class FolkCampaignManager extends HTMLElement {
</div>
${emailLine}
<div class="post__step">Step ${post.stepNumber}</div>
<p class="post__content">${contentPreview.replace(/\n/g, '<br>')}</p>
<p class="post__content" data-preview="${contentPreview.replace(/"/g, '&quot;')}" data-full="${fullContent.replace(/"/g, '&quot;')}" data-collapsed="true">${contentPreview.replace(/\n/g, '<br>')}</p>
${threadBadge}
${threadExpansion}
${threadLink}
<div class="post__tags">${tags}</div>
</div>`;
}
@ -269,6 +282,17 @@ export class FolkCampaignManager extends HTMLElement {
transition: background 0.15s;
}
.thread-badge:hover { background: rgba(96,165,250,0.25); }
.thread-badge--link { border: 1px solid rgba(96,165,250,0.3); text-decoration: none; }
.thread-badge--link:hover { border-color: #60a5fa; }
.post { cursor: pointer; }
.post__content[data-collapsed="false"] { display: block; -webkit-line-clamp: unset; overflow: visible; white-space: normal; }
.post__thread-link {
display: inline-block; font-size: 0.7rem; color: #14b8a6; text-decoration: none;
padding: 2px 8px; border-radius: 4px; margin-bottom: 0.4rem;
background: rgba(20,184,166,0.1); border: 1px solid rgba(20,184,166,0.2);
transition: background 0.15s, border-color 0.15s;
}
.post__thread-link:hover { background: rgba(20,184,166,0.2); border-color: #14b8a6; }
.thread-expansion { margin: 0.4rem 0; padding: 0.5rem; background: var(--rs-input-bg, #0f172a); border-radius: 6px; border: 1px solid var(--rs-input-border, #334155); }
.thread-expansion[hidden] { display: none; }
.thread-post { font-size: 0.75rem; color: var(--rs-text-secondary, #94a3b8); padding: 0.3rem 0; border-bottom: 1px solid rgba(51,65,85,0.5); line-height: 1.4; }
@ -531,15 +555,42 @@ export class FolkCampaignManager extends HTMLElement {
});
});
// ── Thread badge toggles ──
this.shadowRoot.querySelectorAll('.thread-badge').forEach(badge => {
badge.addEventListener('click', () => {
// ── Thread badge toggles (skip linked badges — they navigate) ──
this.shadowRoot.querySelectorAll('.thread-badge:not(.thread-badge--link)').forEach(badge => {
badge.addEventListener('click', (e) => {
e.stopPropagation();
const postId = (badge as HTMLElement).dataset.postId;
if (!postId) return;
const expansion = this.shadowRoot!.getElementById(`thread-${postId}`);
if (expansion) expansion.hidden = !expansion.hidden;
});
});
// ── Linked thread badges (stop post card click from firing) ──
this.shadowRoot.querySelectorAll('.thread-badge--link').forEach(badge => {
badge.addEventListener('click', (e) => e.stopPropagation());
});
// ── Thread link buttons (stop post card click) ──
this.shadowRoot.querySelectorAll('.post__thread-link').forEach(link => {
link.addEventListener('click', (e) => e.stopPropagation());
});
// ── Post card click → toggle content expand ──
this.shadowRoot.querySelectorAll('.post').forEach(post => {
post.addEventListener('click', () => {
const content = post.querySelector('.post__content') as HTMLElement;
if (!content) return;
const collapsed = content.dataset.collapsed === 'true';
if (collapsed) {
content.innerHTML = (content.dataset.full || '').replace(/\n/g, '<br>');
content.dataset.collapsed = 'false';
} else {
content.innerHTML = (content.dataset.preview || '').replace(/\n/g, '<br>');
content.dataset.collapsed = 'true';
}
});
});
}
private async handleGenerate() {

View File

@ -585,7 +585,12 @@ export class FolkCampaignWizard extends HTMLElement {
<div class="cw-success__icon">\u2705</div>
<h2>Campaign Activated!</h2>
<p style="color:var(--rs-text-secondary,#94a3b8)">Your campaign has been committed successfully.</p>
${result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
${result.threads?.length ? `<div style="margin:0.5rem 0">
<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b);margin:0 0 0.4rem">${result.threads.length} thread(s) created:</p>
<div style="display:flex;flex-direction:column;gap:0.3rem">
${result.threads.map((t: any) => `<a href="${this.basePath}/thread-editor/${t.id}/edit" style="font-size:0.8rem;color:#60a5fa;text-decoration:none">\u270F\uFE0F ${t.title || t.id}</a>`).join('')}
</div>
</div>` : result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
${result.newsletters?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.newsletters.length} newsletter draft(s)</p>` : ''}
<div class="cw-success__links">
<a href="${this.basePath}/campaign">View Campaign</a>

View File

@ -19,6 +19,7 @@ interface DraftPostCard {
scheduledAt: string;
status: string;
hashtags: string[];
threadId?: string;
}
export class FolkThreadGallery extends HTMLElement {
@ -107,6 +108,7 @@ export class FolkThreadGallery extends HTMLElement {
scheduledAt: post.scheduledAt,
status: post.status,
hashtags: post.hashtags || [],
threadId: post.threadId,
});
}
}
@ -154,7 +156,10 @@ export class FolkThreadGallery extends HTMLElement {
const statusBadge = p.status === 'scheduled'
? '<span class="badge badge--scheduled">Scheduled</span>'
: '<span class="badge badge--draft">Draft</span>';
return `<div class="card card--draft">
const href = p.threadId
? `${this.basePath}thread-editor/${this.esc(p.threadId)}/edit`
: `${this.basePath}campaign`;
return `<a href="${href}" class="card card--draft">
<div class="card__badges">
${statusBadge}
<span class="badge badge--campaign">${this.esc(p.campaignTitle)}</span>
@ -165,7 +170,7 @@ export class FolkThreadGallery extends HTMLElement {
${p.hashtags.length ? `<span>${p.hashtags.slice(0, 3).join(' ')}</span>` : ''}
${schedDate ? `<span>${schedDate}</span>` : ''}
</div>
</div>`;
</a>`;
}).join('')}
${threads.map(t => {
const initial = (t.name || '?').charAt(0).toUpperCase();

View File

@ -1570,14 +1570,16 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
// 2. Create ThreadData entries for posts with threadPosts
const threadIds: string[] = [];
const threadInfos: { id: string; title: string }[] = [];
for (const post of campaign.posts) {
if (post.threadPosts && post.threadPosts.length > 0) {
const threadId = `thread-${now}-${Math.random().toString(36).substring(2, 6)}`;
const threadTitle = `${campaign.title}${post.phaseLabel} (${post.platform})`;
const threadData: ThreadData = {
id: threadId,
name: post.phaseLabel || 'Campaign Thread',
handle: '@campaign',
title: `${campaign.title}${post.phaseLabel} (${post.platform})`,
title: threadTitle,
tweets: post.threadPosts,
imageUrl: null,
tweetImages: null,
@ -1588,7 +1590,13 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
if (!d.threads) d.threads = {} as any;
(d.threads as any)[threadId] = threadData;
});
// Link the campaign post back to the created thread
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} link post ${post.id} → thread ${threadId}`, (d) => {
const cp = d.campaigns?.[campaign.id]?.posts?.find((p: any) => p.id === post.id);
if (cp) (cp as any).threadId = threadId;
});
threadIds.push(threadId);
threadInfos.push({ id: threadId, title: threadTitle });
}
}
@ -1726,6 +1734,7 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
ok: true,
campaignId: campaign.id,
threadIds,
threads: threadInfos,
workflowId: wfId,
newsletters: newsletterResults,
});

View File

@ -37,6 +37,7 @@ export interface CampaignPost {
phase: number;
phaseLabel: string;
threadPosts?: string[];
threadId?: string;
emailSubject?: string;
emailHtml?: string;
}
@ -359,6 +360,26 @@ export const CAMPAIGN_NODE_CATALOG: CampaignWorkflowNodeDef[] = [
},
];
// ── Newsletter draft types ──
export interface NewsletterSubscriber {
email: string;
name?: string;
addedAt: number;
}
export interface NewsletterDraft {
id: string;
title: string;
subject: string;
body: string; // HTML
status: 'draft' | 'ready' | 'sent';
subscribers: NewsletterSubscriber[];
createdAt: number;
updatedAt: number;
createdBy: string;
}
// ── Approval queue types ──
export interface PendingApproval {

View File

@ -130,6 +130,7 @@ export async function notify(opts: NotifyOptions): Promise<StoredNotification> {
spaceSlug: stored.spaceSlug,
actorUsername: stored.actorUsername,
actionUrl: stored.actionUrl,
metadata: stored.metadata,
createdAt: stored.createdAt,
},
unreadCount,

View File

@ -2156,7 +2156,7 @@ spaces.post("/:slug/invite", async (c) => {
}
if (identityRes.status === 409) {
// Existing user — resolve email to DID, then direct-add
// Existing user — create invite (don't direct-add)
const emailLookupRes = await fetch(
`${ENCRYPTID_URL}/api/internal/user-by-email?email=${encodeURIComponent(body.email)}`,
);
@ -2167,70 +2167,62 @@ spaces.post("/:slug/invite", async (c) => {
id: string; did: string; username: string; displayName: string;
};
// Add to Automerge doc
setMember(slug, existingUser.did, role as any, existingUser.displayName || existingUser.username);
// Sync to EncryptID PostgreSQL
try {
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, {
// Create space invite via EncryptID
const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ userDID: existingUser.did, role }),
body: JSON.stringify({ email: body.email, role }),
});
} catch (e) {
console.error("Failed to sync member to EncryptID:", e);
if (!inviteRes.ok) {
const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
}
const invite = await inviteRes.json() as { id: string; token: string; role: string };
// In-app notification
const acceptUrl = `https://${slug}.rspace.online?inviteToken=${invite.token}`;
const inviterName = claims.username || "an admin";
// In-app notification with accept action
notify({
userDid: existingUser.did,
category: "space",
eventType: "member_joined",
title: `You were added to "${slug}"`,
body: `You were added as ${role} by ${claims.username || "an admin"}.`,
eventType: "space_invite",
title: `${inviterName} invited you to "${slug}"`,
body: `You've been invited to join as ${role}. Accept to get access.`,
spaceSlug: slug,
actorDid: claims.sub,
actorUsername: claims.username,
actionUrl: `https://${slug}.rspace.online`,
metadata: { role },
actionUrl: acceptUrl,
metadata: { inviteToken: invite.token, role },
}).catch(() => {});
// Sync space email alias with new member
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userDid: existingUser.did, role }),
}).catch(() => {});
// Send "you've been added" email
// Send invite email
if (inviteTransport) {
try {
const spaceUrl = `https://${slug}.rspace.online`;
const inviterName = claims.username || "an admin";
await inviteTransport.sendMail({
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
to: body.email,
subject: `${inviterName} added you to "${slug}" on rSpace`,
subject: `${inviterName} invited you to "${slug}" on rSpace`,
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You've been added to ${slug}</h2>
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> added you to the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
<p style="color:#475569;line-height:1.6;">You now have access to all the collaborative tools in this space notes, maps, voting, calendar, and more.</p>
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
<p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools notes, maps, voting, calendar, and more.</p>
<p style="text-align:center;margin:2rem 0;">
<a href="${spaceUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Open ${slug}</a>
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
</p>
<p style="color:#94a3b8;font-size:0.85rem;">rSpace collaborative knowledge work</p>
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. rSpace</p>
</div>`,
});
} catch (emailErr: any) {
console.error("Direct-add email notification failed:", emailErr.message);
console.error("Invite email notification failed:", emailErr.message);
}
}
return c.json({ ok: true, type: "direct-add", username: existingUser.username });
return c.json({ ok: true, type: "invite", inviteToken: invite.token });
}
// Other error from identity invite
@ -2243,7 +2235,7 @@ spaces.post("/:slug/invite", async (c) => {
}
});
// ── Add member by username (direct add, no invite needed) ──
// ── Add member by username (sends invite, requires acceptance) ──
spaces.post("/:slug/members/add", async (c) => {
const { slug } = c.req.param();
@ -2288,73 +2280,76 @@ spaces.post("/:slug/members/add", async (c) => {
if (!user.did) return c.json({ error: "User has no DID" }, 400);
// Add to Automerge doc
setMember(slug, user.did, role as any, user.displayName || user.username);
// Also add to PostgreSQL via EncryptID
try {
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ userDID: user.did, role }),
});
} catch (e) {
console.error("Failed to sync member to EncryptID:", e);
}
// Notify the new member
notify({
userDid: user.did,
category: 'space',
eventType: 'member_joined',
title: `You were added to "${slug}"`,
body: `You were added as ${role} by ${claims.username || "an admin"}.`,
spaceSlug: slug,
actorDid: claims.sub,
actorUsername: claims.username,
actionUrl: `https://${slug}.rspace.online`,
metadata: { role },
}).catch(() => {});
// Sync space email alias with new member
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userDid: user.did, role }),
}).catch(() => {});
// Send email notification (non-fatal)
if (inviteTransport && user.id) {
// Look up user's email for the invite
let targetEmail: string | null = null;
if (user.id) {
try {
const emailRes = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${user.id}`);
if (emailRes.ok) {
const emailData = await emailRes.json() as {
recoveryEmail: string | null; profileEmail: string | null;
};
const targetEmail = emailData.recoveryEmail || emailData.profileEmail;
if (targetEmail) {
const spaceUrl = `https://${slug}.rspace.online`;
targetEmail = emailData.recoveryEmail || emailData.profileEmail;
}
} catch {}
}
// Create space invite via EncryptID
const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ email: targetEmail || undefined, role }),
});
if (!inviteRes.ok) {
const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
}
const invite = await inviteRes.json() as { id: string; token: string; role: string };
const acceptUrl = `https://${slug}.rspace.online?inviteToken=${invite.token}`;
const inviterName = claims.username || "an admin";
// In-app notification with accept action
notify({
userDid: user.did,
category: 'space',
eventType: 'space_invite',
title: `${inviterName} invited you to "${slug}"`,
body: `You've been invited to join as ${role}. Accept to get access.`,
spaceSlug: slug,
actorDid: claims.sub,
actorUsername: claims.username,
actionUrl: acceptUrl,
metadata: { inviteToken: invite.token, role },
}).catch(() => {});
// Send invite email (non-fatal)
if (inviteTransport && targetEmail) {
try {
await inviteTransport.sendMail({
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
to: targetEmail,
subject: `You've been added to "${slug}" on rSpace`,
html: [
`<p>You've been added to <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
`<p><a href="${spaceUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Open Space</a></p>`,
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`,
].join("\n"),
subject: `${inviterName} invited you to "${slug}" on rSpace`,
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
<p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools notes, maps, voting, calendar, and more.</p>
<p style="text-align:center;margin:2rem 0;">
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
</p>
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. rSpace</p>
</div>`,
});
}
}
} catch (emailErr: any) {
console.error("Member-add email notification failed:", emailErr.message);
console.error("Invite email notification failed:", emailErr.message);
}
}
return c.json({ ok: true, did: user.did, username: user.username, role });
return c.json({ ok: true, type: "invite", inviteToken: invite.token, username: user.username });
});
// ── Accept invite via token ──

View File

@ -19,6 +19,7 @@ interface NotificationItem {
spaceSlug: string | null;
actorUsername: string | null;
actionUrl: string | null;
metadata: Record<string, any>;
createdAt: string;
read: boolean;
}
@ -211,6 +212,32 @@ export class RStackNotificationBell extends HTMLElement {
}
}
async #acceptInvite(notifId: string, spaceSlug: string, inviteToken: string) {
const token = this.#getToken();
if (!token) return;
try {
const res = await fetch(`/api/spaces/${spaceSlug}/invite/accept`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ inviteToken }),
});
if (res.ok) {
await this.#markRead(notifId);
// Navigate to the space
window.location.href = `https://${spaceSlug}.rspace.online`;
} else {
const err = await res.json().catch(() => ({ error: "Failed to accept" }));
console.error("[invite] accept failed:", (err as any).error);
}
} catch (e) {
console.error("[invite] accept error:", e);
}
}
// ── Push subscription ──
async #checkPushState() {
@ -346,20 +373,28 @@ export class RStackNotificationBell extends HTMLElement {
} else if (this.#notifications.length === 0) {
body = `<div class="panel-empty">No notifications yet</div>`;
} else {
body = this.#notifications.map(n => `
<div class="notif-item ${n.read ? "read" : "unread"}" data-id="${n.id}">
body = this.#notifications.map(n => {
const isInvite = n.eventType === "space_invite" && n.metadata?.inviteToken && !n.read;
const inviteButtons = isInvite ? `
<div class="notif-actions">
<button class="notif-accept" data-accept="${n.id}" data-space="${n.spaceSlug}" data-token="${n.metadata.inviteToken}">Accept</button>
<button class="notif-decline" data-decline="${n.id}">Decline</button>
</div>` : "";
return `
<div class="notif-item ${n.read ? "read" : "unread"} ${isInvite ? "invite" : ""}" data-id="${n.id}" ${isInvite ? 'data-no-nav="true"' : ""}>
<div class="notif-icon">${this.#categoryIcon(n.category)}</div>
<div class="notif-content">
<div class="notif-title">${n.title}</div>
${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
${inviteButtons}
<div class="notif-meta">
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
</div>
</div>
<button class="notif-dismiss" data-dismiss="${n.id}" title="Dismiss">&times;</button>
</div>
`).join("");
</div>`;
}).join("");
}
panelHTML = `<div class="panel">${header}${body}</div>`;
@ -410,11 +445,13 @@ export class RStackNotificationBell extends HTMLElement {
this.#subscribePush();
});
// Notification item clicks (mark read + navigate)
// Notification item clicks (mark read + navigate, skip for invites)
this.#shadow.querySelectorAll(".notif-item").forEach((el) => {
const id = (el as HTMLElement).dataset.id!;
const noNav = (el as HTMLElement).dataset.noNav === "true";
el.addEventListener("click", (e) => {
e.stopPropagation();
if (noNav) return; // invite items handle via buttons
this.#markRead(id);
const n = this.#notifications.find(n => n.id === id);
if (n?.actionUrl) {
@ -423,6 +460,24 @@ export class RStackNotificationBell extends HTMLElement {
});
});
// Accept invite buttons
this.#shadow.querySelectorAll(".notif-accept").forEach((btn) => {
const el = btn as HTMLElement;
btn.addEventListener("click", (e) => {
e.stopPropagation();
this.#acceptInvite(el.dataset.accept!, el.dataset.space!, el.dataset.token!);
});
});
// Decline invite buttons
this.#shadow.querySelectorAll(".notif-decline").forEach((btn) => {
const id = (btn as HTMLElement).dataset.decline!;
btn.addEventListener("click", (e) => {
e.stopPropagation();
this.#dismiss(id);
});
});
// Dismiss buttons
this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
const id = (btn as HTMLElement).dataset.dismiss!;
@ -608,6 +663,34 @@ const STYLES = `
font-weight: 500;
}
.notif-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.notif-accept, .notif-decline {
padding: 4px 12px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: opacity 0.15s;
}
.notif-accept {
background: linear-gradient(135deg, #14b8a6, #0d9488);
color: white;
}
.notif-decline {
background: none;
border: 1px solid var(--rs-border, rgba(255,255,255,0.15));
color: var(--rs-text-muted, #94a3b8);
}
.notif-accept:hover, .notif-decline:hover {
opacity: 0.85;
}
.notif-dismiss {
flex-shrink: 0;
background: none;

View File

@ -481,8 +481,9 @@ export class RStackSpaceSettings extends HTMLElement {
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="add-btn" id="add-by-username" ${!this._lookupResult ? "disabled" : ""}>Add</button>
<button class="add-btn" id="add-by-username" ${!this._lookupResult ? "disabled" : ""}>Send Invite</button>
</div>
<span id="username-invite-feedback" style="font-size:12px;margin-top:4px;display:block;min-height:16px;"></span>
</div>
` : `
<div class="add-form">
@ -690,6 +691,7 @@ export class RStackSpaceSettings extends HTMLElement {
const token = getToken();
if (!token) return;
const username = input.value;
try {
const res = await fetch(`/api/spaces/${this._space}/members/add`, {
method: "POST",
@ -697,15 +699,24 @@ export class RStackSpaceSettings extends HTMLElement {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ username: input.value, role: roleSelect.value }),
body: JSON.stringify({ username, role: roleSelect.value }),
});
if (res.ok) {
input.value = "";
this._lookupResult = null;
this._lookupError = "";
this._render();
// Show invite-sent feedback briefly
const feedbackEl = sr.getElementById("username-invite-feedback");
if (feedbackEl) {
feedbackEl.textContent = `Invite sent to ${username}`;
feedbackEl.style.color = "#14b8a6";
setTimeout(() => { feedbackEl.textContent = ""; }, 3000);
}
this._loadData();
} else {
const err = await res.json();
this._lookupError = err.error || "Failed to add member";
this._lookupError = err.error || "Failed to invite member";
this._render();
}
} catch {
@ -734,16 +745,10 @@ export class RStackSpaceSettings extends HTMLElement {
body: JSON.stringify({ email: input.value, role: roleSelect.value }),
});
if (res.ok) {
const data = await res.json() as { type?: string; username?: string };
input.value = "";
if (feedback) {
if (data.type === "direct-add") {
feedback.textContent = `${data.username || "User"} added`;
feedback.style.color = "#14b8a6";
} else {
feedback.textContent = "Invite sent";
feedback.style.color = "#14b8a6";
}
setTimeout(() => { feedback.textContent = ""; }, 3000);
}
this._loadData();