feat(rsocials): AI campaign generator from event brief
Add "Generate from Brief" feature: paste unstructured event text, AI (Gemini 2.5 Pro) creates a full multi-phase, multi-platform campaign with threads, emojis, newsletter content, and platform-specific formatting. - Schema v4: add threadPosts, emailSubject, emailHtml to CampaignPost - New POST /api/campaign/generate endpoint with platform-aware prompting - Generate modal with platform checkboxes, tone/style selectors - Preview mode with save/regenerate/discard flow - Dynamic phase rendering (supports 3-5 AI-generated phases) - Thread badge with expandable individual posts - Newsletter platform support (icon + color) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
233b7e3689
commit
154f1230dc
|
|
@ -38,6 +38,7 @@ const PLATFORM_ICONS: Record<string, string> = {
|
|||
youtube: "▶️",
|
||||
threads: "🧵",
|
||||
bluesky: "🦋",
|
||||
newsletter: "📧",
|
||||
};
|
||||
|
||||
const PLATFORM_COLORS: Record<string, string> = {
|
||||
|
|
@ -47,6 +48,7 @@ const PLATFORM_COLORS: Record<string, string> = {
|
|||
youtube: "#FF0000",
|
||||
threads: "#000000",
|
||||
bluesky: "#0085FF",
|
||||
newsletter: "#6366f1",
|
||||
};
|
||||
|
||||
export { PLATFORM_ICONS, PLATFORM_COLORS };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* <folk-campaign-manager> — Campaign viewer/editor with import modal.
|
||||
* <folk-campaign-manager> — Campaign viewer/editor with import modal and AI generator.
|
||||
*
|
||||
* Subscribes to Automerge doc for campaign data. Falls back to MYCOFI_CAMPAIGN
|
||||
* demo data when no campaigns exist or space=demo.
|
||||
|
|
@ -16,12 +16,19 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
private _campaigns: Campaign[] = [];
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
// AI generation state
|
||||
private _generatedCampaign: Campaign | null = null;
|
||||
private _previewMode = false;
|
||||
private _generating = false;
|
||||
private _lastBrief = '';
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.campaign-header', title: "Campaign Overview", message: "This is your campaign dashboard — see the title, description, platforms, and post count at a glance.", advanceOnClick: false },
|
||||
{ target: '.phase:first-of-type', title: "View Posts by Phase", message: "Posts are organised into phases. Each phase has a timeline and its own set of scheduled posts.", advanceOnClick: false },
|
||||
{ target: 'a[href*="thread-editor"]', title: "Open Thread Editor", message: "Jump to the thread editor to compose and preview tweet threads with live card preview.", advanceOnClick: true },
|
||||
{ target: '#generate-btn', title: "AI Campaign Generator", message: "Paste event details and let AI create a full campaign.", advanceOnClick: true },
|
||||
{ target: '#import-md-btn', title: "Import from Markdown", message: "Paste tweets separated by --- to bulk-import content into the campaign.", advanceOnClick: true },
|
||||
];
|
||||
|
||||
|
|
@ -104,39 +111,68 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private renderPostCard(post: CampaignPost): string {
|
||||
const icon = PLATFORM_ICONS[post.platform] || post.platform;
|
||||
const color = PLATFORM_COLORS[post.platform] || '#64748b';
|
||||
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 tags = post.hashtags.map(h => `<span class="tag">#${this.esc(h)}</span>`).join(' ');
|
||||
|
||||
// Thread badge
|
||||
let threadBadge = '';
|
||||
if (post.threadPosts && post.threadPosts.length > 0) {
|
||||
threadBadge = `<span class="thread-badge" data-post-id="${this.esc(post.id)}">🧵 ${post.threadPosts.length}-post thread</span>`;
|
||||
}
|
||||
|
||||
// Email subject for newsletter
|
||||
let emailLine = '';
|
||||
if (post.emailSubject) {
|
||||
emailLine = `<div class="post__email-subject">📧 ${this.esc(post.emailSubject)}</div>`;
|
||||
}
|
||||
|
||||
// Thread expansion area
|
||||
let threadExpansion = '';
|
||||
if (post.threadPosts && post.threadPosts.length > 0) {
|
||||
const threadItems = post.threadPosts.map((t, i) =>
|
||||
`<div class="thread-post"><span class="thread-post__num">${i + 1}.</span> ${this.esc(t)}</div>`
|
||||
).join('');
|
||||
threadExpansion = `<div class="thread-expansion" id="thread-${this.esc(post.id)}" hidden>${threadItems}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="post" data-platform="${this.esc(post.platform)}">
|
||||
<div class="post__header">
|
||||
<span class="post__platform" style="background:${color}">${icon}</span>
|
||||
<div class="post__meta">
|
||||
<strong>${this.esc(post.platform)} — ${this.esc(post.postType)}</strong>
|
||||
<span class="post__date">${dateStr}</span>
|
||||
</div>
|
||||
<span class="status ${statusClass}">${this.esc(post.status)}</span>
|
||||
</div>
|
||||
${emailLine}
|
||||
<div class="post__step">Step ${post.stepNumber}</div>
|
||||
<p class="post__content">${contentPreview.replace(/\n/g, '<br>')}</p>
|
||||
${threadBadge}
|
||||
${threadExpansion}
|
||||
<div class="post__tags">${tags}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderCampaign(c: Campaign): string {
|
||||
const phases = [1, 2, 3];
|
||||
const phaseIcons = ['📣', '🚀', '📡'];
|
||||
// Dynamically derive phase numbers from campaign data
|
||||
const phaseNumbers = [...new Set(c.posts.map(p => p.phase))].sort((a, b) => a - b);
|
||||
// If no posts have phases, fall back to the phases array indices
|
||||
const phases = phaseNumbers.length > 0 ? phaseNumbers : c.phases.map((_, i) => i + 1);
|
||||
const phaseIcons = ['📣', '🚀', '📡', '🎯', '📈'];
|
||||
|
||||
const phaseHTML = phases.map((phaseNum, i) => {
|
||||
const phasePosts = c.posts.filter(p => p.phase === phaseNum);
|
||||
if (!phasePosts.length && !c.phases[i]) return '';
|
||||
const phaseInfo = c.phases[i] || { label: `Phase ${phaseNum}`, days: '' };
|
||||
if (!phasePosts.length) return '';
|
||||
|
||||
const postsHTML = phasePosts.map(post => {
|
||||
const icon = PLATFORM_ICONS[post.platform] || post.platform;
|
||||
const color = PLATFORM_COLORS[post.platform] || '#64748b';
|
||||
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 tags = post.hashtags.map(h => `<span class="tag">#${this.esc(h)}</span>`).join(' ');
|
||||
|
||||
return `
|
||||
<div class="post" data-platform="${this.esc(post.platform)}">
|
||||
<div class="post__header">
|
||||
<span class="post__platform" style="background:${color}">${icon}</span>
|
||||
<div class="post__meta">
|
||||
<strong>${this.esc(post.platform)} — ${this.esc(post.postType)}</strong>
|
||||
<span class="post__date">${dateStr}</span>
|
||||
</div>
|
||||
<span class="status ${statusClass}">${this.esc(post.status)}</span>
|
||||
</div>
|
||||
<div class="post__step">Step ${post.stepNumber}</div>
|
||||
<p class="post__content">${contentPreview.replace(/\n/g, '<br>')}</p>
|
||||
<div class="post__tags">${tags}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const postsHTML = phasePosts.map(post => this.renderPostCard(post)).join('');
|
||||
|
||||
return `
|
||||
<div class="phase">
|
||||
|
|
@ -160,6 +196,7 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
</div>
|
||||
<div class="actions">
|
||||
<a href="${this.basePath}thread-editor" class="btn btn--outline">Open Thread Editor</a>
|
||||
<button class="btn btn--accent" id="generate-btn">Generate from Brief</button>
|
||||
<button class="btn btn--primary" id="import-md-btn">Import from Markdown</button>
|
||||
<button class="btn btn--outline" id="btn-tour" style="margin-left:auto">Tour</button>
|
||||
</div>
|
||||
|
|
@ -167,11 +204,30 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
<div id="imported-posts"></div>`;
|
||||
}
|
||||
|
||||
private renderPreviewBanner(): string {
|
||||
return `
|
||||
<div class="preview-banner">
|
||||
<span class="preview-banner__label">AI-Generated Campaign Preview</span>
|
||||
<div class="preview-banner__actions">
|
||||
<button class="btn btn--primary btn--sm" id="preview-save">Save to Space</button>
|
||||
<button class="btn btn--accent btn--sm" id="preview-regenerate">Regenerate</button>
|
||||
<button class="btn btn--outline btn--sm" id="preview-discard">Discard</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() };
|
||||
const campaignHTML = this.renderCampaign(c);
|
||||
let contentHTML: string;
|
||||
if (this._previewMode && this._generatedCampaign) {
|
||||
contentHTML = this.renderPreviewBanner() + this.renderCampaign(this._generatedCampaign);
|
||||
} else {
|
||||
const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() };
|
||||
contentHTML = this.renderCampaign(c);
|
||||
}
|
||||
|
||||
const allPlatforms = ['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky', 'newsletter'];
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
|
|
@ -206,17 +262,43 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
.post__content { font-size: 0.8rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5; margin: 0 0 0.5rem; }
|
||||
.post__tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.tag { font-size: 0.65rem; color: var(--rs-primary); }
|
||||
.actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.post__email-subject { font-size: 0.75rem; color: #a78bfa; font-weight: 600; margin-bottom: 0.4rem; padding: 0.25rem 0.5rem; background: rgba(167,139,250,0.1); border-radius: 4px; }
|
||||
.thread-badge {
|
||||
display: inline-block; font-size: 0.7rem; color: #60a5fa; background: rgba(96,165,250,0.12);
|
||||
padding: 2px 8px; border-radius: 4px; margin-bottom: 0.4rem; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.thread-badge:hover { background: rgba(96,165,250,0.25); }
|
||||
.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; }
|
||||
.thread-post:last-child { border-bottom: none; }
|
||||
.thread-post__num { color: var(--rs-primary); font-weight: 600; margin-right: 0.3rem; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.btn {
|
||||
padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center;
|
||||
cursor: pointer; transition: all 0.15s; text-decoration: none; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.btn--sm { padding: 0.35rem 0.75rem; font-size: 0.8rem; }
|
||||
.btn--primary { background: var(--rs-primary); color: white; border: none; }
|
||||
.btn--primary:hover { background: var(--rs-primary-hover); }
|
||||
.btn--accent { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; border: none; }
|
||||
.btn--accent:hover { background: linear-gradient(135deg, #4f46e5, #7c3aed); }
|
||||
.btn--outline { background: transparent; color: var(--rs-text-secondary, #94a3b8); border: 1px solid var(--rs-input-border, #334155); }
|
||||
.btn--outline:hover { border-color: var(--rs-primary); color: #c4b5fd; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Import modal */
|
||||
/* Preview banner */
|
||||
.preview-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem;
|
||||
padding: 0.75rem 1rem; margin-bottom: 1.5rem; border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
|
||||
border: 1px solid rgba(99,102,241,0.3);
|
||||
}
|
||||
.preview-banner__label { font-size: 0.85rem; font-weight: 600; color: #a78bfa; }
|
||||
.preview-banner__actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
/* Modals */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 1000;
|
||||
|
|
@ -224,7 +306,8 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
.modal-overlay[hidden] { display: none; }
|
||||
.modal {
|
||||
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem;
|
||||
padding: 1.5rem; width: 90%; max-width: 540px; display: flex; flex-direction: column; gap: 1rem;
|
||||
padding: 1.5rem; width: 90%; max-width: 580px; display: flex; flex-direction: column; gap: 1rem;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.modal__header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.modal__header h3 { margin: 0; font-size: 1.1rem; color: var(--rs-text-primary, #f1f5f9); }
|
||||
|
|
@ -234,21 +317,34 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
}
|
||||
.modal__close:hover { color: var(--rs-text-primary, #f1f5f9); }
|
||||
.modal__textarea {
|
||||
width: 100%; min-height: 200px; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #f1f5f9); border: 1px solid var(--rs-input-border, #334155);
|
||||
width: 100%; min-height: 160px; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #f1f5f9); border: 1px solid var(--rs-input-border, #334155);
|
||||
border-radius: 8px; padding: 0.75rem; font-family: inherit; font-size: 0.85rem; resize: vertical;
|
||||
line-height: 1.5; box-sizing: border-box;
|
||||
}
|
||||
.modal__textarea:focus { outline: none; border-color: var(--rs-primary); }
|
||||
.modal__textarea::placeholder { color: var(--rs-text-muted, #64748b); }
|
||||
.modal__row { display: flex; gap: 0.75rem; align-items: center; }
|
||||
.modal__row { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
|
||||
.modal__select {
|
||||
flex: 1; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #f1f5f9); border: 1px solid var(--rs-input-border, #334155);
|
||||
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem;
|
||||
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.85rem; min-width: 120px;
|
||||
}
|
||||
.modal__select:focus { outline: none; border-color: var(--rs-primary); }
|
||||
.modal__label { font-size: 0.8rem; color: var(--rs-text-secondary, #94a3b8); font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.modal__section { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
/* Platform checkboxes */
|
||||
.platform-checks { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.platform-check { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.platform-check input[type="checkbox"] { accent-color: var(--rs-primary, #6366f1); }
|
||||
.platform-check label { font-size: 0.8rem; color: var(--rs-text-secondary, #94a3b8); cursor: pointer; text-transform: capitalize; }
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.gen-error { color: #f87171; font-size: 0.8rem; margin-top: 0.25rem; }
|
||||
</style>
|
||||
<div class="container">
|
||||
${campaignHTML}
|
||||
${contentHTML}
|
||||
</div>
|
||||
<div class="modal-overlay" id="import-modal" hidden>
|
||||
<div class="modal">
|
||||
|
|
@ -268,6 +364,54 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="generate-modal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal__header">
|
||||
<h3>Generate Campaign from Brief</h3>
|
||||
<button class="modal__close" id="generate-modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal__section">
|
||||
<span class="modal__label">Event Brief</span>
|
||||
<textarea class="modal__textarea" id="gen-brief" placeholder="Paste your event details here, e.g.:\n\nWe're hosting a Regenerative Finance Summit on April 15, 2026 at the Vancouver Convention Centre. Featured speakers include Vitalik Buterin and Kevin Owocki. Topics: quadratic funding, impact certificates, token engineering. Registration opens March 20. Early bird tickets $99."></textarea>
|
||||
</div>
|
||||
<div class="modal__section">
|
||||
<span class="modal__label">Platforms</span>
|
||||
<div class="platform-checks" id="gen-platforms">
|
||||
${allPlatforms.map(p => `
|
||||
<label class="platform-check">
|
||||
<input type="checkbox" value="${p}" checked>
|
||||
<span>${PLATFORM_ICONS[p] || ''} ${p}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__row">
|
||||
<div class="modal__section" style="flex:1">
|
||||
<span class="modal__label">Tone</span>
|
||||
<select class="modal__select" id="gen-tone">
|
||||
<option value="professional">Professional</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="hype">Hype</option>
|
||||
<option value="educational">Educational</option>
|
||||
<option value="rebellious">Rebellious</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal__section" style="flex:1">
|
||||
<span class="modal__label">Style</span>
|
||||
<select class="modal__select" id="gen-style">
|
||||
<option value="event-promo">Event Promo</option>
|
||||
<option value="product-launch">Product Launch</option>
|
||||
<option value="awareness">Awareness</option>
|
||||
<option value="community">Community</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gen-error"></div>
|
||||
<button class="btn btn--accent" id="gen-submit" style="align-self:stretch;justify-content:center">
|
||||
Generate Campaign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
|
|
@ -282,17 +426,18 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
// Tour button
|
||||
this.shadowRoot.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
|
||||
|
||||
const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
|
||||
const openBtn = this.shadowRoot.getElementById('import-md-btn');
|
||||
const closeBtn = this.shadowRoot.getElementById('import-modal-close');
|
||||
// ── Import modal ──
|
||||
const importModal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
|
||||
const importOpenBtn = this.shadowRoot.getElementById('import-md-btn');
|
||||
const importCloseBtn = this.shadowRoot.getElementById('import-modal-close');
|
||||
const parseBtn = this.shadowRoot.getElementById('import-parse-btn');
|
||||
const mdInput = this.shadowRoot.getElementById('import-md-textarea') as HTMLTextAreaElement;
|
||||
const platformSel = this.shadowRoot.getElementById('import-platform') as HTMLSelectElement;
|
||||
const importedEl = this.shadowRoot.getElementById('imported-posts');
|
||||
|
||||
openBtn?.addEventListener('click', () => { modal.hidden = false; });
|
||||
closeBtn?.addEventListener('click', () => { modal.hidden = true; });
|
||||
modal?.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
|
||||
importOpenBtn?.addEventListener('click', () => { importModal.hidden = false; });
|
||||
importCloseBtn?.addEventListener('click', () => { importModal.hidden = true; });
|
||||
importModal?.addEventListener('click', (e) => { if (e.target === importModal) importModal.hidden = true; });
|
||||
|
||||
parseBtn?.addEventListener('click', () => {
|
||||
const raw = mdInput.value;
|
||||
|
|
@ -301,7 +446,6 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
const platform = platformSel.value;
|
||||
const total = tweets.length;
|
||||
|
||||
// Build imported posts as campaign posts and save to Automerge
|
||||
const posts: CampaignPost[] = tweets.map((text, i) => ({
|
||||
id: `imported-${Date.now()}-${i}`,
|
||||
platform,
|
||||
|
|
@ -315,7 +459,6 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
phaseLabel: 'Imported',
|
||||
}));
|
||||
|
||||
// Render imported posts inline
|
||||
let html = `<div class="phase"><h3 class="phase__title">📥 Imported Posts (${total})</h3>`;
|
||||
html += '<div class="phase__posts">';
|
||||
tweets.forEach((text, i) => {
|
||||
|
|
@ -332,9 +475,8 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
});
|
||||
html += '</div></div>';
|
||||
importedEl.innerHTML = html;
|
||||
modal.hidden = true;
|
||||
importModal.hidden = true;
|
||||
|
||||
// Save to Automerge if runtime available
|
||||
if (this._space !== 'demo') {
|
||||
const c = this._campaigns[0];
|
||||
if (c) {
|
||||
|
|
@ -343,6 +485,128 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Generate modal ──
|
||||
const genModal = this.shadowRoot.getElementById('generate-modal') as HTMLElement;
|
||||
const genOpenBtn = this.shadowRoot.getElementById('generate-btn');
|
||||
const genCloseBtn = this.shadowRoot.getElementById('generate-modal-close');
|
||||
const genSubmit = this.shadowRoot.getElementById('gen-submit') as HTMLButtonElement;
|
||||
const genBrief = this.shadowRoot.getElementById('gen-brief') as HTMLTextAreaElement;
|
||||
const genError = this.shadowRoot.getElementById('gen-error') as HTMLElement;
|
||||
|
||||
genOpenBtn?.addEventListener('click', () => {
|
||||
genModal.hidden = false;
|
||||
// Restore last brief if regenerating
|
||||
if (this._lastBrief && genBrief) genBrief.value = this._lastBrief;
|
||||
});
|
||||
genCloseBtn?.addEventListener('click', () => { genModal.hidden = true; });
|
||||
genModal?.addEventListener('click', (e) => { if (e.target === genModal) genModal.hidden = true; });
|
||||
|
||||
genSubmit?.addEventListener('click', () => this.handleGenerate());
|
||||
|
||||
// ── Preview mode buttons ──
|
||||
this.shadowRoot.getElementById('preview-save')?.addEventListener('click', () => {
|
||||
if (!this._generatedCampaign) return;
|
||||
this.saveCampaignToDoc(this._generatedCampaign);
|
||||
this._campaigns.unshift(this._generatedCampaign);
|
||||
this._generatedCampaign = null;
|
||||
this._previewMode = false;
|
||||
this.render();
|
||||
});
|
||||
|
||||
this.shadowRoot.getElementById('preview-discard')?.addEventListener('click', () => {
|
||||
this._generatedCampaign = null;
|
||||
this._previewMode = false;
|
||||
this.render();
|
||||
});
|
||||
|
||||
this.shadowRoot.getElementById('preview-regenerate')?.addEventListener('click', () => {
|
||||
this._previewMode = false;
|
||||
this._generatedCampaign = null;
|
||||
this.render();
|
||||
// Re-open generate modal after render
|
||||
requestAnimationFrame(() => {
|
||||
const modal = this.shadowRoot?.getElementById('generate-modal') as HTMLElement;
|
||||
if (modal) modal.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Thread badge toggles ──
|
||||
this.shadowRoot.querySelectorAll('.thread-badge').forEach(badge => {
|
||||
badge.addEventListener('click', () => {
|
||||
const postId = (badge as HTMLElement).dataset.postId;
|
||||
if (!postId) return;
|
||||
const expansion = this.shadowRoot!.getElementById(`thread-${postId}`);
|
||||
if (expansion) expansion.hidden = !expansion.hidden;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleGenerate() {
|
||||
if (!this.shadowRoot || this._generating) return;
|
||||
|
||||
const briefEl = this.shadowRoot.getElementById('gen-brief') as HTMLTextAreaElement;
|
||||
const errorEl = this.shadowRoot.getElementById('gen-error') as HTMLElement;
|
||||
const submitBtn = this.shadowRoot.getElementById('gen-submit') as HTMLButtonElement;
|
||||
|
||||
const brief = briefEl?.value?.trim();
|
||||
if (!brief || brief.length < 10) {
|
||||
if (errorEl) errorEl.innerHTML = '<span class="gen-error">Please enter at least 10 characters describing your event.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather selected platforms
|
||||
const platformChecks = this.shadowRoot.querySelectorAll('#gen-platforms input[type="checkbox"]');
|
||||
const platforms: string[] = [];
|
||||
platformChecks.forEach((cb: Element) => {
|
||||
if ((cb as HTMLInputElement).checked) platforms.push((cb as HTMLInputElement).value);
|
||||
});
|
||||
if (platforms.length === 0) {
|
||||
if (errorEl) errorEl.innerHTML = '<span class="gen-error">Select at least one platform.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tone = (this.shadowRoot.getElementById('gen-tone') as HTMLSelectElement)?.value || 'professional';
|
||||
const style = (this.shadowRoot.getElementById('gen-style') as HTMLSelectElement)?.value || 'event-promo';
|
||||
|
||||
this._generating = true;
|
||||
this._lastBrief = brief;
|
||||
if (errorEl) errorEl.innerHTML = '';
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner"></span> Generating...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/${this._space}/rsocials/api/campaign/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brief, platforms, tone, style }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const campaign: Campaign = await res.json();
|
||||
this._generatedCampaign = campaign;
|
||||
this._previewMode = true;
|
||||
|
||||
// Close modal and render preview
|
||||
const genModal = this.shadowRoot.getElementById('generate-modal') as HTMLElement;
|
||||
if (genModal) genModal.hidden = true;
|
||||
|
||||
this.render();
|
||||
} catch (e: any) {
|
||||
if (errorEl) errorEl.innerHTML = `<span class="gen-error">${this.esc(e.message || 'Generation failed')}</span>`;
|
||||
} finally {
|
||||
this._generating = false;
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = 'Generate Campaign';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -515,6 +515,127 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
|
|||
return c.json(data, res.status as any);
|
||||
});
|
||||
|
||||
// ── AI Campaign Generator ──
|
||||
|
||||
routes.post("/api/campaign/generate", async (c) => {
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
|
||||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||||
|
||||
const { brief, platforms, style, tone } = await c.req.json();
|
||||
if (!brief || typeof brief !== "string" || brief.trim().length < 10) {
|
||||
return c.json({ error: "brief is required (min 10 characters)" }, 400);
|
||||
}
|
||||
|
||||
const selectedPlatforms = (platforms && Array.isArray(platforms) && platforms.length > 0)
|
||||
? platforms : ["x", "linkedin", "instagram", "youtube", "threads", "bluesky", "newsletter"];
|
||||
const campaignStyle = style || "event-promo";
|
||||
const campaignTone = tone || "professional";
|
||||
|
||||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });
|
||||
|
||||
const systemPrompt = `You are an expert social media campaign strategist. Given an event brief, generate a complete multi-phase, multi-platform social media campaign.
|
||||
|
||||
Style: ${campaignStyle}
|
||||
Tone: ${campaignTone}
|
||||
Target platforms: ${selectedPlatforms.join(", ")}
|
||||
|
||||
Platform specifications:
|
||||
- x: Max 280 chars per post. Support threads (threadPosts array). Use 2-4 hashtags, emojis encouraged.
|
||||
- linkedin: Max 1300 chars. Professional tone. 3-5 hashtags. No emojis in professional mode.
|
||||
- instagram: Carousel descriptions. 20-30 hashtags. Heavy emoji usage.
|
||||
- youtube: Video title + description. SEO-focused with keywords.
|
||||
- threads: Max 500 chars. Casual tone. Support threads (threadPosts array).
|
||||
- bluesky: Max 300 chars. Conversational. Minimal hashtags (1-2).
|
||||
- newsletter: HTML email body (emailHtml) with subject line (emailSubject). Include sections, CTA button.
|
||||
|
||||
Event brief:
|
||||
"""
|
||||
${brief.trim()}
|
||||
"""
|
||||
|
||||
Return ONLY valid JSON (no markdown fences):
|
||||
{
|
||||
"title": "Campaign title",
|
||||
"description": "1-2 sentence campaign description",
|
||||
"duration": "Human-readable date range (e.g. 'Mar 20-25, 2026 (6 days)')",
|
||||
"platforms": [${selectedPlatforms.map((p: string) => `"${p}"`).join(", ")}],
|
||||
"phases": [
|
||||
{ "name": "phase-slug", "label": "Phase Label", "days": "Day -3 to -1" }
|
||||
],
|
||||
"posts": [
|
||||
{
|
||||
"platform": "x",
|
||||
"postType": "thread",
|
||||
"stepNumber": 1,
|
||||
"content": "Main post content with emojis and formatting",
|
||||
"scheduledAt": "2026-03-20T09:00:00",
|
||||
"status": "draft",
|
||||
"hashtags": ["Tag1", "Tag2"],
|
||||
"phase": 1,
|
||||
"phaseLabel": "Phase Label",
|
||||
"threadPosts": ["First tweet of thread", "Second tweet", "Third tweet"],
|
||||
"emailSubject": null,
|
||||
"emailHtml": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Generate 3-5 phases based on event type (pre-launch, launch, amplification, follow-up, etc.)
|
||||
- Create posts for EACH selected platform in each relevant phase (not every platform needs every phase)
|
||||
- For X and Threads posts marked as "thread" postType, include threadPosts array with individual posts
|
||||
- For newsletter posts, include emailSubject and emailHtml with proper HTML (inline styles, CTA button)
|
||||
- Use realistic future scheduledAt dates based on the brief
|
||||
- stepNumber should increment across ALL posts (global ordering)
|
||||
- Each post's content must respect the platform's character limits
|
||||
- Include relevant emojis naturally in post content
|
||||
- Hashtags should be relevant, no # prefix in the array
|
||||
- Make content engaging, not generic — reference specific details from the brief`;
|
||||
|
||||
try {
|
||||
const result = await model.generateContent(systemPrompt);
|
||||
const text = result.response.text();
|
||||
const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
||||
const generated = JSON.parse(jsonStr);
|
||||
|
||||
// Add IDs and timestamps
|
||||
const now = Date.now();
|
||||
const campaignId = `gen-${now}`;
|
||||
const campaign = {
|
||||
id: campaignId,
|
||||
title: generated.title || "Generated Campaign",
|
||||
description: generated.description || "",
|
||||
duration: generated.duration || "",
|
||||
platforms: generated.platforms || selectedPlatforms,
|
||||
phases: generated.phases || [],
|
||||
posts: (generated.posts || []).map((p: any, i: number) => ({
|
||||
id: `${campaignId}-post-${i}`,
|
||||
platform: p.platform || "x",
|
||||
postType: p.postType || "text",
|
||||
stepNumber: p.stepNumber || i + 1,
|
||||
content: p.content || "",
|
||||
scheduledAt: p.scheduledAt || new Date().toISOString(),
|
||||
status: p.status || "draft",
|
||||
hashtags: p.hashtags || [],
|
||||
phase: p.phase || 1,
|
||||
phaseLabel: p.phaseLabel || "",
|
||||
...(p.threadPosts ? { threadPosts: p.threadPosts } : {}),
|
||||
...(p.emailSubject ? { emailSubject: p.emailSubject } : {}),
|
||||
...(p.emailHtml ? { emailHtml: p.emailHtml } : {}),
|
||||
})),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return c.json(campaign);
|
||||
} catch (e: any) {
|
||||
console.error("[rSocials] Campaign generation error:", e.message);
|
||||
return c.json({ error: "Failed to generate campaign: " + (e.message || "unknown error") }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Campaign Workflow CRUD API ──
|
||||
|
||||
routes.get("/api/campaign-workflows", (c) => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ export interface CampaignPost {
|
|||
hashtags: string[];
|
||||
phase: number;
|
||||
phaseLabel: string;
|
||||
threadPosts?: string[];
|
||||
emailSubject?: string;
|
||||
emailHtml?: string;
|
||||
}
|
||||
|
||||
export interface Campaign {
|
||||
|
|
@ -378,12 +381,12 @@ export interface SocialsDoc {
|
|||
export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||
module: 'socials',
|
||||
collection: 'data',
|
||||
version: 3,
|
||||
version: 4,
|
||||
init: (): SocialsDoc => ({
|
||||
meta: {
|
||||
module: 'socials',
|
||||
collection: 'data',
|
||||
version: 3,
|
||||
version: 4,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
|
|
@ -397,7 +400,7 @@ export const socialsSchema: DocSchema<SocialsDoc> = {
|
|||
if (!doc.campaignFlows) (doc as any).campaignFlows = {};
|
||||
if (!doc.activeFlowId) (doc as any).activeFlowId = '';
|
||||
if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {};
|
||||
if (doc.meta) doc.meta.version = 3;
|
||||
if (doc.meta) doc.meta.version = 4;
|
||||
return doc;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue