app: no-build PWA frontend (fidelity port) + image mirror
- app/static/index.html: single-file app — DOM/CSS/stack engine ported
verbatim from reference/mockup-001-fidelity.html (DEPTH/SC/TY/DIM, drag/
fly/undo, one .topcard, no-lock commits, stamps, dossier sheet,
pull-to-close, empty state); production deltas only (UI-SPEC §7): deck
buffer + cursor refill (40/page), optimistic swipe persistence with
2s/5s/10s/15s retry queue + toast, badge-once-per-session, real list
filters + search (server-side), real Matches screen, /img/{id} everywhere
with placeholder fallback, SW registration, safe areas, desktop centering
- manifest.json + sw.js (precache shell, network-first API, cache-first imgs)
- icons: 192/512/maskable/apple-touch-180, dark tile with the brand
accent-gradient mark (Pillow)
- app/imgfetch.py: local mirror, hunt's Chrome-120 UA, ~1s pacing,
skip-existing, .part writes atomic, per-item failure isolation,
fetched/skipped/failed summary; verified: --limit 15 -> 15 fetched, full
run 5 more, re-run 0 fetched 20 skipped (20/20 mirrored)
This commit is contained in:
114
app/imgfetch.py
Normal file
114
app/imgfetch.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Bike-Tinder local image mirror.
|
||||
|
||||
Downloads each active listing's card image once into data/img/{id}.webp.
|
||||
|
||||
.venv/bin/python -m app.imgfetch --db data/bike-tinder.sqlite3
|
||||
.venv/bin/python -m app.imgfetch --limit 15
|
||||
|
||||
Polite by design: ~1 req/s (sleep between fetches), skip-existing, crash-safe
|
||||
(partial bytes land in .part and are atomically renamed), per-item failures
|
||||
never kill the run. Honest end summary: `fetched N · skipped M · failed K`.
|
||||
|
||||
UA + habits match the hunt scanner (reference/motorcycle-hunt-scan.py) so the
|
||||
CDN sees the same client as the crawl. Stdlib only — no requests dependency.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from . import db
|
||||
|
||||
# Same UA string the hunt scanner uses.
|
||||
UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
|
||||
SLEEP = 1.0
|
||||
TIMEOUT = 20
|
||||
|
||||
|
||||
def is_webp(buf):
|
||||
return (len(buf) >= 12 and buf[:4] == b'RIFF' and buf[8:12] == b'WEBP')
|
||||
|
||||
|
||||
def fetch_one(img_url, dst):
|
||||
"""Download img_url -> dst. Returns 'fetched' or an error string."""
|
||||
part = dst + '.part'
|
||||
req = urllib.request.Request(
|
||||
img_url, headers={
|
||||
'User-Agent': UA,
|
||||
'Accept-Language': 'en-CA,en;q=0.9',
|
||||
'Accept': 'image/avif,image/webp,image/*,*/*;q=0.8',
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
if getattr(resp, 'status', 200) != 200:
|
||||
return 'HTTP %s' % resp.status
|
||||
buf = resp.read()
|
||||
if not is_webp(buf):
|
||||
return 'not webp (%d bytes, %r)' % (len(buf), buf[:8])
|
||||
with open(part, 'wb') as fh:
|
||||
fh.write(buf)
|
||||
os.replace(part, dst) # atomic: never a half file under the final name
|
||||
return 'fetched'
|
||||
except urllib.error.HTTPError as e:
|
||||
return 'HTTP %s' % e.code
|
||||
except Exception as e: # noqa: BLE001 — one bad item never kills the run
|
||||
return '%s: %s' % (type(e).__name__, e)
|
||||
finally:
|
||||
if os.path.exists(part):
|
||||
try:
|
||||
os.remove(part)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run_fetch(db_path, img_dir, limit=None, sleep=SLEEP, quiet=False):
|
||||
conn = db.connect(db_path)
|
||||
db.init_db(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id, img_url FROM listings"
|
||||
" WHERE status='active' AND img_url IS NOT NULL"
|
||||
" ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
fetched = skipped = failed = 0
|
||||
for row in rows:
|
||||
if limit is not None and fetched >= limit:
|
||||
break
|
||||
dst = os.path.join(img_dir, '%d.webp' % row['id'])
|
||||
if os.path.exists(dst):
|
||||
skipped += 1
|
||||
continue
|
||||
result = fetch_one(row['img_url'], dst)
|
||||
if result == 'fetched':
|
||||
fetched += 1
|
||||
else:
|
||||
failed += 1
|
||||
if not quiet:
|
||||
print(' fail id=%d %s' % (row['id'], result))
|
||||
time.sleep(sleep)
|
||||
return {'fetched': fetched, 'skipped': skipped, 'failed': failed}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description='Bike-Tinder image mirror')
|
||||
ap.add_argument('--db', default='data/bike-tinder.sqlite3')
|
||||
ap.add_argument('--img-dir', default='data/img')
|
||||
ap.add_argument('--limit', type=int, default=None,
|
||||
help='stop after this many downloads (small test runs)')
|
||||
ap.add_argument('--sleep', type=float, default=SLEEP,
|
||||
help='seconds between fetches (default %.1f)' % SLEEP)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
res = run_fetch(args.db, args.img_dir, limit=args.limit, sleep=args.sleep)
|
||||
print('fetched %d · skipped %d · failed %d' % (
|
||||
res['fetched'], res['skipped'], res['failed']))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
BIN
app/static/icons/apple-touch-icon.png
Normal file
BIN
app/static/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/static/icons/icon-192.png
Normal file
BIN
app/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/static/icons/icon-512-maskable.png
Normal file
BIN
app/static/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/static/icons/icon-512.png
Normal file
BIN
app/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
733
app/static/index.html
Normal file
733
app/static/index.html
Normal file
@@ -0,0 +1,733 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Bike-Tinder</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0b0b0e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Bike-Tinder">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0b0e; --card:#141419; --text:#fff; --mut:rgba(255,255,255,.62);
|
||||
--accent1:#ff7854; --accent2:#fd267d; --like:#46d369; --nope:#fd4d5c; --maybe:#ffb43a;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
html,body{height:100%}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}
|
||||
.app{position:relative;display:flex;flex-direction:column;height:100%;max-width:520px;margin:0 auto}
|
||||
/* header */
|
||||
header{height:calc(50px + env(safe-area-inset-top,0px));display:flex;align-items:center;justify-content:space-between;padding:env(safe-area-inset-top,0px) 16px 0}
|
||||
.brand{display:flex;align-items:center;gap:8px;font-weight:800;font-size:16px;letter-spacing:.2px}
|
||||
.flame{width:18px;height:18px;border-radius:6px;background:linear-gradient(135deg,var(--accent1),var(--accent2))}
|
||||
.pill{font-size:12px;color:var(--mut);background:#1b1b21;border:1px solid #26262e;border-radius:999px;padding:5px 10px}
|
||||
.pill b{color:#fff}
|
||||
/* stage + cards */
|
||||
.stage{position:relative;flex:1;padding:6px 14px 2px;min-height:0}
|
||||
.card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card);
|
||||
box-shadow:0 0 0 1px rgba(255,255,255,.07), 0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none}
|
||||
.nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6}
|
||||
.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
.card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)}
|
||||
.card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3}
|
||||
.chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
|
||||
.chip{font-size:11px;font-weight:600;letter-spacing:.4px;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22)}
|
||||
.title{font-size:23px;font-weight:800;line-height:1.15;text-shadow:0 1px 8px rgba(0,0,0,.5);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.price{font-size:20px;font-weight:600;margin-top:2px}
|
||||
.sub{font-size:13px;color:var(--mut);margin-top:3px}
|
||||
.stamp{position:absolute;top:26px;z-index:4;font-weight:800;letter-spacing:2px;font-size:26px;padding:6px 12px;border:3px solid;border-radius:10px;opacity:0;pointer-events:none}
|
||||
.stamp.like{left:20px;transform:rotate(-14deg);color:var(--like);border-color:var(--like);background:rgba(70,211,105,.08)}
|
||||
.stamp.nope{right:20px;transform:rotate(14deg);color:var(--nope);border-color:var(--nope);background:rgba(253,77,92,.08)}
|
||||
.stamp.maybe{left:50%;top:auto;bottom:130px;transform:translateX(-50%);color:var(--maybe);border-color:var(--maybe);background:rgba(255,180,58,.08)}
|
||||
/* actions */
|
||||
.actions{height:86px;display:flex;align-items:center;justify-content:center;gap:16px}
|
||||
.act{border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .12s}
|
||||
.act:active{transform:scale(.92)}
|
||||
.undo{width:42px;height:42px;background:#1d1d24;color:#f5c451;font-size:18px}
|
||||
.x{width:58px;height:58px;background:#fff;color:#fd5068;font-size:26px;font-weight:700;box-shadow:0 6px 18px rgba(0,0,0,.4)}
|
||||
.s{width:58px;height:58px;background:#1d1d24;color:var(--maybe);font-size:24px;border:1px solid #2a2a33}
|
||||
.h{width:62px;height:62px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:26px;box-shadow:0 8px 22px rgba(253,38,125,.35)}
|
||||
/* tabs */
|
||||
nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items:stretch;border-top:1px solid #1d1d24;background:#0d0d10;padding-bottom:calc(6px + env(safe-area-inset-bottom,0px));position:relative;z-index:60}
|
||||
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-size:10px;letter-spacing:.3px;color:#6f6f7a;cursor:pointer}
|
||||
.tab.on{color:#fff}
|
||||
.tab svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
/* fullscreen detail dossier — iOS-style sheet: top gap + grabber, drag down to close */
|
||||
.dback{position:absolute;inset:0;z-index:69;background:rgba(0,0,0,.38);opacity:0;pointer-events:none;transition:opacity .28s}
|
||||
.dback.on{opacity:1}
|
||||
.detail{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:70;background:#0f0f13;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.45);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);display:flex;flex-direction:column;overflow:hidden}
|
||||
.detail.on{transform:translateY(0)}
|
||||
.dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none}
|
||||
.dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
|
||||
.dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer}
|
||||
.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none}
|
||||
.dphoto{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.dphoto.ph{opacity:.25}
|
||||
.dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))}
|
||||
.dbody{flex:1;overflow-y:auto;padding:14px 18px 20px}
|
||||
.dtitle{font-size:20px;font-weight:800;line-height:1.2}
|
||||
.drow{display:flex;align-items:baseline;gap:10px;margin-top:5px}
|
||||
.dprice{font-size:19px;font-weight:700}
|
||||
.dsub{font-size:12.5px;color:var(--mut)}
|
||||
.dchips{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||
.dsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:18px 0 8px}
|
||||
.specs{border-top:1px solid #1f1f27}
|
||||
.spec{display:flex;justify-content:space-between;gap:14px;padding:9px 2px;border-bottom:1px solid #1f1f27;font-size:13.5px}
|
||||
.spec .k{color:#8f8f9c}
|
||||
.spec .v{font-weight:600;text-align:right}
|
||||
.spec .v.z{color:#5a5a66;font-weight:500}
|
||||
.dnotes{font-size:13.5px;line-height:1.5;color:#b9b9c2}
|
||||
.dopen{display:inline-block;margin-top:16px;color:#ff9a76;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.dacts{flex:none;display:flex;gap:10px;padding:12px 16px calc(14px + env(safe-area-inset-bottom, 0px));border-top:1px solid #1d1d24;background:#0d0d10}
|
||||
.dact{flex:1;height:48px;border-radius:14px;border:1px solid #2a2a33;background:#16161b;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
|
||||
.dact:active{transform:scale(.97)}
|
||||
.dact.no{color:#fd7b89}
|
||||
.dact.mb{color:var(--maybe)}
|
||||
.dact.lk{background:linear-gradient(135deg,var(--accent1),var(--accent2));border:0;box-shadow:0 8px 22px rgba(253,38,125,.3)}
|
||||
.ibtn{position:absolute;top:14px;right:14px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.25);color:#fff;font-size:14px;z-index:4;cursor:pointer}
|
||||
/* empty */
|
||||
.empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px}
|
||||
.empty.on{display:flex}
|
||||
.empty b{color:#fff;font-size:16px}
|
||||
/* list screen */
|
||||
.list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
|
||||
.list.on{display:flex}
|
||||
.lhead{padding:14px 16px 8px}
|
||||
.lhead h1{font-size:20px;font-weight:800}
|
||||
.lhead .sub{margin-top:2px}
|
||||
.filters{display:flex;gap:7px;padding:4px 16px 10px;overflow-x:auto}
|
||||
.fchip{font-size:12px;padding:6px 11px;border-radius:999px;background:#1b1b21;border:1px solid #26262e;color:var(--mut);white-space:nowrap;cursor:pointer}
|
||||
.fchip.on{background:#fff;color:#111;border-color:#fff;font-weight:600}
|
||||
.lrows{flex:1;overflow-y:auto;padding:0 10px 12px}
|
||||
.row{display:flex;gap:11px;align-items:center;padding:9px 8px;border-radius:14px;cursor:pointer}
|
||||
.row + .row{border-top:1px solid #1a1a1f}
|
||||
.thumb{width:56px;height:56px;border-radius:12px;object-fit:cover;background:#1a1a20;flex:none}
|
||||
.thumb.ph{display:flex;align-items:center;justify-content:center;color:#4a4a55;font-size:20px}
|
||||
.rmain{flex:1;min-width:0}
|
||||
.rtitle{font-size:14.5px;font-weight:650;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.rsub{font-size:12px;color:var(--mut);margin-top:2px}
|
||||
.rright{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.rprice{font-size:14px;font-weight:700}
|
||||
.rc{display:flex;gap:5px}
|
||||
.rbtn{width:26px;height:26px;border-radius:50%;border:1px solid #2a2a33;background:#16161b;color:#8a8a95;font-size:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
|
||||
.rbtn.on.like{background:rgba(70,211,105,.15);border-color:var(--like);color:var(--like)}
|
||||
.rbtn.on.maybe{background:rgba(255,180,58,.15);border-color:var(--maybe);color:var(--maybe)}
|
||||
.rbtn.on.nope{background:rgba(253,77,92,.15);border-color:var(--nope);color:var(--nope)}
|
||||
/* matches screen (production) */
|
||||
.matches{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
|
||||
.matches.on{display:flex}
|
||||
.msec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:10px 18px 0}
|
||||
.mempty{margin:10px 18px;font-size:13.5px;color:var(--mut)}
|
||||
/* stub screens */
|
||||
.stub{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--bg);z-index:50;color:var(--mut);text-align:center;padding:40px;font-size:13.5px}
|
||||
.stub.on{display:flex}
|
||||
.stub b{color:#fff;font-size:17px}
|
||||
/* production additions (UI-SPEC §7 deltas) */
|
||||
.search{margin:0 16px 8px;width:calc(100% - 32px);background:#1b1b21;border:1px solid #26262e;border-radius:10px;color:#fff;font-size:14px;padding:9px 14px;outline:none}
|
||||
.search::placeholder{color:#6f6f7a}
|
||||
.toast{position:fixed;left:50%;bottom:150px;transform:translateX(-50%);background:#1d1d24;border:1px solid #2a2a33;color:#fff;padding:10px 16px;border-radius:12px;font-size:13px;z-index:80;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
|
||||
.toast.on{opacity:1}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
|
||||
<div class="pill" id="pill">—</div>
|
||||
</header>
|
||||
|
||||
<div class="stage" id="stage">
|
||||
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions">
|
||||
<button class="act undo" id="btnUndo" title="Undo">↺</button>
|
||||
<button class="act x" id="btnNope" title="Nope">✕</button>
|
||||
<button class="act s" id="btnMaybe" title="Maybe">★</button>
|
||||
<button class="act h" id="btnLike" title="Like">♥</button>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="tab on" data-tab="deck"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="13" height="16" rx="2.5"/><path d="M8.5 2.5h9A2.5 2.5 0 0 1 20 5v12"/></svg>Deck</div>
|
||||
<div class="tab" data-tab="list"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>List</div>
|
||||
<div class="tab" data-tab="matches"><svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Matches</div>
|
||||
<div class="tab" data-tab="trends"><svg viewBox="0 0 24 24"><path d="M5 20V10M12 20V4M19 20v-7"/></svg>Trends</div>
|
||||
</nav>
|
||||
|
||||
<div class="dback" id="dback"></div>
|
||||
<div class="detail" id="detail">
|
||||
<div class="dgrab" id="dgrab"><i></i></div>
|
||||
<button class="dclose" id="dclose">⌄</button>
|
||||
<div class="dhero"><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
|
||||
<div class="dbody" id="dbody">
|
||||
<div class="dtitle" id="dtitle"></div>
|
||||
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
|
||||
<div class="dchips" id="dchips"></div>
|
||||
<div class="dsec">The machine</div>
|
||||
<div class="specs" id="dspecs"></div>
|
||||
<div class="dsec">Seller's notes</div>
|
||||
<div class="dnotes" id="dnotes"></div>
|
||||
<a class="dopen" id="dopen" href="#" target="_blank" rel="noopener">Open on Kijiji ↗</a>
|
||||
</div>
|
||||
<div class="dacts">
|
||||
<button class="dact no" id="dNo">✕ Nope</button>
|
||||
<button class="dact mb" id="dMb">★ Maybe</button>
|
||||
<button class="dact lk" id="dLk">♥ Like</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list" id="listScreen">
|
||||
<div class="lhead"><h1>All bikes</h1><div class="sub" id="lsub">—</div></div>
|
||||
<input class="search" id="lsearch" type="search" placeholder="Search titles" autocomplete="off">
|
||||
<div class="filters" id="fdec">
|
||||
<div class="fchip on" data-d="all">All</div><div class="fchip" data-d="like">Liked</div><div class="fchip" data-d="maybe">Maybe</div><div class="fchip" data-d="nope">Noped</div><div class="fchip" data-d="undecided">Undecided</div>
|
||||
</div>
|
||||
<div class="filters" id="fsort">
|
||||
<div class="fchip on" data-s="score">Score</div><div class="fchip" data-s="price" id="fprice">Price ↕</div><div class="fchip" data-s="newest">Newest</div><div class="fchip" data-s="km">km</div>
|
||||
</div>
|
||||
<div class="lrows" id="lrows"></div>
|
||||
</div>
|
||||
|
||||
<div class="matches" id="matchesScreen">
|
||||
<div class="lhead"><h1>Your shortlist</h1></div>
|
||||
<div class="msec" id="mlikeSec">♥ Liked (0)</div>
|
||||
<div class="lrows" id="mlike"></div>
|
||||
<div class="msec" id="mmaybeSec">★ Maybe (0)</div>
|
||||
<div class="lrows" id="mmaybe"></div>
|
||||
<div class="mempty" id="mempty">No likes yet — swipe right on the deck.</div>
|
||||
</div>
|
||||
|
||||
<div class="stub" id="stubTrends"><b>Trends — Phase 3</b>The deck learns your taste as you swipe.</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const $=id=>document.getElementById(id);
|
||||
const stage=$('stage');
|
||||
const DEPTH=5; // a 5-card live stack at all times
|
||||
const SC=.026, TY=16, DIM=.08; // per-depth step: scale, y-offset, dimming
|
||||
let pile=[]; // top-first [{d,el}] — every card its own swipable object
|
||||
let hist=[]; // undo history: full payloads
|
||||
let dragEl=null, sx=0, sy=0, dx=0, dy=0, moved=false;
|
||||
let buffer=[]; // deck feed from GET /api/deck (payloads)
|
||||
let deckCursor=null, deckLoaded=false, deckLoading=false;
|
||||
|
||||
/* ---------- production wiring: API + retry queue + toast ---------- */
|
||||
let toastTimer=null;
|
||||
function toast(msg){
|
||||
const t=$('toast'); t.textContent=msg; t.classList.add('on');
|
||||
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('on'),2500);
|
||||
}
|
||||
async function getJSON(url){
|
||||
try{
|
||||
const res=await fetch(url);
|
||||
if(!res.ok){ if(!navigator.onLine) toast('Offline — retrying'); return null; }
|
||||
return await res.json();
|
||||
}catch(e){ if(!navigator.onLine) toast('Offline — retrying'); return null; }
|
||||
}
|
||||
/* swipe persistence: optimistic, never waits; failures retry 2s/5s/10s then
|
||||
every 15s while the tab lives. Last write per listing wins; the queue runs
|
||||
serially so reorderings can't land out of sequence. */
|
||||
const pend=new Map(); // listing_id -> {act, fails, warned}
|
||||
let qRunning=false, qTimer=null, qWarned=false;
|
||||
function enqueue(listingId, act){ // act: 'like'|'nope'|'maybe'|null (delete)
|
||||
pend.set(listingId,{act,fails:0});
|
||||
scheduleQueue();
|
||||
}
|
||||
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
|
||||
function scheduleQueue(){
|
||||
if(qRunning||!pend.size||qTimer) return;
|
||||
qTimer=setTimeout(runQueue,0);
|
||||
}
|
||||
async function runQueue(){
|
||||
qTimer=null; qRunning=true;
|
||||
try{
|
||||
const ops=[...pend.entries()];
|
||||
let minDelay=0;
|
||||
for(const [id,op] of ops){
|
||||
if(!pend.has(id)) continue; // replaced mid-batch
|
||||
const p=pend.get(id);
|
||||
if(p!==op) continue;
|
||||
try{
|
||||
const res= p.act==null
|
||||
? await fetch('/api/swipe/'+id,{method:'DELETE'})
|
||||
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({listing_id:id,action:p.act})});
|
||||
if(res.ok){ pend.delete(id); }
|
||||
else { p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); } minDelay=Math.max(minDelay,queDelay(p)); }
|
||||
}catch(e){
|
||||
p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); }
|
||||
minDelay=Math.max(minDelay,queDelay(p));
|
||||
}
|
||||
}
|
||||
}finally{ qRunning=false; }
|
||||
if(pend.size){ qTimer=setTimeout(runQueue,Math.max(minDelay||15000,1000)); }
|
||||
}
|
||||
/* optimistic decision set for list/matches + deck commits share this */
|
||||
function setDecision(id, act){
|
||||
updateRowDecision(id, act); // optimistic paint
|
||||
enqueue(id, act);
|
||||
}
|
||||
|
||||
/* ---------- payload -> display helpers ---------- */
|
||||
const REGION_NAMES={ns:'NS',nb:'NB'};
|
||||
function fmtNum(n){ return n==null?null:Number(n).toLocaleString('en-US'); }
|
||||
function cardChips(it){ // max 3: seller chip + first 2 extras
|
||||
const out=[];
|
||||
if(it.seller_type==='dealer') out.push('Dealer');
|
||||
out.push(...(it.extras||[]).slice(0,2));
|
||||
return out.slice(0,3);
|
||||
}
|
||||
function subFor(it){
|
||||
const parts=[];
|
||||
if(it.location) parts.push(it.location);
|
||||
parts.push(REGION_NAMES[it.region]||it.region||'');
|
||||
let s=parts.filter(Boolean).join(', ');
|
||||
if(it.seller_type==='dealer') s+=' · dealer';
|
||||
else if(it.seller_type==='private') s+=' · private';
|
||||
return s;
|
||||
}
|
||||
function priceFor(it){
|
||||
if(it.price_display) return it.price_display;
|
||||
if(it.price_num!=null) return '$'+fmtNum(it.price_num);
|
||||
return '—';
|
||||
}
|
||||
function relSeen(fs){
|
||||
if(!fs) return '—';
|
||||
const d=new Date(fs), now=new Date();
|
||||
if(isNaN(d)) return '—';
|
||||
const days=Math.floor((now-d)/86400000);
|
||||
return days<=0?'Today':(days===1?'1 day ago':days+' days ago');
|
||||
}
|
||||
function ccFrom(it){
|
||||
const m=((it.title||'')+' '+((it.desc||'').split(' [also listed:')[0])).match(/(\d[\d,]*)\s*cc/i);
|
||||
return m?(fmtNum(m[1].replace(/,/g,''))+' cc'):null;
|
||||
}
|
||||
function catFromUrl(url){
|
||||
const m=(url||'').match(/\/v-([^/]+)\//);
|
||||
if(!m) return null;
|
||||
const words=m[1].split('-').filter(Boolean).map(w=>w[0].toUpperCase()+w.slice(1));
|
||||
return words.length?words.join(' '):null;
|
||||
}
|
||||
|
||||
/* ---------- docs/mockup core: cards, layer, pile ---------- */
|
||||
function isTop(el){ return pile[0]&&pile[0].el===el; }
|
||||
function setStamps(el,a,b,c){
|
||||
el.querySelector('.stamp.like').style.opacity=a;
|
||||
el.querySelector('.stamp.nope').style.opacity=b;
|
||||
el.querySelector('.stamp.maybe').style.opacity=c;
|
||||
}
|
||||
function cardNode(item){
|
||||
const el=document.createElement('div');
|
||||
el.className='card';
|
||||
el.innerHTML=`${item.img?`<img class="photo" src="${item.img}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}
|
||||
<div class="grad"></div>
|
||||
<div class="stamp like">LIKE</div>
|
||||
<div class="stamp nope">NOPE</div>
|
||||
<div class="stamp maybe">MAYBE</div>
|
||||
<button class="ibtn">ⓘ</button>
|
||||
<div class="info"><div class="chips">${cardChips(item).map(x=>`<span class="chip">${x}</span>`).join('')}</div><div class="title"></div><div class="price"></div><div class="sub"></div></div>`;
|
||||
// text set via textContent — data is untrusted (titles carry junk chars)
|
||||
const info=el.querySelector('.info');
|
||||
info.querySelector('.title').textContent=item.title||'';
|
||||
info.querySelector('.price').textContent=priceFor(item);
|
||||
info.querySelector('.sub').textContent=subFor(item);
|
||||
const ph=el.querySelector('.photo');
|
||||
if(ph) ph.addEventListener('error',()=>{
|
||||
ph.remove();
|
||||
el.insertAdjacentHTML('afterbegin','<div class="nophoto">Photo pending — thumbnail backfill queued</div>');
|
||||
});
|
||||
el.addEventListener('pointerdown',down);
|
||||
el.addEventListener('pointermove',move);
|
||||
el.addEventListener('pointerup',upH);
|
||||
el.addEventListener('pointercancel',upH);
|
||||
const ib=el.querySelector('.ibtn');
|
||||
ib.addEventListener('pointerdown',e=>e.stopPropagation());
|
||||
ib.addEventListener('click',e=>{ e.stopPropagation(); if(isTop(el)) openDetail(item,{type:'deck'}); });
|
||||
return {d:item,el};
|
||||
}
|
||||
function layer(el,d,anim){
|
||||
el.classList.toggle('topcard',d===0);
|
||||
el.style.transition=anim?'transform .22s cubic-bezier(.2,.8,.3,1), filter .22s ease, opacity .22s ease':'none';
|
||||
el.style.transform=`translateY(${TY*d}px) scale(${1-SC*d})`;
|
||||
el.style.filter=`brightness(${1-DIM*d})`;
|
||||
el.style.opacity='1';
|
||||
el.style.zIndex=10-d;
|
||||
el.style.pointerEvents=d===0?'auto':'none';
|
||||
}
|
||||
function relayer(){ pile.forEach((c,d)=>layer(c.el,d,true)); }
|
||||
function fillPile(){
|
||||
while(pile.length<DEPTH&&buffer.length){
|
||||
const item=buffer.shift();
|
||||
const entry=cardNode(item), d=pile.length;
|
||||
entry.el.style.transition='none';
|
||||
entry.el.style.transform=`translateY(${TY*(d+2)}px) scale(${1-SC*(d+2)})`;
|
||||
entry.el.style.opacity='0';
|
||||
stage.appendChild(entry.el);
|
||||
pile.push(entry);
|
||||
void entry.el.offsetWidth;
|
||||
layer(entry.el,d,true);
|
||||
}
|
||||
relayer();
|
||||
}
|
||||
/* deck feed: cursor pages of 40; refill whenever the buffer thins */
|
||||
async function loadDeck(){
|
||||
if(deckLoading||deckLoaded) return;
|
||||
deckLoading=true;
|
||||
try{
|
||||
const res=await getJSON('/api/deck?limit=40'+(deckCursor?('&cursor='+encodeURIComponent(deckCursor)):''));
|
||||
if(!res) { setTimeout(()=>loadDeck(),15000); return; }
|
||||
if(res.items&&res.items.length){
|
||||
buffer.push(...res.items);
|
||||
fillPile();
|
||||
}
|
||||
deckCursor=res.next_cursor||null;
|
||||
if(!deckCursor) deckLoaded=true;
|
||||
}finally{ deckLoading=false; }
|
||||
}
|
||||
function maybeRefill(){
|
||||
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
|
||||
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
|
||||
}
|
||||
|
||||
/* ---------- docs/mockup core: commit / fly / undo — no lock ---------- */
|
||||
function commit(el,dir){
|
||||
if(!isTop(el)) return;
|
||||
const t=pile.shift();
|
||||
hist.push(t.d);
|
||||
setDecision(t.d.id, dir==='maybe'?'maybe':dir); // optimistic persist
|
||||
flyOut(el,dir);
|
||||
fillPile(); relayer();
|
||||
maybeRefill();
|
||||
}
|
||||
function flyOut(el,dir){
|
||||
const x= dir==='maybe'?0:(dir==='like'?620:-620);
|
||||
const y= dir==='maybe'?-780:40;
|
||||
const rot= dir==='maybe'?0:(dir==='like'?26:-26);
|
||||
setStamps(el, dir==='like'?1:0, dir==='nope'?1:0, dir==='maybe'?1:0);
|
||||
el.classList.remove('topcard');
|
||||
el.style.zIndex=12; el.style.pointerEvents='none';
|
||||
el.style.transition='transform .3s ease-in, opacity .3s';
|
||||
el.style.transform=`translate(${x}px,${y}px) rotate(${rot}deg)`;
|
||||
el.style.opacity=.15;
|
||||
setTimeout(()=>{ el.remove(); },420);
|
||||
}
|
||||
function undo(){
|
||||
if(!hist.length) return;
|
||||
const item=hist.pop();
|
||||
setDecision(item.id, null); // DELETE through the queue
|
||||
const entry=cardNode(item);
|
||||
entry.el.style.transition='none';
|
||||
entry.el.style.transform='translateY(-46px) scale(1.02)';
|
||||
entry.el.style.opacity='0';
|
||||
stage.appendChild(entry.el);
|
||||
pile.unshift(entry);
|
||||
if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); buffer.unshift(drop.d); }
|
||||
void entry.el.offsetWidth;
|
||||
relayer();
|
||||
$('empty').classList.remove('on');
|
||||
}
|
||||
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
|
||||
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
|
||||
$('btnMaybe').onclick=()=>{ if(pile[0]) commit(pile[0].el,'maybe'); };
|
||||
$('btnUndo').onclick=undo;
|
||||
|
||||
/* ---------- docs/mockup core: drag — only the top card is grabbable ---------- */
|
||||
function down(e){
|
||||
const el=e.currentTarget;
|
||||
if(!isTop(el)) return;
|
||||
dragEl=el; moved=false; sx=e.clientX; sy=e.clientY; dx=0; dy=0;
|
||||
try{ el.setPointerCapture(e.pointerId) }catch(_){}
|
||||
el.style.transition='none';
|
||||
}
|
||||
function move(e){
|
||||
const el=e.currentTarget;
|
||||
if(dragEl!==el) return;
|
||||
dx=e.clientX-sx; dy=e.clientY-sy;
|
||||
if(Math.abs(dx)>6||Math.abs(dy)>6) moved=true;
|
||||
const rot=Math.max(-12,Math.min(12,dx/12));
|
||||
el.style.transform=`translate(${dx}px,${dy}px) rotate(${rot}deg)`;
|
||||
setStamps(el, Math.max(0,Math.min(1,dx/120)), Math.max(0,Math.min(1,-dx/120)), dy<-20?Math.max(0,Math.min(1,-dy/130)):0);
|
||||
}
|
||||
function upH(e){
|
||||
const el=e.currentTarget;
|
||||
if(dragEl!==el) return;
|
||||
dragEl=null;
|
||||
const w=stage.clientWidth||360;
|
||||
el.style.transition='transform .3s cubic-bezier(.2,.8,.3,1)';
|
||||
if(dx>w*0.35) commit(el,'like');
|
||||
else if(dx<-w*0.35) commit(el,'nope');
|
||||
else if(dy<-140) commit(el,'maybe');
|
||||
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) openDetail(pile[0].d,{type:'deck'}); }
|
||||
}
|
||||
|
||||
/* ---------- docs/mockup core: fullscreen dossier ---------- */
|
||||
let dCtx=null;
|
||||
function openDetail(data,ctx){
|
||||
dCtx=ctx||null;
|
||||
const ph=$('dphoto');
|
||||
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); }
|
||||
else { ph.removeAttribute('src'); ph.classList.add('ph'); }
|
||||
ph.onerror=()=>{ ph.removeAttribute('src'); ph.classList.add('ph'); };
|
||||
$('dtitle').textContent=data.title||'';
|
||||
$('dprice').textContent=priceFor(data);
|
||||
$('dsub').textContent=subFor(data);
|
||||
$('dchips').innerHTML=cardChips(data).map(x=>`<span class="chip">${x}</span>`).join('');
|
||||
const rows=[
|
||||
["Year",data.year!=null?String(data.year):null],
|
||||
["Odometer",data.km!=null?(fmtNum(data.km)+' km'):null],
|
||||
["Engine",ccFrom(data)],
|
||||
["Family",data.family],
|
||||
["Category",catFromUrl(data.url)],
|
||||
["Location",subFor(data).split(' · ')[0]],
|
||||
["Seller",data.seller_type==='dealer'?'Dealer':(data.seller_type==='private'?'Private':null)],
|
||||
["Source","Kijiji"],
|
||||
["First seen",relSeen(data.first_seen)],
|
||||
];
|
||||
$('dspecs').innerHTML=rows.map(r=>`<div class="spec"><span class="k">${r[0]}</span><span class="v${(!r[1]||r[1]==='—')?' z':''}">${r[1]||'—'}</span></div>`).join('');
|
||||
$('dnotes').textContent=(data.desc||'').split(' [also listed:')[0]||'';
|
||||
$('dopen').href=data.url||'#';
|
||||
$('detail').classList.add('on'); $('dback').classList.add('on');
|
||||
$('dbody').scrollTop=0;
|
||||
}
|
||||
function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; }
|
||||
$('dclose').onclick=closeDetail;
|
||||
$('dclose').addEventListener('pointerdown',e=>e.stopPropagation());
|
||||
/* pull-down-to-close (grabber bar + photo) */
|
||||
let dDragY=null;
|
||||
function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
|
||||
function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); $('detail').style.transform=`translateY(${dy}px)`; }
|
||||
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
|
||||
if(dy>110){ closeDetail(); }
|
||||
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; } }
|
||||
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
|
||||
function dAction(a){
|
||||
const ctx=dCtx; closeDetail();
|
||||
if(!ctx) return;
|
||||
if(ctx.type==='deck'){
|
||||
if(pile[0]) commit(pile[0].el, a==='like'?'like':(a==='nope'?'nope':'maybe'));
|
||||
} else {
|
||||
setDecision(ctx.it.id, a==='like'?'like':(a==='nope'?'nope':'maybe'));
|
||||
if(ctx.screen==='list') renderList();
|
||||
else renderMatches();
|
||||
}
|
||||
}
|
||||
$('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like');
|
||||
|
||||
/* ---------- list screen (real filters + search) ---------- */
|
||||
let listItems=[];
|
||||
const listCtl={decision:'all',sort:'score',q:'',priceDir:1};
|
||||
async function fetchList(){
|
||||
let items=[],total=0,offset=0;
|
||||
do{
|
||||
const q=listCtl.q?'&q='+encodeURIComponent(listCtl.q):'';
|
||||
const res=await getJSON(`/api/list?decision=${listCtl.decision}&sort=${listCtl.sort}${q}&limit=200&offset=${offset}`);
|
||||
if(!res) break;
|
||||
total=res.total;
|
||||
items.push(...res.items);
|
||||
offset+=res.items.length;
|
||||
if(!res.items.length||offset>=total||offset>=500) break;
|
||||
}while(true);
|
||||
listItems=items;
|
||||
renderList();
|
||||
}
|
||||
function thumbFor(it){
|
||||
if(it.img) return `<img class="thumb" src="/img/${it.id}" alt="" data-i="${it.id}">`;
|
||||
return `<div class="thumb ph">◍</div>`;
|
||||
}
|
||||
function rowHTML(it){
|
||||
const on=act=>it.decision===act;
|
||||
return `
|
||||
<div class="row" data-i="${it.id}">
|
||||
${thumbFor(it)}
|
||||
<div class="rmain"><div class="rtitle"></div><div class="rsub"></div></div>
|
||||
<div class="rright"><div class="rprice"></div>
|
||||
<div class="rc" data-i="${it.id}">
|
||||
<button class="rbtn like${on('like')?' on':''}" data-s="like">♥</button>
|
||||
<button class="rbtn maybe${on('maybe')?' on':''}" data-s="maybe">★</button>
|
||||
<button class="rbtn nope${on('nope')?' on':''}" data-s="nope">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
function attachThumbFallbacks(box){
|
||||
box.querySelectorAll('img.thumb').forEach(im=>im.addEventListener('error',()=>{
|
||||
const d=document.createElement('div'); d.className='thumb ph'; d.textContent='◍';
|
||||
im.replaceWith(d);
|
||||
}));
|
||||
}
|
||||
function renderList(){
|
||||
$('lrows').innerHTML=listItems.map(rowHTML).join('');
|
||||
attachThumbFallbacks($('lrows'));
|
||||
listItems.forEach(it=>{
|
||||
const el=$('lrows').querySelector(`.row[data-i="${it.id}"]`);
|
||||
if(!el) return;
|
||||
el.querySelector('.rtitle').textContent=it.title||'';
|
||||
el.querySelector('.rsub').textContent=subFor(it);
|
||||
el.querySelector('.rprice').textContent=priceFor(it);
|
||||
});
|
||||
if(!listItems.length) $('lrows').innerHTML='<div class="mempty">No bikes match.</div>';
|
||||
}
|
||||
/* optimistic decision updates for whichever screens show this listing */
|
||||
function updateRowDecision(id, act){
|
||||
const find=[listItems];
|
||||
for(const arr of find){
|
||||
const it=arr.find(x=>x.id===id);
|
||||
if(it) it.decision=act;
|
||||
}
|
||||
const el=$('lrows').querySelector(`.rc[data-i="${id}"]`);
|
||||
if(el) el.querySelectorAll('.rbtn').forEach(b=>b.classList.toggle('on',b.dataset.s===act));
|
||||
['mlike','mmaybe'].forEach(sec=>{
|
||||
const box=$(sec);
|
||||
if(box.querySelector(`.rc[data-i="${id}"]`)) refreshRowStateInMatches(id,act);
|
||||
});
|
||||
}
|
||||
function refreshRowStateInMatches(id,act){
|
||||
// a changed decision may move the row between sections — refetch quietly
|
||||
fetchMatches();
|
||||
}
|
||||
$('lrows').addEventListener('click',e=>{
|
||||
const b=e.target.closest('.rbtn');
|
||||
if(!b){
|
||||
const row=e.target.closest('.row'); if(!row) return;
|
||||
const id=+row.dataset.i;
|
||||
const it=listItems.find(x=>x.id===id);
|
||||
if(it) openDetail(it,{type:'row',it,screen:'list'});
|
||||
return;
|
||||
}
|
||||
const id=+b.closest('.rc').dataset.i, s=b.dataset.s;
|
||||
const it=listItems.find(x=>x.id===id);
|
||||
if(!it) return;
|
||||
const next= it.decision===s?null:s; // tapping the lit button clears
|
||||
setDecision(id,next);
|
||||
if(next===null && (listCtl.decision===s)) renderList(); // row leaves a filtered view
|
||||
});
|
||||
$('fdec').addEventListener('click',e=>{
|
||||
const chip=e.target.closest('.fchip'); if(!chip) return;
|
||||
$('fdec').querySelectorAll('.fchip').forEach(c=>c.classList.toggle('on',c===chip));
|
||||
listCtl.decision=chip.dataset.d;
|
||||
fetchList();
|
||||
});
|
||||
$('fsort').addEventListener('click',e=>{
|
||||
const chip=e.target.closest('.fchip'); if(!chip) return;
|
||||
const s=chip.dataset.s;
|
||||
if(s==='price'){
|
||||
if(listCtl.sort==='price_asc'||listCtl.sort==='price_desc'){
|
||||
listCtl.priceDir= listCtl.sort==='price_asc'?1:-1; // toggle on re-tap
|
||||
if(listCtl.priceDir===1) listCtl.sort='price_desc';
|
||||
else listCtl.sort='price_asc';
|
||||
} else { listCtl.sort='price_asc'; listCtl.priceDir=0; }
|
||||
} else { listCtl.sort=s; }
|
||||
$('fsort').querySelectorAll('.fchip').forEach(c=>{
|
||||
const cs=c.dataset.s;
|
||||
c.classList.toggle('on', cs==='score'?listCtl.sort==='score':cs==='newest'?listCtl.sort==='newest':cs==='km'?listCtl.sort==='km':(listCtl.sort==='price_asc'||listCtl.sort==='price_desc'));
|
||||
});
|
||||
$('fprice').textContent= listCtl.sort==='price_asc'?'Price ↑':listCtl.sort==='price_desc'?'Price ↓':'Price ↕';
|
||||
fetchList();
|
||||
});
|
||||
let searchTimer=null;
|
||||
$('lsearch').addEventListener('input',e=>{
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer=setTimeout(()=>{ listCtl.q=e.target.value.trim(); fetchList(); },300);
|
||||
});
|
||||
|
||||
/* ---------- matches screen (production: REAL) ---------- */
|
||||
let mItems={like:[],maybe:[]};
|
||||
async function fetchMatches(){
|
||||
mItems.like=[];
|
||||
mItems.maybe=[];
|
||||
const [l,m]=await Promise.all([
|
||||
getJSON('/api/list?decision=like&limit=500'),
|
||||
getJSON('/api/list?decision=maybe&limit=500'),
|
||||
]);
|
||||
if(l) mItems.like=l.items;
|
||||
if(m) mItems.maybe=m.items;
|
||||
renderMatches();
|
||||
}
|
||||
function renderMatches(){
|
||||
$('mlike').innerHTML=mItems.like.map(rowHTML).join('');
|
||||
$('mmaybe').innerHTML=mItems.maybe.map(rowHTML).join('');
|
||||
attachThumbFallbacks($('mlike'));
|
||||
attachThumbFallbacks($('mmaybe'));
|
||||
mItems.like.concat(mItems.maybe).forEach(it=>{
|
||||
[['mlike',mItems.like],['mmaybe',mItems.maybe]].forEach(([boxId])=>{
|
||||
const el=$(boxId).querySelector(`.row[data-i="${it.id}"]`);
|
||||
if(!el) return;
|
||||
el.querySelector('.rtitle').textContent=it.title||'';
|
||||
el.querySelector('.rsub').textContent=subFor(it);
|
||||
el.querySelector('.rprice').textContent=priceFor(it);
|
||||
});
|
||||
});
|
||||
$('mlikeSec').textContent='♥ Liked ('+mItems.like.length+')';
|
||||
$('mmaybeSec').textContent='★ Maybe ('+mItems.maybe.length+')';
|
||||
$('mempty').style.display= (mItems.like.length||mItems.maybe.length)?'none':'';
|
||||
}
|
||||
['mlike','mmaybe'].forEach(sec=>{
|
||||
$(sec).addEventListener('click',e=>{
|
||||
const b=e.target.closest('.rbtn');
|
||||
if(!b){
|
||||
const row=e.target.closest('.row'); if(!row) return;
|
||||
const id=+row.dataset.i;
|
||||
const it=mItems.like.concat(mItems.maybe).find(x=>x.id===id);
|
||||
if(it) openDetail(it,{type:'row',it,screen:'matches'});
|
||||
return;
|
||||
}
|
||||
const id=+b.closest('.rc').dataset.i, s=b.dataset.s;
|
||||
const it=mItems.like.concat(mItems.maybe).find(x=>x.id===id);
|
||||
if(!it) return;
|
||||
const next= it.decision===s?null:s;
|
||||
setDecision(id,next);
|
||||
fetchMatches();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- header + badge ---------- */
|
||||
let badgeFired=false;
|
||||
async function loadStats(){
|
||||
const res=await getJSON('/api/stats');
|
||||
if(!res) return;
|
||||
$('pill').innerHTML=`<b>${res.live}</b> live`+(res.new>0?` · <b>${res.new}</b> new`:'');
|
||||
$('lsub').innerHTML=`${res.live} active · `+(res.new>0?`<b style="color:#fff">${res.new} new</b>`:res.new+' new')+' since your last visit';
|
||||
if(!badgeFired){
|
||||
badgeFired=true;
|
||||
fetch('/api/badge/seen',{method:'POST'}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
|
||||
const name=t.dataset.tab;
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
|
||||
$('listScreen').classList.toggle('on',name==='list');
|
||||
$('matchesScreen').classList.toggle('on',name==='matches');
|
||||
$('stubTrends').classList.toggle('on',name==='trends');
|
||||
if(name==='list') fetchList(); // fresh decisions every visit
|
||||
else if(name==='matches') fetchMatches();
|
||||
});
|
||||
|
||||
/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')) return;
|
||||
if(e.key==='ArrowRight'){ if(pile[0]) commit(pile[0].el,'like'); }
|
||||
else if(e.key==='ArrowLeft'){ if(pile[0]) commit(pile[0].el,'nope'); }
|
||||
else if(e.key==='ArrowUp'){ if(pile[0]) commit(pile[0].el,'maybe'); }
|
||||
else if(e.key==='Backspace'){ undo(); }
|
||||
});
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
loadStats();
|
||||
loadDeck();
|
||||
|
||||
/* PWA */
|
||||
if('serviceWorker' in navigator && (location.protocol==='https:'||location.hostname==='localhost'||location.hostname==='127.0.0.1')){
|
||||
navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
app/static/manifest.json
Normal file
28
app/static/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Bike-Tinder",
|
||||
"short_name": "Bike-Tinder",
|
||||
"description": "Swipe through Nova Scotia and New Brunswick motorcycle listings.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0b0e",
|
||||
"theme_color": "#0b0b0e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
67
app/static/sw.js
Normal file
67
app/static/sw.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* Bike-Tinder service worker.
|
||||
- Precache the app shell at install; index.html + sw.js are served with
|
||||
Cache-Control: no-cache so updates are picked up.
|
||||
- /api/* : network-first; offline -> 503 JSON (the page toasts "Offline").
|
||||
- /img/* : cache-first with network fill (mirrored thumbnails stay usable
|
||||
offline once seen).
|
||||
- Everything else (shell/navigation): network-first, fall back to cache.
|
||||
*/
|
||||
const VERSION = 'bike-tinder-v1';
|
||||
const SHELL = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/apple-touch-icon.png',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-512-maskable.png'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then((c) => c.addAll(SHELL))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const req = e.request;
|
||||
if (req.method !== 'GET') return;
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
e.respondWith(
|
||||
fetch(req).catch(() => new Response(JSON.stringify({ error: 'offline' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } }))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/img/')) {
|
||||
e.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
if (res.ok) {
|
||||
const clone = res.clone();
|
||||
caches.open(VERSION).then((c) => c.put(req, clone));
|
||||
}
|
||||
return res;
|
||||
}).catch(() => new Response('', { status: 404 })))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// shell + navigation: network first, cache fallback (offline launch)
|
||||
e.respondWith(
|
||||
fetch(req).then((res) => res).catch(() =>
|
||||
caches.match(req).then((hit) => hit || caches.match('/'))
|
||||
)
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user