Files
Scrapyard/static/index.html

1090 lines
52 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrapyard Laptop Browser</title>
<style>
/* ── Reset / Base ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--card: #21263a;
--border: #2e3350;
--accent: #5b8af0;
--accent2: #3ecf8e;
--text: #e2e8f0;
--muted: #8892b0;
--danger: #f06565;
--radius: 8px;
--sidebar: 300px;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', system-ui, sans-serif; font-size: 14px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* ── Top bar ──────────────────────────────────────────────────────── */
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; }
header h1 { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
header h1 span { color: var(--accent); }
#search-box { flex: 1; max-width: 400px; padding: 7px 12px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; outline: none; }
#search-box:focus { border-color: var(--accent); }
#sort-select { padding: 7px 10px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px; cursor: pointer; }
#result-count { margin-left: auto; color: var(--muted); font-size: 13px; white-space: nowrap; }
/* ── Layout ───────────────────────────────────────────────────────── */
.workspace { display: flex; flex: 1; overflow: hidden; }
/* ── Sidebar ──────────────────────────────────────────────────────── */
aside { width: var(--sidebar); min-width: var(--sidebar); background: var(--surface); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 14px; flex-shrink: 0; }
aside h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 14px; }
.filter-section { margin-bottom: 20px; }
.filter-section label.section-label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin-bottom: 8px; }
.filter-row { display: flex; gap: 6px; margin-bottom: 6px; }
.filter-row input[type=number] { width: 100%; padding: 5px 8px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px; -moz-appearance: textfield; }
.filter-row input[type=number]:focus { outline: none; border-color: var(--accent); }
.filter-row span { align-self: center; color: var(--muted); flex-shrink: 0; }
.chip-group { display: flex; flex-wrap: wrap; gap: 5px; }
.chip { padding: 3px 10px; background: var(--card); border: 1px solid var(--border); border-radius: 20px; font-size: 12px; cursor: pointer; user-select: none; transition: background .15s, border-color .15s; }
.chip:hover { border-color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.toggle-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer; }
.toggle-row input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; cursor: pointer; }
.toggle-row span { font-size: 13px; }
#btn-reset { width: 100%; padding: 8px; background: transparent; border: 1px solid var(--border); border-radius: var(--radius); color: var(--muted); cursor: pointer; font-size: 13px; transition: border-color .15s, color .15s; }
#btn-reset:hover { border-color: var(--danger); color: var(--danger); }
/* ── Main content ─────────────────────────────────────────────────── */
main { flex: 1; overflow-y: auto; padding: 16px; }
#laptop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
/* ── Laptop card ──────────────────────────────────────────────────── */
.laptop-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color .15s, transform .1s; display: flex; flex-direction: column; gap: 8px; }
.laptop-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.card-title { font-weight: 600; font-size: 13px; line-height: 1.4; }
.card-price { font-size: 16px; font-weight: 700; color: var(--accent2); white-space: nowrap; }
.card-orig-price { font-size: 11px; color: var(--muted); text-decoration: line-through; }
.card-badges { display: flex; flex-wrap: wrap; gap: 4px; }
.badge { padding: 2px 7px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge-grade { background: #2d3b55; color: #7eb8f5; }
.badge-ram { background: #2d4a3e; color: #3ecf8e; }
.badge-storage { background: #3b2d4a; color: #bf85f7; }
.badge-screen { background: #3b3a2d; color: #f5c842; }
.badge-gpu-nvidia { background: #1e3a1e; color: #4ddb4d; }
.badge-gpu-amd { background: #3a1e1e; color: #db4d4d; }
.badge-gpu-intel { background: #1e2a3a; color: #4d9edb; }
.badge-gpu-apple { background: #2a1e3a; color: #b04ddb; }
.badge-gpu-default { background: #2a2a2a; color: #aaa; }
.card-cpu { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-extra { font-size: 11px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
.card-extra span::before { content: '·'; margin-right: 4px; }
.card-extra span:first-child::before { display: none; }
/* ── Pagination ───────────────────────────────────────────────────── */
#pagination { display: flex; justify-content: center; align-items: center; gap: 8px; padding: 20px 0 10px; }
.page-btn { padding: 6px 14px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); cursor: pointer; font-size: 13px; transition: border-color .15s; }
.page-btn:hover:not(:disabled) { border-color: var(--accent); }
.page-btn:disabled { opacity: .4; cursor: default; }
.page-btn.active { border-color: var(--accent); color: var(--accent); }
#page-info { color: var(--muted); font-size: 13px; }
/* ── Detail drawer ────────────────────────────────────────────────── */
#detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: none; }
#detail-overlay.open { display: block; }
#detail-panel { position: fixed; top: 0; right: 0; width: min(700px, 100vw); height: 100vh; background: var(--surface); border-left: 1px solid var(--border); z-index: 101; overflow-y: auto; transform: translateX(100%); transition: transform .25s ease; }
#detail-panel.open { transform: translateX(0); }
.detail-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--border); gap: 16px; }
.detail-header h2 { font-size: 17px; font-weight: 700; line-height: 1.4; flex: 1; }
.detail-header .close-btn { background: none; border: none; color: var(--muted); font-size: 24px; cursor: pointer; line-height: 1; padding: 0; flex-shrink: 0; }
.detail-header .close-btn:hover { color: var(--text); }
.detail-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 24px; }
.detail-prices { display: flex; align-items: baseline; gap: 14px; }
.detail-price { font-size: 28px; font-weight: 800; color: var(--accent2); }
.detail-orig { font-size: 16px; color: var(--muted); text-decoration: line-through; }
.detail-discount { font-size: 13px; color: var(--accent2); font-weight: 600; }
.detail-section { }
.detail-section h3 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
.spec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; }
.spec-item { }
.spec-item .spec-label { font-size: 11px; color: var(--muted); margin-bottom: 2px; }
.spec-item .spec-value { font-size: 13px; font-weight: 500; }
.spec-item .spec-value.highlight { color: var(--accent2); }
.detail-link { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; padding: 7px 14px; background: var(--accent); color: #fff; border-radius: var(--radius); text-decoration: none; font-size: 13px; font-weight: 600; transition: opacity .15s; }
.detail-link:hover { opacity: .85; }
.detail-link.secondary { background: var(--card); border: 1px solid var(--border); color: var(--text); }
.detail-link.secondary:hover { border-color: var(--accent); }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.cpu-enriched-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; background: #2d3b55; color: #7eb8f5; border-radius: 4px; font-size: 11px; font-weight: 600; margin-bottom: 8px; }
.badge-source-bt { background: #2d3b55; color: #7eb8f5; }
.badge-source-nuvoo { background: #1e3a2d; color: #3ecf8e; }
/* ── Card image ─────────────────────────────────────────────────────── */
.card-thumb-wrap { position: relative; width: 100%; height: 140px; border-radius: 4px; overflow: hidden; background: #111827; flex-shrink: 0; }
.card-image { width: 100%; height: 100%; object-fit: contain; border-radius: 4px; background: #111827; display: block; }
.card-image-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 11px; }
.card-source-tag { position: absolute; bottom: 6px; right: 6px; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .3px; pointer-events: none; }
.card-source-tag.bt { background: rgba(91,138,240,.85); color: #fff; }
.card-source-tag.nuvoo { background: rgba(62,207,142,.85); color: #0f1117; }
/* ── Card spec list ──────────────────────────────────────────────────── */
.card-specs { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
.card-spec-row { display: flex; gap: 6px; line-height: 1.4; }
.card-spec-label { color: var(--muted); flex-shrink: 0; width: 58px; font-size: 11px; padding-top: 1px; }
.card-spec-value { color: var(--text); flex: 1; }
/* ── Detail image gallery ───────────────────────────────────────────── */
.image-gallery { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.image-gallery img { width: 90px; height: 90px; object-fit: contain; border-radius: 4px; background: #111827; cursor: pointer; border: 2px solid transparent; transition: border-color .15s; flex-shrink: 0; }
.image-gallery img.active, .image-gallery img:hover { border-color: var(--accent); }
.image-hero { width: 100%; max-height: 300px; object-fit: contain; border-radius: var(--radius); background: #111827; margin-bottom: 10px; }
/* ── Empty / loading states ───────────────────────────────────────── */
#empty-state { display: none; text-align: center; padding: 60px 20px; color: var(--muted); }
#empty-state svg { margin-bottom: 16px; opacity: .4; }
.loading-spinner { display: none; text-align: center; padding: 60px 20px; color: var(--muted); }
/* ── Scrape panel ─────────────────────────────────────────────────── */
.scrape-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); }
.scrape-section h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 12px; }
.scrape-item { margin-bottom: 10px; }
.scrape-btn { width: 100%; padding: 7px 10px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 12px; cursor: pointer; text-align: left; transition: border-color .15s; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.scrape-btn:hover:not(:disabled) { border-color: var(--accent); }
.scrape-btn:disabled { opacity: .5; cursor: default; }
.scrape-btn .btn-label { font-weight: 600; }
.scrape-status { font-size: 10px; color: var(--muted); margin-top: 4px; padding: 0 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scrape-status.running { color: var(--accent); }
.scrape-status.error { color: var(--danger); }
.spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Scrollbar ────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Alternatives ───────────────────────────────────────────── */
.alt-accordion { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; transition: border-color .2s, box-shadow .2s; }
.alt-accordion.has-deal { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.18); }
.alt-accordion-hdr { display: flex; align-items: center; gap: 8px; padding: 11px 14px; cursor: pointer; user-select: none; font-weight: 600; font-size: 13px; background: var(--surface); }
.alt-accordion-hdr:hover { background: var(--card); }
.alt-accordion.has-deal .alt-accordion-hdr { background: rgba(245,158,11,.08); }
.alt-accordion-hdr .alt-hdr-icon { font-size: 14px; }
.alt-accordion-hdr .alt-count { background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
.alt-accordion.has-deal .alt-accordion-hdr .alt-count { background: #f59e0b; }
.alt-accordion-hdr .alt-chevron { margin-left: auto; font-size: 10px; color: var(--muted); transition: transform .2s; }
.alt-accordion.open .alt-chevron { transform: rotate(180deg); }
.alt-accordion-body { display: none; padding: 0 14px 12px; }
.alt-accordion.open .alt-accordion-body { display: block; }
.alt-group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin: 14px 0 6px; }
.alt-card { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color .15s; margin-bottom: 6px; }
.alt-card:hover { border-color: var(--accent); }
.alt-card-thumb { width: 52px; height: 40px; object-fit: contain; border-radius: 3px; background: #111827; flex-shrink: 0; }
.alt-card-thumb-placeholder { width: 52px; height: 40px; background: var(--surface); border-radius: 3px; flex-shrink: 0; }
.alt-card-body { flex: 1; min-width: 0; }
.alt-card-title { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.alt-card-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.alt-card-right { text-align: right; flex-shrink: 0; }
.alt-card-price { font-size: 13px; font-weight: 700; color: var(--accent); }
.alt-reason { display: inline-block; margin-top: 3px; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 3px; }
.alt-reason.cheaper { background: rgba(52,211,153,.15); color: #34d399; }
.alt-reason.better { background: rgba(96,165,250,.15); color: #60a5fa; }</style>
</head>
<body>
<!-- Top bar -->
<header>
<h1>Scrap<span>yard</span> <span style="color:var(--muted);font-weight:400;font-size:14px">Laptop Browser</span></h1>
<input id="search-box" type="search" placeholder="Search title, brand, CPU, GPU…">
<select id="sort-select">
<option value="price_asc">Price: Low → High</option>
<option value="price_desc">Price: High → Low</option>
<option value="ram_desc">RAM: Most first</option>
<option value="storage_desc">Storage: Most first</option>
<option value="brand_asc">Brand AZ</option>
</select>
<span id="result-count"></span>
</header>
<div class="workspace">
<!-- Sidebar filters -->
<aside>
<h2>Filters</h2>
<!-- Price -->
<div class="filter-section">
<label class="section-label">Price (SEK)</label>
<div class="filter-row">
<input id="price-min" type="number" placeholder="Min">
<span></span>
<input id="price-max" type="number" placeholder="Max">
</div>
</div>
<!-- RAM -->
<div class="filter-section">
<label class="section-label">RAM (GB)</label>
<div id="ram-chips" class="chip-group"></div>
</div>
<!-- Storage -->
<div class="filter-section">
<label class="section-label">Storage (GB)</label>
<div id="storage-chips" class="chip-group"></div>
</div>
<!-- Brand -->
<div class="filter-section">
<label class="section-label">Brand</label>
<div id="brand-chips" class="chip-group"></div>
</div>
<!-- CPU Cores -->
<div class="filter-section">
<label class="section-label">Min CPU Cores</label>
<div id="cores-chips" class="chip-group"></div>
</div>
<!-- Storage type -->
<div class="filter-section">
<label class="section-label">Storage Type</label>
<div id="storage-type-chips" class="chip-group"></div>
</div>
<!-- RAM type -->
<div class="filter-section">
<label class="section-label">RAM Type</label>
<div id="ram-type-chips" class="chip-group"></div>
</div>
<!-- Source -->
<div class="filter-section">
<label class="section-label">Source</label>
<div id="source-chips" class="chip-group"></div>
</div>
<!-- Condition -->
<div class="filter-section">
<label class="section-label">Condition</label>
<div id="condition-chips" class="chip-group"></div>
</div>
<!-- GPU -->
<div class="filter-section">
<label class="section-label">GPU</label>
<div id="gpu-family-chips" class="chip-group"></div>
</div>
<!-- Toggles -->
<div class="filter-section">
<label class="section-label">Features</label>
<label class="toggle-row"><input id="tog-dedicated" type="checkbox"><span>Dedicated GPU only</span></label>
<label class="toggle-row"><input id="tog-touch" type="checkbox"><span>Touchscreen</span></label>
<label class="toggle-row"><input id="tog-backlit" type="checkbox"><span>Backlit Keyboard</span></label>
<label class="toggle-row"><input id="tog-webcam" type="checkbox"><span>Webcam</span></label>
</div>
<button id="btn-reset">Reset all filters</button>
<!-- Scraping controls -->
<div class="scrape-section">
<h2>Scraping</h2>
<div class="scrape-item">
<button class="scrape-btn" id="scrape-billig" data-scraper="billigteknik">
<span class="btn-label">Scrape BilligTeknik</span>
<span class="spinner" id="spin-billigteknik" style="display:none"></span>
</button>
<div class="scrape-status" id="status-billigteknik">Idle</div>
</div>
<div class="scrape-item">
<button class="scrape-btn" id="scrape-nuvoo" data-scraper="nuvoo">
<span class="btn-label">Scrape Nuvoo</span>
<span class="spinner" id="spin-nuvoo" style="display:none"></span>
</button>
<div class="scrape-status" id="status-nuvoo">Idle</div>
</div>
<div class="scrape-item">
<button class="scrape-btn" id="scrape-cpu" data-scraper="cpu">
<span class="btn-label">Enrich CPUs</span>
<span class="spinner" id="spin-cpu" style="display:none"></span>
</button>
<div class="scrape-status" id="status-cpu">Idle</div>
</div>
</div>
</aside>
<!-- Main laptop grid -->
<main>
<div class="loading-spinner" id="loading">Loading…</div>
<div id="empty-state">
<svg width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div>No laptops match your filters.</div>
</div>
<div id="laptop-grid"></div>
<div id="pagination"></div>
</main>
</div>
<!-- Detail overlay + panel -->
<div id="detail-overlay"></div>
<div id="detail-panel">
<div class="detail-header">
<h2 id="detail-title"></h2>
<button class="close-btn" id="detail-close">×</button>
</div>
<div class="detail-body" id="detail-body"></div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const state = {
q: '', sort: 'price_asc', page: 1, pageSize: 48,
brands: [], ramValues: [], storageValues: [],
coresMin: null, ramTypes: [], storageTypes: [], gpuFamilies: [], sources: [],
conditions: [],
priceMin: '', priceMax: '',
touchscreen: false, backlit: false, webcam: false, dedicatedGpu: false,
};
let debounceTimer = null;
// ── Helpers ────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const fmt = n => n != null ? n.toLocaleString('sv-SE') : '';
function buildQuery() {
const p = new URLSearchParams();
if (state.q) p.set('q', state.q);
p.set('sort', state.sort);
p.set('page', state.page);
p.set('page_size', state.pageSize);
if (state.priceMin) p.set('price_min', state.priceMin);
if (state.priceMax) p.set('price_max', state.priceMax);
state.brands.forEach(v => p.append('brand', v));
state.ramValues.forEach(v => p.append('ram_min', v)); // reuse as exact chips
state.storageValues.forEach(v => p.append('storage_min', v));
state.ramTypes.forEach(v => p.append('ram_type', v));
state.storageTypes.forEach(v => p.append('storage_type', v));
if (state.coresMin) p.set('cpu_cores_min', state.coresMin);
if (state.touchscreen) p.set('touchscreen', '1');
if (state.backlit) p.set('backlit', '1');
if (state.webcam) p.set('webcam', '1');
return p;
}
// For single RAM / storage chip selections we want exact match behaviour.
// We override the query to use exact IN-style by mapping chip values to min.
// (server handles this as: ram_gb >= val; we deselect all others so effectively exact)
// A cleaner approach: we send only the selected values as separate ram_min params →
// replaced by proper multi filter below.
function buildQueryProper() {
const p = new URLSearchParams();
if (state.q) p.set('q', state.q);
p.set('sort', state.sort);
p.set('page', state.page);
p.set('page_size', state.pageSize);
if (state.priceMin) p.set('price_min', state.priceMin);
if (state.priceMax) p.set('price_max', state.priceMax);
state.brands.forEach(v => p.append('brand', v));
state.ramTypes.forEach(v => p.append('ram_type', v));
state.storageTypes.forEach(v => p.append('storage_type', v));
if (state.coresMin) p.set('cpu_cores_min', state.coresMin);
state.gpuFamilies.forEach(v => p.append('gpu_family', v));
state.sources.forEach(v => p.append('source', v));
state.conditions.forEach(v => p.append('condition', v));
if (state.dedicatedGpu) p.set('dedicated_gpu', '1');
if (state.touchscreen) p.set('touchscreen', '1');
if (state.backlit) p.set('backlit', '1');
if (state.webcam) p.set('webcam', '1');
// RAM: if chips selected, set range to minmax of selected
if (state.ramValues.length) {
p.set('ram_min', Math.min(...state.ramValues));
p.set('ram_max', Math.max(...state.ramValues));
}
// Storage chips: same idea
if (state.storageValues.length) {
p.set('storage_min', Math.min(...state.storageValues));
p.set('storage_max', Math.max(...state.storageValues));
}
return p;
}
// ── Chips helper ───────────────────────────────────────────────────────────
function makeChips(containerId, values, stateKey, label = v => v, onChange) {
const container = $(containerId);
if (!container) return;
container.innerHTML = '';
values.forEach(v => {
const chip = document.createElement('div');
chip.className = 'chip' + (state[stateKey].includes(v) ? ' active' : '');
chip.textContent = label(v);
chip.addEventListener('click', () => {
if (state[stateKey].includes(v)) {
state[stateKey] = state[stateKey].filter(x => x !== v);
} else {
state[stateKey].push(v);
}
chip.classList.toggle('active');
state.page = 1;
onChange();
});
container.appendChild(chip);
});
}
function makeSingleChips(containerId, values, stateKey, label = v => v, onChange) {
// Single-select: clicking sets the value; clicking again clears
const container = $(containerId);
if (!container) return;
container.innerHTML = '';
values.forEach(v => {
const chip = document.createElement('div');
chip.className = 'chip' + (state[stateKey] === v ? ' active' : '');
chip.textContent = label(v);
chip.addEventListener('click', () => {
state[stateKey] = state[stateKey] === v ? null : v;
container.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
if (state[stateKey] !== null) chip.classList.add('active');
state.page = 1;
onChange();
});
container.appendChild(chip);
});
}
// ── Render card ────────────────────────────────────────────────────────────
function renderCard(l) {
const discount = l.original_price_sek && l.price_sek
? Math.round((1 - l.price_sek / l.original_price_sek) * 100)
: null;
const firstImage = Array.isArray(l.image_urls) && l.image_urls.length
? l.image_urls[0] : null;
// ── Spec rows ──────────────────────────────────────────────────────────
const specRows = [];
// Size & Screen — always shown
const cleanSz = cleanScreenSize(l.screen_size);
const screenParts = [cleanSz];
if (l.screen_resolution) screenParts.push(l.screen_resolution);
// Only append screen_type if not already embedded in screen_size
if (l.screen_type && !cleanSz.toLowerCase().includes(l.screen_type.toLowerCase())) {
screenParts.push(l.screen_type);
}
specRows.push(specRow('Size', screenParts.filter(Boolean).join(' · ') || 'Unknown'));
// CPU
const cpuName = l.cpu_model || l.cpu_full;
const cores = l.cs_total_cores || l.cpu_cores;
const threads = l.cs_total_threads || l.cpu_threads;
if (cpuName) {
let cpuVal = escHtml(cpuName);
if (cores) {
cpuVal += ` <span style="color:var(--muted)">(C${cores}${threads ? '/T' + threads : ''})</span>`;
}
specRows.push(specRowRaw('CPU', cpuVal));
}
// GPU — dedicated GPU and iGPU as separate rows
// l.has_dedicated_gpu distinguishes a discrete dGPU from an iGPU stored in l.gpu
if (l.gpu && l.has_dedicated_gpu) specRows.push(specRow('GPU', l.gpu));
const igpu = l.cs_igpu_name || (!l.has_dedicated_gpu && l.gpu ? l.gpu : null);
if (igpu) specRows.push(specRow('iGPU', igpu));
// Storage
if (l.storage_gb != null) {
const storVal = l.storage_gb + ' GB' + (l.storage_type ? ' ' + l.storage_type : '');
specRows.push(specRow('Storage', storVal));
} else {
specRows.push(specRow('Storage', 'Unknown'));
}
// Memory
if (l.ram_gb != null) {
const maxRam = l.cs_max_ram_gb || l.max_ram_gb;
const ramVal = l.ram_gb + ' GB' + (maxRam ? ` <span style="color:var(--muted)">(max ${maxRam} GB)</span>` : '');
specRows.push(specRowRaw('Memory', ramVal));
} else {
specRows.push(specRow('Memory', 'Unknown'));
}
// Extra — only if something notable
const extras = [];
if (l.touchscreen) extras.push('Touch');
if (l.keyboard_backlit) extras.push('Backlit KB');
if (l.has_4g_5g) extras.push('4G/5G');
if (extras.length) specRows.push(specRow('Extra', extras.join(', ')));
// ── Build card ─────────────────────────────────────────────────────────
const card = document.createElement('div');
card.className = 'laptop-card';
card.innerHTML = `
<div class="card-thumb-wrap">
${firstImage
? `<img class="card-image" src="${escHtml(firstImage)}" alt="${escHtml(l.title)}" loading="lazy">`
: `<div class="card-image-placeholder">No image</div>`}
<span class="card-source-tag ${l.source === 'nuvoo' ? 'nuvoo' : 'bt'}">
${l.source === 'nuvoo' ? 'Nuvoo' : 'BilligTeknik'}
</span>
</div>
<div class="card-header">
<div class="card-title">${escHtml(l.title)}</div>
<div style="text-align:right;flex-shrink:0">
<div class="card-price">${fmt(l.price_sek)} kr</div>
${l.original_price_sek && l.original_price_sek > l.price_sek
? `<div class="card-orig-price">${fmt(l.original_price_sek)} kr</div>` : ''}
${discount && discount > 0
? `<div style="font-size:11px;color:#f06565;font-weight:600">-${discount}%</div>` : ''}
</div>
</div>
<div class="card-specs">${specRows.join('')}</div>
`;
card.addEventListener('click', () => loadDetail(l.id));
return card;
}
function specRow(label, value) {
return `<div class="card-spec-row"><span class="card-spec-label">${escHtml(label)}</span><span class="card-spec-value">${escHtml(String(value))}</span></div>`;
}
function specRowRaw(label, valueHtml) {
return `<div class="card-spec-row"><span class="card-spec-label">${escHtml(label)}</span><span class="card-spec-value">${valueHtml}</span></div>`;
}
function gpuBadge(family, dedicated) {
if (!family) return '';
let cls = 'badge-gpu-default';
if (family === 'NVIDIA') cls = 'badge-gpu-nvidia';
else if (family === 'AMD Radeon') cls = 'badge-gpu-amd';
else if (family.startsWith('Intel')) cls = 'badge-gpu-intel';
else if (family === 'Apple GPU') cls = 'badge-gpu-apple';
const star = dedicated ? ' ★' : '';
return `<span class="badge ${cls}">${escHtml(family)}${star}</span>`;
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function cleanScreenSize(s) {
if (!s) return '';
// Positively extract the numeric size from values like "Pekskärm 11.6\"" or "14\" Full HD LED-skärm"
const m = s.match(/[\d,.]+\s*(?:[-\u2013]?\s*tum|["\u2033\u201d\u201c])/i);
return m ? m[0].trim() : s;
}
// ── Load laptops ───────────────────────────────────────────────────────────
async function loadLaptops() {
$('loading').style.display = 'block';
$('empty-state').style.display = 'none';
$('laptop-grid').innerHTML = '';
$('pagination').innerHTML = '';
try {
const res = await fetch('/api/laptops?' + buildQueryProper());
const data = await res.json();
$('loading').style.display = 'none';
$('result-count').textContent = `${data.total.toLocaleString()} laptops`;
if (!data.laptops.length) {
$('empty-state').style.display = 'block';
return;
}
const grid = $('laptop-grid');
data.laptops.forEach(l => grid.appendChild(renderCard(l)));
renderPagination(data.page, data.pages);
} catch (e) {
$('loading').style.display = 'none';
console.error(e);
}
}
// ── Pagination ─────────────────────────────────────────────────────────────
function renderPagination(current, total) {
if (total <= 1) return;
const div = $('pagination');
const addBtn = (label, page, disabled = false, active = false) => {
const b = document.createElement('button');
b.className = 'page-btn' + (active ? ' active' : '');
b.textContent = label;
b.disabled = disabled;
b.addEventListener('click', () => { state.page = page; loadLaptops(); window.scrollTo(0,0); });
div.appendChild(b);
};
addBtn(' Prev', current - 1, current === 1);
// Show window of pages
const start = Math.max(1, current - 2);
const end = Math.min(total, current + 2);
if (start > 1) { addBtn('1', 1); if (start > 2) div.appendChild(Object.assign(document.createElement('span'), { className: 'page-btn', textContent: '…', style: 'cursor:default; opacity:.4' })); }
for (let i = start; i <= end; i++) addBtn(i, i, false, i === current);
if (end < total) { if (end < total - 1) div.appendChild(Object.assign(document.createElement('span'), { className: 'page-btn', textContent: '…', style: 'cursor:default; opacity:.4' })); addBtn(total, total); }
addBtn('Next ', current + 1, current === total);
const info = document.createElement('span');
info.id = 'page-info';
info.textContent = `Page ${current} of ${total}`;
div.appendChild(info);
}
// ── Detail panel ───────────────────────────────────────────────────────────
async function loadDetail(id) {
const res = await fetch(`/api/laptops/${id}`);
const l = await res.json();
$('detail-title').textContent = l.title;
const discount = l.original_price_sek && l.price_sek
? Math.round((1 - l.price_sek / l.original_price_sek) * 100)
: null;
const hasCpuSpecs = l.cs_lookup_status === 'ok';
// Images
const images = Array.isArray(l.image_urls) ? l.image_urls : [];
const imageSection = images.length ? `
<div class="detail-section">
<img id="detail-hero" class="image-hero" src="${escHtml(images[0])}" alt="${escHtml(l.title)}">
${images.length > 1 ? `
<div class="image-gallery">
${images.map((src, i) => `<img src="${escHtml(src)}" class="${i===0?'active':''}" onclick="switchHero('${escHtml(src)}',this)" loading="lazy">`).join('')}
</div>` : ''}
</div>` : '';
// Source link label
const sourceLabel = l.source === 'nuvoo' ? 'View on Nuvoo ↗' : 'View on BilligTeknik ↗';
// Clean URL (strip ?grade=... suffix if Nuvoo)
const productUrl = (l.url || '').replace(/\?grade=\w+$/, '');
let raw = {};
if (l.specs_raw && typeof l.specs_raw === 'object') raw = l.specs_raw;
$('detail-body').innerHTML = `
${imageSection}
<!-- Price -->
<div class="detail-section">
<div class="detail-prices">
<div class="detail-price">${fmt(l.price_sek)} kr</div>
${l.original_price_sek && l.original_price_sek > l.price_sek
? `<div class="detail-orig">${fmt(l.original_price_sek)} kr</div>
<div class="detail-discount">-${discount}% off</div>`
: ''}
</div>
<div style="margin-top:8px; display:flex; gap:6px; flex-wrap:wrap">
${l.condition_grade ? `<span class="badge badge-grade">Condition: ${escHtml(l.condition_grade)}</span>` : ''}
${l.condition_detail ? `<span style="font-size:12px;color:var(--muted)">${escHtml(l.condition_detail)}</span>` : ''}
</div>
</div>
<!-- Actions -->
<div class="detail-actions">
<a class="detail-link" href="${escHtml(productUrl)}" target="_blank" rel="noopener">
${sourceLabel}
</a>
</div>
<div id="alt-section"></div>
<!-- Display -->
<div class="detail-section">
<h3>Display</h3>
<div class="spec-grid">
${specItem('Screen Size', cleanScreenSize(l.screen_size))}
${specItem('Resolution', l.screen_resolution)}
${specItem('Panel Type', l.screen_type)}
${specItem('Touchscreen', l.touchscreen ? 'Yes' : 'No')}
</div>
</div>
<!-- CPU -->
<div class="detail-section">
<h3>Processor</h3>
${hasCpuSpecs ? '<div class="cpu-enriched-badge">✦ Enriched CPU data</div>' : ''}
<div class="spec-grid">
${specItem('CPU', l.cs_full_name || l.cpu_full)}
${specItem('Model', l.cpu_model)}
${specItem('Codename', l.cs_codename)}
${specItem('Lithography', l.cs_lithography)}
${specItem('Launch Date', l.cs_launch_date)}
${specItem('Cores', hasCpuSpecs ? l.cs_total_cores : l.cpu_cores, hasCpuSpecs)}
${hasCpuSpecs && l.cs_performance_cores ? specItem('Perf Cores', l.cs_performance_cores) : ''}
${hasCpuSpecs && l.cs_efficient_cores ? specItem('Eff Cores', l.cs_efficient_cores) : ''}
${specItem('Threads', hasCpuSpecs ? l.cs_total_threads : l.cpu_threads, hasCpuSpecs)}
${specItem('Base Freq', hasCpuSpecs ? (l.cs_base_freq_ghz ? l.cs_base_freq_ghz + ' GHz' : null) : (l.cpu_base_ghz ? l.cpu_base_ghz + ' GHz' : null), hasCpuSpecs)}
${specItem('Turbo Freq', hasCpuSpecs ? (l.cs_max_turbo_ghz ? l.cs_max_turbo_ghz + ' GHz' : null) : (l.cpu_turbo_ghz ? l.cpu_turbo_ghz + ' GHz' : null), hasCpuSpecs)}
${l.cs_l2_cache ? specItem('L2 Cache', l.cs_l2_cache) : ''}
${(l.cs_l3_cache || l.cpu_cache) ? specItem('L3 Cache', l.cs_l3_cache || l.cpu_cache) : ''}
${specItem('TDP', l.cs_tdp_w ? l.cs_tdp_w + ' W' : null)}
${l.cs_max_tdp_w ? specItem('Max Turbo Power', l.cs_max_tdp_w + ' W') : ''}
${specItem('PCIe', l.cs_pcie_version ? 'PCIe ' + l.cs_pcie_version : null)}
</div>
${hasCpuSpecs && l.cs_source_url ? `<a class="detail-link secondary" href="${escHtml(l.cs_source_url)}" target="_blank" rel="noopener" style="margin-top:10px;font-size:12px">Intel spec page ↗</a>` : ''}
</div>
<!-- Memory -->
<div class="detail-section">
<h3>Memory</h3>
<div class="spec-grid">
${specItem('RAM', l.ram_gb ? l.ram_gb + ' GB' : null, true)}
${specItem('RAM Type', l.cs_ram_types || l.ram_type)}
${specItem('Max RAM', (l.cs_max_ram_gb || l.max_ram_gb) ? (l.cs_max_ram_gb || l.max_ram_gb) + ' GB' : null)}
</div>
</div>
<!-- Storage -->
<div class="detail-section">
<h3>Storage</h3>
<div class="spec-grid">
${specItem('Capacity', l.storage_gb ? l.storage_gb + ' GB' : null, true)}
${specItem('Type', l.storage_type)}
</div>
</div>
<!-- Graphics -->
<div class="detail-section">
<h3>Graphics</h3>
<div class="spec-grid">
${l.has_dedicated_gpu ? specItem('GPU', l.gpu) : ''}
${specItem('iGPU', l.cs_igpu_name || (!l.has_dedicated_gpu ? l.gpu : null))}
${l.cs_igpu_base_mhz ? specItem('iGPU Base', l.cs_igpu_base_mhz + ' MHz') : ''}
${l.cs_igpu_boost_mhz ? specItem('iGPU Boost', (l.cs_igpu_boost_mhz / 1000).toFixed(2) + ' GHz') : ''}
${l.cs_igpu_exec_units ? specItem('Exec Units', l.cs_igpu_exec_units) : ''}
${l.cs_igpu_max_vram_gb ? specItem('Video RAM', l.cs_igpu_max_vram_gb + ' GB') : ''}
${l.cs_igpu_directx ? specItem('DirectX', l.cs_igpu_directx) : ''}
</div>
</div>
<!-- Connectivity -->
<div class="detail-section">
<h3>Connectivity</h3>
<div class="spec-grid">
${specItem('WiFi', l.wifi)}
${specItem('Bluetooth', l.bluetooth)}
${specItem('Ethernet', l.wired_ethernet ? 'Yes' : 'No')}
${specItem('4G/5G', l.has_4g_5g ? 'Yes' : 'No')}
${specItem('USB Ports', l.usb_ports)}
${specItem('Thunderbolt', l.thunderbolt_ports || 'None')}
${specItem('HDMI', l.hdmi)}
</div>
</div>
<!-- Physical -->
<div class="detail-section">
<h3>Physical</h3>
<div class="spec-grid">
${specItem('Weight', l.weight_kg ? l.weight_kg + ' kg' : null)}
${specItem('Dimensions', [l.width_cm, l.height_cm, l.depth_cm].every(Boolean) ? `${l.width_cm} × ${l.height_cm} × ${l.depth_cm} cm` : null)}
${specItem('Battery', l.battery)}
</div>
</div>
<!-- Features -->
<div class="detail-section">
<h3>Features</h3>
<div class="spec-grid">
${specItem('OS', l.operating_system)}
${specItem('Backlit KB', l.keyboard_backlit ? 'Yes' : 'No')}
${specItem('Webcam', l.webcam ? 'Yes' : 'No')}
${specItem('Optical Drive', l.optical_drive ? 'Yes' : 'No')}
${specItem('Warranty', l.warranty)}
${specItem('Eco Cert', l.eco_cert)}
</div>
</div>
`;
$('detail-overlay').classList.add('open');
$('detail-panel').classList.add('open');
loadAlternatives(id, l.price_sek);
}
// ── Alternatives panel ───────────────────────────────────────────────────
async function loadAlternatives(id, currentPrice) {
const section = $('alt-section');
if (!section) return;
try {
const res = await fetch(`/api/laptops/${id}/alternatives`);
const data = await res.json();
const cheaper = data.cheaper_same_spec || [];
const better = data.better_spec_same_price || [];
if (!cheaper.length && !better.length) return;
// Hot deal: any alternative is ≥15% cheaper, or any better-spec alternative exists
const hasDeal = cheaper.some(a => currentPrice && a.price_sek <= currentPrice * 0.85)
|| better.length > 0;
const totalCount = cheaper.length + better.length;
const icon = hasDeal ? '🔥 ' : '✦ ';
const renderAltCard = (a, type) => {
const imgs = Array.isArray(a.image_urls) ? a.image_urls : [];
const thumb = imgs[0]
? `<img class="alt-card-thumb" src="${escHtml(imgs[0])}" loading="lazy">`
: `<div class="alt-card-thumb-placeholder"></div>`;
const src = a.source === 'nuvoo'
? `<span style="color:#10b981;font-weight:700">Nuvoo</span>`
: `<span style="color:#3b82f6;font-weight:700">BilligTeknik</span>`;
const cond = a.condition_grade
? ` · <span style="color:var(--muted)">Grade ${escHtml(a.condition_grade)}</span>`
: '';
const specs = [
a.ram_gb ? a.ram_gb + ' GB RAM' : null,
a.storage_gb ? a.storage_gb + ' GB SSD' : null,
].filter(Boolean).join(' · ');
return `
<div class="alt-card" onclick="loadDetail(${a.id})">
${thumb}
<div class="alt-card-body">
<div class="alt-card-title">${escHtml(a.title)}</div>
<div class="alt-card-meta">${src}${cond}${specs ? ' · ' + escHtml(specs) : ''}</div>
</div>
<div class="alt-card-right">
<div class="alt-card-price">${fmt(a.price_sek)} kr</div>
<span class="alt-reason ${type}">${escHtml(a._reason)}</span>
</div>
</div>`;
};
let cardsHtml = '';
if (cheaper.length) {
cardsHtml += `<div class="alt-group-label">Same specs \u2014 lower price</div>`;
cardsHtml += cheaper.map(a => renderAltCard(a, 'cheaper')).join('');
}
if (better.length) {
cardsHtml += `<div class="alt-group-label">Same price \u2014 better specs</div>`;
cardsHtml += better.map(a => renderAltCard(a, 'better')).join('');
}
section.className = 'alt-accordion' + (hasDeal ? ' has-deal open' : '');
section.innerHTML = `
<div class="alt-accordion-hdr" onclick="this.closest('.alt-accordion').classList.toggle('open')">
<span class="alt-hdr-icon">${icon}</span>
<span>Alternatives</span>
<span class="alt-count">${totalCount}</span>
<span class="alt-chevron">▼</span>
</div>
<div class="alt-accordion-body">${cardsHtml}</div>
`;
} catch (e) {
console.error('Failed to load alternatives', e);
}
}
function specItem(label, value, highlight = false) {
return `<div class="spec-item">
<div class="spec-label">${escHtml(label)}</div>
<div class="spec-value ${highlight ? 'highlight' : ''}">${escHtml(String(value))}</div>
</div>`;
}
function switchHero(src, thumb) {
const hero = document.getElementById('detail-hero');
if (hero) hero.src = src;
document.querySelectorAll('.image-gallery img').forEach(i => i.classList.remove('active'));
if (thumb) thumb.classList.add('active');
}
function closeDetail() {
$('detail-overlay').classList.remove('open');
$('detail-panel').classList.remove('open');
}
$('detail-close').addEventListener('click', closeDetail);
$('detail-overlay').addEventListener('click', closeDetail);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
// ── Initialise filters ─────────────────────────────────────────────────────
async function initFilters() {
const res = await fetch('/api/filters');
const f = await res.json();
const refresh = () => { state.page = 1; loadLaptops(); };
makeChips('brand-chips', f.brands, 'brands', v => v, refresh);
makeChips('ram-type-chips', f.ram_types, 'ramTypes', v => v, refresh);
makeChips('storage-type-chips', f.storage_types, 'storageTypes', v => v, refresh);
makeChips('ram-chips', f.ram_values, 'ramValues', v => v + ' GB', refresh);
makeChips('storage-chips', f.storage_values, 'storageValues', v => v >= 1000 ? (v/1000)+'TB' : v+'GB', refresh);
makeSingleChips('cores-chips', f.cpu_cores_values.filter(v => v >= 2), 'coresMin', v => v+'+', refresh);
// GPU family chips label includes count
if (f.gpu_families && f.gpu_families.length) {
makeChips('gpu-family-chips', f.gpu_families.map(g => g.name), 'gpuFamilies', v => {
const entry = f.gpu_families.find(g => g.name === v);
return v + (entry ? ` (${entry.count})` : '');
}, refresh);
}
// Source chips
if (f.sources && f.sources.length) {
makeChips('source-chips', f.sources.map(s => s.name), 'sources', v => {
const entry = f.sources.find(s => s.name === v);
const label = v === 'nuvoo' ? 'Nuvoo' : 'BilligTeknik';
return label + (entry ? ` (${entry.count})` : '');
}, refresh);
}
// Condition chips
const GRADE_LABEL = { S: 'S Like New', A: 'A Excellent', B: 'B Good', C: 'C Fair' };
const condOrder = ['S', 'A', 'B', 'C'];
const condVals = condOrder.filter(g => (f.conditions || []).includes(g));
if (condVals.length) {
makeChips('condition-chips', condVals, 'conditions', v => GRADE_LABEL[v] || v, refresh);
}
// Price range inputs
$('price-min').placeholder = 'Min ' + (f.price_min || 0);
$('price-max').placeholder = 'Max ' + (f.price_max || '');
['price-min','price-max'].forEach(id => {
$(id).addEventListener('input', () => {
state.priceMin = $('price-min').value;
state.priceMax = $('price-max').value;
state.page = 1;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadLaptops, 500);
});
});
}
// ── Wire up controls ───────────────────────────────────────────────────────
$('search-box').addEventListener('input', e => {
state.q = e.target.value;
state.page = 1;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadLaptops, 350);
});
$('sort-select').addEventListener('change', e => {
state.sort = e.target.value;
state.page = 1;
loadLaptops();
});
['tog-dedicated','tog-touch','tog-backlit','tog-webcam'].forEach(id => {
$(id).addEventListener('change', e => {
const key = { 'tog-dedicated': 'dedicatedGpu', 'tog-touch': 'touchscreen', 'tog-backlit': 'backlit', 'tog-webcam': 'webcam' }[id];
state[key] = e.target.checked;
state.page = 1;
loadLaptops();
});
});
$('btn-reset').addEventListener('click', () => {
Object.assign(state, {
q: '', page: 1, brands: [], ramValues: [], storageValues: [],
coresMin: null, ramTypes: [], storageTypes: [], gpuFamilies: [], sources: [],
conditions: [],
priceMin: '', priceMax: '',
touchscreen: false, backlit: false, webcam: false, dedicatedGpu: false,
});
$('search-box').value = '';
$('sort-select').value = 'price_asc';
$('price-min').value = '';
$('price-max').value = '';
['tog-dedicated','tog-touch','tog-backlit','tog-webcam'].forEach(id => $(id).checked = false);
// Re-render chips to clear active state
initFilters();
loadLaptops();
});
// ── Boot ───────────────────────────────────────────────────────────────────
initFilters().then(() => loadLaptops());
// ── Scrape trigger panel ───────────────────────────────────────────────────
const SCRAPERS = ['billigteknik', 'nuvoo', 'cpu'];
function fmtTime(iso) {
if (!iso) return 'never';
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function applyStatus(name, st) {
const btn = document.querySelector(`.scrape-btn[data-scraper="${name}"]`);
const spin = $(`spin-${name}`);
const lbl = $(`status-${name}`);
if (!btn) return;
if (st.running) {
btn.disabled = true;
spin.style.display = 'inline-block';
lbl.textContent = 'Running…';
lbl.className = 'scrape-status running';
} else {
btn.disabled = false;
spin.style.display = 'none';
if (st.error) {
lbl.textContent = `Error: ${st.error}`;
lbl.className = 'scrape-status error';
} else if (st.last_run) {
lbl.textContent = `Last run: ${fmtTime(st.last_run)} (${st.last_count} items)`;
lbl.className = 'scrape-status';
} else {
lbl.textContent = 'Idle';
lbl.className = 'scrape-status';
}
}
}
async function pollScrapeStatus() {
try {
const data = await fetch('/api/scrape/status').then(r => r.json());
let anyRunning = false;
for (const name of SCRAPERS) {
if (data[name]) {
applyStatus(name, data[name]);
if (data[name].running) anyRunning = true;
}
}
return anyRunning;
} catch { return false; }
}
let _pollTimer = null;
function startPolling() {
if (_pollTimer) return;
_pollTimer = setInterval(async () => {
const anyRunning = await pollScrapeStatus();
if (!anyRunning) {
clearInterval(_pollTimer);
_pollTimer = null;
// Reload laptops list now that new data may be available
loadLaptops();
}
}, 2500);
}
// Wire up scrape buttons
document.querySelectorAll('.scrape-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const name = btn.dataset.scraper;
btn.disabled = true;
try {
const res = await fetch(`/api/scrape/${name}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({}) });
if (res.status === 202) {
applyStatus(name, { running: true, last_run: null, last_count: 0, error: null });
startPolling();
} else {
const data = await res.json();
alert(data.error || `Failed to start ${name} scraper.`);
btn.disabled = false;
}
} catch (e) {
alert(`Network error: ${e.message}`);
btn.disabled = false;
}
});
});
// Initial status fetch on page load
pollScrapeStatus();
</script>
</body>
</html>