7e19b740cb
- Search box: type anything, toggle filter pills (hybrid, price range, distance, etc.) - Backend calls Anthropic to parse query into search params - Playwright crawls Craigslist, CarGurus, AutoTempest with dynamic URLs - Results scored/ranked with car-specific scoring engine - Second Anthropic call generates a custom buying guide from actual results - Guide sections: top picks, checklist, mechanics, negotiation, budget - All visible live in the browser panel via noVNC
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 | AI Car 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;
|
|
--pill-bg: #21262d;
|
|
--pill-active: #1f6feb;
|
|
}
|
|
|
|
* { 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);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.app-header {
|
|
padding: 14px 20px 10px;
|
|
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;
|
|
}
|
|
|
|
/* Search box area */
|
|
.search-area {
|
|
padding: 16px 20px;
|
|
background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.search-input-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 14px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.search-input:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.search-input::placeholder {
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.btn {
|
|
padding: 10px 24px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn-search {
|
|
background: #238636;
|
|
color: #fff;
|
|
}
|
|
.btn-search:hover { background: #2ea043; }
|
|
.btn-search:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Filter pills */
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.pill {
|
|
padding: 5px 12px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
border: 1px solid var(--border);
|
|
background: var(--pill-bg);
|
|
color: var(--text-dim);
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.pill:hover {
|
|
border-color: var(--accent);
|
|
color: var(--text);
|
|
}
|
|
|
|
.pill.active {
|
|
background: var(--pill-active);
|
|
border-color: var(--pill-active);
|
|
color: #fff;
|
|
}
|
|
|
|
/* Scrollable content area */
|
|
.content-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
}
|
|
|
|
/* Status bar */
|
|
.status-bar {
|
|
padding: 10px 20px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
background: var(--bg);
|
|
border-bottom: 1px solid var(--border);
|
|
min-height: 36px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Results section */
|
|
.results-section {
|
|
padding: 16px 20px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
/* Listing cards */
|
|
.listing-card {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
margin-bottom: 10px;
|
|
transition: border-color 0.15s;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.listing-card:hover { border-color: var(--accent); }
|
|
|
|
.card-body {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
}
|
|
|
|
.card-img {
|
|
width: 80px;
|
|
height: 60px;
|
|
border-radius: 6px;
|
|
object-fit: cover;
|
|
background: var(--pill-bg);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.card-img-placeholder {
|
|
width: 80px;
|
|
height: 60px;
|
|
border-radius: 6px;
|
|
background: var(--pill-bg);
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 24px;
|
|
color: var(--border);
|
|
}
|
|
|
|
.card-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
line-height: 1.3;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.card-title a {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
}
|
|
.card-title a:hover { text-decoration: underline; }
|
|
|
|
.card-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.card-price {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--green);
|
|
}
|
|
|
|
.score-badge {
|
|
display: inline-block;
|
|
padding: 1px 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: 1px 8px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
background: var(--pill-bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* Guide section */
|
|
.guide-section {
|
|
padding: 16px 20px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.guide-accordion {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.guide-accordion-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 14px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.guide-accordion-header:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.guide-accordion-header h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.guide-accordion-header .arrow {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.guide-accordion.open .arrow {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.guide-accordion-body {
|
|
display: none;
|
|
padding: 14px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
border-top: none;
|
|
border-radius: 0 0 8px 8px;
|
|
font-size: 13px;
|
|
line-height: 1.7;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.guide-accordion.open .guide-accordion-header {
|
|
border-radius: 8px 8px 0 0;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.guide-accordion.open .guide-accordion-body {
|
|
display: block;
|
|
}
|
|
|
|
.guide-accordion-body h1,
|
|
.guide-accordion-body h2,
|
|
.guide-accordion-body h3,
|
|
.guide-accordion-body h4 {
|
|
color: var(--text);
|
|
margin: 12px 0 6px;
|
|
}
|
|
.guide-accordion-body h1 { font-size: 16px; }
|
|
.guide-accordion-body h2 { font-size: 15px; }
|
|
.guide-accordion-body h3 { font-size: 14px; }
|
|
.guide-accordion-body h4 { font-size: 13px; }
|
|
|
|
.guide-accordion-body p {
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.guide-accordion-body ul, .guide-accordion-body ol {
|
|
margin: 6px 0 6px 20px;
|
|
}
|
|
|
|
.guide-accordion-body li {
|
|
margin: 3px 0;
|
|
}
|
|
|
|
.guide-accordion-body strong {
|
|
color: var(--text);
|
|
}
|
|
|
|
.guide-accordion-body code {
|
|
background: var(--bg);
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.empty-state .icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
|
.empty-state p { font-size: 14px; line-height: 1.6; }
|
|
.empty-state .hint { font-size: 12px; margin-top: 8px; color: var(--text-dim); }
|
|
|
|
/* Error state */
|
|
.error-box {
|
|
padding: 14px;
|
|
background: #2d1b1b;
|
|
border: 1px solid #5c2020;
|
|
border-radius: 8px;
|
|
color: var(--red);
|
|
font-size: 13px;
|
|
margin: 16px 20px;
|
|
}
|
|
|
|
/* 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</span>
|
|
</div>
|
|
<div class="browser-frame">
|
|
<iframe id="vnc-frame" src="about:blank" 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">AI-powered car search -- Woodinville WA</div>
|
|
</div>
|
|
|
|
<!-- Search area -->
|
|
<div class="search-area">
|
|
<div class="search-input-row">
|
|
<input type="text" class="search-input" id="search-query"
|
|
placeholder="What are you looking for?"
|
|
onkeydown="if(event.key==='Enter') runSearch()">
|
|
<button class="btn btn-search" id="btn-search" onclick="runSearch()">Search</button>
|
|
</div>
|
|
<div class="filter-row" id="filter-row">
|
|
<!-- Filters rendered by JS -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status bar -->
|
|
<div class="status-bar" id="status-bar">
|
|
Type what you want and hit Search
|
|
</div>
|
|
|
|
<!-- Scrollable content -->
|
|
<div class="content-area" id="content-area">
|
|
<div class="empty-state" id="empty-state">
|
|
<div class="icon">🚗</div>
|
|
<p>Describe the car you want and I'll find it.</p>
|
|
<div class="hint">Try: "reliable hybrid under 8K for a teen" or "Toyota Prius 2012-2015 low miles"</div>
|
|
</div>
|
|
<div id="results-container" style="display:none"></div>
|
|
<div id="guide-container" style="display:none"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// --- Filter definitions ---
|
|
const FILTERS = [
|
|
{ id: "hybrid", label: "Hybrid", default: true },
|
|
{ id: "electric", label: "Electric", default: false },
|
|
{ id: "under_8k", label: "Under $8K", default: false },
|
|
{ id: "under_10k", label: "Under $10K", default: true },
|
|
{ id: "toyota_honda", label: "Toyota/Honda only", default: false },
|
|
{ id: "low_miles", label: "Low miles (<100K)", default: false },
|
|
{ id: "within_25mi", label: "Within 25mi", default: false },
|
|
{ id: "within_50mi", label: "Within 50mi", default: true },
|
|
{ id: "private_party", label: "Private party only", default: false },
|
|
{ id: "dealer_only", label: "Dealer only", default: false },
|
|
];
|
|
|
|
let activeFilters = new Set(FILTERS.filter(f => f.default).map(f => f.id));
|
|
|
|
function renderFilters() {
|
|
const row = document.getElementById('filter-row');
|
|
row.innerHTML = FILTERS.map(f => {
|
|
const active = activeFilters.has(f.id) ? 'active' : '';
|
|
return `<div class="pill ${active}" data-filter="${f.id}" onclick="toggleFilter('${f.id}', this)">${f.label}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function toggleFilter(id, el) {
|
|
// Handle mutual exclusions
|
|
if (id === 'under_8k' && !activeFilters.has(id)) activeFilters.delete('under_10k');
|
|
if (id === 'under_10k' && !activeFilters.has(id)) activeFilters.delete('under_8k');
|
|
if (id === 'within_25mi' && !activeFilters.has(id)) activeFilters.delete('within_50mi');
|
|
if (id === 'within_50mi' && !activeFilters.has(id)) activeFilters.delete('within_25mi');
|
|
if (id === 'private_party' && !activeFilters.has(id)) activeFilters.delete('dealer_only');
|
|
if (id === 'dealer_only' && !activeFilters.has(id)) activeFilters.delete('private_party');
|
|
if (id === 'hybrid' && !activeFilters.has(id)) activeFilters.delete('electric');
|
|
if (id === 'electric' && !activeFilters.has(id)) activeFilters.delete('hybrid');
|
|
|
|
if (activeFilters.has(id)) {
|
|
activeFilters.delete(id);
|
|
} else {
|
|
activeFilters.add(id);
|
|
}
|
|
renderFilters();
|
|
}
|
|
|
|
// --- Search ---
|
|
let searching = false;
|
|
|
|
async function runSearch() {
|
|
if (searching) return;
|
|
const query = document.getElementById('search-query').value.trim();
|
|
if (!query) {
|
|
setStatus('Please enter what you\'re looking for');
|
|
return;
|
|
}
|
|
|
|
searching = true;
|
|
const btn = document.getElementById('btn-search');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Searching...';
|
|
|
|
// Hide empty state, show containers
|
|
document.getElementById('empty-state').style.display = 'none';
|
|
document.getElementById('results-container').style.display = 'none';
|
|
document.getElementById('guide-container').style.display = 'none';
|
|
|
|
setStatus('<span class="spinner"></span> Researching... parsing your query, searching sites, ranking results. Watch the browser on the left.', true);
|
|
|
|
try {
|
|
const resp = await fetch('/api/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
query: query,
|
|
filters: Array.from(activeFilters),
|
|
}),
|
|
});
|
|
|
|
const data = await resp.json();
|
|
|
|
if (data.error) {
|
|
setStatus('Error: ' + data.error);
|
|
document.getElementById('results-container').innerHTML =
|
|
`<div class="error-box">${escHtml(data.error)}</div>`;
|
|
document.getElementById('results-container').style.display = 'block';
|
|
} else {
|
|
const total = data.total || 0;
|
|
const summary = data.search_summary || query;
|
|
setStatus(`Found ${total} listings for "${summary}"`);
|
|
renderResults(data.listings || []);
|
|
renderGuide(data.guide || {});
|
|
}
|
|
} catch (e) {
|
|
setStatus('Search failed: ' + e.message);
|
|
document.getElementById('results-container').innerHTML =
|
|
`<div class="error-box">${escHtml(e.message)}</div>`;
|
|
document.getElementById('results-container').style.display = 'block';
|
|
}
|
|
|
|
searching = false;
|
|
btn.disabled = false;
|
|
btn.textContent = 'Search';
|
|
}
|
|
|
|
function setStatus(html, raw) {
|
|
const bar = document.getElementById('status-bar');
|
|
if (raw) {
|
|
bar.innerHTML = html;
|
|
} else {
|
|
bar.textContent = html;
|
|
}
|
|
}
|
|
|
|
// --- Render results ---
|
|
function renderResults(listings) {
|
|
const container = document.getElementById('results-container');
|
|
if (!listings.length) {
|
|
container.innerHTML = `
|
|
<div class="results-section">
|
|
<div class="section-title">Results</div>
|
|
<div class="empty-state" style="padding:30px">
|
|
<p>No listings found. Try broadening your search.</p>
|
|
</div>
|
|
</div>`;
|
|
container.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
let html = `<div class="results-section">
|
|
<div class="section-title">Results (${listings.length})</div>`;
|
|
|
|
for (const l of listings) {
|
|
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 listing';
|
|
const price = l.price || '';
|
|
const link = l.url
|
|
? `<a href="${escHtml(l.url)}" target="_blank">${escHtml(title)}</a>`
|
|
: escHtml(title);
|
|
|
|
const imgHtml = l.image
|
|
? `<img class="card-img" src="${escHtml(l.image)}" alt="" onerror="this.outerHTML='<div class=\\'card-img-placeholder\\'>🚗</div>'">`
|
|
: `<div class="card-img-placeholder">🚗</div>`;
|
|
|
|
html += `
|
|
<div class="listing-card">
|
|
<div class="card-body">
|
|
${imgHtml}
|
|
<div class="card-info">
|
|
<div class="card-title">${link}</div>
|
|
<div class="card-meta">
|
|
${price ? `<span class="card-price">${escHtml(price)}</span>` : ''}
|
|
<span class="source-badge">${escHtml(l.source || '?')}</span>
|
|
${scoreBadge}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
container.style.display = 'block';
|
|
}
|
|
|
|
// --- Render guide ---
|
|
function renderGuide(guide) {
|
|
const container = document.getElementById('guide-container');
|
|
if (!guide || Object.keys(guide).length === 0) {
|
|
container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const sections = [
|
|
{ key: 'top_picks', title: 'Top Picks', defaultOpen: true },
|
|
{ key: 'checklist', title: 'What to Check', defaultOpen: false },
|
|
{ key: 'mechanics', title: 'Nearby Mechanics', defaultOpen: false },
|
|
{ key: 'negotiation', title: 'Negotiation Tips', defaultOpen: false },
|
|
{ key: 'budget', title: 'Budget Breakdown', defaultOpen: false },
|
|
];
|
|
|
|
let html = `<div class="guide-section">
|
|
<div class="section-title">Your Custom Buying Guide</div>`;
|
|
|
|
for (const sec of sections) {
|
|
const content = guide[sec.key];
|
|
if (!content) continue;
|
|
const openClass = sec.defaultOpen ? 'open' : '';
|
|
html += `
|
|
<div class="guide-accordion ${openClass}">
|
|
<div class="guide-accordion-header" onclick="this.parentElement.classList.toggle('open')">
|
|
<h3>${sec.title}</h3>
|
|
<span class="arrow">▼</span>
|
|
</div>
|
|
<div class="guide-accordion-body">
|
|
${renderMarkdown(content)}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
container.style.display = 'block';
|
|
}
|
|
|
|
// --- Simple markdown renderer ---
|
|
function renderMarkdown(text) {
|
|
if (!text) return '';
|
|
let html = escHtml(text);
|
|
|
|
// Bold
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
// Italic
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
// Inline code
|
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
|
|
// Headers (process before line breaks)
|
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// Unordered lists
|
|
html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
// Wrap consecutive <li> in <ul>
|
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
|
|
|
// Numbered lists
|
|
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
|
|
|
// Paragraphs: double newlines
|
|
html = html.replace(/\n\n/g, '</p><p>');
|
|
// Single newlines that aren't inside tags
|
|
html = html.replace(/\n/g, '<br>');
|
|
|
|
// Wrap in paragraph if not starting with a tag
|
|
if (!html.startsWith('<')) {
|
|
html = '<p>' + html + '</p>';
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
// --- Util ---
|
|
function escHtml(s) {
|
|
if (!s) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = String(s);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// --- Init ---
|
|
renderFilters();
|
|
|
|
// noVNC iframe setup
|
|
(function() {
|
|
const vncFrame = document.getElementById('vnc-frame');
|
|
const host = window.location.hostname;
|
|
const proto = window.location.protocol;
|
|
if (host.endsWith('.ampbox.io')) {
|
|
vncFrame.src = proto + '//vnc.ampbox.io/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000';
|
|
} else {
|
|
vncFrame.src = proto + '//' + host + ':6080/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000';
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|