feat(spaces,rsocials): invite-based member adds + clickable campaign content

Space invites: Convert both username-add and email-invite (existing user)
paths from direct-add to invite flow — creates space_invite via EncryptID,
sends invite email with Accept button, dispatches space_invite notification
with Accept/Decline buttons in the notification bell. No one gets forcefully
added to a space anymore.

rSocials content linking: All generated campaign content now links through
to actual editable content. Draft post cards in thread gallery are clickable
(thread editor or campaign view). Campaign manager post cards expand on click
and thread badges link to thread editor. Wizard success screen shows
individual thread links. CampaignPost schema gains threadId field, stored on
commit so posts maintain their thread association.

Also includes canvas social media tools, social shape components
(folk-social-thread, folk-social-campaign, folk-social-newsletter),
and MI context-aware suggestion registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 20:03:52 -07:00
parent b5a54265ee
commit f966f02909
15 changed files with 1646 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 ── // ── Design Agent Tool ──
registry.push({ registry.push({
declaration: { 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 // Social Media / Campaign Shapes
export * from "./folk-social-post"; export * from "./folk-social-post";
export * from "./folk-social-thread";
export * from "./folk-social-campaign";
export * from "./folk-social-newsletter";
// Decision/Choice Shapes // Decision/Choice Shapes
export * from "./folk-choice-vote"; 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,13 +117,19 @@ export class FolkCampaignManager extends HTMLElement {
const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft'; const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft';
const date = new Date(post.scheduledAt); const date = new Date(post.scheduledAt);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); 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(' '); 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 = ''; let threadBadge = '';
if (post.threadPosts && post.threadPosts.length > 0) { if (post.threadPosts && post.threadPosts.length > 0) {
threadBadge = `<span class="thread-badge" data-post-id="${this.esc(post.id)}">🧵 ${post.threadPosts.length}-post thread</span>`; 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 // Email subject for newsletter
@ -141,8 +147,14 @@ export class FolkCampaignManager extends HTMLElement {
threadExpansion = `<div class="thread-expansion" id="thread-${this.esc(post.id)}" hidden>${threadItems}</div>`; 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 ` 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"> <div class="post__header">
<span class="post__platform" style="background:${color}">${icon}</span> <span class="post__platform" style="background:${color}">${icon}</span>
<div class="post__meta"> <div class="post__meta">
@ -153,9 +165,10 @@ export class FolkCampaignManager extends HTMLElement {
</div> </div>
${emailLine} ${emailLine}
<div class="post__step">Step ${post.stepNumber}</div> <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} ${threadBadge}
${threadExpansion} ${threadExpansion}
${threadLink}
<div class="post__tags">${tags}</div> <div class="post__tags">${tags}</div>
</div>`; </div>`;
} }
@ -269,6 +282,17 @@ export class FolkCampaignManager extends HTMLElement {
transition: background 0.15s; transition: background 0.15s;
} }
.thread-badge:hover { background: rgba(96,165,250,0.25); } .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 { 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-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; } .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 ── // ── Thread badge toggles (skip linked badges — they navigate) ──
this.shadowRoot.querySelectorAll('.thread-badge').forEach(badge => { this.shadowRoot.querySelectorAll('.thread-badge:not(.thread-badge--link)').forEach(badge => {
badge.addEventListener('click', () => { badge.addEventListener('click', (e) => {
e.stopPropagation();
const postId = (badge as HTMLElement).dataset.postId; const postId = (badge as HTMLElement).dataset.postId;
if (!postId) return; if (!postId) return;
const expansion = this.shadowRoot!.getElementById(`thread-${postId}`); const expansion = this.shadowRoot!.getElementById(`thread-${postId}`);
if (expansion) expansion.hidden = !expansion.hidden; 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() { private async handleGenerate() {

View File

@ -585,7 +585,12 @@ export class FolkCampaignWizard extends HTMLElement {
<div class="cw-success__icon">\u2705</div> <div class="cw-success__icon">\u2705</div>
<h2>Campaign Activated!</h2> <h2>Campaign Activated!</h2>
<p style="color:var(--rs-text-secondary,#94a3b8)">Your campaign has been committed successfully.</p> <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>` : ''} ${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"> <div class="cw-success__links">
<a href="${this.basePath}/campaign">View Campaign</a> <a href="${this.basePath}/campaign">View Campaign</a>

View File

@ -19,6 +19,7 @@ interface DraftPostCard {
scheduledAt: string; scheduledAt: string;
status: string; status: string;
hashtags: string[]; hashtags: string[];
threadId?: string;
} }
export class FolkThreadGallery extends HTMLElement { export class FolkThreadGallery extends HTMLElement {
@ -107,6 +108,7 @@ export class FolkThreadGallery extends HTMLElement {
scheduledAt: post.scheduledAt, scheduledAt: post.scheduledAt,
status: post.status, status: post.status,
hashtags: post.hashtags || [], hashtags: post.hashtags || [],
threadId: post.threadId,
}); });
} }
} }
@ -154,7 +156,10 @@ export class FolkThreadGallery extends HTMLElement {
const statusBadge = p.status === 'scheduled' const statusBadge = p.status === 'scheduled'
? '<span class="badge badge--scheduled">Scheduled</span>' ? '<span class="badge badge--scheduled">Scheduled</span>'
: '<span class="badge badge--draft">Draft</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"> <div class="card__badges">
${statusBadge} ${statusBadge}
<span class="badge badge--campaign">${this.esc(p.campaignTitle)}</span> <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>` : ''} ${p.hashtags.length ? `<span>${p.hashtags.slice(0, 3).join(' ')}</span>` : ''}
${schedDate ? `<span>${schedDate}</span>` : ''} ${schedDate ? `<span>${schedDate}</span>` : ''}
</div> </div>
</div>`; </a>`;
}).join('')} }).join('')}
${threads.map(t => { ${threads.map(t => {
const initial = (t.name || '?').charAt(0).toUpperCase(); 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 // 2. Create ThreadData entries for posts with threadPosts
const threadIds: string[] = []; const threadIds: string[] = [];
const threadInfos: { id: string; title: string }[] = [];
for (const post of campaign.posts) { for (const post of campaign.posts) {
if (post.threadPosts && post.threadPosts.length > 0) { if (post.threadPosts && post.threadPosts.length > 0) {
const threadId = `thread-${now}-${Math.random().toString(36).substring(2, 6)}`; const threadId = `thread-${now}-${Math.random().toString(36).substring(2, 6)}`;
const threadTitle = `${campaign.title}${post.phaseLabel} (${post.platform})`;
const threadData: ThreadData = { const threadData: ThreadData = {
id: threadId, id: threadId,
name: post.phaseLabel || 'Campaign Thread', name: post.phaseLabel || 'Campaign Thread',
handle: '@campaign', handle: '@campaign',
title: `${campaign.title}${post.phaseLabel} (${post.platform})`, title: threadTitle,
tweets: post.threadPosts, tweets: post.threadPosts,
imageUrl: null, imageUrl: null,
tweetImages: null, tweetImages: null,
@ -1588,7 +1590,13 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
if (!d.threads) d.threads = {} as any; if (!d.threads) d.threads = {} as any;
(d.threads as any)[threadId] = threadData; (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); threadIds.push(threadId);
threadInfos.push({ id: threadId, title: threadTitle });
} }
} }
@ -1726,6 +1734,7 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
ok: true, ok: true,
campaignId: campaign.id, campaignId: campaign.id,
threadIds, threadIds,
threads: threadInfos,
workflowId: wfId, workflowId: wfId,
newsletters: newsletterResults, newsletters: newsletterResults,
}); });

View File

@ -37,6 +37,7 @@ export interface CampaignPost {
phase: number; phase: number;
phaseLabel: string; phaseLabel: string;
threadPosts?: string[]; threadPosts?: string[];
threadId?: string;
emailSubject?: string; emailSubject?: string;
emailHtml?: string; emailHtml?: string;
} }

View File

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

View File

@ -2156,7 +2156,7 @@ spaces.post("/:slug/invite", async (c) => {
} }
if (identityRes.status === 409) { 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( const emailLookupRes = await fetch(
`${ENCRYPTID_URL}/api/internal/user-by-email?email=${encodeURIComponent(body.email)}`, `${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; id: string; did: string; username: string; displayName: string;
}; };
// Add to Automerge doc // Create space invite via EncryptID
setMember(slug, existingUser.did, role as any, existingUser.displayName || existingUser.username); const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
method: "POST",
// Sync to EncryptID PostgreSQL headers: {
try { "Content-Type": "application/json",
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, { "Authorization": `Bearer ${token}`,
method: "POST", },
headers: { body: JSON.stringify({ email: body.email, role }),
"Content-Type": "application/json", });
"Authorization": `Bearer ${token}`, if (!inviteRes.ok) {
}, const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
body: JSON.stringify({ userDID: existingUser.did, role }), return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
});
} catch (e) {
console.error("Failed to sync member to EncryptID:", e);
} }
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({ notify({
userDid: existingUser.did, userDid: existingUser.did,
category: "space", category: "space",
eventType: "member_joined", eventType: "space_invite",
title: `You were added to "${slug}"`, title: `${inviterName} invited you to "${slug}"`,
body: `You were added as ${role} by ${claims.username || "an admin"}.`, body: `You've been invited to join as ${role}. Accept to get access.`,
spaceSlug: slug, spaceSlug: slug,
actorDid: claims.sub, actorDid: claims.sub,
actorUsername: claims.username, actorUsername: claims.username,
actionUrl: `https://${slug}.rspace.online`, actionUrl: acceptUrl,
metadata: { role }, metadata: { inviteToken: invite.token, role },
}).catch(() => {}); }).catch(() => {});
// Sync space email alias with new member // Send invite email
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
if (inviteTransport) { if (inviteTransport) {
try { try {
const spaceUrl = `https://${slug}.rspace.online`;
const inviterName = claims.username || "an admin";
await inviteTransport.sendMail({ await inviteTransport.sendMail({
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>", from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
to: body.email, to: body.email,
subject: `${inviterName} added you to "${slug}" on rSpace`, subject: `${inviterName} invited you to "${slug}" on rSpace`,
html: ` html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;"> <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> <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> added you to the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p> <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;">You now have access to all the collaborative tools in this space notes, maps, voting, calendar, and more.</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;"> <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>
<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>`, </div>`,
}); });
} catch (emailErr: any) { } 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 // 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) => { spaces.post("/:slug/members/add", async (c) => {
const { slug } = c.req.param(); 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); if (!user.did) return c.json({ error: "User has no DID" }, 400);
// Add to Automerge doc // Look up user's email for the invite
setMember(slug, user.did, role as any, user.displayName || user.username); let targetEmail: string | null = null;
if (user.id) {
// 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) {
try { try {
const emailRes = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${user.id}`); const emailRes = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${user.id}`);
if (emailRes.ok) { if (emailRes.ok) {
const emailData = await emailRes.json() as { const emailData = await emailRes.json() as {
recoveryEmail: string | null; profileEmail: string | null; recoveryEmail: string | null; profileEmail: string | null;
}; };
const targetEmail = emailData.recoveryEmail || emailData.profileEmail; targetEmail = emailData.recoveryEmail || emailData.profileEmail;
if (targetEmail) {
const spaceUrl = `https://${slug}.rspace.online`;
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"),
});
}
} }
} 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: `${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) { } 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 ── // ── Accept invite via token ──

View File

@ -19,6 +19,7 @@ interface NotificationItem {
spaceSlug: string | null; spaceSlug: string | null;
actorUsername: string | null; actorUsername: string | null;
actionUrl: string | null; actionUrl: string | null;
metadata: Record<string, any>;
createdAt: string; createdAt: string;
read: boolean; 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 ── // ── Push subscription ──
async #checkPushState() { async #checkPushState() {
@ -346,20 +373,28 @@ export class RStackNotificationBell extends HTMLElement {
} else if (this.#notifications.length === 0) { } else if (this.#notifications.length === 0) {
body = `<div class="panel-empty">No notifications yet</div>`; body = `<div class="panel-empty">No notifications yet</div>`;
} else { } else {
body = this.#notifications.map(n => ` body = this.#notifications.map(n => {
<div class="notif-item ${n.read ? "read" : "unread"}" data-id="${n.id}"> 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-icon">${this.#categoryIcon(n.category)}</div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-title">${n.title}</div> <div class="notif-title">${n.title}</div>
${n.body ? `<div class="notif-body">${n.body}</div>` : ""} ${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
${inviteButtons}
<div class="notif-meta"> <div class="notif-meta">
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""} ${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span> <span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
</div> </div>
</div> </div>
<button class="notif-dismiss" data-dismiss="${n.id}" title="Dismiss">&times;</button> <button class="notif-dismiss" data-dismiss="${n.id}" title="Dismiss">&times;</button>
</div> </div>`;
`).join(""); }).join("");
} }
panelHTML = `<div class="panel">${header}${body}</div>`; panelHTML = `<div class="panel">${header}${body}</div>`;
@ -410,11 +445,13 @@ export class RStackNotificationBell extends HTMLElement {
this.#subscribePush(); this.#subscribePush();
}); });
// Notification item clicks (mark read + navigate) // Notification item clicks (mark read + navigate, skip for invites)
this.#shadow.querySelectorAll(".notif-item").forEach((el) => { this.#shadow.querySelectorAll(".notif-item").forEach((el) => {
const id = (el as HTMLElement).dataset.id!; const id = (el as HTMLElement).dataset.id!;
const noNav = (el as HTMLElement).dataset.noNav === "true";
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
if (noNav) return; // invite items handle via buttons
this.#markRead(id); this.#markRead(id);
const n = this.#notifications.find(n => n.id === id); const n = this.#notifications.find(n => n.id === id);
if (n?.actionUrl) { 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 // Dismiss buttons
this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => { this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
const id = (btn as HTMLElement).dataset.dismiss!; const id = (btn as HTMLElement).dataset.dismiss!;
@ -608,6 +663,34 @@ const STYLES = `
font-weight: 500; 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 { .notif-dismiss {
flex-shrink: 0; flex-shrink: 0;
background: none; background: none;

View File

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