← back to SDCC Stories
public/js/app.js
1062 lines
/**
* SDCC Story Engine — Frontend
* Safe DOM rendering (no raw innerHTML with user data)
*/
const AUTH = 'Basic ' + btoa('admin:DWSecure2024!');
const headers = { 'Authorization': AUTH, 'Content-Type': 'application/json' };
let currentPage = 1;
let searchTimer = null;
// ─── Safe text escaping ───
function esc(s) {
if (s === null || s === undefined) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.textContent;
}
// Safe render: uses template element to parse trusted HTML with escaped dynamic values
function render(el, html) {
const t = document.createElement('template');
t.setHTMLUnsafe(html);
el.replaceChildren(t.content.cloneNode(true));
}
// ─── API helper ───
async function api(path, opts = {}) {
const res = await fetch(path, { headers, ...opts });
return res.json();
}
// ─── Tab switching ───
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
if (name === 'candidates') loadCandidates();
if (name === 'stories') loadStories();
if (name === 'calendar') loadCalendar();
if (name === 'heatmap') loadHeatmap();
if (name === 'ideas') loadIdeas();
if (name === 'settings') loadSettings();
}
// ─── Stats Bar ───
async function loadStats() {
const s = await api('/api/stats');
const bar = document.getElementById('stats-bar');
const items = [
{ label: 'Total', value: s.total, color: '' },
{ label: 'Tier A', value: s.tierA, color: 'var(--lane-a)' },
{ label: 'Tier B', value: s.tierB, color: 'var(--lane-b)' },
{ label: 'Tier C', value: s.tierC, color: 'var(--lane-c)' },
{ label: 'W/ Story', value: s.withStory, color: '' },
{ label: 'Consent', value: s.withConsent, color: 'var(--success)' },
{ label: 'Press Ready', value: s.pressReady, color: 'var(--warning)' },
{ label: 'Stories Made', value: s.totalStories, color: '' },
{ label: 'Scheduled', value: s.scheduled, color: 'var(--lane-b)' },
{ label: 'Posted', value: s.posted, color: 'var(--success)' }
];
bar.replaceChildren();
for (const item of items) {
const card = document.createElement('div');
card.className = 'stat-card';
const val = document.createElement('div');
val.className = 'stat-value';
val.textContent = item.value || 0;
if (item.color) val.style.color = item.color;
const lbl = document.createElement('div');
lbl.className = 'stat-label';
lbl.textContent = item.label;
card.append(val, lbl);
// Click tier stats to filter candidates
if (['Tier A', 'Tier B', 'Tier C'].includes(item.label)) {
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
document.getElementById('filter-tier').value = item.label.split(' ')[1];
switchTab('candidates');
});
}
bar.appendChild(card);
}
}
// ─── Candidates List ───
async function loadCandidates() {
const tier = document.getElementById('filter-tier').value;
const search = document.getElementById('filter-search').value;
const params = new URLSearchParams({ page: currentPage, limit: 50 });
if (tier) params.set('tier', tier);
if (search) params.set('search', search);
const data = await api('/api/candidates?' + params);
document.getElementById('candidate-count').textContent =
data.total + ' candidates' + (tier ? ' (Tier ' + tier + ')' : '') + (search ? ' matching "' + search + '"' : '');
const list = document.getElementById('candidate-list');
list.replaceChildren();
for (const c of data.candidates) {
const card = document.createElement('div');
card.className = 'candidate-card';
card.addEventListener('click', () => openCandidate(c.id));
// Score circle
const score = document.createElement('div');
score.className = 'score-circle';
score.textContent = c.candidate_score;
score.style.background = c.tier === 'A' ? 'var(--lane-a)' : c.tier === 'B' ? 'var(--lane-b)' : c.tier === 'C' ? 'var(--lane-c)' : 'var(--text-muted)';
// Name + tier
const nameEl = document.createElement('div');
const nameSpan = document.createElement('span');
nameSpan.className = 'candidate-name';
nameSpan.textContent = (c.first_name || 'Anonymous') + (c.last_name ? ' ' + c.last_name.charAt(0) + '.' : '');
const badge = document.createElement('span');
badge.className = 'badge badge-' + c.tier.toLowerCase();
badge.textContent = c.tier;
badge.style.marginLeft = '6px';
nameEl.append(nameSpan, badge);
// Amount
const amt = document.createElement('div');
amt.className = 'candidate-amount';
amt.textContent = c.amount_display || c.amount_owed_raw || '—';
// Snippet
const snippet = document.createElement('div');
snippet.className = 'candidate-snippet';
snippet.textContent = (c.emotional_impact || c.story || '').slice(0, 120);
// Location
const loc = document.createElement('div');
loc.className = 'candidate-meta';
loc.textContent = (c.zip_code || '') + (c.district_code ? ' | ' + c.district_code : '') + (c.representative_name ? ' (' + c.representative_name + ')' : '');
// Consent
const consent = document.createElement('div');
consent.className = 'candidate-meta';
const isConsent = (c.sharing_consent || '').toLowerCase().includes('yes');
consent.textContent = isConsent ? 'Consented' : 'No consent';
consent.style.color = isConsent ? 'var(--success)' : 'var(--text-muted)';
card.append(score, nameEl, amt, snippet, loc, consent);
list.appendChild(card);
}
// Pagination
const totalPages = Math.ceil(data.total / data.limit);
const pag = document.getElementById('pagination');
pag.replaceChildren();
for (let p = 1; p <= Math.min(totalPages, 20); p++) {
const btn = document.createElement('button');
btn.textContent = p;
if (p === currentPage) btn.classList.add('active');
btn.addEventListener('click', () => { currentPage = p; loadCandidates(); });
pag.appendChild(btn);
}
}
function debounceSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { currentPage = 1; loadCandidates(); }, 300);
}
// ─── Candidate Detail Modal ───
async function openCandidate(id) {
const data = await api('/api/candidates/' + id);
const c = data.candidate;
const fd = data.fullData || {};
const modal = document.getElementById('candidate-modal');
const detail = document.getElementById('candidate-detail');
detail.replaceChildren();
// Header
const h2 = document.createElement('h2');
h2.textContent = (c.first_name || 'Anonymous') + ' ' + (c.last_name || '');
h2.style.marginBottom = '4px';
const sub = document.createElement('div');
sub.style.cssText = 'display:flex;gap:8px;margin-bottom:16px;align-items:center';
const tierBadge = document.createElement('span');
tierBadge.className = 'badge badge-' + (c.tier || 'd').toLowerCase();
tierBadge.textContent = 'Tier ' + (c.tier || 'D') + ' — Score ' + (c.candidate_score || 0);
const amtSpan = document.createElement('span');
amtSpan.style.cssText = 'color:var(--secondary);font-weight:700;font-size:16px';
amtSpan.textContent = c.amount_display || c.amount_owed_raw || '—';
sub.append(tierBadge, amtSpan);
detail.append(h2, sub);
// Sections
const sections = [
{ title: 'Demographics', rows: [
['Age', c.age], ['ZIP Code', c.zip_code], ['Gender', c.gender],
['Race/Ethnicity', c.race_ethnicity], ['Marital Status', c.marital_status],
['Household Size', c.household_size], ['Employment', c.employment],
['Education', c.education], ['Income Type', c.income_type],
['Income Range', c.income_range], ['Voter', c.is_voter]
]},
{ title: 'Loan Details', rows: [
['Amount Owed (Raw)', c.amount_owed_raw], ['Amount Display', c.amount_display],
['Amount Low', c.amount_low], ['Amount High', c.amount_high],
['Loan Types', c.loan_types_raw], ['Repayment Plan', c.repayment_plan],
['Monthly Payment', c.monthly_payment], ['First Loan Year', c.first_loan_year]
]},
{ title: 'Story Essentials (P/Q/R)', rows: [
['Emotional Impact (P)', c.emotional_impact],
['Can\'t Afford (Q)', c.affordability_impacts_raw],
['Would Spend On (R)', c.alt_spending]
]},
{ title: 'Story Content', rows: [
['Their Story', c.story], ['Key Quote', c.key_quote],
['Story Category', c.story_category], ['Story Strength', c.story_strength]
]},
{ title: 'Consent & Civic', rows: [
['Sharing Consent', c.sharing_consent], ['Press Ready', c.press_ready],
['Registered Voter', c.is_voter], ['Platform Fit', c.platform_fit]
]},
{ title: 'Congressional District', rows: [
['District', c.district_code], ['Representative', c.representative_name],
['Party', c.representative_party]
]}
];
for (const section of sections) {
const sec = document.createElement('div');
sec.className = 'detail-section';
const h3 = document.createElement('h3');
h3.textContent = section.title;
sec.appendChild(h3);
for (const [label, value] of section.rows) {
if (!value && value !== 0) continue;
const row = document.createElement('div');
row.className = 'detail-row';
const lbl = document.createElement('div');
lbl.className = 'detail-label';
lbl.textContent = label;
const val = document.createElement('div');
val.className = 'detail-value';
val.textContent = String(value);
// Highlight long text fields
if (String(value).length > 100) {
val.style.cssText = 'font-size:12px;line-height:1.5;white-space:pre-wrap';
}
row.append(lbl, val);
sec.appendChild(row);
}
detail.appendChild(sec);
}
// Key quote highlight
if (c.key_quote) {
const quoteBox = document.createElement('div');
quoteBox.style.cssText = 'font-style:italic;color:var(--text-secondary);padding:12px;border-left:3px solid var(--lane-a);margin:12px 0;font-size:15px';
quoteBox.textContent = '"' + c.key_quote + '"';
detail.insertBefore(quoteBox, detail.children[2]);
}
// Action buttons
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:8px;margin-top:16px;flex-wrap:wrap';
const genBtn = document.createElement('button');
genBtn.className = 'btn btn-action';
genBtn.textContent = 'Generate Story';
genBtn.addEventListener('click', () => generateStoryForCandidate(c.id));
actions.appendChild(genBtn);
// Show existing stories
if (data.stories && data.stories.length > 0) {
const storyHeader = document.createElement('h3');
storyHeader.textContent = 'Generated Stories (' + data.stories.length + ')';
storyHeader.style.cssText = 'color:var(--secondary);margin-top:16px;border-bottom:1px solid var(--border);padding-bottom:4px';
detail.appendChild(storyHeader);
for (const st of data.stories) {
const stCard = document.createElement('div');
stCard.className = 'story-card';
stCard.style.marginTop = '8px';
const oneLiner = document.createElement('div');
oneLiner.style.cssText = 'font-weight:700;font-size:14px;margin-bottom:6px';
oneLiner.textContent = st.story_one_liner || '';
const igCap = document.createElement('div');
igCap.style.cssText = 'font-size:12px;color:var(--text-secondary);white-space:pre-wrap;margin-bottom:6px';
igCap.textContent = st.instagram_caption || '';
const xPost = document.createElement('div');
xPost.style.cssText = 'font-size:12px;color:var(--lane-b);margin-bottom:6px';
xPost.textContent = 'X: ' + (st.x_post || '');
const statusBadge = document.createElement('span');
statusBadge.className = 'badge badge-' + (st.status || 'draft');
statusBadge.textContent = st.status || 'draft';
const stActions = document.createElement('div');
stActions.style.cssText = 'display:flex;gap:6px;margin-top:8px';
const gfxBtn = document.createElement('button');
gfxBtn.className = 'btn btn-sm btn-action';
gfxBtn.textContent = 'Generate Graphic';
gfxBtn.addEventListener('click', (e) => { e.stopPropagation(); generateGraphicForStory(st.id); });
const lwBtn = document.createElement('button');
lwBtn.className = 'btn btn-sm btn-policy';
lwBtn.textContent = 'Lawmaker Graphic';
lwBtn.addEventListener('click', (e) => { e.stopPropagation(); generateLawmakerGraphicForStory(st.id); });
stActions.append(gfxBtn, lwBtn);
if (st.image_path) {
const img = document.createElement('img');
img.src = st.image_path;
img.className = 'story-image';
img.style.maxWidth = '300px';
stCard.append(oneLiner, igCap, xPost, statusBadge, stActions, img);
} else {
stCard.append(oneLiner, igCap, xPost, statusBadge, stActions);
}
detail.appendChild(stCard);
}
}
detail.appendChild(actions);
modal.classList.add('open');
}
function closeModal() {
document.getElementById('candidate-modal').classList.remove('open');
}
// Close modal on backdrop click
document.getElementById('candidate-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
// ─── Story Generation ───
async function generateStoryForCandidate(candidateId) {
const btn = event.target;
btn.textContent = 'Generating...';
btn.disabled = true;
try {
const result = await api('/api/stories/generate', { method: 'POST', body: JSON.stringify({ candidate_id: candidateId }) });
if (result.id) {
openCandidate(candidateId); // Refresh modal
} else {
alert('Generation failed: ' + (result.error || 'Unknown error'));
}
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.textContent = 'Generate Story';
btn.disabled = false;
}
}
async function generateGraphicForStory(storyId) {
const result = await api('/api/stories/' + storyId + '/generate-graphic', { method: 'POST', body: '{}' });
if (result.success) {
loadStories();
// Refresh the current modal if open
const detail = document.getElementById('candidate-detail');
if (detail.children.length > 0) {
// Re-open the same candidate
}
}
}
async function generateLawmakerGraphicForStory(storyId) {
const result = await api('/api/stories/' + storyId + '/generate-lawmaker-graphic', { method: 'POST', body: '{}' });
if (result.success) {
loadStories();
}
}
// ─── Stories Tab (expandable) ───
async function loadStories() {
const filter = document.getElementById('story-filter').value;
const params = filter ? '?status=' + filter : '';
const stories = await api('/api/stories' + params);
const list = document.getElementById('story-list');
list.replaceChildren();
if (stories.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'color:var(--text-muted);text-align:center;padding:40px';
empty.textContent = 'No stories yet. Go to Candidates tab and generate stories for borrowers who consented to social media sharing.';
list.appendChild(empty);
return;
}
for (const s of stories) {
const card = document.createElement('div');
card.className = 'story-card';
card.style.cursor = 'pointer';
card.dataset.storyId = s.id;
// Header row
const header = document.createElement('div');
header.className = 'story-header';
const title = document.createElement('div');
const nameSpan = document.createElement('strong');
nameSpan.textContent = (s.first_name || 'Unknown') + (s.last_name ? ' ' + s.last_name.charAt(0) + '.' : '');
const amtSpan = document.createElement('span');
amtSpan.style.cssText = 'color:var(--secondary);margin-left:8px';
amtSpan.textContent = s.amount_owed_raw || '';
const tierBdg = document.createElement('span');
tierBdg.className = 'badge badge-' + (s.tier || 'd').toLowerCase();
tierBdg.textContent = s.tier || '?';
tierBdg.style.marginLeft = '8px';
title.append(nameSpan, amtSpan, tierBdg);
const statusBdg = document.createElement('span');
statusBdg.className = 'badge badge-' + (s.status || 'draft');
statusBdg.textContent = s.status || 'draft';
header.append(title, statusBdg);
// One-liner (always visible)
const oneLiner = document.createElement('div');
oneLiner.style.cssText = 'font-size:13px;color:var(--text-secondary);margin-top:6px';
oneLiner.textContent = s.story_one_liner || '(no story text)';
// Expandable detail section
const detail = document.createElement('div');
detail.style.cssText = 'display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:12px';
// Borrower details
const borrowerSection = document.createElement('div');
borrowerSection.style.marginBottom = '12px';
const bTitle = document.createElement('h4');
bTitle.textContent = 'Borrower Details';
bTitle.style.cssText = 'color:var(--secondary);font-size:13px;margin-bottom:6px';
borrowerSection.appendChild(bTitle);
const details = [
['Name', (s.first_name || '') + ' ' + (s.last_name || '')],
['ZIP', s.zip_code || ''],
['Amount', s.amount_owed_raw || ''],
['District', s.district_code || ''],
['Representative', s.representative_name || ''],
['Emotional Impact', s.emotional_impact || ''],
['Can\'t Afford', s.affordability_impacts_raw || ''],
['Would Spend On', s.alt_spending || '']
];
for (const [lbl, val] of details) {
if (!val) continue;
const row = document.createElement('div');
row.className = 'detail-row';
const l = document.createElement('div');
l.className = 'detail-label';
l.textContent = lbl;
const v = document.createElement('div');
v.className = 'detail-value';
v.textContent = val;
if (val.length > 80) v.style.cssText = 'font-size:12px;white-space:pre-wrap';
row.append(l, v);
borrowerSection.appendChild(row);
}
detail.appendChild(borrowerSection);
// Instagram caption
if (s.instagram_caption) {
const igSec = document.createElement('div');
igSec.style.marginBottom = '8px';
const igLabel = document.createElement('div');
igLabel.style.cssText = 'font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px';
igLabel.textContent = 'Instagram Caption';
const igText = document.createElement('div');
igText.style.cssText = 'font-size:12px;color:var(--text-secondary);white-space:pre-wrap';
igText.textContent = s.instagram_caption;
igSec.append(igLabel, igText);
detail.appendChild(igSec);
}
// X post
if (s.x_post) {
const xSec = document.createElement('div');
xSec.style.marginBottom = '8px';
const xLabel = document.createElement('div');
xLabel.style.cssText = 'font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px';
xLabel.textContent = 'X Post';
const xText = document.createElement('div');
xText.style.cssText = 'font-size:12px;color:var(--lane-b)';
xText.textContent = s.x_post;
xSec.append(xLabel, xText);
detail.appendChild(xSec);
}
// Image
if (s.image_path) {
const img = document.createElement('img');
img.src = s.image_path;
img.className = 'story-image';
img.style.maxWidth = '300px';
detail.appendChild(img);
}
// Action buttons
const actions = document.createElement('div');
actions.className = 'story-actions';
const gfxBtn = document.createElement('button');
gfxBtn.className = 'btn btn-sm btn-action';
gfxBtn.textContent = 'Generate Graphic';
gfxBtn.addEventListener('click', (e) => { e.stopPropagation(); generateGraphicForStory(s.id); });
const lwBtn = document.createElement('button');
lwBtn.className = 'btn btn-sm btn-policy';
lwBtn.textContent = 'Lawmaker Graphic';
lwBtn.addEventListener('click', (e) => { e.stopPropagation(); generateLawmakerGraphicForStory(s.id); });
const pubBtn = document.createElement('button');
pubBtn.className = 'btn btn-sm btn-resource';
pubBtn.textContent = 'Publish to IG';
pubBtn.addEventListener('click', (e) => { e.stopPropagation(); publishStory(s.id); });
actions.append(gfxBtn, lwBtn, pubBtn);
detail.appendChild(actions);
// Toggle expand
card.addEventListener('click', () => {
const open = detail.style.display !== 'none';
detail.style.display = open ? 'none' : 'block';
});
card.append(header, oneLiner, detail);
list.appendChild(card);
}
}
async function publishStory(id) {
if (!confirm('Publish this story to Instagram?')) return;
const result = await api('/api/stories/' + id + '/publish', { method: 'POST', body: '{}' });
if (result.success) {
loadStories();
} else {
alert('Publish failed: ' + (result.error || 'Unknown'));
}
}
// ─── Calendar ───
async function loadCalendar() {
const stories = await api('/api/calendar');
const grid = document.getElementById('calendar-grid');
grid.replaceChildren();
// Day headers
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
for (const d of days) {
const hdr = document.createElement('div');
hdr.style.cssText = 'text-align:center;font-weight:700;color:var(--text-muted);font-size:12px;padding:4px';
hdr.textContent = d;
grid.appendChild(hdr);
}
// Group stories by date
const byDate = {};
for (const s of stories) {
const date = (s.scheduled_at || '').split('T')[0];
if (!byDate[date]) byDate[date] = [];
byDate[date].push(s);
}
// Generate 28-day grid starting from today
const today = new Date();
const startDay = new Date(today);
startDay.setDate(today.getDate() - today.getDay()); // Start on Sunday
for (let i = 0; i < 28; i++) {
const d = new Date(startDay);
d.setDate(startDay.getDate() + i);
const dateStr = d.toISOString().split('T')[0];
const cell = document.createElement('div');
cell.className = 'calendar-day';
if (dateStr === today.toISOString().split('T')[0]) cell.style.borderColor = 'var(--primary)';
const dayHeader = document.createElement('div');
dayHeader.className = 'day-header';
dayHeader.textContent = d.getDate() + ' ' + d.toLocaleDateString('en-US', { month: 'short' });
cell.appendChild(dayHeader);
const dayStories = byDate[dateStr] || [];
for (const s of dayStories) {
const slot = document.createElement('div');
slot.className = 'calendar-slot ' + (s.platform || 'instagram');
const time = (s.scheduled_at || '').includes('09:') ? '9am' : '6pm';
slot.textContent = time + ' ' + (s.first_name || '?') + ' (' + (s.platform || 'ig') + ')';
slot.style.cursor = 'pointer';
slot.addEventListener('click', () => {
if (s.candidate_id) openCandidate(s.candidate_id);
});
cell.appendChild(slot);
}
grid.appendChild(cell);
}
}
async function autoFillCalendar() {
if (!confirm('Auto-fill 7 days with Tier A/B candidate stories (2x daily)?')) return;
const btn = event.target;
btn.textContent = 'Filling...';
btn.disabled = true;
try {
const result = await api('/api/calendar/auto-fill', { method: 'POST', body: JSON.stringify({ days: 7 }) });
alert('Scheduled ' + (result.scheduled || 0) + ' stories');
loadCalendar();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.textContent = 'Auto-Fill 7 Days';
btn.disabled = false;
}
}
// ─── Heat Map ───
async function loadHeatmap() {
const data = await api('/api/heatmap');
const container = document.getElementById('heatmap-container');
container.replaceChildren();
// Story Classification Tree — clickable branches
const treeSection = document.createElement('div');
treeSection.style.cssText = 'width:100%;margin-bottom:20px;grid-column:1/-1';
const treeTitle = document.createElement('h3');
treeTitle.textContent = 'Story Classification';
treeTitle.style.cssText = 'color:var(--secondary);margin-bottom:12px';
treeSection.appendChild(treeTitle);
const tiers = data.byTier || [];
const tierColors = { A: 'var(--lane-a)', B: 'var(--lane-b)', C: 'var(--lane-c)', D: 'var(--text-muted)' };
const tierLabels = { A: 'Press + Social + Lawmakers', B: 'Social + Lawmakers', C: 'Social Only', D: 'Needs Enrichment' };
const tierTotal = tiers.reduce((sum, t) => sum + t.count, 0) || 1;
for (const t of tiers) {
const branch = document.createElement('div');
branch.style.cssText = 'display:flex;align-items:center;gap:12px;padding:8px 12px;margin-bottom:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:all 0.15s';
branch.addEventListener('mouseenter', () => { branch.style.borderColor = tierColors[t.tier] || 'var(--border)'; });
branch.addEventListener('mouseleave', () => { branch.style.borderColor = 'var(--border)'; });
// Click to filter candidates by this tier
branch.addEventListener('click', () => {
document.getElementById('filter-tier').value = t.tier;
currentPage = 1;
switchTab('candidates');
});
const badge = document.createElement('span');
badge.className = 'badge badge-' + (t.tier || 'd').toLowerCase();
badge.textContent = 'Tier ' + t.tier;
badge.style.minWidth = '50px';
const label = document.createElement('span');
label.style.cssText = 'color:var(--text-secondary);font-size:13px;flex:1';
label.textContent = tierLabels[t.tier] || '';
const count = document.createElement('span');
count.style.cssText = 'font-weight:700;font-size:16px;min-width:60px;text-align:right';
count.textContent = t.count;
const barOuter = document.createElement('div');
barOuter.style.cssText = 'flex:2;height:8px;background:var(--surface-el);border-radius:4px;overflow:hidden';
const barInner = document.createElement('div');
barInner.style.cssText = 'height:100%;border-radius:4px;background:' + (tierColors[t.tier] || 'var(--text-muted)');
barInner.style.width = Math.round((t.count / tierTotal) * 100) + '%';
barOuter.appendChild(barInner);
branch.append(badge, label, count, barOuter);
treeSection.appendChild(branch);
}
container.appendChild(treeSection);
// State breakdown
if (data.byState && data.byState.length > 0) {
const stateTitle = document.createElement('h3');
stateTitle.textContent = 'By State';
stateTitle.style.cssText = 'color:var(--secondary);margin-bottom:8px;grid-column:1/-1';
container.appendChild(stateTitle);
const maxCount = Math.max(...data.byState.map(s => s.count));
for (const s of data.byState.slice(0, 30)) {
const card = document.createElement('div');
card.className = 'heatmap-card';
const state = document.createElement('div');
state.className = 'district';
state.textContent = s.state;
const cnt = document.createElement('div');
cnt.className = 'count';
cnt.textContent = s.count;
const avgScore = document.createElement('div');
avgScore.style.cssText = 'font-size:11px;color:var(--text-muted)';
avgScore.textContent = 'Avg score: ' + Math.round(s.avg_score || 0);
const bar = document.createElement('div');
bar.className = 'bar';
bar.style.background = 'linear-gradient(90deg, var(--lane-a) 0%, var(--lane-b) 100%)';
bar.style.width = Math.round((s.count / maxCount) * 100) + '%';
card.append(state, cnt, avgScore, bar);
container.appendChild(card);
}
}
// District breakdown
if (data.byDistrict && data.byDistrict.length > 0) {
const distTitle = document.createElement('h3');
distTitle.textContent = 'By Congressional District';
distTitle.style.cssText = 'color:var(--secondary);margin-bottom:8px;grid-column:1/-1;margin-top:16px';
container.appendChild(distTitle);
for (const d of data.byDistrict.slice(0, 20)) {
const card = document.createElement('div');
card.className = 'heatmap-card';
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
document.getElementById('filter-search').value = d.district_code;
document.getElementById('filter-tier').value = '';
currentPage = 1;
switchTab('candidates');
});
const dist = document.createElement('div');
dist.className = 'district';
dist.textContent = d.district_code;
const rep = document.createElement('div');
rep.className = 'rep-name';
rep.textContent = (d.representative_name || '—') + ' (' + (d.representative_party || '?') + ')';
const cnt = document.createElement('div');
cnt.className = 'count';
cnt.textContent = d.count;
const tierBdg = document.createElement('span');
tierBdg.className = 'badge badge-' + (d.tier || 'd').toLowerCase();
tierBdg.textContent = 'Tier ' + (d.tier || '?');
card.append(dist, rep, cnt, tierBdg);
container.appendChild(card);
}
}
}
// ─── Content Ideas ───
async function loadIdeas() {
const ideas = await api('/api/ideas');
const grid = document.getElementById('ideas-grid');
grid.replaceChildren();
const laneColors = { action: 'var(--lane-a)', policy: 'var(--lane-b)', resources: 'var(--lane-c)' };
for (const idea of ideas) {
const card = document.createElement('div');
card.className = 'idea-card';
const title = document.createElement('div');
title.className = 'idea-title';
title.textContent = idea.title;
const desc = document.createElement('div');
desc.className = 'idea-desc';
desc.textContent = idea.description;
const meta = document.createElement('div');
meta.className = 'idea-meta';
const laneBdg = document.createElement('span');
laneBdg.className = 'badge';
laneBdg.textContent = idea.lane;
laneBdg.style.background = laneColors[idea.lane] || 'var(--text-muted)';
laneBdg.style.color = 'white';
const platBdg = document.createElement('span');
platBdg.className = 'badge badge-draft';
platBdg.textContent = idea.platform;
meta.append(laneBdg, platBdg);
card.append(title, desc, meta);
grid.appendChild(card);
}
}
// ─── Settings ───
async function loadSettings() {
const settings = await api('/api/settings');
const form = document.getElementById('settings-form');
form.replaceChildren();
const title = document.createElement('h3');
title.textContent = 'Instagram API Settings';
title.style.cssText = 'color:var(--secondary);margin-bottom:16px';
form.appendChild(title);
const fields = [
{ key: 'ig_app_id', label: 'Meta App ID', type: 'text' },
{ key: 'ig_app_secret', label: 'App Secret', type: 'password' },
{ key: 'ig_access_token', label: 'Access Token (auto-filled on connect)', type: 'text' },
{ key: 'ig_business_account_id', label: 'IG Business Account ID', type: 'text' },
{ key: 'app_url', label: 'App URL (for OAuth redirect)', type: 'text' },
{ key: 'post_time_morning', label: 'Morning Post Time', type: 'text' },
{ key: 'post_time_evening', label: 'Evening Post Time', type: 'text' },
{ key: 'auto_schedule', label: 'Auto-Post Scheduled Stories', type: 'text' }
];
const inputs = {};
for (const f of fields) {
const row = document.createElement('div');
row.className = 'setting-row';
const label = document.createElement('label');
label.textContent = f.label;
const input = document.createElement('input');
input.type = f.type;
input.value = settings[f.key] || '';
input.placeholder = f.label;
inputs[f.key] = input;
row.append(label, input);
form.appendChild(row);
}
const saveBtn = document.createElement('button');
saveBtn.className = 'btn';
saveBtn.textContent = 'Save Settings';
saveBtn.style.marginTop = '12px';
saveBtn.addEventListener('click', async () => {
const body = {};
for (const [key, input] of Object.entries(inputs)) {
body[key] = input.value;
}
await api('/api/settings', { method: 'PUT', body: JSON.stringify(body) });
alert('Settings saved');
});
form.appendChild(saveBtn);
}
// ─── Instagram Connection ───
function connectInstagram() {
window.location.href = '/auth/instagram';
}
async function checkIGStatus() {
try {
const status = await api('/api/instagram/status');
const el = document.getElementById('ig-status');
if (status.connected) {
el.textContent = 'Instagram: Connected';
el.className = 'badge badge-posted';
} else {
el.textContent = 'Instagram: Not Connected';
el.className = 'badge badge-draft';
}
} catch (e) { /* ignore */ }
}
// ─── Refresh data from Google Sheet ───
async function refreshData() {
if (!confirm('Re-download data from Google Sheet and re-import all candidates?')) return;
const btn = event.target;
btn.textContent = 'Refreshing...';
btn.disabled = true;
try {
const result = await api('/api/import/refresh', { method: 'POST', body: '{}' });
alert('Imported ' + (result.imported || 0) + ' candidates');
loadStats();
loadCandidates();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.textContent = 'Refresh from Sheet';
btn.disabled = false;
}
}
// ─── Global Search ───
let gsTimer = null;
let gsResults = [];
let gsSelected = 0;
function openGlobalSearch() {
const panel = document.getElementById('global-search-results');
if (panel) panel.hidden = false;
}
function closeGlobalSearch() {
const panel = document.getElementById('global-search-results');
if (panel) panel.hidden = true;
}
function onGlobalSearchInput(e) {
const q = (e.target.value || '').trim();
clearTimeout(gsTimer);
if (q.length < 2) {
gsResults = [];
renderGlobalSearchResults('');
return;
}
gsTimer = setTimeout(() => runGlobalSearch(q), 220);
}
async function runGlobalSearch(q) {
const panel = document.getElementById('global-search-results');
if (!panel) return;
panel.innerHTML = '<div class="gs-empty">Searching…</div>';
panel.hidden = false;
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=25', { credentials: 'include' });
if (!res.ok) {
panel.innerHTML = '<div class="gs-empty">Search failed (HTTP ' + res.status + ').</div>';
return;
}
const data = await res.json();
gsResults = data.results || [];
gsSelected = 0;
renderGlobalSearchResults(q, data);
} catch (err) {
panel.innerHTML = '<div class="gs-empty">Search error: ' + (err.message || err) + '</div>';
}
}
function renderGlobalSearchResults(q, data) {
const panel = document.getElementById('global-search-results');
if (!panel) return;
if (!q || gsResults.length === 0) {
if (q && q.length >= 2) {
panel.innerHTML = '<div class="gs-empty">No results for “' + escapeHtml(q) + '”.</div>'
+ '<div class="gs-footer"><span><span class="gs-kbd">esc</span> close</span></div>';
panel.hidden = false;
} else {
panel.innerHTML = '';
panel.hidden = true;
}
return;
}
// Group by type for sections
const groups = { candidate: [], story: [], setting: [] };
for (const r of gsResults) {
if (groups[r.type]) groups[r.type].push(r);
}
const LABELS = { candidate: 'Candidates', story: 'Stories', setting: 'Settings' };
const ICONS = { candidate: 'C', story: 'S', setting: '⚙' };
let html = '';
let flatIdx = 0;
for (const type of ['candidate', 'story', 'setting']) {
const arr = groups[type];
if (!arr.length) continue;
const cnt = (data && data.counts && data.counts[type + 's']) || arr.length;
html += '<div class="gs-section"><div class="gs-section-label">' + LABELS[type] + ' (' + cnt + ')</div>';
for (const r of arr) {
const selected = flatIdx === gsSelected ? ' gs-selected' : '';
html += '<div class="gs-item' + selected + '" data-idx="' + flatIdx + '" data-type="' + type + '" data-id="' + escapeAttr(r.id) + '">'
+ '<div class="gs-item-icon gs-icon-' + type + '">' + ICONS[type] + '</div>'
+ '<div class="gs-item-body">'
+ '<div class="gs-item-title">' + escapeHtml(r.title) + '</div>'
+ (r.subtitle ? '<div class="gs-item-subtitle">' + escapeHtml(r.subtitle) + '</div>' : '')
+ (r.meta ? '<div class="gs-item-meta">' + escapeHtml(r.meta) + '</div>' : '')
+ '</div></div>';
flatIdx++;
}
html += '</div>';
}
html += '<div class="gs-footer">'
+ '<span><span class="gs-kbd">↑</span> <span class="gs-kbd">↓</span> navigate <span class="gs-kbd">↵</span> open</span>'
+ '<span><span class="gs-kbd">esc</span> close</span>'
+ '</div>';
panel.innerHTML = html;
panel.hidden = false;
// Wire clicks
panel.querySelectorAll('.gs-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
pickGlobalSearchResult(idx);
});
el.addEventListener('mouseenter', () => {
gsSelected = parseInt(el.dataset.idx, 10);
highlightGlobalSearchSelection();
});
});
}
function highlightGlobalSearchSelection() {
const panel = document.getElementById('global-search-results');
if (!panel) return;
panel.querySelectorAll('.gs-item').forEach(el => {
el.classList.toggle('gs-selected', parseInt(el.dataset.idx, 10) === gsSelected);
});
// Ensure selection visible
const sel = panel.querySelector('.gs-selected');
if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
}
function onGlobalSearchKeyDown(e) {
if (e.key === 'Escape') {
e.target.value = '';
gsResults = [];
closeGlobalSearch();
e.target.blur();
return;
}
if (!gsResults.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
gsSelected = Math.min(gsSelected + 1, gsResults.length - 1);
highlightGlobalSearchSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
gsSelected = Math.max(gsSelected - 1, 0);
highlightGlobalSearchSelection();
} else if (e.key === 'Enter') {
e.preventDefault();
pickGlobalSearchResult(gsSelected);
}
}
function pickGlobalSearchResult(idx) {
const r = gsResults[idx];
if (!r) return;
closeGlobalSearch();
const input = document.getElementById('global-search-input');
if (input) input.value = '';
if (r.type === 'candidate') {
switchTab('candidates');
// Best-effort: openCandidate exists in this file
if (typeof openCandidate === 'function') {
setTimeout(() => openCandidate(r.id), 50);
}
} else if (r.type === 'story') {
switchTab('stories');
// Try to scroll the matching story card into view
setTimeout(() => {
const card = document.querySelector('[data-story-id="' + r.id + '"]');
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.style.outline = '2px solid var(--primary)';
setTimeout(() => { card.style.outline = ''; }, 1500);
}
}, 250);
} else if (r.type === 'setting') {
switchTab('settings');
}
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function escapeAttr(s) { return escapeHtml(s); }
// Close on outside click + ⌘K / Ctrl+K to focus the search input.
document.addEventListener('click', (e) => {
const wrap = document.querySelector('.global-search-wrap');
if (wrap && !wrap.contains(e.target)) closeGlobalSearch();
});
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
const input = document.getElementById('global-search-input');
if (input) { input.focus(); input.select(); }
}
});
// ─── Init ───
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadCandidates();
checkIGStatus();
});