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 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-07 13:30:15 -04:00
parent 5f8d84f5c5
commit 010d304c2a
3 changed files with 299 additions and 17 deletions

View file

@ -94,6 +94,7 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
<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>
@ -101,15 +102,12 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
<div id="tab-pipeline" class="tab-content active">
<div class="form-section">
<h2>Load Brief from JSON</h2>
<div class="json-upload-row">
<label class="upload-btn" for="jsonFile">Choose JSON File</label>
<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)">
<span id="jsonFileName" style="font-size:12px;color:#888;margin-left:12px">No file selected</span>
</div>
<div id="jsonPreview" style="display:none;margin-top:12px">
<div style="font-size:11px;color:#f5a623;font-weight:600;margin-bottom:6px">LOADED BRIEF</div>
<pre id="jsonPreviewText" style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:8px;padding:12px;font-size:11px;color:#888;max-height:120px;overflow-y:auto;white-space:pre-wrap"></pre>
<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>
@ -162,6 +160,11 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
</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>
@ -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 = '<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 &amp; Run</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');
}
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;

View file

@ -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') {

View file

@ -79,6 +79,7 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
<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>
@ -86,15 +87,12 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
<div id="tab-pipeline" class="tab-content active">
<div class="form-section">
<h2>Load Brief from JSON</h2>
<div class="json-upload-row">
<label class="upload-btn" for="jsonFile">Choose JSON File</label>
<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)">
<span id="jsonFileName" style="font-size:12px;color:#888;margin-left:12px">No file selected</span>
</div>
<div id="jsonPreview" style="display:none;margin-top:12px">
<div style="font-size:11px;color:#f5a623;font-weight:600;margin-bottom:6px">LOADED BRIEF</div>
<pre id="jsonPreviewText" style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:8px;padding:12px;font-size:11px;color:#888;max-height:120px;overflow-y:auto;white-space:pre-wrap"></pre>
<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>
@ -147,6 +145,11 @@ button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
</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>
@ -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 = '<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 &amp; Run</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
}
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;