8dfafcc63d
- 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
785 lines
20 KiB
HTML
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>
|