Wellfound-AI / templates /index.html
Zoey7Web's picture
Upload 26 files
67c9c05 verified
Raw
History Blame Contribute Delete
27 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wellfound AI - Excel Data Completion</title>
<style>
:root {
--bg: #f8fafc;
--surface: #ffffff;
--surface2: #f1f5f9;
--border: #e2e8f0;
--text: #0f172a;
--text2: #475569;
--text3: #94a3b8;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-light: #dbeafe;
--success: #16a34a;
--success-light: #dcfce7;
--warning: #d97706;
--warning-light: #fef3c7;
--danger: #dc2626;
--danger-light: #fee2e2;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
--shadow-lg: 0 10px 15px rgba(0,0,0,.1), 0 4px 6px rgba(0,0,0,.05);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* ─── Layout ─── */
.app-container {
max-width: 900px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ─── Header ─── */
.app-header {
text-align: center;
padding: 32px 0 40px;
}
.app-header .logo {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.app-header .logo-icon {
width: 42px;
height: 42px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
}
.app-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
}
.app-header .subtitle {
color: var(--text2);
font-size: 15px;
margin-top: 4px;
}
/* ─── Cards ─── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 20px;
box-shadow: var(--shadow);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.card-header .step-num {
width: 28px;
height: 28px;
background: var(--primary-light);
color: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.card-header h2 {
font-size: 17px;
font-weight: 600;
}
/* ─── Form Elements ─── */
.form-group {
margin-bottom: 16px;
}
.form-group:last-child { margin-bottom: 0; }
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text2);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input, .form-select {
width: 100%;
padding: 10px 14px;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: var(--font);
background: var(--surface);
color: var(--text);
transition: border-color .15s, box-shadow .15s;
outline: none;
}
.form-input:focus, .form-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.15);
}
.form-input::placeholder { color: var(--text3); }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 500px) {
.form-row { grid-template-columns: 1fr; }
}
/* ─── Toggle Switch ─── */
.toggle-group {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.toggle-track {
width: 44px;
height: 26px;
background: #cbd5e1;
border-radius: 13px;
position: relative;
transition: background .2s;
}
.toggle-track.active {
background: var(--primary);
}
.toggle-thumb {
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
position: absolute;
top: 3px;
left: 3px;
transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.toggle-track.active .toggle-thumb {
transform: translateX(18px);
}
.toggle-label {
font-size: 14px;
font-weight: 500;
}
/* ─── Upload Zone ─── */
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all .2s;
background: var(--surface2);
}
.upload-zone:hover, .upload-zone.drag-over {
border-color: var(--primary);
background: var(--primary-light);
}
.upload-zone .upload-icon {
font-size: 36px;
margin-bottom: 10px;
}
.upload-zone .upload-text {
font-size: 15px;
font-weight: 500;
color: var(--text2);
}
.upload-zone .upload-hint {
font-size: 12px;
color: var(--text3);
margin-top: 4px;
}
.upload-zone input[type=file] { display: none; }
.file-info {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--success-light);
border-radius: var(--radius-sm);
margin-top: 14px;
}
.file-info .file-icon { font-size: 24px; }
.file-info .file-name { font-weight: 600; font-size: 14px; }
.file-info .file-meta { font-size: 12px; color: var(--text2); }
.file-info .file-remove {
margin-left: auto;
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
}
.file-info .file-remove:hover { background: var(--danger-light); }
/* ─── Buttons ─── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 24px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all .15s;
font-family: var(--font);
white-space: nowrap;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled {
background: #93c5fd;
cursor: not-allowed;
}
.btn-outline {
background: var(--surface);
color: var(--primary);
border: 1.5px solid var(--primary);
}
.btn-outline:hover { background: var(--primary-light); }
.btn-success {
background: var(--success);
color: #fff;
}
.btn-success:hover { opacity: .9; }
.btn-lg { padding: 13px 32px; font-size: 15px; }
.btn-block { width: 100%; }
/* ─── Progress ─── */
.progress-section { margin-top: 18px; }
.progress-bar-outer {
width: 100%;
height: 10px;
background: var(--surface2);
border-radius: 5px;
overflow: hidden;
}
.progress-bar-inner {
height: 100%;
background: linear-gradient(90deg, var(--primary), #7c3aed);
border-radius: 5px;
transition: width .3s;
width: 0%;
}
.progress-stats {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 13px;
color: var(--text2);
}
.progress-current {
font-size: 13px;
color: var(--text3);
margin-top: 6px;
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─── Status Badges ─── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: .3px;
}
.badge-success { background: var(--success-light); color: var(--success); }
.badge-warning { background: var(--warning-light); color: var(--warning); }
.badge-error { background: var(--danger-light); color: var(--danger); }
.badge-info { background: var(--primary-light); color: var(--primary); }
/* ─── Error List ─── */
.error-list {
margin-top: 14px;
max-height: 180px;
overflow-y: auto;
background: var(--danger-light);
border-radius: var(--radius-sm);
padding: 10px;
}
.error-item {
font-size: 12px;
color: var(--danger);
padding: 3px 0;
font-family: var(--mono);
border-bottom: 1px solid rgba(220,38,38,.1);
}
.error-item:last-child { border-bottom: none; }
/* ─── Steps ─── */
.steps-indicator {
display: flex;
gap: 4px;
margin-bottom: 24px;
}
.step-dot {
flex: 1;
height: 3px;
background: var(--border);
border-radius: 2px;
transition: background .3s;
}
.step-dot.done { background: var(--success); }
.step-dot.active { background: var(--primary); }
/* ─── Data Preview ─── */
.preview-table-wrapper {
overflow-x: auto;
margin-top: 14px;
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.preview-table th {
background: var(--surface2);
padding: 8px 10px;
text-align: left;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .3px;
color: var(--text2);
border-bottom: 2px solid var(--border);
white-space: nowrap;
}
.preview-table td {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-table .null-cell { color: var(--text3); font-style: italic; }
/* ─── Deployment Info ─── */
.deploy-info {
margin-top: 18px;
padding: 16px;
background: var(--surface2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text2);
}
.deploy-info h3 {
font-size: 14px;
margin-bottom: 8px;
}
.deploy-info table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.deploy-info th, .deploy-info td {
padding: 6px 10px;
border: 1px solid var(--border);
text-align: left;
}
.deploy-info th { background: var(--surface); font-weight: 600; }
.deploy-info .best { background: #dcfce7; font-weight: 600; }
/* ─── Toast ─── */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
box-shadow: var(--shadow-lg);
z-index: 1000;
animation: slideIn .3s ease;
max-width: 400px;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.toast-success { background: var(--success-light); color: var(--success); border: 1px solid var(--success); }
.toast-error { background: var(--danger-light); color: var(--danger); border: 1px solid var(--danger); }
/* ─── Hidden ─── */
.hidden { display: none !important; }
/* ─── Responsive ─── */
@media (max-width: 600px) {
.app-container { padding: 16px 12px; }
.app-header h1 { font-size: 22px; }
.card { padding: 16px; }
}
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="logo">
<div class="logo-icon">πŸ€–</div>
<h1>Wellfound AI</h1>
</div>
<p class="subtitle">AI-Powered Excel Data Completion Tool</p>
</header>
<!-- Steps indicator -->
<div class="steps-indicator" id="stepsIndicator">
<div class="step-dot done"></div>
<div class="step-dot" id="stepDot2"></div>
<div class="step-dot" id="stepDot3"></div>
<div class="step-dot" id="stepDot4"></div>
</div>
<!-- Step 1: AI Configuration -->
<div class="card" id="cardConfig">
<div class="card-header">
<span class="step-num">1</span>
<h2>AI Configuration</h2>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">AI Service Provider</label>
<select class="form-select" id="providerSelect">
<option value="openai">OpenAI (GPT-4o)</option>
<option value="deepseek">DeepSeek (DeepSeek-Chat)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Model</label>
<select class="form-select" id="modelSelect">
<option value="auto">Auto (Best Available)</option>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o-mini (Fast)</option>
<option value="deepseek-chat">DeepSeek-Chat</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-input" id="apiKeyInput"
placeholder="sk-... (OpenAI) or sk-... (DeepSeek)"
autocomplete="off">
</div>
<div class="toggle-group">
<label class="toggle-item" id="toggleAi">
<div class="toggle-track active" id="toggleAiTrack">
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label">AI Extraction</span>
</label>
<label class="toggle-item" id="toggleWeb">
<div class="toggle-track active" id="toggleWebTrack">
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label">Web Scraping</span>
</label>
</div>
</div>
<!-- Step 2: File Upload -->
<div class="card" id="cardUpload">
<div class="card-header">
<span class="step-num">2</span>
<h2>Upload Excel File</h2>
</div>
<div class="upload-zone" id="uploadZone">
<div class="upload-icon">πŸ“</div>
<div class="upload-text">Drag & drop your Excel file here</div>
<div class="upload-hint">Supports .xlsx, .xls (Wellfound export format)</div>
<input type="file" id="fileInput" accept=".xlsx,.xls">
</div>
<div class="file-info hidden" id="fileInfo">
<span class="file-icon">πŸ“„</span>
<div>
<div class="file-name" id="fileName"></div>
<div class="file-meta" id="fileMeta"></div>
</div>
<button class="file-remove" id="fileRemove" title="Remove file">&times;</button>
</div>
<div class="preview-table-wrapper hidden" id="previewSection">
<table class="preview-table" id="previewTable">
<thead id="previewHead"></thead>
<tbody id="previewBody"></tbody>
</table>
</div>
</div>
<!-- Step 3: Process -->
<div class="card" id="cardProcess">
<div class="card-header">
<span class="step-num">3</span>
<h2>Process Data</h2>
<span id="statusBadge" class="hidden"></span>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<div class="form-group" style="flex:1;min-width:120px;">
<label class="form-label">Start Row</label>
<input type="number" class="form-input" id="startRowInput" value="0" min="0">
</div>
<div class="form-group" style="flex:1;min-width:120px;">
<label class="form-label">Max Rows (0=All)</label>
<input type="number" class="form-input" id="maxRowsInput" value="0" min="0">
</div>
<div class="form-group" style="flex:1;min-width:120px;">
<label class="form-label">Concurrency</label>
<input type="number" class="form-input" id="concurrencyInput" value="2" min="1" max="5">
</div>
</div>
<button class="btn btn-primary btn-lg btn-block" id="btnStart" disabled>
⚑ Start Processing
</button>
<!-- Progress -->
<div class="progress-section hidden" id="progressSection">
<div class="progress-bar-outer">
<div class="progress-bar-inner" id="progressBar"></div>
</div>
<div class="progress-stats">
<span id="progressText">0 / 0 processed</span>
<span id="progressPct">0%</span>
</div>
<div class="progress-current" id="progressCurrent"></div>
</div>
<!-- Errors -->
<div class="error-list hidden" id="errorList"></div>
</div>
<!-- Step 4: Download -->
<div class="card hidden" id="cardDownload">
<div class="card-header">
<span class="step-num">4</span>
<h2>Download Results</h2>
</div>
<button class="btn btn-success btn-lg btn-block" id="btnDownload">
⬇ Download Processed Excel
</button>
</div>
<!-- Deployment Evaluation -->
<div class="card">
<div class="card-header">
<span class="step-num">πŸ“Š</span>
<h2>Deployment Evaluation</h2>
</div>
<div class="deploy-info">
<table>
<thead>
<tr>
<th>Criteria</th>
<th>Hugging Face Space</th>
<th>n8n Workflow</th>
<th>GitHub Open Source</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>File Processing</strong></td>
<td class="best">⭐ Persistent storage, up to 50GB</td>
<td>⚠ Limited file handling</td>
<td>⭐ Self-hosted, unlimited</td>
</tr>
<tr>
<td><strong>Deployment Ease</strong></td>
<td class="best">⭐ Git push / GUI, 1-click</td>
<td>⚠ Requires n8n instance</td>
<td>❌ Manual setup required</td>
</tr>
<tr>
<td><strong>Entry Barrier</strong></td>
<td class="best">⭐ Low, browser-based</td>
<td>⚠ Medium, workflow knowledge</td>
<td>❌ High, dev skills needed</td>
</tr>
<tr>
<td><strong>Web Scraping</strong></td>
<td class="best">⭐ Playwright supported</td>
<td>⚠ HTTP nodes only</td>
<td>⭐ Full control</td>
</tr>
<tr>
<td><strong>Cost</strong></td>
<td class="best">⭐ Free tier available</td>
<td>⚠ Self-host or paid</td>
<td>⭐ Free (own infra)</td>
</tr>
</tbody>
</table>
<p style="margin-top:12px;font-weight:600;color:var(--success);">
πŸ† Best Choice: <strong>Hugging Face Space</strong> β€” optimal balance of ease, features, and cost
</p>
</div>
</div>
</div>
<script>
// ─── State ───
const state = {
fileId: null,
filename: null,
taskId: null,
useAi: true,
useWeb: true,
pollingInterval: null,
};
// ─── DOM Elements ───
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const uploadZone = $('#uploadZone');
const fileInput = $('#fileInput');
const fileInfo = $('#fileInfo');
const fileName = $('#fileName');
const fileMeta = $('#fileMeta');
const fileRemove = $('#fileRemove');
const previewSection = $('#previewSection');
const previewHead = $('#previewHead');
const previewBody = $('#previewBody');
const btnStart = $('#btnStart');
const btnDownload = $('#btnDownload');
const progressSection = $('#progressSection');
const progressBar = $('#progressBar');
const progressText = $('#progressText');
const progressPct = $('#progressPct');
const progressCurrent = $('#progressCurrent');
const errorList = $('#errorList');
const statusBadge = $('#statusBadge');
const cardDownload = $('#cardDownload');
const apiKeyInput = $('#apiKeyInput');
const providerSelect = $('#providerSelect');
const modelSelect = $('#modelSelect');
// ─── Toggle Switches ───
function setupToggle(toggleId, trackId, stateKey) {
const toggle = document.getElementById(toggleId);
const track = document.getElementById(trackId);
toggle.addEventListener('click', () => {
state[stateKey] = !state[stateKey];
if (state[stateKey]) {
track.classList.add('active');
} else {
track.classList.remove('active');
}
});
}
setupToggle('toggleAi', 'toggleAiTrack', 'useAi');
setupToggle('toggleWeb', 'toggleWebTrack', 'useWeb');
// Update model options based on provider
providerSelect.addEventListener('change', () => {
const provider = providerSelect.value;
modelSelect.innerHTML = '<option value="auto">Auto (Best Available)</option>';
if (provider === 'openai') {
modelSelect.innerHTML += `
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o-mini (Fast)</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
`;
} else {
modelSelect.innerHTML += `
<option value="deepseek-chat">DeepSeek-Chat</option>
<option value="deepseek-reasoner">DeepSeek-Reasoner</option>
`;
}
});
// ─── File Upload ───
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFileSelect);
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
handleFileSelect();
}
});
fileRemove.addEventListener('click', () => {
state.fileId = null;
state.filename = null;
fileInput.value = '';
fileInfo.classList.add('hidden');
previewSection.classList.add('hidden');
btnStart.disabled = true;
cardDownload.classList.add('hidden');
});
async function handleFileSelect() {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
if (!res.ok) {
const err = await res.json();
showToast(err.detail || 'Upload failed', 'error');
return;
}
const data = await res.json();
state.fileId = data.file_id;
state.filename = data.filename;
fileName.textContent = data.filename;
fileMeta.textContent = `${data.rows} rows, ${data.columns.length} columns | Missing data in ${Object.keys(data.missing_data).length} columns`;
fileInfo.classList.remove('hidden');
btnStart.disabled = false;
// Show column preview
previewHead.innerHTML = '<tr>' + data.columns.map(c => `<th>${c}</th>`).join('') + '</tr>';
previewSection.classList.remove('hidden');
showToast(`File loaded: ${data.rows} rows`, 'success');
} catch (err) {
showToast('Upload failed: ' + err.message, 'error');
}
}
// ─── Start Processing ───
btnStart.addEventListener('click', startProcessing);
async function startProcessing() {
if (!state.fileId) return;
const apiKey = apiKeyInput.value.trim();
if (state.useAi && !apiKey) {
showToast('Please enter your API key', 'error');
apiKeyInput.focus();
return;
}
btnStart.disabled = true;
btnStart.textContent = '⏳ Starting...';
progressSection.classList.remove('hidden');
errorList.classList.add('hidden');
errorList.innerHTML = '';
cardDownload.classList.add('hidden');
const payload = {
file_id: state.fileId,
api_key: apiKey,
provider: providerSelect.value,
model: modelSelect.value,
start_row: parseInt($('#startRowInput').value) || 0,
max_rows: parseInt($('#maxRowsInput').value) || 0,
use_ai: state.useAi,
use_web_scraping: state.useWeb,
concurrency: parseInt($('#concurrencyInput').value) || 2,
};
try {
const res = await fetch('/api/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json();
showToast(err.detail || 'Failed to start', 'error');
btnStart.disabled = false;
btnStart.textContent = '⚑ Start Processing';
return;
}
const data = await res.json();
state.taskId = data.task_id;
showToast('Processing started!', 'success');
startPolling();
} catch (err) {
showToast('Error: ' + err.message, 'error');
btnStart.disabled = false;
btnStart.textContent = '⚑ Start Processing';
}
}
// ─── Polling ───
function startPolling() {
if (state.pollingInterval) clearInterval(state.pollingInterval);
state.pollingInterval = setInterval(async () => {
try {
const res = await fetch(`/api/status/${state.taskId}`);
if (!res.ok) return;
const data = await res.json();
updateProgress(data);
if (data.status === 'completed' || data.status === 'error') {
clearInterval(state.pollingInterval);
state.pollingInterval = null;
if (data.status === 'completed') {
showToast('Processing complete!', 'success');
cardDownload.classList.remove('hidden');
btnDownload.onclick = () => {
window.location.href = `/api/download/${state.taskId}`;
};
statusBadge.textContent = 'Complete';
statusBadge.className = 'badge badge-success';
statusBadge.classList.remove('hidden');
} else {
statusBadge.textContent = 'Error';
statusBadge.className = 'badge badge-error';
statusBadge.classList.remove('hidden');
}
}
} catch (err) {
console.error('Poll error:', err);
}
}, 1000);
}
function updateProgress(data) {
const pct = data.total > 0 ? Math.round((data.progress / data.total) * 100) : 0;
progressBar.style.width = pct + '%';
progressText.textContent = `${data.progress} / ${data.total} processed`;
progressPct.textContent = pct + '%';
if (data.current) {
progressCurrent.textContent = data.current;
}
// Status badge
if (data.status === 'processing') {
statusBadge.textContent = 'Processing';
statusBadge.className = 'badge badge-info';
statusBadge.classList.remove('hidden');
btnStart.textContent = '⏳ Processing...';
}
// Errors
if (data.errors && data.errors.length > 0) {
errorList.classList.remove('hidden');
errorList.innerHTML = data.errors.map(e =>
`<div class="error-item">⚠ ${escapeHtml(e)}</div>`
).join('');
}
}
// ─── Helpers ───
function showToast(message, type) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity .3s';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>