603 lines
29 KiB
HTML
603 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Social Listening Pipeline</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif; background: #0a0a0a; color: #e0e0e0; min-height: 100vh; }
|
|
.container { max-width: 860px; margin: 0 auto; padding: 40px 24px; }
|
|
h1 { font-size: 28px; font-weight: 800; margin-bottom: 8px; letter-spacing: -0.5px; }
|
|
.subtitle { color: #888; margin-bottom: 24px; font-size: 14px; }
|
|
.tabs { display: flex; gap: 0; margin-bottom: 32px; border-bottom: 1px solid #2a2a2a; }
|
|
.tab { padding: 10px 20px; font-size: 13px; font-weight: 600; color: #666; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
|
.tab:hover { color: #e0e0e0; }
|
|
.tab.active { color: #f5a623; border-bottom-color: #f5a623; }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.form-section { background: #141414; border: 1px solid #2a2a2a; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
|
.form-section h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #f5a623; margin-bottom: 16px; }
|
|
.field { margin-bottom: 16px; }
|
|
.field label { display: block; font-size: 12px; font-weight: 600; color: #aaa; margin-bottom: 6px; }
|
|
.field input, .field select, .field textarea { width: 100%; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 10px 14px; color: #e0e0e0; font-size: 13px; font-family: 'Montserrat', sans-serif; }
|
|
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: #f5a623; }
|
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.checkbox-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
|
.checkbox-row label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
|
|
.checkbox-row input[type="checkbox"] { width: auto; accent-color: #f5a623; }
|
|
.json-upload-row { display: flex; align-items: center; }
|
|
.upload-btn { display: inline-block; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 8px; padding: 8px 16px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: 'Montserrat', sans-serif; transition: all 0.2s; }
|
|
.upload-btn:hover { background: #333; border-color: #f5a623; }
|
|
button.run { width: 100%; background: #f5a623; color: #000; border: none; border-radius: 8px; padding: 14px; font-size: 15px; font-weight: 700; cursor: pointer; letter-spacing: 0.5px; font-family: 'Montserrat', sans-serif; }
|
|
button.run:hover { background: #e69920; }
|
|
button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
|
|
.cost-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 20px 0; }
|
|
.cost-card { background: #141414; border: 1px solid #2a2a2a; border-radius: 10px; padding: 16px; text-align: center; }
|
|
.cost-value { font-size: 22px; font-weight: 800; color: #f5a623; font-variant-numeric: tabular-nums; }
|
|
.cost-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #666; margin-top: 4px; }
|
|
.progress-section { margin-top: 24px; }
|
|
.stage-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #141414; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; }
|
|
.stage-dot { width: 10px; height: 10px; border-radius: 50%; background: #333; flex-shrink: 0; }
|
|
.stage-dot.running { background: #f5a623; animation: pulse 1s infinite; }
|
|
.stage-dot.done { background: #4caf50; }
|
|
.stage-dot.error { background: #f44336; }
|
|
.stage-name { flex: 1; font-size: 13px; font-weight: 500; }
|
|
.stage-detail { font-size: 11px; color: #888; }
|
|
.stage-cost { font-size: 11px; color: #f5a623; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 60px; text-align: right; }
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.log-box { background: #0a0a0a; border: 1px solid #2a2a2a; border-radius: 8px; padding: 16px; margin-top: 16px; max-height: 250px; overflow-y: auto; font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 11px; color: #888; line-height: 1.8; }
|
|
.history-table { width: 100%; border-collapse: collapse; }
|
|
.history-table th { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #666; text-align: left; padding: 10px 12px; border-bottom: 1px solid #2a2a2a; }
|
|
.history-table td { font-size: 13px; padding: 12px; border-bottom: 1px solid #1a1a1a; }
|
|
.history-table tr:hover td { background: #141414; }
|
|
.history-table .cost { color: #f5a623; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
.status-badge { display: inline-block; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.status-badge.completed { background: #1b3a1b; color: #4caf50; }
|
|
.status-badge.running { background: #3a2e1b; color: #f5a623; }
|
|
.status-badge.failed { background: #3a1b1b; color: #f44336; }
|
|
.expand-btn { background: none; border: 1px solid #333; color: #888; border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; font-family: 'Montserrat', sans-serif; }
|
|
.expand-btn:hover { border-color: #f5a623; color: #f5a623; }
|
|
.cost-detail-row td { padding: 0; }
|
|
.cost-detail { background: #0a0a0a; border: 1px solid #1a1a1a; border-radius: 8px; margin: 8px 12px 12px; padding: 16px; }
|
|
.cost-detail table { width: 100%; }
|
|
.cost-detail th { font-size: 9px; color: #555; padding: 6px 8px; }
|
|
.cost-detail td { font-size: 12px; padding: 6px 8px; border-bottom: 1px solid #141414; }
|
|
.empty-state { text-align: center; padding: 60px 20px; color: #555; font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div style="display:flex;justify-content:space-between;align-items:start">
|
|
<div>
|
|
<h1>Social Listening Pipeline</h1>
|
|
<p class="subtitle">Automated social media research → client-ready reports</p>
|
|
</div>
|
|
<a href="javascript:void(0)" id="logoutBtn" style="font-size:12px;color:#666;text-decoration:none;padding:8px 14px;border:1px solid #333;border-radius:6px;font-family:Montserrat,sans-serif;font-weight:600" onmouseover="this.style.borderColor='#f5a623';this.style.color='#f5a623'" onmouseout="this.style.borderColor='#333';this.style.color='#666'">Sign Out</a>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="switchTab('pipeline')">Pipeline</div>
|
|
<div class="tab" onclick="switchTab('briefs')">Saved Briefs</div>
|
|
<div class="tab" onclick="switchTab('history')">Run History</div>
|
|
</div>
|
|
|
|
<!-- PIPELINE TAB -->
|
|
<div id="tab-pipeline" class="tab-content active">
|
|
|
|
<div class="form-section">
|
|
<h2>Quick Load</h2>
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
<label class="upload-btn" for="jsonFile">Load from File</label>
|
|
<input type="file" id="jsonFile" accept=".json" style="display:none" onchange="loadJSON(this)">
|
|
<button class="upload-btn" onclick="saveBriefToServer()">Save Current Brief</button>
|
|
<span id="jsonFileName" style="font-size:12px;color:#888;margin-left:4px"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-section">
|
|
<h2>Client Brief</h2>
|
|
<div class="field-row">
|
|
<div class="field"><label>Client Name</label><input id="clientName" placeholder="H&M"></div>
|
|
<div class="field"><label>Category</label><input id="category" placeholder="fast fashion"></div>
|
|
</div>
|
|
<div class="field"><label>Hashtags (comma-separated)</label><input id="hashtags" placeholder="#hm, #handm, #hmfashion"></div>
|
|
<div class="field"><label>Keywords (comma-separated)</label><input id="keywords" placeholder="hm haul, hm try on"></div>
|
|
<h2 style="margin-top:24px">Platforms</h2>
|
|
<div class="checkbox-row">
|
|
<label><input type="checkbox" id="p-tiktok" checked> TikTok</label>
|
|
<label><input type="checkbox" id="p-instagram"> Instagram</label>
|
|
<label><input type="checkbox" id="p-youtube"> YouTube</label>
|
|
</div>
|
|
<h2>Influencers</h2>
|
|
<div class="field"><label>TikTok handles</label><input id="inf-tiktok" placeholder="@hm, @hmusa"></div>
|
|
<div class="field"><label>Instagram handles</label><input id="inf-instagram" placeholder="hm, hmusa"></div>
|
|
<div class="field"><label>YouTube handles</label><input id="inf-youtube" placeholder="@hm"></div>
|
|
</div>
|
|
|
|
<button class="run" id="runBtn" onclick="startPipeline()">Run Pipeline</button>
|
|
|
|
<!-- Live cost tracker -->
|
|
<div id="costSection" style="display:none">
|
|
<div class="cost-bar" style="grid-template-columns: repeat(5, 1fr);">
|
|
<div class="cost-card"><div class="cost-value" id="costTotal">$0.00</div><div class="cost-label">Total Cost</div></div>
|
|
<div class="cost-card"><div class="cost-value" id="costClaude">$0.00</div><div class="cost-label">Claude API</div></div>
|
|
<div class="cost-card">
|
|
<div class="cost-value" id="costApify">$0.00</div>
|
|
<div class="cost-label">Apify</div>
|
|
<div id="apifyBudgetBar" style="margin-top:6px;display:none">
|
|
<div style="background:#2a2a2a;border-radius:4px;height:4px;overflow:hidden">
|
|
<div id="apifyBudgetFill" style="height:100%;background:#f5a623;width:0%;transition:width 0.3s"></div>
|
|
</div>
|
|
<div id="apifyBudgetText" style="font-size:9px;color:#666;margin-top:2px">$0 / $5</div>
|
|
</div>
|
|
</div>
|
|
<div class="cost-card"><div class="cost-value" id="costTokens">0</div><div class="cost-label">Tokens</div></div>
|
|
<div class="cost-card"><div class="cost-value" id="costBudget" style="font-size:16px">—</div><div class="cost-label">Apify Budget</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-section" id="progressSection" style="display:none">
|
|
<div id="stages"></div>
|
|
<div class="log-box" id="logBox"></div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- SAVED BRIEFS TAB -->
|
|
<div id="tab-briefs" class="tab-content">
|
|
<div id="briefsContent"><div class="empty-state">Loading...</div></div>
|
|
</div>
|
|
|
|
<!-- HISTORY TAB -->
|
|
<div id="tab-history" class="tab-content">
|
|
<div id="historyContent"><div class="empty-state">Loading...</div></div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script src="config.js"></script>
|
|
<script>
|
|
// ─── API base URL (set by deploy, empty = same origin) ───
|
|
const API = window.__API_BASE || '';
|
|
const SSE_BASE = window.__SSE_BASE || '';
|
|
|
|
const STAGES = [
|
|
'Brief Validation', 'Strategy Review', 'Discovery Scrape', 'Data Review',
|
|
'Enrichment Scrape', 'Pre-Report Review', 'Desk Research', 'Report Generation'
|
|
];
|
|
|
|
let eventSource;
|
|
let loadedBrief = null;
|
|
let totalClaude = 0, totalApify = 0, totalTokens = 0;
|
|
let apifyBudgetLimit = 5;
|
|
const stageCosts = {};
|
|
|
|
// ─── Auth check on load ───
|
|
(async function checkAuth() {
|
|
try {
|
|
const res = await fetch(API + '/api/auth', { credentials: 'include' });
|
|
if (!res.ok) { window.location.href = './login.html'; }
|
|
} catch { window.location.href = './login.html'; }
|
|
})();
|
|
|
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
|
await fetch(API + '/api/logout', { credentials: 'include' });
|
|
window.location.href = './login.html';
|
|
});
|
|
|
|
// ─── Tabs ───
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
document.querySelector(`.tab-content#tab-${name}`).classList.add('active');
|
|
event.target.classList.add('active');
|
|
if (name === 'history') loadHistory();
|
|
if (name === 'briefs') loadSavedBriefs();
|
|
}
|
|
|
|
// ─── JSON upload ───
|
|
function loadJSON(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const brief = JSON.parse(e.target.result);
|
|
populateForm(brief);
|
|
document.getElementById('jsonFileName').textContent = file.name + ' (loaded)';
|
|
} catch (err) { alert('Invalid JSON: ' + err.message); }
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// ─── Build brief from form ───
|
|
function buildBriefFromForm() {
|
|
const splitVal = (id) => document.getElementById(id).value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const platforms = [];
|
|
if (document.getElementById('p-tiktok').checked) platforms.push('tiktok');
|
|
if (document.getElementById('p-instagram').checked) platforms.push('instagram');
|
|
if (document.getElementById('p-youtube').checked) platforms.push('youtube');
|
|
return {
|
|
clientName: document.getElementById('clientName').value,
|
|
category: document.getElementById('category').value,
|
|
hashtags: splitVal('hashtags'),
|
|
keywords: splitVal('keywords'),
|
|
platforms,
|
|
influencers: {
|
|
tiktok: splitVal('inf-tiktok'),
|
|
instagram: splitVal('inf-instagram'),
|
|
youtube: splitVal('inf-youtube'),
|
|
},
|
|
dateRange: (loadedBrief && loadedBrief.dateRange) ? loadedBrief.dateRange : undefined,
|
|
};
|
|
}
|
|
|
|
function populateForm(brief) {
|
|
loadedBrief = brief;
|
|
if (brief.clientName) document.getElementById('clientName').value = brief.clientName;
|
|
if (brief.category) document.getElementById('category').value = brief.category;
|
|
if (brief.hashtags) document.getElementById('hashtags').value = brief.hashtags.join(', ');
|
|
if (brief.keywords) document.getElementById('keywords').value = brief.keywords.join(', ');
|
|
document.getElementById('p-tiktok').checked = (brief.platforms || []).includes('tiktok');
|
|
document.getElementById('p-instagram').checked = (brief.platforms || []).includes('instagram');
|
|
document.getElementById('p-youtube').checked = (brief.platforms || []).includes('youtube');
|
|
if (brief.influencers) {
|
|
if (brief.influencers.tiktok) document.getElementById('inf-tiktok').value = brief.influencers.tiktok.join(', ');
|
|
if (brief.influencers.instagram) document.getElementById('inf-instagram').value = brief.influencers.instagram.join(', ');
|
|
if (brief.influencers.youtube) document.getElementById('inf-youtube').value = brief.influencers.youtube.join(', ');
|
|
}
|
|
}
|
|
|
|
// ─── Save/load briefs to server ───
|
|
async function saveBriefToServer() {
|
|
const brief = buildBriefFromForm();
|
|
if (!brief.clientName) { alert('Enter a client name first'); return; }
|
|
try {
|
|
const res = await fetch(API + '/api/briefs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(brief),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
document.getElementById('jsonFileName').textContent = 'Saved to server!';
|
|
setTimeout(() => { document.getElementById('jsonFileName').textContent = ''; }, 2000);
|
|
} else { alert('Save failed: ' + (data.error || 'unknown')); }
|
|
} catch (err) { alert('Save failed: ' + err.message); }
|
|
}
|
|
|
|
async function loadSavedBriefs() {
|
|
const el = document.getElementById('briefsContent');
|
|
try {
|
|
const res = await fetch(API + '/api/briefs', { credentials: 'include' });
|
|
const briefs = await res.json();
|
|
if (!briefs.length) {
|
|
el.innerHTML = '<div class="empty-state">No saved briefs yet. Fill in a brief on the Pipeline tab and click "Save Current Brief".</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = `<div style="display:grid;gap:12px">${briefs.map(b => {
|
|
const d = b.data;
|
|
const platforms = (d.platforms || []).join(', ');
|
|
const hashtags = (d.hashtags || []).slice(0, 5).join(', ');
|
|
const infCount = Object.values(d.influencers || {}).flat().length;
|
|
return `<div class="form-section" style="margin-bottom:0">
|
|
<div style="display:flex;justify-content:space-between;align-items:start">
|
|
<div>
|
|
<div style="font-size:16px;font-weight:700;color:#e0e0e0;margin-bottom:4px">${esc(d.clientName || b.name)}</div>
|
|
<div style="font-size:12px;color:#888;margin-bottom:8px">${esc(d.category || '')}</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px">
|
|
<button class="upload-btn" onclick='loadBriefAndSwitch(${JSON.stringify(JSON.stringify(d))})'>Load</button>
|
|
<button class="expand-btn" onclick='exportBrief(${JSON.stringify(JSON.stringify(d))}, "${esc(b.name)}")'>Export</button>
|
|
<button class="expand-btn" onclick="deleteServerBrief('${esc(b.name)}')" style="color:#f44336;border-color:#552222">Delete</button>
|
|
</div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;font-size:12px;color:#888">
|
|
<div><span style="color:#666;font-weight:600;text-transform:uppercase;font-size:10px;letter-spacing:0.5px">Platforms</span><br>${esc(platforms) || '—'}</div>
|
|
<div><span style="color:#666;font-weight:600;text-transform:uppercase;font-size:10px;letter-spacing:0.5px">Hashtags</span><br>${esc(hashtags) || '—'}</div>
|
|
<div><span style="color:#666;font-weight:600;text-transform:uppercase;font-size:10px;letter-spacing:0.5px">Influencers</span><br>${infCount} handle${infCount !== 1 ? 's' : ''}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('')}</div>`;
|
|
} catch (err) {
|
|
el.innerHTML = `<div class="empty-state">Failed to load briefs: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function loadBriefAndSwitch(jsonStr) {
|
|
const brief = JSON.parse(jsonStr);
|
|
populateForm(brief);
|
|
document.getElementById('jsonFileName').textContent = brief.clientName + ' (loaded)';
|
|
// Switch to pipeline tab
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
document.getElementById('tab-pipeline').classList.add('active');
|
|
document.querySelector('.tab').classList.add('active'); // first tab = Pipeline
|
|
}
|
|
|
|
function exportBrief(jsonStr, name) {
|
|
const blob = new Blob([JSON.stringify(JSON.parse(jsonStr), null, 2)], { type: 'application/json' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `${name}-brief.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
async function deleteServerBrief(name) {
|
|
if (!confirm(`Delete saved brief "${name}"?`)) return;
|
|
try {
|
|
await fetch(API + `/api/briefs/${encodeURIComponent(name)}`, { method: 'DELETE', credentials: 'include' });
|
|
loadSavedBriefs();
|
|
} catch {}
|
|
}
|
|
|
|
// ─── Cost display ───
|
|
function updateCosts() {
|
|
const total = totalClaude + totalApify;
|
|
document.getElementById('costTotal').textContent = '$' + total.toFixed(2);
|
|
document.getElementById('costClaude').textContent = '$' + totalClaude.toFixed(2);
|
|
document.getElementById('costApify').textContent = '$' + totalApify.toFixed(2);
|
|
document.getElementById('costTokens').textContent = totalTokens.toLocaleString();
|
|
const pct = Math.min(100, (totalApify / apifyBudgetLimit) * 100);
|
|
const budgetBar = document.getElementById('apifyBudgetBar');
|
|
if (budgetBar) budgetBar.style.display = 'block';
|
|
const fill = document.getElementById('apifyBudgetFill');
|
|
if (fill) {
|
|
fill.style.width = pct + '%';
|
|
fill.style.background = pct >= 100 ? '#f44336' : pct >= 80 ? '#ff9800' : '#f5a623';
|
|
}
|
|
const budgetText = document.getElementById('apifyBudgetText');
|
|
if (budgetText) budgetText.textContent = '$' + totalApify.toFixed(2) + ' / $' + apifyBudgetLimit.toFixed(2);
|
|
const budgetCard = document.getElementById('costBudget');
|
|
if (budgetCard) {
|
|
const remaining = Math.max(0, apifyBudgetLimit - totalApify);
|
|
budgetCard.textContent = '$' + remaining.toFixed(2);
|
|
budgetCard.style.color = pct >= 100 ? '#f44336' : pct >= 80 ? '#ff9800' : '#4caf50';
|
|
}
|
|
for (const [stage, cost] of Object.entries(stageCosts)) {
|
|
const el = document.getElementById(`stagecost-${stage}`);
|
|
if (el) el.textContent = '$' + cost.toFixed(2);
|
|
}
|
|
}
|
|
|
|
// ─── Pipeline ───
|
|
function log(msg) {
|
|
const box = document.getElementById('logBox');
|
|
box.textContent += msg + '\n';
|
|
box.scrollTop = box.scrollHeight;
|
|
}
|
|
|
|
function renderStages() {
|
|
document.getElementById('stages').innerHTML = STAGES.map((name, i) =>
|
|
`<div class="stage-row" id="stage-${i+1}">
|
|
<div class="stage-dot" id="dot-${i+1}"></div>
|
|
<div class="stage-name">Stage ${i+1}: ${name}</div>
|
|
<div class="stage-cost" id="stagecost-${i+1}"></div>
|
|
<div class="stage-detail" id="detail-${i+1}"></div>
|
|
</div>`
|
|
).join('');
|
|
}
|
|
|
|
function startPipeline() {
|
|
const btn = document.getElementById('runBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Running...';
|
|
document.getElementById('progressSection').style.display = 'block';
|
|
document.getElementById('costSection').style.display = 'block';
|
|
totalClaude = 0; totalApify = 0; totalTokens = 0;
|
|
Object.keys(stageCosts).forEach(k => delete stageCosts[k]);
|
|
updateCosts();
|
|
renderStages();
|
|
|
|
const platforms = [];
|
|
if (document.getElementById('p-tiktok').checked) platforms.push('tiktok');
|
|
if (document.getElementById('p-instagram').checked) platforms.push('instagram');
|
|
if (document.getElementById('p-youtube').checked) platforms.push('youtube');
|
|
|
|
const splitVal = (id) => document.getElementById(id).value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const now = new Date();
|
|
const ago = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const brief = {
|
|
clientName: document.getElementById('clientName').value,
|
|
category: document.getElementById('category').value,
|
|
hashtags: splitVal('hashtags'),
|
|
keywords: splitVal('keywords'),
|
|
platforms,
|
|
influencers: {
|
|
tiktok: splitVal('inf-tiktok'),
|
|
instagram: splitVal('inf-instagram'),
|
|
youtube: splitVal('inf-youtube'),
|
|
},
|
|
dateRange: (loadedBrief && loadedBrief.dateRange)
|
|
? loadedBrief.dateRange
|
|
: { from: ago.toISOString(), to: now.toISOString() },
|
|
};
|
|
|
|
const sseUrl = (SSE_BASE || API) + '/events';
|
|
eventSource = new EventSource(sseUrl, { withCredentials: true });
|
|
log('Connecting to server...');
|
|
|
|
let pipelineStarted = false;
|
|
eventSource.addEventListener('connected', (e) => {
|
|
try { const d = JSON.parse(e.data); if (d.apifyBudgetLimit) apifyBudgetLimit = d.apifyBudgetLimit; updateCosts(); } catch {}
|
|
if (pipelineStarted) { log('SSE reconnected.'); return; }
|
|
pipelineStarted = true;
|
|
log('Connected. Starting pipeline...');
|
|
fetch(API + '/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(brief),
|
|
}).catch(err => log('Failed to start: ' + err.message));
|
|
});
|
|
|
|
eventSource.addEventListener('progress', (e) => {
|
|
const d = JSON.parse(e.data);
|
|
const dot = document.getElementById(`dot-${d.stage}`);
|
|
const detail = document.getElementById(`detail-${d.stage}`);
|
|
if (d.status === 'start') { dot.className = 'stage-dot running'; }
|
|
if (d.status === 'done') { dot.className = 'stage-dot done'; if (detail) detail.textContent = d.detail || ''; }
|
|
if (d.status === 'error') { dot.className = 'stage-dot error'; if (detail) detail.textContent = d.detail || ''; }
|
|
log(`[Stage ${d.stage}] ${d.name} — ${d.status}${d.detail ? ': ' + d.detail : ''}`);
|
|
});
|
|
|
|
eventSource.addEventListener('cost', (e) => {
|
|
const d = JSON.parse(e.data);
|
|
if (d.source === 'claude') {
|
|
totalClaude += d.costUsd;
|
|
totalTokens += (d.inputTokens || 0) + (d.outputTokens || 0);
|
|
} else {
|
|
totalApify += d.costUsd;
|
|
}
|
|
stageCosts[d.stage] = (stageCosts[d.stage] || 0) + d.costUsd;
|
|
updateCosts();
|
|
log(` [$] ${d.source}: $${d.costUsd.toFixed(2)} — ${d.label}`);
|
|
});
|
|
|
|
eventSource.addEventListener('complete', (e) => {
|
|
const d = JSON.parse(e.data);
|
|
log(`\nPipeline complete! ${d.trends} trends, ${d.insights} insights, ${d.opportunities} opportunities`);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Run Pipeline';
|
|
eventSource.close();
|
|
if (d.reportUrl) {
|
|
const reportDiv = document.createElement('div');
|
|
reportDiv.style.cssText = 'text-align:center;margin-top:20px';
|
|
reportDiv.innerHTML = `<a href="${API}${d.reportUrl}" target="_blank" style="display:inline-block;background:#f5a623;color:#000;padding:14px 32px;border-radius:8px;font-size:15px;font-weight:700;text-decoration:none;font-family:Montserrat,sans-serif;letter-spacing:0.5px">View Report</a>`;
|
|
document.getElementById('progressSection').appendChild(reportDiv);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('error', (e) => {
|
|
if (e.data) {
|
|
const d = JSON.parse(e.data);
|
|
log(`ERROR: ${d.message}`);
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'Run Pipeline';
|
|
});
|
|
}
|
|
|
|
// ─── History ───
|
|
async function loadHistory() {
|
|
const el = document.getElementById('historyContent');
|
|
try {
|
|
const res = await fetch(API + '/api/runs', { credentials: 'include' });
|
|
const runs = await res.json();
|
|
|
|
if (!runs.length) {
|
|
el.innerHTML = '<div class="empty-state">No runs yet. Start a pipeline to see history here.</div>';
|
|
return;
|
|
}
|
|
|
|
const hasFailed = runs.some(r => r.status === 'failed' || r.status === 'completed');
|
|
el.innerHTML = `
|
|
${hasFailed ? `<div style="margin-bottom:16px;display:flex;gap:8px">
|
|
<button class="expand-btn" onclick="clearRuns('failed')" style="color:#f44336;border-color:#f44336">Remove Failed</button>
|
|
<button class="expand-btn" onclick="clearRuns('completed')">Remove Completed</button>
|
|
</div>` : ''}
|
|
<table class="history-table">
|
|
<thead><tr>
|
|
<th>Client</th><th>Category</th><th>Status</th>
|
|
<th>Claude</th><th>Apify</th><th>Total</th>
|
|
<th>Tokens</th><th>Date</th><th></th>
|
|
</tr></thead>
|
|
<tbody>${runs.map(r => {
|
|
const actions = [];
|
|
if (r.report_path) {
|
|
actions.push(`<a href="${API}/report/${r.id}" target="_blank" class="expand-btn" style="text-decoration:none">View</a>`);
|
|
actions.push(`<a href="${API}/report/${r.id}/download" class="expand-btn" style="text-decoration:none">Download</a>`);
|
|
}
|
|
actions.push(`<button class="expand-btn" onclick="toggleCostDetail(${r.id}, this)">Details</button>`);
|
|
if (r.status !== 'running') {
|
|
actions.push(`<button class="expand-btn" onclick="deleteRun(${r.id})" style="color:#f44336;border-color:#552222">Del</button>`);
|
|
}
|
|
return `
|
|
<tr id="run-row-${r.id}">
|
|
<td style="font-weight:600">${esc(r.client_name)}</td>
|
|
<td style="color:#888">${esc(r.category)}</td>
|
|
<td><span class="status-badge ${r.status}">${r.status}</span></td>
|
|
<td class="cost">$${Number(r.claude_cost_usd).toFixed(2)}</td>
|
|
<td class="cost">$${Number(r.apify_cost_usd).toFixed(2)}</td>
|
|
<td class="cost" style="color:#fff">$${Number(r.total_cost_usd).toFixed(2)}</td>
|
|
<td style="color:#888;font-size:12px">${(Number(r.total_input_tokens) + Number(r.total_output_tokens)).toLocaleString()}</td>
|
|
<td style="color:#666;font-size:11px">${new Date(r.started_at).toLocaleDateString()} ${new Date(r.started_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
|
<td style="display:flex;gap:4px;flex-wrap:wrap">${actions.join('')}</td>
|
|
</tr>
|
|
<tr class="cost-detail-row" id="detail-row-${r.id}" style="display:none">
|
|
<td colspan="9"><div class="cost-detail" id="cost-detail-${r.id}">Loading...</div></td>
|
|
</tr>`;
|
|
}).join('')}</tbody>
|
|
</table>`;
|
|
} catch (err) {
|
|
el.innerHTML = `<div class="empty-state">Failed to load history: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function toggleCostDetail(runId, btn) {
|
|
const row = document.getElementById(`detail-row-${runId}`);
|
|
if (row.style.display !== 'none') {
|
|
row.style.display = 'none';
|
|
btn.textContent = 'Details';
|
|
return;
|
|
}
|
|
row.style.display = '';
|
|
btn.textContent = 'Hide';
|
|
|
|
const el = document.getElementById(`cost-detail-${runId}`);
|
|
try {
|
|
const res = await fetch(API + `/api/runs/${runId}/costs`, { credentials: 'include' });
|
|
const costs = await res.json();
|
|
|
|
if (!costs.length) {
|
|
el.innerHTML = '<div style="color:#555;font-size:12px">No cost data recorded for this run.</div>';
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = `
|
|
<table>
|
|
<thead><tr>
|
|
<th>Stage</th><th>Source</th><th>Label</th>
|
|
<th>Input Tokens</th><th>Output Tokens</th><th>Cost</th>
|
|
</tr></thead>
|
|
<tbody>${costs.map(c => `
|
|
<tr>
|
|
<td style="color:#888">S${c.stage}</td>
|
|
<td><span style="color:${c.source === 'claude' ? '#a78bfa' : '#60a5fa'};font-weight:600;font-size:11px">${c.source.toUpperCase()}</span></td>
|
|
<td style="font-size:11px">${esc(c.label)}</td>
|
|
<td style="color:#888;font-size:11px">${c.input_tokens.toLocaleString()}</td>
|
|
<td style="color:#888;font-size:11px">${c.output_tokens.toLocaleString()}</td>
|
|
<td class="cost">$${Number(c.cost_usd).toFixed(2)}</td>
|
|
</tr>
|
|
`).join('')}</tbody>
|
|
</table>`;
|
|
} catch (err) {
|
|
el.innerHTML = `<div style="color:#f44336;font-size:12px">Error: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function deleteRun(runId) {
|
|
if (!confirm('Delete this run and its cost data?')) return;
|
|
try {
|
|
await fetch(API + `/api/runs/${runId}`, { method: 'DELETE', credentials: 'include' });
|
|
loadHistory();
|
|
} catch (err) { alert('Delete failed: ' + err.message); }
|
|
}
|
|
|
|
async function clearRuns(status) {
|
|
if (!confirm(`Delete all ${status} runs?`)) return;
|
|
try {
|
|
await fetch(API + `/api/runs?status=${status}`, { method: 'DELETE', credentials: 'include' });
|
|
loadHistory();
|
|
} catch (err) { alert('Clear failed: ' + err.message); }
|
|
}
|
|
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
|
</script>
|
|
</body>
|
|
</html>
|