1058 lines
50 KiB
HTML
1058 lines
50 KiB
HTML
<!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-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 A–Z</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 min–max 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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>
|
||
|
||
<!-- 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)}
|
||
${specItem('Max Turbo Power', l.cs_max_tdp_w ? l.cs_max_tdp_w + ' W' : null)}
|
||
${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))}
|
||
${specItem('iGPU Boost', l.cs_igpu_boost_mhz ? (l.cs_igpu_boost_mhz / 1000).toFixed(2) + ' GHz' : null)}
|
||
</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>
|
||
<div id="alt-section"></div>
|
||
`;
|
||
|
||
$('detail-overlay').classList.add('open');
|
||
$('detail-panel').classList.add('open');
|
||
loadAlternatives(id);
|
||
}
|
||
|
||
// ── Alternatives panel ───────────────────────────────────────────────────
|
||
async function loadAlternatives(id) {
|
||
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;
|
||
|
||
const renderCard = (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 html = '<div class="detail-section"><h3>Alternatives</h3>';
|
||
if (cheaper.length) {
|
||
html += `<div class="alt-group-label">Same specs \u2014 lower price</div>`;
|
||
html += cheaper.map(a => renderCard(a, 'cheaper')).join('');
|
||
}
|
||
if (better.length) {
|
||
html += `<div class="alt-group-label">Same price \u2014 better specs</div>`;
|
||
html += better.map(a => renderCard(a, 'better')).join('');
|
||
}
|
||
html += '</div>';
|
||
section.innerHTML = html;
|
||
} 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>
|