From f966f02909a127eb0ae3ba7269a5bafbd2668428 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 20:03:52 -0700 Subject: [PATCH] feat(spaces,rsocials): invite-based member adds + clickable campaign content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/canvas-tools.ts | 107 +++++ lib/folk-social-campaign.ts | 395 ++++++++++++++++++ lib/folk-social-newsletter.ts | 354 ++++++++++++++++ lib/folk-social-thread.ts | 394 +++++++++++++++++ lib/index.ts | 3 + lib/mi-suggestions.ts | 113 +++++ .../components/folk-campaign-manager.ts | 67 ++- .../components/folk-campaign-wizard.ts | 7 +- .../components/folk-thread-gallery.ts | 9 +- modules/rsocials/mod.ts | 11 +- modules/rsocials/schemas.ts | 1 + server/notification-service.ts | 1 + server/spaces.ts | 189 ++++----- shared/components/rstack-notification-bell.ts | 93 ++++- shared/components/rstack-space-settings.ts | 27 +- 15 files changed, 1646 insertions(+), 125 deletions(-) create mode 100644 lib/folk-social-campaign.ts create mode 100644 lib/folk-social-newsletter.ts create mode 100644 lib/folk-social-thread.ts create mode 100644 lib/mi-suggestions.ts diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 8e6579d..a4d8dad 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -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: { diff --git a/lib/folk-social-campaign.ts b/lib/folk-social-campaign.ts new file mode 100644 index 0000000..da61643 --- /dev/null +++ b/lib/folk-social-campaign.ts @@ -0,0 +1,395 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const PLATFORM_ICONS: Record = { + 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) => `${PLATFORM_ICONS[p] || p[0]?.toUpperCase() || "?"}`) + .join(""); + + const wrapper = document.createElement("div"); + wrapper.style.position = "relative"; + wrapper.style.height = "100%"; + + wrapper.innerHTML = html` +
+
+ 📢 + ${escTitle} +
+
+ +
+
+
+
${escDesc}
+
${platformChips || 'No platforms'}
+
+
Phase Progress
+
+
+
+
${this.#draftCount} draft
+
${this.#scheduledCount} sched
+
${this.#publishedCount} pub
+
+
+ + `; + + 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): 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): 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, + }; + } +} diff --git a/lib/folk-social-newsletter.ts b/lib/folk-social-newsletter.ts new file mode 100644 index 0000000..f524956 --- /dev/null +++ b/lib/folk-social-newsletter.ts @@ -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` +
+
+ 📧 + Newsletter +
+
+ +
+
+
+
${escSubject}
+
+ ${escListName} + ${this.#subscriberCount} subscribers +
+
${escPreview}
+
+ + `; + + 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): 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): 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, + }; + } +} diff --git a/lib/folk-social-thread.ts b/lib/folk-social-thread.ts new file mode 100644 index 0000000..3289310 --- /dev/null +++ b/lib/folk-social-thread.ts @@ -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 = { + 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` +
+
+ 🧵 + ${escTitle} +
+
+ +
+
+
+ ${tweetCount} tweet${tweetCount !== 1 ? "s" : ""} +
${firstTweet}
+
+ ${this.#imageUrl ? `Thread cover` : ""} +
+
+ + `; + + 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 = `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): 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): 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, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index e6011ed..1284ff5 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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"; diff --git a/lib/mi-suggestions.ts b/lib/mi-suggestions.ts new file mode 100644 index 0000000..ce78557 --- /dev/null +++ b/lib/mi-suggestions.ts @@ -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 = { + 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; +} diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts index d93c5f7..852d365 100644 --- a/modules/rsocials/components/folk-campaign-manager.ts +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -117,13 +117,19 @@ 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 => `#${this.esc(h)}`).join(' '); - // Thread badge + // Thread badge — links to thread editor if threadId exists let threadBadge = ''; if (post.threadPosts && post.threadPosts.length > 0) { - threadBadge = `🧵 ${post.threadPosts.length}-post thread`; + if (post.threadId) { + threadBadge = `🧵 ${post.threadPosts.length}-post thread — Edit`; + } else { + threadBadge = `🧵 ${post.threadPosts.length}-post thread`; + } } // Email subject for newsletter @@ -141,8 +147,14 @@ export class FolkCampaignManager extends HTMLElement { threadExpansion = ``; } + // "Open Thread" link for posts with created threads + let threadLink = ''; + if (post.threadId && !(post.threadPosts && post.threadPosts.length > 0)) { + threadLink = `Open Thread`; + } + return ` -
+
${icon} ${emailLine}
Step ${post.stepNumber}
-

${contentPreview.replace(/\n/g, '
')}

+

${contentPreview.replace(/\n/g, '
')}

${threadBadge} ${threadExpansion} + ${threadLink}
`; } @@ -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, '
'); + content.dataset.collapsed = 'false'; + } else { + content.innerHTML = (content.dataset.preview || '').replace(/\n/g, '
'); + content.dataset.collapsed = 'true'; + } + }); + }); } private async handleGenerate() { diff --git a/modules/rsocials/components/folk-campaign-wizard.ts b/modules/rsocials/components/folk-campaign-wizard.ts index 3635771..09dbbe3 100644 --- a/modules/rsocials/components/folk-campaign-wizard.ts +++ b/modules/rsocials/components/folk-campaign-wizard.ts @@ -585,7 +585,12 @@ export class FolkCampaignWizard extends HTMLElement {
\u2705

Campaign Activated!

Your campaign has been committed successfully.

- ${result.threadIds?.length ? `

${result.threadIds.length} thread(s) created

` : ''} + ${result.threads?.length ? `
+

${result.threads.length} thread(s) created:

+
+ ${result.threads.map((t: any) => `\u270F\uFE0F ${t.title || t.id}`).join('')} +
+
` : result.threadIds?.length ? `

${result.threadIds.length} thread(s) created

` : ''} ${result.newsletters?.length ? `

${result.newsletters.length} newsletter draft(s)

` : ''} +
` : `
@@ -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"; - } + feedback.textContent = "Invite sent"; + feedback.style.color = "#14b8a6"; setTimeout(() => { feedback.textContent = ""; }, 3000); } this._loadData();