From e1bdc98b98bd3e92608a374d121195e6ef60ee76 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 12:21:59 -0700 Subject: [PATCH] feat(rsocials): multi-view campaign planner (timeline, platform, table) Add three alternative views to the campaign planner canvas: - Timeline: horizontal chronological layout with day columns and phase bars - Platform: kanban columns grouped by platform with post cards - Table: compact sortable table with status, platform, content, dates View switcher in toolbar preserves canvas state when switching. Clicking any post in alt views navigates back to canvas with that node selected and centered. Keyboard shortcuts guarded to canvas-only view. Co-Authored-By: Claude Opus 4.6 --- .../rsocials/components/campaign-planner.css | 389 ++++++++++++++++++ .../components/folk-campaign-planner.ts | 361 +++++++++++++++- 2 files changed, 744 insertions(+), 6 deletions(-) diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index bcd4ef9..92d1469 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -470,3 +470,392 @@ folk-campaign-planner { flex-wrap: wrap; } } + +/* ── View Switcher ── */ +.cp-view-switcher { + display: flex; + gap: 2px; + background: var(--rs-input-bg, #16162a); + border: 1px solid var(--rs-input-border, #3d3d5c); + border-radius: 8px; + padding: 2px; +} + +.cp-view-btn { + width: 30px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--rs-text-muted, #94a3b8); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.cp-view-btn:hover { + background: var(--rs-bg-surface-raised, #252545); + color: var(--rs-text-primary, #e2e8f0); +} + +.cp-view-btn.active { + background: #6366f133; + color: #818cf8; +} + +/* ── Alt view container ── */ +.cp-alt-view { + display: none; + flex: 1; + overflow: auto; + background: var(--rs-canvas-bg, #0f0f23); +} + +/* ── Timeline View ── */ +.cp-timeline { + display: flex; + flex-direction: column; + height: 100%; +} + +.cp-tl-scroll { + flex: 1; + overflow: auto; + padding: 16px; +} + +.cp-tl-grid { + display: grid; + gap: 0; + min-width: max-content; +} + +.cp-tl-header { + display: contents; +} + +.cp-tl-day { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--rs-text-muted, #94a3b8); + border-bottom: 1px solid var(--rs-border, #2d2d44); + position: sticky; + top: 0; + background: var(--rs-canvas-bg, #0f0f23); + z-index: 2; + text-align: center; +} + +.cp-tl-phases { + display: flex; + gap: 4px; + padding: 8px 0; +} + +.cp-tl-phase { + padding: 4px 10px; + border-radius: 6px; + white-space: nowrap; +} + +.cp-tl-body { + display: grid; + gap: 0; +} + +.cp-tl-col { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 6px; + border-right: 1px solid var(--rs-border, #2d2d44); + min-height: 60px; +} + +.cp-tl-col:last-child { + border-right: none; +} + +.cp-tl-card { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border, #2d2d44); + border-radius: 6px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + font-size: 11px; +} + +.cp-tl-card:hover { + border-color: #6366f1; + background: var(--rs-bg-surface-raised, #252545); +} + +.cp-tl-card__icon { + font-size: 12px; + flex-shrink: 0; +} + +.cp-tl-card__label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--rs-text-primary, #e2e8f0); +} + +.cp-tl-card__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.cp-tl-card__time { + font-size: 9px; + color: var(--rs-text-muted, #94a3b8); + white-space: nowrap; +} + +/* ── Platform View ── */ +.cp-platform-view { + display: flex; + gap: 16px; + padding: 16px; + height: 100%; + overflow-x: auto; +} + +.cp-pv-column { + flex: 1; + min-width: 220px; + max-width: 320px; + display: flex; + flex-direction: column; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border, #2d2d44); + border-radius: 10px; + overflow: hidden; +} + +.cp-pv-column__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--rs-border, #2d2d44); +} + +.cp-pv-column__title { + font-size: 13px; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); + flex: 1; +} + +.cp-pv-column__count { + font-size: 10px; + background: var(--rs-bg-surface-raised, #252545); + color: var(--rs-text-muted, #94a3b8); + padding: 2px 6px; + border-radius: 10px; + font-weight: 600; +} + +.cp-pv-column__body { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 180px); +} + +.cp-pv-card { + padding: 10px; + background: var(--rs-input-bg, #16162a); + border: 1px solid var(--rs-border, #2d2d44); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.cp-pv-card:hover { + border-color: #6366f1; + background: var(--rs-bg-surface-raised, #252545); +} + +.cp-pv-card__header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.cp-pv-card__label { + font-size: 12px; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cp-pv-card__dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.cp-pv-card__preview { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 6px; +} + +.cp-pv-card__meta { + display: flex; + justify-content: space-between; + font-size: 10px; + color: #64748b; +} + +.cp-pv-card__type { + text-transform: capitalize; +} + +.cp-pv-card__tags { + display: flex; + gap: 4px; + margin-top: 6px; + flex-wrap: wrap; +} + +.cp-pv-tag { + font-size: 9px; + padding: 1px 5px; + border-radius: 4px; + background: #6366f122; + color: #818cf8; +} + +/* ── Table View ── */ +.cp-table-view { + padding: 16px; + overflow: auto; + height: 100%; +} + +.cp-tv-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.cp-tv-table thead th { + position: sticky; + top: 0; + background: var(--rs-bg-surface, #1a1a2e); + text-align: left; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--rs-text-muted, #94a3b8); + border-bottom: 1px solid var(--rs-border-strong, #3d3d5c); + white-space: nowrap; + z-index: 2; +} + +.cp-tv-row { + cursor: pointer; + transition: background 0.1s; +} + +.cp-tv-row:hover { + background: var(--rs-bg-surface-raised, #252545); +} + +.cp-tv-row td { + padding: 8px 12px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + color: var(--rs-text-primary, #e2e8f0); + white-space: nowrap; +} + +.cp-tv-content { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap !important; + color: var(--rs-text-muted, #94a3b8) !important; +} + +.cp-tv-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.cp-tv-tag { + font-size: 9px; + padding: 1px 5px; + border-radius: 4px; + background: #6366f122; + color: #818cf8; + margin-right: 3px; +} + +/* ── Mobile: alt views ── */ +@media (max-width: 768px) { + .cp-view-switcher { + order: -1; + width: 100%; + justify-content: center; + } + + .cp-platform-view { + flex-direction: column; + gap: 12px; + } + + .cp-pv-column { + max-width: 100%; + min-width: 0; + } + + .cp-pv-column__body { + max-height: 300px; + } + + .cp-tl-card { + font-size: 10px; + padding: 4px 6px; + } + + .cp-tv-table { + font-size: 11px; + } + + .cp-tv-row td { + padding: 6px 8px; + } + + .cp-tv-content { + max-width: 150px; + } +} diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index 9df0aab..3dd5ce0 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -120,6 +120,7 @@ function getUsername(): string | null { class FolkCampaignPlanner extends HTMLElement { private shadow: ShadowRoot; private space = ''; + private currentView: 'canvas' | 'timeline' | 'platform' | 'table' = 'canvas'; private get basePath() { const host = window.location.hostname; @@ -228,7 +229,11 @@ class FolkCampaignPlanner extends HTMLElement { if (flow) { this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })); this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })); - this.drawCanvasContent(); + if (this.currentView === 'canvas') { + this.drawCanvasContent(); + } else { + this.switchView(this.currentView); + } } }); @@ -909,6 +914,316 @@ class FolkCampaignPlanner extends HTMLElement { this.shadow.getElementById('cp-context-menu')?.remove(); } + // ── View switching ── + + private switchView(view: 'canvas' | 'timeline' | 'platform' | 'table') { + this.currentView = view; + const canvasEl = this.shadow.getElementById('cp-canvas'); + const altEl = this.shadow.getElementById('cp-alt-view'); + const zoomControls = this.shadow.querySelector('.cp-zoom-controls') as HTMLElement | null; + + // Update active button + this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-view') === view); + }); + + if (view === 'canvas') { + if (canvasEl) canvasEl.style.display = ''; + if (altEl) { altEl.innerHTML = ''; altEl.style.display = 'none'; } + if (zoomControls) zoomControls.style.display = ''; + return; + } + + // Hide canvas SVG (preserves state) + if (canvasEl) canvasEl.style.display = 'none'; + if (zoomControls) zoomControls.style.display = 'none'; + if (!altEl) return; + altEl.style.display = ''; + + switch (view) { + case 'timeline': altEl.innerHTML = this.renderTimeline(); break; + case 'platform': altEl.innerHTML = this.renderPlatformView(); break; + case 'table': altEl.innerHTML = this.renderTableView(); break; + } + this.attachAltViewListeners(); + } + + // ── Timeline view ── + + private renderTimeline(): string { + const posts = this.nodes.filter(n => n.type === 'post' || n.type === 'thread'); + const phases = this.nodes.filter(n => n.type === 'phase'); + + // Collect all dates; group posts by day + const dated = posts.map(n => { + const d = n.data as PostNodeData; + const dt = d.scheduledAt ? new Date(d.scheduledAt) : null; + return { node: n, date: dt }; + }).sort((a, b) => { + if (!a.date && !b.date) return 0; + if (!a.date) return 1; + if (!b.date) return -1; + return a.date.getTime() - b.date.getTime(); + }); + + // Build day buckets + const dayMap = new Map(); + for (const item of dated) { + const key = item.date ? item.date.toISOString().split('T')[0] : 'unscheduled'; + if (!dayMap.has(key)) dayMap.set(key, []); + dayMap.get(key)!.push(item); + } + + // Determine date range for columns + const allDates = dated.filter(d => d.date).map(d => d.date!); + if (allDates.length === 0) { + return '
No scheduled posts yet. Add dates in canvas view.
'; + } + + const minDate = new Date(Math.min(...allDates.map(d => d.getTime()))); + const maxDate = new Date(Math.max(...allDates.map(d => d.getTime()))); + minDate.setDate(minDate.getDate() - 1); + maxDate.setDate(maxDate.getDate() + 1); + + const days: string[] = []; + const cur = new Date(minDate); + while (cur <= maxDate) { + days.push(cur.toISOString().split('T')[0]); + cur.setDate(cur.getDate() + 1); + } + + // Phase bars + let phaseBars = ''; + for (const phase of phases) { + const pd = phase.data as PhaseNodeData; + if (!pd.dateRange) continue; + const parts = pd.dateRange.split(/\s*[-–]\s*/); + if (parts.length < 2) continue; + const start = days.indexOf(parts[0].trim()); + const end = days.indexOf(parts[1].trim()); + if (start < 0 || end < 0) continue; + phaseBars += `
+ ${esc(pd.label)} +
`; + } + + // Day headers + cards + const headers = days.map(d => { + const dt = new Date(d + 'T12:00:00'); + const label = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + return `
${label}
`; + }).join(''); + + const columns = days.map(dayKey => { + const items = dayMap.get(dayKey) || []; + const cards = items.map(item => { + const d = item.node.data as PostNodeData; + const platform = d.platform || 'x'; + const color = PLATFORM_COLORS[platform] || '#888'; + const icon = PLATFORM_ICONS[platform] || platform.charAt(0); + const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const time = item.date ? item.date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : ''; + return `
+ ${icon} + ${esc(d.label || (d.content || '').split('\n')[0].substring(0, 30) || 'Post')} + + ${time ? `${time}` : ''} +
`; + }).join(''); + return `
${cards}
`; + }).join(''); + + // Unscheduled section + let unscheduledHtml = ''; + const unscheduled = dayMap.get('unscheduled'); + if (unscheduled && unscheduled.length > 0) { + const cards = unscheduled.map(item => { + const d = item.node.data as PostNodeData; + const platform = d.platform || 'x'; + const color = PLATFORM_COLORS[platform] || '#888'; + const icon = PLATFORM_ICONS[platform] || platform.charAt(0); + return `
+ ${icon} + ${esc(d.label || 'Post')} + +
`; + }).join(''); + unscheduledHtml = `
+
Unscheduled
+
${cards}
+
`; + } + + return `
+
+
+
${headers}
+
${phaseBars}
+
${columns}
+
+
+ ${unscheduledHtml} +
`; + } + + // ── Platform view ── + + private renderPlatformView(): string { + const posts = this.nodes.filter(n => n.type === 'post'); + + // Group by platform + const platformMap = new Map(); + for (const node of posts) { + const d = node.data as PostNodeData; + const p = d.platform || 'x'; + if (!platformMap.has(p)) platformMap.set(p, []); + platformMap.get(p)!.push(node); + } + + // Sort each platform's posts by date + platformMap.forEach(items => { + items.sort((a, b) => { + const da = (a.data as PostNodeData).scheduledAt; + const db = (b.data as PostNodeData).scheduledAt; + if (!da && !db) return 0; + if (!da) return 1; + if (!db) return -1; + return new Date(da).getTime() - new Date(db).getTime(); + }); + }); + + if (platformMap.size === 0) { + return '
No posts yet. Add posts in canvas view.
'; + } + + const columns = Array.from(platformMap.entries()).map(([platform, items]) => { + const color = PLATFORM_COLORS[platform] || '#888'; + const icon = PLATFORM_ICONS[platform] || platform.charAt(0); + + const cards = items.map(node => { + const d = node.data as PostNodeData; + const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'Unscheduled'; + const preview = (d.content || '').split('\n')[0].substring(0, 60); + const tags = d.hashtags.slice(0, 3).map(h => + `${esc(h.startsWith('#') ? h : '#' + h)}` + ).join(''); + + return `
+
+ ${esc(d.label || preview || 'Post')} + +
+ ${preview ? `
${esc(preview)}
` : ''} +
+ ${esc(d.postType)} + ${dateStr} +
+ ${tags ? `
${tags}
` : ''} +
`; + }).join(''); + + return `
+
+ ${icon} + ${platform.charAt(0).toUpperCase() + platform.slice(1)} + ${items.length} +
+
${cards}
+
`; + }).join(''); + + return `
${columns}
`; + } + + // ── Table view ── + + private renderTableView(): string { + const posts = this.nodes.filter(n => n.type === 'post' || n.type === 'thread'); + + posts.sort((a, b) => { + const da = (a.data as PostNodeData).scheduledAt || ''; + const db = (b.data as PostNodeData).scheduledAt || ''; + if (!da && !db) return 0; + if (!da) return 1; + if (!db) return -1; + return da.localeCompare(db); + }); + + if (posts.length === 0) { + return '
No posts yet.
'; + } + + const rows = posts.map(node => { + const d = node.data as PostNodeData; + const platform = d.platform || 'x'; + const color = PLATFORM_COLORS[platform] || '#888'; + const icon = PLATFORM_ICONS[platform] || platform.charAt(0); + const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const statusLabel = d.status ? d.status.charAt(0).toUpperCase() + d.status.slice(1) : 'Draft'; + const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; + const preview = (d.content || '').split('\n')[0].substring(0, 50); + const tags = (d.hashtags || []).slice(0, 3).map(h => + `${esc(h.startsWith('#') ? h : '#' + h)}` + ).join(''); + + return ` + ${statusLabel} + ${icon} ${platform.charAt(0).toUpperCase() + platform.slice(1)} + ${esc(d.postType || node.type)} + ${esc(preview)} + ${dateStr} + ${tags} + `; + }).join(''); + + return `
+ + + + + ${rows} +
StatusPlatformTypeContentScheduledHashtags
+
`; + } + + // ── Alt view click-to-navigate ── + + private attachAltViewListeners() { + const altEl = this.shadow.getElementById('cp-alt-view'); + if (!altEl) return; + altEl.querySelectorAll('[data-nav-node]').forEach(el => { + el.addEventListener('click', () => { + const nodeId = el.getAttribute('data-nav-node'); + if (!nodeId) return; + this.navigateToCanvasNode(nodeId); + }); + }); + } + + private navigateToCanvasNode(nodeId: string) { + this.switchView('canvas'); + const node = this.nodes.find(n => n.id === nodeId); + if (!node) return; + + this.selectedNodeId = nodeId; + this.updateSelectionHighlight(); + + // Center node in view + const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null; + if (svg) { + const rect = svg.getBoundingClientRect(); + const s = getNodeSize(node); + const cx = node.position.x + s.w / 2; + const cy = node.position.y + s.h / 2; + this.canvasPanX = rect.width / 2 - cx * this.canvasZoom; + this.canvasPanY = rect.height / 2 - cy * this.canvasZoom; + this.updateCanvasTransform(); + } + + this.enterInlineEdit(nodeId); + } + // ── Rendering ── private render() { @@ -922,6 +1237,20 @@ class FolkCampaignPlanner extends HTMLElement { 📢 ${esc(this.flowName || 'Campaign Planner')} ${this.space === 'demo' ? 'Demo' : ''} +
+ + + + +
@@ -951,6 +1280,7 @@ class FolkCampaignPlanner extends HTMLElement {
+
@@ -1208,6 +1538,14 @@ class FolkCampaignPlanner extends HTMLElement { const canvas = this.shadow.getElementById('cp-canvas'); if (!svg || !canvas) return; + // View switcher buttons + this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => { + btn.addEventListener('click', () => { + const view = btn.getAttribute('data-view') as 'canvas' | 'timeline' | 'platform' | 'table'; + if (view) this.switchView(view); + }); + }); + // Toolbar add buttons this.shadow.getElementById('add-post')?.addEventListener('click', () => { const rect = svg.getBoundingClientRect(); @@ -1481,6 +1819,22 @@ class FolkCampaignPlanner extends HTMLElement { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + // Escape: in alt views → switch back to canvas + if (e.key === 'Escape') { + if (this.currentView !== 'canvas') { + this.switchView('canvas'); + return; + } + if (this.wiringActive) this.cancelWiring(); + else if (this.inlineEditNodeId) this.exitInlineEdit(); + else if (this.selectedNodeId) { this.selectedNodeId = null; this.updateSelectionHighlight(); } + else if (this.selectedEdgeKey) { this.selectedEdgeKey = null; this.redrawEdges(); } + return; + } + + // Canvas-only shortcuts + if (this.currentView !== 'canvas') return; + if (e.key === 'Delete' || e.key === 'Backspace') { if (this.selectedNodeId) { this.deleteNode(this.selectedNodeId); @@ -1489,11 +1843,6 @@ class FolkCampaignPlanner extends HTMLElement { } } else if (e.key === 'f' || e.key === 'F') { this.fitView(); - } else if (e.key === 'Escape') { - if (this.wiringActive) this.cancelWiring(); - else if (this.inlineEditNodeId) this.exitInlineEdit(); - else if (this.selectedNodeId) { this.selectedNodeId = null; this.updateSelectionHighlight(); } - else if (this.selectedEdgeKey) { this.selectedEdgeKey = null; this.redrawEdges(); } } else if (e.key === '+' || e.key === '=') { const rect = svg.getBoundingClientRect(); this.zoomAt(rect.width / 2, rect.height / 2, 1.15);