Files
research-workbench/static/index.html
T
Ken 8dfafcc63d feat: car-help web UI with browser view, search API, and buying guide
- FastAPI backend on port 8080 with noVNC proxy for same-origin iframe
- Split-panel UI: live browser (55%) + search/guide/saved tabs (45%)
- Car buying guide: target models, pre-purchase checklist, mechanics nearby,
  negotiation tips, budget breakdown
- Scoring/ranking engine for listings (Toyota Prius = highest)
- Docker container runs Xvfb + x11vnc + noVNC + FastAPI together
- Accessible at browser.ampbox.io via cloudflared tunnel
2026-05-25 20:34:16 +00:00

785 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>car-help | Hybrid Finder</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--orange: #db6d28;
--card-bg: #1c2128;
--hover: #1f2937;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.layout {
display: flex;
height: 100vh;
}
/* Left panel -- browser view */
.browser-panel {
width: 55%;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.browser-header {
padding: 8px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-dim);
}
.browser-header .dot {
width: 10px; height: 10px; border-radius: 50%;
}
.dot.green { background: var(--green); }
.dot.yellow { background: var(--yellow); }
.dot.red { background: var(--red); }
.browser-header span { margin-left: 8px; font-weight: 500; }
.browser-frame {
flex: 1;
}
.browser-frame iframe {
width: 100%;
height: 100%;
border: none;
}
/* Right panel -- app */
.app-panel {
width: 45%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.app-header {
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.app-header h1 {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.app-header .subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 2px;
}
/* Tabs */
.tabs {
display: flex;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Tab content */
.tab-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* Search tab */
.search-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover { background: var(--hover); border-color: var(--accent); }
.btn.primary {
background: #238636;
border-color: #2ea043;
}
.btn.primary:hover { background: #2ea043; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-status {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 16px;
min-height: 18px;
}
/* Listing cards */
.listing-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 10px;
transition: border-color 0.15s;
}
.listing-card:hover { border-color: var(--accent); }
.listing-card .card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.listing-card .title {
font-size: 14px;
font-weight: 500;
color: var(--text);
flex: 1;
}
.listing-card .title a {
color: var(--accent);
text-decoration: none;
}
.listing-card .title a:hover { text-decoration: underline; }
.listing-card .price {
font-size: 15px;
font-weight: 700;
color: var(--green);
white-space: nowrap;
}
.listing-card .meta {
display: flex;
gap: 12px;
margin-top: 6px;
font-size: 12px;
color: var(--text-dim);
}
.score-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
}
.score-high { background: #238636; }
.score-mid { background: #9e6a03; }
.score-low { background: #da3633; }
.source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-dim);
}
/* Guide tab */
.guide-section {
margin-bottom: 28px;
}
.guide-section h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: var(--accent);
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
}
.guide-section h3 {
font-size: 14px;
font-weight: 600;
margin: 12px 0 6px;
color: var(--text);
}
.guide-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
}
.guide-card .model-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.guide-card .model-score {
float: right;
font-weight: 700;
font-size: 15px;
}
.guide-card p {
font-size: 13px;
color: var(--text-dim);
margin-top: 4px;
line-height: 1.5;
}
.guide-card .model-stats {
display: flex;
gap: 16px;
margin-top: 6px;
font-size: 12px;
color: var(--text-dim);
}
.guide-card .model-stats strong { color: var(--text); }
.avoid-card {
background: #2d1b1b;
border-color: #5c2020;
}
.avoid-card .model-name { color: var(--red); }
.checklist-item {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 6px;
}
.checklist-item .item-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.checklist-item .item-detail {
font-size: 12px;
color: var(--text-dim);
margin-top: 3px;
line-height: 1.4;
}
.tip-item {
padding: 6px 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
.tip-item:last-child { border-bottom: none; }
.mechanic-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 8px;
}
.mechanic-card .mech-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
}
.mechanic-card .mech-addr {
font-size: 12px;
color: var(--text-dim);
}
.mechanic-card .mech-notes {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
.mechanic-card .mech-dist {
font-size: 11px;
color: var(--accent);
float: right;
}
.budget-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.budget-table th {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
color: var(--text-dim);
font-weight: 500;
}
.budget-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.budget-table .amount { color: var(--green); font-weight: 600; }
.budget-table .note-col { color: var(--text-dim); font-size: 12px; }
.budget-total {
margin-top: 12px;
padding: 12px 14px;
background: #0f2b1a;
border: 1px solid #238636;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: var(--green);
}
.callout {
padding: 12px 14px;
background: #1a1f2e;
border: 1px solid #3b4f80;
border-radius: 8px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.5;
margin: 12px 0;
}
.callout.warn {
background: #2a1f0a;
border-color: #6e4b0a;
}
.good-bad-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
.good-list, .bad-list {
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.good-list li::marker { color: var(--green); }
.bad-list li::marker { color: var(--red); }
/* Saved tab */
.saved-file {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color 0.15s;
}
.saved-file:hover { border-color: var(--accent); }
.saved-file .filename {
font-size: 14px;
font-weight: 500;
color: var(--accent);
}
.saved-file .file-meta {
font-size: 12px;
color: var(--text-dim);
margin-top: 3px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state .big { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="layout">
<!-- Left: Browser view -->
<div class="browser-panel">
<div class="browser-header">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
<span>Live Browser -- browser.ampbox.io</span>
</div>
<div class="browser-frame">
<iframe src="/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000" allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<!-- Right: App panel -->
<div class="app-panel">
<div class="app-header">
<h1>car-help</h1>
<div class="subtitle">Used hybrid finder -- Woodinville WA -- Budget $8-10K</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="search">Search</div>
<div class="tab" data-tab="guide">Guide</div>
<div class="tab" data-tab="saved">Saved</div>
</div>
<div class="tab-content">
<!-- Search pane -->
<div class="tab-pane active" id="pane-search">
<div class="search-controls">
<button class="btn primary" id="btn-search" onclick="runSearch()">Run Search</button>
<button class="btn" onclick="rankResults()">Rank Results</button>
</div>
<div class="search-status" id="search-status"></div>
<div id="search-results"></div>
</div>
<!-- Guide pane -->
<div class="tab-pane" id="pane-guide">
<div id="guide-content">
<div class="empty-state">
<div class="big">...</div>
<p>Loading guide...</p>
</div>
</div>
</div>
<!-- Saved pane -->
<div class="tab-pane" id="pane-saved">
<div id="saved-content">
<div class="empty-state">
<div class="big">...</div>
<p>Loading saved results...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// --- Tab switching ---
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('pane-' + tab.dataset.tab).classList.add('active');
});
});
// --- Current search results (in memory) ---
let currentListings = [];
// --- Search ---
async function runSearch() {
const btn = document.getElementById('btn-search');
const status = document.getElementById('search-status');
btn.disabled = true;
btn.textContent = 'Searching...';
status.textContent = 'Launching Playwright searches across Craigslist, CarGurus, AutoTempest, Facebook... this takes 1-2 minutes. Watch the browser on the left.';
try {
const resp = await fetch('/api/search');
const data = await resp.json();
if (data.status === 'ok') {
currentListings = data.listings || [];
status.textContent = `Found ${data.total_listings} listings. Saved to ${data.file}.`;
renderListings(currentListings);
loadSaved(); // refresh saved tab
} else {
status.textContent = 'Search error: ' + (data.message || 'unknown');
console.error(data.log);
}
} catch (e) {
status.textContent = 'Search failed: ' + e.message;
}
btn.disabled = false;
btn.textContent = 'Run Search';
}
async function rankResults() {
if (currentListings.length === 0) {
document.getElementById('search-status').textContent = 'No results to rank. Run a search first.';
return;
}
document.getElementById('search-status').textContent = 'Ranking...';
try {
const resp = await fetch('/api/rank', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentListings),
});
const ranked = await resp.json();
currentListings = ranked;
document.getElementById('search-status').textContent = `Ranked ${ranked.length} listings by score.`;
renderListings(ranked);
} catch (e) {
document.getElementById('search-status').textContent = 'Ranking failed: ' + e.message;
}
}
function renderListings(listings) {
const container = document.getElementById('search-results');
if (!listings.length) {
container.innerHTML = '<div class="empty-state"><div class="big">0</div><p>No listings yet. Hit "Run Search" to start.</p></div>';
return;
}
container.innerHTML = listings.map(l => {
const score = l.score != null ? l.score : null;
let scoreBadge = '';
if (score !== null) {
const cls = score >= 70 ? 'score-high' : score >= 50 ? 'score-mid' : 'score-low';
scoreBadge = `<span class="score-badge ${cls}">${score}</span>`;
}
const title = l.title || 'Unknown';
const price = l.price || 'N/A';
const link = l.url ? `<a href="${escHtml(l.url)}" target="_blank">${escHtml(title)}</a>` : escHtml(title);
return `
<div class="listing-card">
<div class="card-top">
<div class="title">${link}</div>
<div class="price">${escHtml(price)}</div>
</div>
<div class="meta">
<span class="source-badge">${escHtml(l.source || '?')}</span>
${scoreBadge}
</div>
</div>`;
}).join('');
}
// --- Guide ---
async function loadGuide() {
try {
const resp = await fetch('/api/guide');
const g = await resp.json();
renderGuide(g);
} catch (e) {
document.getElementById('guide-content').innerHTML = '<p>Failed to load guide.</p>';
}
}
function renderGuide(g) {
let html = '';
// Target cars
html += '<div class="guide-section"><h2>Recommended Cars</h2>';
for (const car of g.target_cars) {
html += `
<div class="guide-card">
<div class="model-score" style="color: ${car.score >= 9 ? 'var(--green)' : car.score >= 8 ? 'var(--yellow)' : 'var(--text-dim)'}">${car.score}/10</div>
<div class="model-name">${escHtml(car.model)}</div>
<p>${escHtml(car.why)}</p>
<div class="model-stats">
<span>MPG: <strong>${escHtml(car.mpg)}</strong></span>
<span>Budget: <strong>${escHtml(car.budget)}</strong></span>
</div>
<p style="margin-top:6px; font-style:italic">${escHtml(car.notes)}</p>
</div>`;
}
html += '</div>';
// Avoid
html += '<div class="guide-section"><h2>Avoid These</h2>';
for (const car of g.avoid) {
html += `
<div class="guide-card avoid-card">
<div class="model-name">${escHtml(car.model)}</div>
<p>${escHtml(car.reason)}</p>
</div>`;
}
html += '</div>';
// Pre-purchase checklist
html += '<div class="guide-section"><h2>Pre-Purchase Checklist</h2>';
for (const item of g.pre_purchase_checklist) {
html += `
<div class="checklist-item">
<div class="item-title">${escHtml(item.item)}</div>
<div class="item-detail">${escHtml(item.detail)}</div>
</div>`;
}
html += '</div>';
// Mechanics nearby
html += '<div class="guide-section"><h2>Mechanics for Pre-Purchase Inspection</h2>';
html += `<div class="callout warn">${escHtml(g.mechanics_note)}</div>`;
for (const m of g.mechanics_nearby) {
html += `
<div class="mechanic-card">
<span class="mech-dist">${escHtml(m.distance)}</span>
<div class="mech-name">${escHtml(m.name)}</div>
<div class="mech-addr">${escHtml(m.address)}</div>
<div class="mech-notes">${escHtml(m.notes)}</div>
</div>`;
}
html += '</div>';
// Mechanic red flags
html += '<div class="guide-section"><h2>Choosing a Mechanic</h2>';
html += '<div class="good-bad-grid"><div><h3 style="color:var(--green)">Good Signs</h3><ul class="good-list">';
for (const s of g.mechanic_red_flags.good_signs) {
html += `<li>${escHtml(s)}</li>`;
}
html += '</ul></div><div><h3 style="color:var(--red)">Red Flags</h3><ul class="bad-list">';
for (const s of g.mechanic_red_flags.red_flags) {
html += `<li>${escHtml(s)}</li>`;
}
html += '</ul></div></div>';
html += `<div class="callout">${escHtml(g.mechanic_red_flags.key_question)}</div>`;
html += '</div>';
// Negotiation tips
html += '<div class="guide-section"><h2>Negotiation Tips</h2>';
for (const tip of g.negotiation_tips) {
html += `<div class="tip-item">${escHtml(tip)}</div>`;
}
html += '</div>';
// Budget breakdown
html += '<div class="guide-section"><h2>Budget Breakdown</h2>';
html += '<table class="budget-table"><thead><tr><th>Item</th><th>Cost</th><th>Notes</th></tr></thead><tbody>';
for (const b of g.budget_breakdown) {
html += `<tr><td>${escHtml(b.item)}</td><td class="amount">${escHtml(b.range)}</td><td class="note-col">${escHtml(b.note)}</td></tr>`;
}
html += '</tbody></table>';
html += `<div class="budget-total">${escHtml(g.budget_total)}</div>`;
html += '</div>';
document.getElementById('guide-content').innerHTML = html;
}
// --- Saved results ---
async function loadSaved() {
try {
const resp = await fetch('/api/results');
const files = await resp.json();
const container = document.getElementById('saved-content');
if (!files.length) {
container.innerHTML = '<div class="empty-state"><div class="big">0</div><p>No saved searches yet.</p></div>';
return;
}
container.innerHTML = files.map(f => `
<div class="saved-file" onclick="loadSavedFile('${escHtml(f.filename)}')">
<div class="filename">${escHtml(f.filename)}</div>
<div class="file-meta">${f.total} listings -- ${f.date ? new Date(f.date).toLocaleString() : 'unknown date'}</div>
</div>`).join('');
} catch (e) {
document.getElementById('saved-content').innerHTML = '<p>Failed to load saved results.</p>';
}
}
async function loadSavedFile(filename) {
try {
const resp = await fetch('/api/results/' + filename);
const data = await resp.json();
currentListings = data.listings || [];
// Switch to search tab and render
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelector('[data-tab="search"]').classList.add('active');
document.getElementById('pane-search').classList.add('active');
document.getElementById('search-status').textContent = `Loaded ${filename}: ${data.total_listings} listings`;
renderListings(currentListings);
} catch (e) {
alert('Failed to load: ' + e.message);
}
}
// --- Util ---
function escHtml(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
// --- Init ---
loadGuide();
loadSaved();
</script>
</body>
</html>