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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 12:21:59 -07:00
parent fc65bec9dc
commit e1bdc98b98
2 changed files with 744 additions and 6 deletions

View File

@ -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;
}
}

View File

@ -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<string, typeof dated>();
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 '<div class="cp-timeline"><div style="padding:40px;color:#94a3b8;text-align:center">No scheduled posts yet. Add dates in canvas view.</div></div>';
}
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 += `<div class="cp-tl-phase" style="grid-column:${start + 1}/${end + 2};background:${pd.color}22;border:1px solid ${pd.color}44">
<span style="color:${pd.color};font-weight:600;font-size:11px">${esc(pd.label)}</span>
</div>`;
}
// 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 `<div class="cp-tl-day">${label}</div>`;
}).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 `<div class="cp-tl-card" data-nav-node="${item.node.id}">
<span class="cp-tl-card__icon" style="color:${color}">${icon}</span>
<span class="cp-tl-card__label">${esc(d.label || (d.content || '').split('\n')[0].substring(0, 30) || 'Post')}</span>
<span class="cp-tl-card__dot" style="background:${statusColor}"></span>
${time ? `<span class="cp-tl-card__time">${time}</span>` : ''}
</div>`;
}).join('');
return `<div class="cp-tl-col">${cards}</div>`;
}).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 `<div class="cp-tl-card" data-nav-node="${item.node.id}">
<span class="cp-tl-card__icon" style="color:${color}">${icon}</span>
<span class="cp-tl-card__label">${esc(d.label || 'Post')}</span>
<span class="cp-tl-card__dot" style="background:#f59e0b"></span>
</div>`;
}).join('');
unscheduledHtml = `<div style="padding:12px 16px;border-top:1px solid #2d2d44">
<div style="font-size:11px;color:#94a3b8;margin-bottom:8px;font-weight:600">Unscheduled</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">${cards}</div>
</div>`;
}
return `<div class="cp-timeline">
<div class="cp-tl-scroll">
<div class="cp-tl-grid" style="grid-template-columns:repeat(${days.length},minmax(120px,1fr))">
<div class="cp-tl-header">${headers}</div>
<div class="cp-tl-phases" style="grid-column:1/-1">${phaseBars}</div>
<div class="cp-tl-body" style="grid-template-columns:repeat(${days.length},minmax(120px,1fr))">${columns}</div>
</div>
</div>
${unscheduledHtml}
</div>`;
}
// ── Platform view ──
private renderPlatformView(): string {
const posts = this.nodes.filter(n => n.type === 'post');
// Group by platform
const platformMap = new Map<string, CampaignPlannerNode[]>();
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 '<div class="cp-platform-view"><div style="padding:40px;color:#94a3b8;text-align:center">No posts yet. Add posts in canvas view.</div></div>';
}
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 =>
`<span class="cp-pv-tag">${esc(h.startsWith('#') ? h : '#' + h)}</span>`
).join('');
return `<div class="cp-pv-card" data-nav-node="${node.id}">
<div class="cp-pv-card__header">
<span class="cp-pv-card__label">${esc(d.label || preview || 'Post')}</span>
<span class="cp-pv-card__dot" style="background:${statusColor}" title="${d.status}"></span>
</div>
${preview ? `<div class="cp-pv-card__preview">${esc(preview)}</div>` : ''}
<div class="cp-pv-card__meta">
<span class="cp-pv-card__type">${esc(d.postType)}</span>
<span class="cp-pv-card__date">${dateStr}</span>
</div>
${tags ? `<div class="cp-pv-card__tags">${tags}</div>` : ''}
</div>`;
}).join('');
return `<div class="cp-pv-column">
<div class="cp-pv-column__header" style="border-top:3px solid ${color}">
<span style="color:${color};font-size:14px">${icon}</span>
<span class="cp-pv-column__title">${platform.charAt(0).toUpperCase() + platform.slice(1)}</span>
<span class="cp-pv-column__count">${items.length}</span>
</div>
<div class="cp-pv-column__body">${cards}</div>
</div>`;
}).join('');
return `<div class="cp-platform-view">${columns}</div>`;
}
// ── 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 '<div class="cp-table-view"><div style="padding:40px;color:#94a3b8;text-align:center">No posts yet.</div></div>';
}
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 =>
`<span class="cp-tv-tag">${esc(h.startsWith('#') ? h : '#' + h)}</span>`
).join('');
return `<tr class="cp-tv-row" data-nav-node="${node.id}">
<td><span class="cp-tv-dot" style="background:${statusColor}"></span> ${statusLabel}</td>
<td><span style="color:${color};margin-right:4px">${icon}</span> ${platform.charAt(0).toUpperCase() + platform.slice(1)}</td>
<td>${esc(d.postType || node.type)}</td>
<td class="cp-tv-content">${esc(preview)}</td>
<td>${dateStr}</td>
<td>${tags}</td>
</tr>`;
}).join('');
return `<div class="cp-table-view">
<table class="cp-tv-table">
<thead><tr>
<th>Status</th><th>Platform</th><th>Type</th><th>Content</th><th>Scheduled</th><th>Hashtags</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ── 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 {
&#x1f4e2; ${esc(this.flowName || 'Campaign Planner')}
${this.space === 'demo' ? '<span class="cp-demo-badge">Demo</span>' : ''}
</span>
<div class="cp-view-switcher">
<button class="cp-view-btn ${this.currentView === 'canvas' ? 'active' : ''}" data-view="canvas" title="Canvas">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="4" cy="4" r="2" fill="currentColor"/><circle cx="12" cy="4" r="2" fill="currentColor"/><circle cx="4" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><line x1="6" y1="4" x2="10" y2="4" stroke="currentColor" stroke-width="1.2"/><line x1="4" y1="6" x2="4" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="6" y1="12" x2="10" y2="12" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="cp-view-btn ${this.currentView === 'timeline' ? 'active' : ''}" data-view="timeline" title="Timeline">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="4" height="10" rx="1" fill="currentColor" opacity="0.3"/><rect x="6" y="1" width="4" height="14" rx="1" fill="currentColor" opacity="0.5"/><rect x="11" y="5" width="4" height="8" rx="1" fill="currentColor" opacity="0.7"/></svg>
</button>
<button class="cp-view-btn ${this.currentView === 'platform' ? 'active' : ''}" data-view="platform" title="Platform">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="4" height="14" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="6" y="1" width="4" height="10" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="11" y="1" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="cp-view-btn ${this.currentView === 'table' ? 'active' : ''}" data-view="table" title="Table">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="3" rx="0.5" fill="currentColor" opacity="0.5"/><rect x="1" y="5.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="9" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="12.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/></svg>
</button>
</div>
<div class="cp-toolbar__actions">
<button class="cp-btn cp-btn--add" id="add-post">+ Post</button>
<button class="cp-btn cp-btn--add" id="add-platform">+ Platform</button>
@ -951,6 +1280,7 @@ class FolkCampaignPlanner extends HTMLElement {
<button class="cp-zoom-btn" id="zoom-fit" title="Fit to view (F)">&#x2922;</button>
</div>
</div>
<div id="cp-alt-view" class="cp-alt-view"></div>
<div class="cp-postiz-panel ${this.postizOpen ? 'open' : ''}" id="postiz-panel">
<div class="cp-postiz-header">
@ -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);