315 lines
14 KiB
TypeScript
315 lines
14 KiB
TypeScript
/**
|
|
* <folk-campaign-manager> — Campaign viewer/editor with import modal.
|
|
*
|
|
* 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';
|
|
|
|
export class FolkCampaignManager extends HTMLElement {
|
|
private _space = 'demo';
|
|
private _campaigns: Campaign[] = [];
|
|
private _offlineUnsub: (() => void) | null = null;
|
|
|
|
static get observedAttributes() { return ['space']; }
|
|
|
|
connectedCallback() {
|
|
this.attachShadow({ mode: 'open' });
|
|
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();
|
|
}
|
|
}
|
|
|
|
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 esc(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
private renderCampaign(c: Campaign): string {
|
|
const phases = [1, 2, 3];
|
|
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: '' };
|
|
|
|
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('');
|
|
|
|
return `
|
|
<div class="phase">
|
|
<h3 class="phase__title">${phaseIcons[i] || '📋'} Phase ${phaseNum}: ${this.esc(phaseInfo.label)} <span class="phase__days">${this.esc(phaseInfo.days)}</span></h3>
|
|
<div class="phase__posts">${postsHTML}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="campaign-header">
|
|
<span class="campaign-icon">🍄</span>
|
|
<div>
|
|
<h1 class="campaign-title">${this.esc(c.title)}</h1>
|
|
<p class="campaign-desc">${this.esc(c.description)}</p>
|
|
<div class="campaign-stats">
|
|
<span>📅 ${this.esc(c.duration)}</span>
|
|
<span>📱 ${c.platforms.join(', ')}</span>
|
|
<span>📝 ${c.posts.length} posts across ${c.phases.length} phases</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<a href="/${this.esc(this._space)}/rsocials/thread" class="btn btn--outline">Open Thread Builder</a>
|
|
<button class="btn btn--primary" id="import-md-btn">Import from Markdown</button>
|
|
</div>
|
|
${phaseHTML}
|
|
<div id="imported-posts"></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);
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host { display: block; }
|
|
.container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
|
|
.campaign-header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--rs-input-border, #334155); }
|
|
.campaign-icon { font-size: 3rem; }
|
|
.campaign-title { margin: 0; font-size: 1.5rem; color: var(--rs-text-primary, #f1f5f9); }
|
|
.campaign-desc { margin: 0.25rem 0 0.5rem; color: var(--rs-text-secondary, #94a3b8); font-size: 0.9rem; line-height: 1.5; }
|
|
.campaign-stats { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.8rem; color: var(--rs-text-muted, #64748b); }
|
|
.phase { margin-bottom: 2rem; }
|
|
.phase__title { font-size: 1.15rem; color: var(--rs-text-primary, #f1f5f9); margin: 0 0 1rem; display: flex; align-items: center; gap: 0.5rem; }
|
|
.phase__days { font-size: 0.8rem; color: var(--rs-text-muted, #64748b); font-weight: 400; }
|
|
.phase__posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
|
|
.post {
|
|
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-input-border, #334155); border-radius: 0.75rem; padding: 1rem;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.post:hover { border-color: #6366f1; }
|
|
.post__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
|
.post__platform {
|
|
width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
|
|
color: white; font-size: 0.75rem; font-weight: 700; flex-shrink: 0;
|
|
}
|
|
.post__meta { flex: 1; min-width: 0; }
|
|
.post__meta strong { display: block; font-size: 0.8rem; color: var(--rs-text-primary, #f1f5f9); text-transform: capitalize; }
|
|
.post__date { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); }
|
|
.post__step { font-size: 0.65rem; color: #6366f1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
|
.status { font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; white-space: nowrap; }
|
|
.status--scheduled { background: rgba(16,185,129,0.15); color: #34d399; }
|
|
.status--draft { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
|
.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: #7dd3fc; }
|
|
.actions { display: flex; 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;
|
|
}
|
|
.btn--primary { background: #6366f1; color: white; border: none; }
|
|
.btn--primary:hover { background: #818cf8; }
|
|
.btn--outline { background: transparent; color: var(--rs-text-secondary, #94a3b8); border: 1px solid var(--rs-input-border, #334155); }
|
|
.btn--outline:hover { border-color: #6366f1; color: #c4b5fd; }
|
|
|
|
/* Import modal */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex;
|
|
align-items: center; justify-content: center; z-index: 1000;
|
|
}
|
|
.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;
|
|
}
|
|
.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); }
|
|
.modal__close {
|
|
background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1.5rem; cursor: pointer;
|
|
line-height: 1; padding: 0;
|
|
}
|
|
.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);
|
|
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: #6366f1; }
|
|
.modal__textarea::placeholder { color: var(--rs-text-muted, #64748b); }
|
|
.modal__row { display: flex; gap: 0.75rem; align-items: center; }
|
|
.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;
|
|
}
|
|
.modal__select:focus { outline: none; border-color: #6366f1; }
|
|
</style>
|
|
<div class="container">
|
|
${campaignHTML}
|
|
</div>
|
|
<div class="modal-overlay" id="import-modal" hidden>
|
|
<div class="modal">
|
|
<div class="modal__header">
|
|
<h3>Import from Markdown</h3>
|
|
<button class="modal__close" id="import-modal-close">×</button>
|
|
</div>
|
|
<textarea class="modal__textarea" id="import-md-textarea" placeholder="Paste tweets separated by ---\n\nFirst tweet\n---\nSecond tweet\n---\nThird tweet"></textarea>
|
|
<div class="modal__row">
|
|
<select class="modal__select" id="import-platform">
|
|
<option value="twitter">Twitter / X</option>
|
|
<option value="bluesky">Bluesky</option>
|
|
<option value="mastodon">Mastodon</option>
|
|
<option value="linkedin">LinkedIn</option>
|
|
</select>
|
|
<button class="btn btn--primary" id="import-parse-btn">Parse & Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.bindEvents();
|
|
}
|
|
|
|
private bindEvents() {
|
|
if (!this.shadowRoot) return;
|
|
|
|
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');
|
|
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; });
|
|
|
|
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;
|
|
|
|
// Build imported posts as campaign posts and save to Automerge
|
|
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',
|
|
}));
|
|
|
|
// 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) => {
|
|
const preview = text.length > 180 ? this.esc(text.substring(0, 180)) + '...' : this.esc(text);
|
|
html += `<div class="post">
|
|
<div class="post__header">
|
|
<span class="post__platform" style="background:#6366f1">${this.esc(platform.charAt(0).toUpperCase())}</span>
|
|
<div class="post__meta"><strong>${this.esc(platform)}</strong></div>
|
|
<span class="status status--draft">imported</span>
|
|
</div>
|
|
<div class="post__step">Tweet ${i + 1}/${total}</div>
|
|
<p class="post__content">${preview.replace(/\n/g, '<br>')}</p>
|
|
</div>`;
|
|
});
|
|
html += '</div></div>';
|
|
importedEl.innerHTML = html;
|
|
modal.hidden = true;
|
|
|
|
// Save to Automerge if runtime available
|
|
if (this._space !== 'demo') {
|
|
const c = this._campaigns[0];
|
|
if (c) {
|
|
c.posts = [...c.posts, ...posts];
|
|
this.saveCampaignToDoc(c);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-campaign-manager', FolkCampaignManager);
|