From 010d304c2a52ef8b3c6cf1df26c6b8e709cdf684 Mon Sep 17 00:00:00 2001 From: DJP Date: Tue, 7 Apr 2026 13:30:15 -0400 Subject: [PATCH] Add saved briefs feature: server-side storage with dedicated tab - Backend: GET/POST/DELETE /api/briefs endpoints storing JSON files in briefs/ dir - Frontend: new Saved Briefs tab with cards showing client details, Load & Run, Delete - Save Current Brief button on Pipeline tab persists form to server - Both standalone dashboard and static frontend updated Co-Authored-By: Claude Opus 4.6 --- agents/social-listening/dashboard/index.html | 132 ++++++++++++++++-- agents/social-listening/dashboard/server.ts | 49 ++++++- frontend/index.html | 135 +++++++++++++++++-- 3 files changed, 299 insertions(+), 17 deletions(-) diff --git a/agents/social-listening/dashboard/index.html b/agents/social-listening/dashboard/index.html index 627c9be..c8acb36 100644 --- a/agents/social-listening/dashboard/index.html +++ b/agents/social-listening/dashboard/index.html @@ -94,6 +94,7 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
Pipeline
+
Saved Briefs
Run History
@@ -101,15 +102,12 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
-

Load Brief from JSON

-
- +

Quick Load

+
+ - No file selected -
-
@@ -162,6 +160,11 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
+ +
+
Loading...
+
+
Loading...
@@ -188,6 +191,7 @@ function switchTab(name) { document.querySelector(`.tab-content#tab-${name}`).classList.add('active'); event.target.classList.add('active'); if (name === 'history') loadHistory(); + if (name === 'briefs') loadSavedBriefs(); } // ─── JSON upload ─── @@ -219,6 +223,118 @@ function loadJSON(input) { 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/briefs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + 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/briefs'); + const briefs = await res.json(); + if (!briefs.length) { + el.innerHTML = '
No saved briefs yet. Fill in a brief on the Pipeline tab and click "Save Current Brief".
'; + return; + } + el.innerHTML = `
${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 `
+
+
+
${esc(d.clientName || b.name)}
+
${esc(d.category || '')}
+
+
+ + +
+
+
+
Platforms
${esc(platforms) || '—'}
+
Hashtags
${esc(hashtags) || '—'}
+
Influencers
${infCount} handle${infCount !== 1 ? 's' : ''}
+
+
`; + }).join('')}
`; + } catch (err) { + el.innerHTML = `
Failed to load briefs: ${err.message}
`; + } +} + +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'); +} + +async function deleteServerBrief(name) { + if (!confirm(`Delete saved brief "${name}"?`)) return; + try { + await fetch(`/api/briefs/${encodeURIComponent(name)}`, { method: 'DELETE' }); + loadSavedBriefs(); + } catch {} +} + // ─── Cost display ─── function updateCosts() { const total = totalClaude + totalApify; diff --git a/agents/social-listening/dashboard/server.ts b/agents/social-listening/dashboard/server.ts index d65caa1..d6ec3ca 100644 --- a/agents/social-listening/dashboard/server.ts +++ b/agents/social-listening/dashboard/server.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx // ─── Dashboard Server (HTTP + SSE) ─── import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { readFileSync } from 'fs'; +import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { createHmac, randomBytes } from 'crypto'; import { runPipeline } from '../pipeline-v2.js'; @@ -11,6 +11,8 @@ import { getApifyCostLimit } from '../apify.js'; const PORT = parseInt(process.env.DASHBOARD_PORT || '3456', 10); const __dir = new URL('.', import.meta.url).pathname; +const BRIEFS_DIR = join(__dir, '..', 'briefs'); +if (!existsSync(BRIEFS_DIR)) mkdirSync(BRIEFS_DIR, { recursive: true }); // ─── Auth ─── const DASH_USER = process.env.DASH_USER || 'admin'; @@ -264,6 +266,51 @@ const server = createServer(async (req, res) => { return; } + // ─── Briefs API ─── + + if (url.pathname === '/api/briefs' && req.method === 'GET') { + try { + const files = readdirSync(BRIEFS_DIR).filter(f => f.endsWith('.json')); + const briefs = files.map(f => { + const data = JSON.parse(readFileSync(join(BRIEFS_DIR, f), 'utf-8')); + return { name: f.replace(/\.json$/, ''), data }; + }); + sendJSON(res, 200, briefs); + } catch (err) { + sendJSON(res, 500, { error: (err as Error).message }); + } + return; + } + + if (url.pathname === '/api/briefs' && req.method === 'POST') { + const body = await parseBody(req); + try { + const brief = JSON.parse(body); + const name = (brief.clientName || 'untitled').replace(/[^a-zA-Z0-9_&-]/g, '-').toLowerCase(); + writeFileSync(join(BRIEFS_DIR, `${name}.json`), JSON.stringify(brief, null, 2)); + sendJSON(res, 200, { ok: true, name }); + } catch (err) { + sendJSON(res, 400, { error: (err as Error).message }); + } + return; + } + + if (url.pathname.startsWith('/api/briefs/') && req.method === 'DELETE') { + const name = decodeURIComponent(url.pathname.split('/')[3]); + const filePath = join(BRIEFS_DIR, `${name}.json`); + try { + if (existsSync(filePath)) { + unlinkSync(filePath); + sendJSON(res, 200, { ok: true }); + } else { + sendJSON(res, 404, { error: 'Brief not found' }); + } + } catch (err) { + sendJSON(res, 500, { error: (err as Error).message }); + } + return; + } + // ─── Routes ─── if (url.pathname === '/' && req.method === 'GET') { diff --git a/frontend/index.html b/frontend/index.html index c5ca82e..10e6bd5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -79,6 +79,7 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
Pipeline
+
Saved Briefs
Run History
@@ -86,15 +87,12 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
-

Load Brief from JSON

-
- +

Quick Load

+
+ - No file selected -
-
@@ -147,6 +145,11 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
+ +
+
Loading...
+
+
Loading...
@@ -191,6 +194,7 @@ function switchTab(name) { document.querySelector(`.tab-content#tab-${name}`).classList.add('active'); event.target.classList.add('active'); if (name === 'history') loadHistory(); + if (name === 'briefs') loadSavedBriefs(); } // ─── JSON upload ─── @@ -222,6 +226,121 @@ function loadJSON(input) { 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(', '); + } + document.getElementById('jsonPreview').style.display = 'block'; + document.getElementById('jsonPreviewText').textContent = JSON.stringify(brief, null, 2); +} + +// ─── 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 = '
No saved briefs yet. Fill in a brief on the Pipeline tab and click "Save Current Brief".
'; + return; + } + el.innerHTML = `
${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 `
+
+
+
${esc(d.clientName || b.name)}
+
${esc(d.category || '')}
+
+
+ + +
+
+
+
Platforms
${esc(platforms) || '—'}
+
Hashtags
${esc(hashtags) || '—'}
+
Influencers
${infCount} handle${infCount !== 1 ? 's' : ''}
+
+
`; + }).join('')}
`; + } catch (err) { + el.innerHTML = `
Failed to load briefs: ${err.message}
`; + } +} + +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 +} + +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;