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:
parent
5f8d84f5c5
commit
010d304c2a
3 changed files with 299 additions and 17 deletions
|
|
@ -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 & 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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 & 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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue