Merge branch 'dev'
This commit is contained in:
commit
943c8ec084
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -117,14 +117,20 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft';
|
||||
const date = new Date(post.scheduledAt);
|
||||
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
const contentPreview = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content);
|
||||
const isLong = post.content.length > 180;
|
||||
const contentPreview = isLong ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content);
|
||||
const fullContent = this.esc(post.content);
|
||||
const tags = post.hashtags.map(h => `<span class="tag">#${this.esc(h)}</span>`).join(' ');
|
||||
|
||||
// Thread badge
|
||||
// Thread badge — links to thread editor if threadId exists
|
||||
let threadBadge = '';
|
||||
if (post.threadPosts && post.threadPosts.length > 0) {
|
||||
if (post.threadId) {
|
||||
threadBadge = `<a href="${this.basePath}thread-editor/${this.esc(post.threadId)}/edit" class="thread-badge thread-badge--link" data-post-id="${this.esc(post.id)}">🧵 ${post.threadPosts.length}-post thread — Edit</a>`;
|
||||
} else {
|
||||
threadBadge = `<span class="thread-badge" data-post-id="${this.esc(post.id)}">🧵 ${post.threadPosts.length}-post thread</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Email subject for newsletter
|
||||
let emailLine = '';
|
||||
|
|
@ -141,8 +147,14 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
threadExpansion = `<div class="thread-expansion" id="thread-${this.esc(post.id)}" hidden>${threadItems}</div>`;
|
||||
}
|
||||
|
||||
// "Open Thread" link for posts with created threads
|
||||
let threadLink = '';
|
||||
if (post.threadId && !(post.threadPosts && post.threadPosts.length > 0)) {
|
||||
threadLink = `<a href="${this.basePath}thread-editor/${this.esc(post.threadId)}/edit" class="post__thread-link">Open Thread</a>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="post" data-platform="${this.esc(post.platform)}">
|
||||
<div class="post" data-platform="${this.esc(post.platform)}" data-post-id="${this.esc(post.id)}">
|
||||
<div class="post__header">
|
||||
<span class="post__platform" style="background:${color}">${icon}</span>
|
||||
<div class="post__meta">
|
||||
|
|
@ -153,9 +165,10 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
</div>
|
||||
${emailLine}
|
||||
<div class="post__step">Step ${post.stepNumber}</div>
|
||||
<p class="post__content">${contentPreview.replace(/\n/g, '<br>')}</p>
|
||||
<p class="post__content" data-preview="${contentPreview.replace(/"/g, '"')}" data-full="${fullContent.replace(/"/g, '"')}" data-collapsed="true">${contentPreview.replace(/\n/g, '<br>')}</p>
|
||||
${threadBadge}
|
||||
${threadExpansion}
|
||||
${threadLink}
|
||||
<div class="post__tags">${tags}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -269,6 +282,17 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
transition: background 0.15s;
|
||||
}
|
||||
.thread-badge:hover { background: rgba(96,165,250,0.25); }
|
||||
.thread-badge--link { border: 1px solid rgba(96,165,250,0.3); text-decoration: none; }
|
||||
.thread-badge--link:hover { border-color: #60a5fa; }
|
||||
.post { cursor: pointer; }
|
||||
.post__content[data-collapsed="false"] { display: block; -webkit-line-clamp: unset; overflow: visible; white-space: normal; }
|
||||
.post__thread-link {
|
||||
display: inline-block; font-size: 0.7rem; color: #14b8a6; text-decoration: none;
|
||||
padding: 2px 8px; border-radius: 4px; margin-bottom: 0.4rem;
|
||||
background: rgba(20,184,166,0.1); border: 1px solid rgba(20,184,166,0.2);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.post__thread-link:hover { background: rgba(20,184,166,0.2); border-color: #14b8a6; }
|
||||
.thread-expansion { margin: 0.4rem 0; padding: 0.5rem; background: var(--rs-input-bg, #0f172a); border-radius: 6px; border: 1px solid var(--rs-input-border, #334155); }
|
||||
.thread-expansion[hidden] { display: none; }
|
||||
.thread-post { font-size: 0.75rem; color: var(--rs-text-secondary, #94a3b8); padding: 0.3rem 0; border-bottom: 1px solid rgba(51,65,85,0.5); line-height: 1.4; }
|
||||
|
|
@ -531,15 +555,42 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Thread badge toggles ──
|
||||
this.shadowRoot.querySelectorAll('.thread-badge').forEach(badge => {
|
||||
badge.addEventListener('click', () => {
|
||||
// ── Thread badge toggles (skip linked badges — they navigate) ──
|
||||
this.shadowRoot.querySelectorAll('.thread-badge:not(.thread-badge--link)').forEach(badge => {
|
||||
badge.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const postId = (badge as HTMLElement).dataset.postId;
|
||||
if (!postId) return;
|
||||
const expansion = this.shadowRoot!.getElementById(`thread-${postId}`);
|
||||
if (expansion) expansion.hidden = !expansion.hidden;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Linked thread badges (stop post card click from firing) ──
|
||||
this.shadowRoot.querySelectorAll('.thread-badge--link').forEach(badge => {
|
||||
badge.addEventListener('click', (e) => e.stopPropagation());
|
||||
});
|
||||
|
||||
// ── Thread link buttons (stop post card click) ──
|
||||
this.shadowRoot.querySelectorAll('.post__thread-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => e.stopPropagation());
|
||||
});
|
||||
|
||||
// ── Post card click → toggle content expand ──
|
||||
this.shadowRoot.querySelectorAll('.post').forEach(post => {
|
||||
post.addEventListener('click', () => {
|
||||
const content = post.querySelector('.post__content') as HTMLElement;
|
||||
if (!content) return;
|
||||
const collapsed = content.dataset.collapsed === 'true';
|
||||
if (collapsed) {
|
||||
content.innerHTML = (content.dataset.full || '').replace(/\n/g, '<br>');
|
||||
content.dataset.collapsed = 'false';
|
||||
} else {
|
||||
content.innerHTML = (content.dataset.preview || '').replace(/\n/g, '<br>');
|
||||
content.dataset.collapsed = 'true';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleGenerate() {
|
||||
|
|
|
|||
|
|
@ -585,7 +585,12 @@ export class FolkCampaignWizard extends HTMLElement {
|
|||
<div class="cw-success__icon">\u2705</div>
|
||||
<h2>Campaign Activated!</h2>
|
||||
<p style="color:var(--rs-text-secondary,#94a3b8)">Your campaign has been committed successfully.</p>
|
||||
${result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
|
||||
${result.threads?.length ? `<div style="margin:0.5rem 0">
|
||||
<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b);margin:0 0 0.4rem">${result.threads.length} thread(s) created:</p>
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem">
|
||||
${result.threads.map((t: any) => `<a href="${this.basePath}/thread-editor/${t.id}/edit" style="font-size:0.8rem;color:#60a5fa;text-decoration:none">\u270F\uFE0F ${t.title || t.id}</a>`).join('')}
|
||||
</div>
|
||||
</div>` : result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
|
||||
${result.newsletters?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.newsletters.length} newsletter draft(s)</p>` : ''}
|
||||
<div class="cw-success__links">
|
||||
<a href="${this.basePath}/campaign">View Campaign</a>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface DraftPostCard {
|
|||
scheduledAt: string;
|
||||
status: string;
|
||||
hashtags: string[];
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
export class FolkThreadGallery extends HTMLElement {
|
||||
|
|
@ -107,6 +108,7 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
scheduledAt: post.scheduledAt,
|
||||
status: post.status,
|
||||
hashtags: post.hashtags || [],
|
||||
threadId: post.threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +156,10 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
const statusBadge = p.status === 'scheduled'
|
||||
? '<span class="badge badge--scheduled">Scheduled</span>'
|
||||
: '<span class="badge badge--draft">Draft</span>';
|
||||
return `<div class="card card--draft">
|
||||
const href = p.threadId
|
||||
? `${this.basePath}thread-editor/${this.esc(p.threadId)}/edit`
|
||||
: `${this.basePath}campaign`;
|
||||
return `<a href="${href}" class="card card--draft">
|
||||
<div class="card__badges">
|
||||
${statusBadge}
|
||||
<span class="badge badge--campaign">${this.esc(p.campaignTitle)}</span>
|
||||
|
|
@ -165,7 +170,7 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
${p.hashtags.length ? `<span>${p.hashtags.slice(0, 3).join(' ')}</span>` : ''}
|
||||
${schedDate ? `<span>${schedDate}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
</a>`;
|
||||
}).join('')}
|
||||
${threads.map(t => {
|
||||
const initial = (t.name || '?').charAt(0).toUpperCase();
|
||||
|
|
|
|||
|
|
@ -1570,14 +1570,16 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
|
|||
|
||||
// 2. Create ThreadData entries for posts with threadPosts
|
||||
const threadIds: string[] = [];
|
||||
const threadInfos: { id: string; title: string }[] = [];
|
||||
for (const post of campaign.posts) {
|
||||
if (post.threadPosts && post.threadPosts.length > 0) {
|
||||
const threadId = `thread-${now}-${Math.random().toString(36).substring(2, 6)}`;
|
||||
const threadTitle = `${campaign.title} — ${post.phaseLabel} (${post.platform})`;
|
||||
const threadData: ThreadData = {
|
||||
id: threadId,
|
||||
name: post.phaseLabel || 'Campaign Thread',
|
||||
handle: '@campaign',
|
||||
title: `${campaign.title} — ${post.phaseLabel} (${post.platform})`,
|
||||
title: threadTitle,
|
||||
tweets: post.threadPosts,
|
||||
imageUrl: null,
|
||||
tweetImages: null,
|
||||
|
|
@ -1588,7 +1590,13 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
|
|||
if (!d.threads) d.threads = {} as any;
|
||||
(d.threads as any)[threadId] = threadData;
|
||||
});
|
||||
// Link the campaign post back to the created thread
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} link post ${post.id} → thread ${threadId}`, (d) => {
|
||||
const cp = d.campaigns?.[campaign.id]?.posts?.find((p: any) => p.id === post.id);
|
||||
if (cp) (cp as any).threadId = threadId;
|
||||
});
|
||||
threadIds.push(threadId);
|
||||
threadInfos.push({ id: threadId, title: threadTitle });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1726,6 +1734,7 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
|
|||
ok: true,
|
||||
campaignId: campaign.id,
|
||||
threadIds,
|
||||
threads: threadInfos,
|
||||
workflowId: wfId,
|
||||
newsletters: newsletterResults,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface CampaignPost {
|
|||
phase: number;
|
||||
phaseLabel: string;
|
||||
threadPosts?: string[];
|
||||
threadId?: string;
|
||||
emailSubject?: string;
|
||||
emailHtml?: string;
|
||||
}
|
||||
|
|
@ -359,6 +360,26 @@ export const CAMPAIGN_NODE_CATALOG: CampaignWorkflowNodeDef[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// ── Newsletter draft types ──
|
||||
|
||||
export interface NewsletterSubscriber {
|
||||
email: string;
|
||||
name?: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface NewsletterDraft {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
body: string; // HTML
|
||||
status: 'draft' | 'ready' | 'sent';
|
||||
subscribers: NewsletterSubscriber[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
// ── Approval queue types ──
|
||||
|
||||
export interface PendingApproval {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export async function notify(opts: NotifyOptions): Promise<StoredNotification> {
|
|||
spaceSlug: stored.spaceSlug,
|
||||
actorUsername: stored.actorUsername,
|
||||
actionUrl: stored.actionUrl,
|
||||
metadata: stored.metadata,
|
||||
createdAt: stored.createdAt,
|
||||
},
|
||||
unreadCount,
|
||||
|
|
|
|||
171
server/spaces.ts
171
server/spaces.ts
|
|
@ -2156,7 +2156,7 @@ spaces.post("/:slug/invite", async (c) => {
|
|||
}
|
||||
|
||||
if (identityRes.status === 409) {
|
||||
// Existing user — resolve email to DID, then direct-add
|
||||
// Existing user — create invite (don't direct-add)
|
||||
const emailLookupRes = await fetch(
|
||||
`${ENCRYPTID_URL}/api/internal/user-by-email?email=${encodeURIComponent(body.email)}`,
|
||||
);
|
||||
|
|
@ -2167,70 +2167,62 @@ spaces.post("/:slug/invite", async (c) => {
|
|||
id: string; did: string; username: string; displayName: string;
|
||||
};
|
||||
|
||||
// Add to Automerge doc
|
||||
setMember(slug, existingUser.did, role as any, existingUser.displayName || existingUser.username);
|
||||
|
||||
// Sync to EncryptID PostgreSQL
|
||||
try {
|
||||
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, {
|
||||
// Create space invite via EncryptID
|
||||
const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ userDID: existingUser.did, role }),
|
||||
body: JSON.stringify({ email: body.email, role }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to sync member to EncryptID:", e);
|
||||
if (!inviteRes.ok) {
|
||||
const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
|
||||
return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
|
||||
}
|
||||
const invite = await inviteRes.json() as { id: string; token: string; role: string };
|
||||
|
||||
// In-app notification
|
||||
const acceptUrl = `https://${slug}.rspace.online?inviteToken=${invite.token}`;
|
||||
const inviterName = claims.username || "an admin";
|
||||
|
||||
// In-app notification with accept action
|
||||
notify({
|
||||
userDid: existingUser.did,
|
||||
category: "space",
|
||||
eventType: "member_joined",
|
||||
title: `You were added to "${slug}"`,
|
||||
body: `You were added as ${role} by ${claims.username || "an admin"}.`,
|
||||
eventType: "space_invite",
|
||||
title: `${inviterName} invited you to "${slug}"`,
|
||||
body: `You've been invited to join as ${role}. Accept to get access.`,
|
||||
spaceSlug: slug,
|
||||
actorDid: claims.sub,
|
||||
actorUsername: claims.username,
|
||||
actionUrl: `https://${slug}.rspace.online`,
|
||||
metadata: { role },
|
||||
actionUrl: acceptUrl,
|
||||
metadata: { inviteToken: invite.token, role },
|
||||
}).catch(() => {});
|
||||
|
||||
// Sync space email alias with new member
|
||||
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userDid: existingUser.did, role }),
|
||||
}).catch(() => {});
|
||||
|
||||
// Send "you've been added" email
|
||||
// Send invite email
|
||||
if (inviteTransport) {
|
||||
try {
|
||||
const spaceUrl = `https://${slug}.rspace.online`;
|
||||
const inviterName = claims.username || "an admin";
|
||||
await inviteTransport.sendMail({
|
||||
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
|
||||
to: body.email,
|
||||
subject: `${inviterName} added you to "${slug}" on rSpace`,
|
||||
subject: `${inviterName} invited you to "${slug}" on rSpace`,
|
||||
html: `
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
|
||||
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You've been added to ${slug}</h2>
|
||||
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> added you to the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
|
||||
<p style="color:#475569;line-height:1.6;">You now have access to all the collaborative tools in this space — notes, maps, voting, calendar, and more.</p>
|
||||
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
|
||||
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
|
||||
<p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools — notes, maps, voting, calendar, and more.</p>
|
||||
<p style="text-align:center;margin:2rem 0;">
|
||||
<a href="${spaceUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Open ${slug}</a>
|
||||
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:0.85rem;">rSpace — collaborative knowledge work</p>
|
||||
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. — rSpace</p>
|
||||
</div>`,
|
||||
});
|
||||
} catch (emailErr: any) {
|
||||
console.error("Direct-add email notification failed:", emailErr.message);
|
||||
console.error("Invite email notification failed:", emailErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, type: "direct-add", username: existingUser.username });
|
||||
return c.json({ ok: true, type: "invite", inviteToken: invite.token });
|
||||
}
|
||||
|
||||
// Other error from identity invite
|
||||
|
|
@ -2243,7 +2235,7 @@ spaces.post("/:slug/invite", async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Add member by username (direct add, no invite needed) ──
|
||||
// ── Add member by username (sends invite, requires acceptance) ──
|
||||
|
||||
spaces.post("/:slug/members/add", async (c) => {
|
||||
const { slug } = c.req.param();
|
||||
|
|
@ -2288,73 +2280,76 @@ spaces.post("/:slug/members/add", async (c) => {
|
|||
|
||||
if (!user.did) return c.json({ error: "User has no DID" }, 400);
|
||||
|
||||
// Add to Automerge doc
|
||||
setMember(slug, user.did, role as any, user.displayName || user.username);
|
||||
|
||||
// Also add to PostgreSQL via EncryptID
|
||||
try {
|
||||
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ userDID: user.did, role }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to sync member to EncryptID:", e);
|
||||
}
|
||||
|
||||
// Notify the new member
|
||||
notify({
|
||||
userDid: user.did,
|
||||
category: 'space',
|
||||
eventType: 'member_joined',
|
||||
title: `You were added to "${slug}"`,
|
||||
body: `You were added as ${role} by ${claims.username || "an admin"}.`,
|
||||
spaceSlug: slug,
|
||||
actorDid: claims.sub,
|
||||
actorUsername: claims.username,
|
||||
actionUrl: `https://${slug}.rspace.online`,
|
||||
metadata: { role },
|
||||
}).catch(() => {});
|
||||
|
||||
// Sync space email alias with new member
|
||||
fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userDid: user.did, role }),
|
||||
}).catch(() => {});
|
||||
|
||||
// Send email notification (non-fatal)
|
||||
if (inviteTransport && user.id) {
|
||||
// Look up user's email for the invite
|
||||
let targetEmail: string | null = null;
|
||||
if (user.id) {
|
||||
try {
|
||||
const emailRes = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${user.id}`);
|
||||
if (emailRes.ok) {
|
||||
const emailData = await emailRes.json() as {
|
||||
recoveryEmail: string | null; profileEmail: string | null;
|
||||
};
|
||||
const targetEmail = emailData.recoveryEmail || emailData.profileEmail;
|
||||
if (targetEmail) {
|
||||
const spaceUrl = `https://${slug}.rspace.online`;
|
||||
targetEmail = emailData.recoveryEmail || emailData.profileEmail;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Create space invite via EncryptID
|
||||
const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ email: targetEmail || undefined, role }),
|
||||
});
|
||||
if (!inviteRes.ok) {
|
||||
const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
|
||||
return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
|
||||
}
|
||||
const invite = await inviteRes.json() as { id: string; token: string; role: string };
|
||||
|
||||
const acceptUrl = `https://${slug}.rspace.online?inviteToken=${invite.token}`;
|
||||
const inviterName = claims.username || "an admin";
|
||||
|
||||
// In-app notification with accept action
|
||||
notify({
|
||||
userDid: user.did,
|
||||
category: 'space',
|
||||
eventType: 'space_invite',
|
||||
title: `${inviterName} invited you to "${slug}"`,
|
||||
body: `You've been invited to join as ${role}. Accept to get access.`,
|
||||
spaceSlug: slug,
|
||||
actorDid: claims.sub,
|
||||
actorUsername: claims.username,
|
||||
actionUrl: acceptUrl,
|
||||
metadata: { inviteToken: invite.token, role },
|
||||
}).catch(() => {});
|
||||
|
||||
// Send invite email (non-fatal)
|
||||
if (inviteTransport && targetEmail) {
|
||||
try {
|
||||
await inviteTransport.sendMail({
|
||||
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
|
||||
to: targetEmail,
|
||||
subject: `You've been added to "${slug}" on rSpace`,
|
||||
html: [
|
||||
`<p>You've been added to <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
||||
`<p><a href="${spaceUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Open Space</a></p>`,
|
||||
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`,
|
||||
].join("\n"),
|
||||
subject: `${inviterName} invited you to "${slug}" on rSpace`,
|
||||
html: `
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
|
||||
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
|
||||
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
|
||||
<p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools — notes, maps, voting, calendar, and more.</p>
|
||||
<p style="text-align:center;margin:2rem 0;">
|
||||
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. — rSpace</p>
|
||||
</div>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (emailErr: any) {
|
||||
console.error("Member-add email notification failed:", emailErr.message);
|
||||
console.error("Invite email notification failed:", emailErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, did: user.did, username: user.username, role });
|
||||
return c.json({ ok: true, type: "invite", inviteToken: invite.token, username: user.username });
|
||||
});
|
||||
|
||||
// ── Accept invite via token ──
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface NotificationItem {
|
|||
spaceSlug: string | null;
|
||||
actorUsername: string | null;
|
||||
actionUrl: string | null;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
|
@ -211,6 +212,32 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
async #acceptInvite(notifId: string, spaceSlug: string, inviteToken: string) {
|
||||
const token = this.#getToken();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/spaces/${spaceSlug}/invite/accept`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ inviteToken }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.#markRead(notifId);
|
||||
// Navigate to the space
|
||||
window.location.href = `https://${spaceSlug}.rspace.online`;
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Failed to accept" }));
|
||||
console.error("[invite] accept failed:", (err as any).error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[invite] accept error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push subscription ──
|
||||
|
||||
async #checkPushState() {
|
||||
|
|
@ -346,20 +373,28 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
} else if (this.#notifications.length === 0) {
|
||||
body = `<div class="panel-empty">No notifications yet</div>`;
|
||||
} else {
|
||||
body = this.#notifications.map(n => `
|
||||
<div class="notif-item ${n.read ? "read" : "unread"}" data-id="${n.id}">
|
||||
body = this.#notifications.map(n => {
|
||||
const isInvite = n.eventType === "space_invite" && n.metadata?.inviteToken && !n.read;
|
||||
const inviteButtons = isInvite ? `
|
||||
<div class="notif-actions">
|
||||
<button class="notif-accept" data-accept="${n.id}" data-space="${n.spaceSlug}" data-token="${n.metadata.inviteToken}">Accept</button>
|
||||
<button class="notif-decline" data-decline="${n.id}">Decline</button>
|
||||
</div>` : "";
|
||||
return `
|
||||
<div class="notif-item ${n.read ? "read" : "unread"} ${isInvite ? "invite" : ""}" data-id="${n.id}" ${isInvite ? 'data-no-nav="true"' : ""}>
|
||||
<div class="notif-icon">${this.#categoryIcon(n.category)}</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-title">${n.title}</div>
|
||||
${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
|
||||
${inviteButtons}
|
||||
<div class="notif-meta">
|
||||
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
|
||||
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="notif-dismiss" data-dismiss="${n.id}" title="Dismiss">×</button>
|
||||
</div>
|
||||
`).join("");
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
panelHTML = `<div class="panel">${header}${body}</div>`;
|
||||
|
|
@ -410,11 +445,13 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
this.#subscribePush();
|
||||
});
|
||||
|
||||
// Notification item clicks (mark read + navigate)
|
||||
// Notification item clicks (mark read + navigate, skip for invites)
|
||||
this.#shadow.querySelectorAll(".notif-item").forEach((el) => {
|
||||
const id = (el as HTMLElement).dataset.id!;
|
||||
const noNav = (el as HTMLElement).dataset.noNav === "true";
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (noNav) return; // invite items handle via buttons
|
||||
this.#markRead(id);
|
||||
const n = this.#notifications.find(n => n.id === id);
|
||||
if (n?.actionUrl) {
|
||||
|
|
@ -423,6 +460,24 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Accept invite buttons
|
||||
this.#shadow.querySelectorAll(".notif-accept").forEach((btn) => {
|
||||
const el = btn as HTMLElement;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#acceptInvite(el.dataset.accept!, el.dataset.space!, el.dataset.token!);
|
||||
});
|
||||
});
|
||||
|
||||
// Decline invite buttons
|
||||
this.#shadow.querySelectorAll(".notif-decline").forEach((btn) => {
|
||||
const id = (btn as HTMLElement).dataset.decline!;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#dismiss(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Dismiss buttons
|
||||
this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
|
||||
const id = (btn as HTMLElement).dataset.dismiss!;
|
||||
|
|
@ -608,6 +663,34 @@ const STYLES = `
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.notif-accept, .notif-decline {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.notif-accept {
|
||||
background: linear-gradient(135deg, #14b8a6, #0d9488);
|
||||
color: white;
|
||||
}
|
||||
.notif-decline {
|
||||
background: none;
|
||||
border: 1px solid var(--rs-border, rgba(255,255,255,0.15));
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
.notif-accept:hover, .notif-decline:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.notif-dismiss {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
|
|
|
|||
|
|
@ -481,8 +481,9 @@ export class RStackSpaceSettings extends HTMLElement {
|
|||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="add-btn" id="add-by-username" ${!this._lookupResult ? "disabled" : ""}>Add</button>
|
||||
<button class="add-btn" id="add-by-username" ${!this._lookupResult ? "disabled" : ""}>Send Invite</button>
|
||||
</div>
|
||||
<span id="username-invite-feedback" style="font-size:12px;margin-top:4px;display:block;min-height:16px;"></span>
|
||||
</div>
|
||||
` : `
|
||||
<div class="add-form">
|
||||
|
|
@ -690,6 +691,7 @@ export class RStackSpaceSettings extends HTMLElement {
|
|||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const username = input.value;
|
||||
try {
|
||||
const res = await fetch(`/api/spaces/${this._space}/members/add`, {
|
||||
method: "POST",
|
||||
|
|
@ -697,15 +699,24 @@ export class RStackSpaceSettings extends HTMLElement {
|
|||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ username: input.value, role: roleSelect.value }),
|
||||
body: JSON.stringify({ username, role: roleSelect.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
input.value = "";
|
||||
this._lookupResult = null;
|
||||
this._lookupError = "";
|
||||
this._render();
|
||||
// Show invite-sent feedback briefly
|
||||
const feedbackEl = sr.getElementById("username-invite-feedback");
|
||||
if (feedbackEl) {
|
||||
feedbackEl.textContent = `Invite sent to ${username}`;
|
||||
feedbackEl.style.color = "#14b8a6";
|
||||
setTimeout(() => { feedbackEl.textContent = ""; }, 3000);
|
||||
}
|
||||
this._loadData();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
this._lookupError = err.error || "Failed to add member";
|
||||
this._lookupError = err.error || "Failed to invite member";
|
||||
this._render();
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -734,16 +745,10 @@ export class RStackSpaceSettings extends HTMLElement {
|
|||
body: JSON.stringify({ email: input.value, role: roleSelect.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { type?: string; username?: string };
|
||||
input.value = "";
|
||||
if (feedback) {
|
||||
if (data.type === "direct-add") {
|
||||
feedback.textContent = `${data.username || "User"} added`;
|
||||
feedback.style.color = "#14b8a6";
|
||||
} else {
|
||||
feedback.textContent = "Invite sent";
|
||||
feedback.style.color = "#14b8a6";
|
||||
}
|
||||
setTimeout(() => { feedback.textContent = ""; }, 3000);
|
||||
}
|
||||
this._loadData();
|
||||
|
|
|
|||
Loading…
Reference in New Issue