/** * โ€” 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. */ import { socialsSchema, socialsDocId } from '../schemas'; import type { SocialsDoc, Campaign, CampaignPost } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; import { TourEngine } from '../../../shared/tour-engine'; export class FolkCampaignManager extends HTMLElement { private _space = 'demo'; 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 }, ]; static get observedAttributes() { return ['space']; } connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._tour = new TourEngine( this.shadowRoot!, FolkCampaignManager.TOUR_STEPS, "rsocials_tour_done", () => this.shadowRoot!.querySelector('.container') as HTMLElement, ); this._space = this.getAttribute('space') || 'demo'; // Start with demo campaign this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }]; this.render(); if (this._space !== 'demo') { this.subscribeOffline(); } // Auto-start tour on first visit if (!localStorage.getItem("rsocials_tour_done")) { setTimeout(() => this._tour.start(), 1200); } } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this._space = val; } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docId = socialsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, socialsSchema); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this.renderFromDoc(updated); }); } catch { // Runtime unavailable โ€” use demo data } } private renderFromDoc(doc: SocialsDoc) { if (!doc?.campaigns || Object.keys(doc.campaigns).length === 0) return; this._campaigns = Object.values(doc.campaigns).sort((a, b) => b.updatedAt - a.updatedAt); this.render(); } private saveCampaignToDoc(campaign: Campaign) { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const docId = socialsDocId(this._space) as DocumentId; runtime.change(docId, `Save campaign ${campaign.title}`, (d: SocialsDoc) => { if (!d.campaigns) d.campaigns = {} as any; campaign.updatedAt = Date.now(); d.campaigns[campaign.id] = campaign; }); } private get basePath() { const host = window.location.hostname; if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) { return '/rsocials/'; } return `/${this._space}/rsocials/`; } private esc(s: string): string { return s.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 => `#${this.esc(h)}`).join(' '); // Thread badge let threadBadge = ''; if (post.threadPosts && post.threadPosts.length > 0) { threadBadge = `๐Ÿงต ${post.threadPosts.length}-post thread`; } // Email subject for newsletter let emailLine = ''; if (post.emailSubject) { emailLine = `
๐Ÿ“ง ${this.esc(post.emailSubject)}
`; } // Thread expansion area let threadExpansion = ''; if (post.threadPosts && post.threadPosts.length > 0) { const threadItems = post.threadPosts.map((t, i) => `
${i + 1}. ${this.esc(t)}
` ).join(''); threadExpansion = ``; } return `
${icon} ${this.esc(post.status)}
${emailLine}
Step ${post.stepNumber}

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

${threadBadge} ${threadExpansion}
`; } private renderCampaign(c: Campaign): string { // 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); const phaseInfo = c.phases[i] || { label: `Phase ${phaseNum}`, days: '' }; if (!phasePosts.length) return ''; const postsHTML = phasePosts.map(post => this.renderPostCard(post)).join(''); return `

${phaseIcons[i] || '๐Ÿ“‹'} Phase ${phaseNum}: ${this.esc(phaseInfo.label)} ${this.esc(phaseInfo.days)}

${postsHTML}
`; }).join(''); return `
๐Ÿ„

${this.esc(c.title)}

${this.esc(c.description)}

๐Ÿ“… ${this.esc(c.duration)} ๐Ÿ“ฑ ${c.platforms.join(', ')} ๐Ÿ“ ${c.posts.length} posts across ${c.phases.length} phases
Open Thread Editor
${phaseHTML}
`; } private renderPreviewBanner(): string { return `
AI-Generated Campaign Preview
`; } private render() { if (!this.shadowRoot) return; 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 = `
${contentHTML}
`; this.bindEvents(); this._tour.renderOverlay(); } startTour() { this._tour.start(); } private bindEvents() { if (!this.shadowRoot) return; // Tour button this.shadowRoot.getElementById('btn-tour')?.addEventListener('click', () => this.startTour()); // โ”€โ”€ 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'); 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; const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean); if (!tweets.length || !importedEl) return; const platform = platformSel.value; const total = tweets.length; const posts: CampaignPost[] = tweets.map((text, i) => ({ id: `imported-${Date.now()}-${i}`, platform, postType: 'text', stepNumber: i + 1, content: text, scheduledAt: new Date().toISOString(), status: 'imported', hashtags: [], phase: 1, phaseLabel: 'Imported', })); let html = `

๐Ÿ“ฅ Imported Posts (${total})

`; html += '
'; tweets.forEach((text, i) => { const preview = text.length > 180 ? this.esc(text.substring(0, 180)) + '...' : this.esc(text); html += `
${this.esc(platform.charAt(0).toUpperCase())} imported
Tweet ${i + 1}/${total}

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

`; }); html += '
'; importedEl.innerHTML = html; importModal.hidden = true; if (this._space !== 'demo') { const c = this._campaigns[0]; if (c) { c.posts = [...c.posts, ...posts]; this.saveCampaignToDoc(c); } } }); // โ”€โ”€ 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 = 'Please enter at least 10 characters describing your event.'; 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 = 'Select at least one platform.'; 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 = ' 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 = `${this.esc(e.message || 'Generation failed')}`; } finally { this._generating = false; if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = 'Generate Campaign'; } } } } customElements.define('folk-campaign-manager', FolkCampaignManager);