From 57fae0df49ee67aad7ffa49909158b2ffdf1e583 Mon Sep 17 00:00:00 2001 From: Torres Date: Tue, 10 Nov 2026 21:21:27 -0400 Subject: [PATCH] app: no-build PWA frontend (fidelity port) + image mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 1 + app/imgfetch.py | 114 ++++ app/static/icons/apple-touch-icon.png | Bin 0 -> 1301 bytes app/static/icons/icon-192.png | Bin 0 -> 1421 bytes app/static/icons/icon-512-maskable.png | Bin 0 -> 3996 bytes app/static/icons/icon-512.png | Bin 0 -> 4837 bytes app/static/index.html | 733 +++++++++++++++++++++++++ app/static/manifest.json | 28 + app/static/sw.js | 67 +++ 9 files changed, 943 insertions(+) create mode 100644 app/imgfetch.py create mode 100644 app/static/icons/apple-touch-icon.png create mode 100644 app/static/icons/icon-192.png create mode 100644 app/static/icons/icon-512-maskable.png create mode 100644 app/static/icons/icon-512.png create mode 100644 app/static/index.html create mode 100644 app/static/manifest.json create mode 100644 app/static/sw.js diff --git a/.gitignore b/.gitignore index f564ead..306fe8b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ # scratch scratch/ +.venv/ diff --git a/app/imgfetch.py b/app/imgfetch.py new file mode 100644 index 0000000..44f9908 --- /dev/null +++ b/app/imgfetch.py @@ -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()) diff --git a/app/static/icons/apple-touch-icon.png b/app/static/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4500220741b765ddce58df71a2c8bc138de0c013 GIT binary patch literal 1301 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbof#r^;i(^Q|oVRzQ^F-Vw+8*8) zED!r$C!q1f!NHK*!>J>%!IV|(z{vx=OoAr@Bi*K~Syat<*tPdeMces9Z`Z9`zw7?@ zRcq#Ho(-{mCAE0tjobJ0n_nc?hW%olkpDnI(!zs{Z&G7(3LTlT8h@FNt^fbi&E-gz zzEYCt?Z?4-OFwE(_nW)0@~6`G=-E5=R{mMf`FF1L>x&C&dy7xsn6ySI7GZ>x7aXJ;=iy#LQ6^k?H1J;{w-|8Bo`i*4v! z_2%5g`_>(q-%IaE&f4zp9@VM2>gBn>-8~Vm^?!dCUQ3#^>YyRBWagdt--$h6XDqTR zIOFCf_5J>phl`nHj3-Y#_V?=S=W#RM?0h_9X-~=C-?#3zoAybbj##Utv}=8x&2^^K zbK=L}q%M))tzTO^<55IMPsz&PkFI@~dTOR---bKaWsK~6-c48Td-bpMk8t_~qp*vy zg?lP*Jd9Y-WAe`Kf98Wpsi*ncAMW#0NPZBNdXB&S<~~35#}!Vq_Y_#v7pF5x-hFyl zarHD^eVMS|R$pxozb`O6^zfVQh2nDtF2}TXdAphOE6cp*`L=aRkMMLJqr-_C)}OFA z$Ju^x-?ZruzgTy3_XX8=n!Mv#eBy=PJ-H1HmPH2zmwvzMa;(#R+Br6MqeoAac{d$w z?71uNo!-nWnPxPnKq2{iBKqfT`|lMxxQJ|Mikv5+pYt78E?Y-u*n9L1-=9hH>84{q)_ea;43d|h_bDsQvX@g{>>>J~qvPB8 z<95Gy2}oWAvDdrv{oW-bY5EYze(!!_#-_c0|8Dz|ZBcx8YV_5UE4p1*Ewese_|~5P zx^l{sHK(kPrvcMV!PJM}{`PDRv+WBhe|f>n?X6Cb@xhIe=p@R+s<) literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..f7d84035f615a95b44e042613791f408503366e0 GIT binary patch literal 1421 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfi=X_#WAE}&fB}OeZuY%tq;!! zzH@u~Re?oGLr`M_BgcNl6z>*Z6=R+l1;(Zpk4X1_W*7G|JYQs!#&&exrhePfecyh^ z_3S#f=*qgP7u#BO)!5lDE8N{S8mSdllt4&JpW>I_x9trHtEgB7&83SJLetUP;JiUwv&<9KmTpj z>~h=O@BdmiFuXm@l~ECu7oq-mPL z3U(BhH($+&TNZue!{hB|xBp~X_SQ~r-F7`+?_$ik+?EaAm)qHK2g^5;fXVb3BJuXk_ zYnW8GFMWB{uhslv>moPHFrM9f^VzeiIq_^a8NI%oyuy9ec=nfYxpgr+&2BPsEzkXS zadXu{sb}mb(q_N?5^?Xu9)_EB%IVRUyQ+Sj?w__UcJUsD*5hW?uRiXXIFr%nM%tG( zQzaK%yZvU}@%O0{1Q$HJpX|SI`Tj3I{!d>Y=+w}G0kwvJw5rdRGj@2fGM;7j92m#5Lb(`WnB`h`kTG5$V?g>jY`nF=!Ix}zsIbS zk?YG9p1GfZUbm@|VJyw>fA0Rm(t6In-17fLEGGQ6r-e`!}H<+-yd?=KASF|?I3vZZxt|HTeD`E zF6eksXU+3P;yItjx6t#;-S>JktoU8AqrGa`f3+Qdo-l|A-hID+!ST&94PtF;wpF@c zR=XrDIJvnw^L25=`hUOn{=E3&%aV$#e( zog9|G*0=3jR&{4z=bo=_d>v)6r?!85B22#$21lXxkNHi^*Jo~gN2UOaSq4v6KbLh* G2~7af+FC<%g4Q3+Ks+(NV>AXK0NMY&CMv8bpN zEw+o?YA=XD0s@5uLZ}z&rCgN+M2d=XaS%#jKn5nWClk7V_SxszKl{&LnSArT-}`;< zdFRZWQ{G-43*WSO69BMq>&I?80bsyO1~An9BwDzZ0${R#tDDR2BRMa7?wr4bzuowx zrD!;?9bY|v&HT<#aK>D^qD;UD@%8?V_3=)*@t2QMW0Bk~>6XJ6OZT8Xj?}nw%hg*e zuGS@>#1D;+6v>GW$X5fQX<7piy@UZ+o&an%0{r(8V7MHB@0fu5H2|rLfW-Ut&DSF6 zoxtNwa3rx_8v(bglVbOGblF?ufSBGoWim@oH9SB;|w7UZQCLE7>>fGq3X!#X7UekFwI> z9PNf*f;J-22Qm8$RsSYR5K*%4&$zJ@9>^*WP?Op;q3Lwe7|YV_Mt79`jf0fiV!WGi zjxb^5IL?_+XmdRfOBZlt3C5d!8_|^k+zgaZdRXS6uN|UT(ux`Ts zrqEGzU1`d9hzpY|(5GhMHfO;Popq$0+)o-K3iU%x5CR2n=&SXinCFl$mS2v}RFd3|ZXCdjKqgKenq_twKgnDsG zX!FoiQm;>O!~{Y{>+U;SVtPo}FT!a3gueFwoJ_WPlnG)hQeM`J!Ab{?v|DySgW@$Z zL}4mXf6$bMt1lt&`!lvoyo|G!>qt!^*)&8WKNYz_q+In`R2XOpg4NK}IF6GUC z54>D#Zm7wXsqC}ZgYFOaQ+cEj|4 zuqEeCFG$5Ei}6AR5>|Md4+;Fy(=18QU*Wfq!Q%d;U~!t5n)x?2RP2YIHq~RhgzEeR zWI>f~n-E(+k`tJBVF5Aa-{!_ZsYg zpplu$RoVqEAYsKCRu^1|_n8HEYd)q+`Gmjb8`lF3j;N7>)PnH$qp&J;j zUizTBHBc_r2fb;%!s46!QI+X~bg<4Rn5%#axzc00O+j2NjT?lDuH(|U5g{fpp>azn z3#B$?*%dRW^lg46bfkjl*$t-=#{-`2pb#7a;Ihfq%WKtkgwT5PyIDyIVwFPpxw0`vgFyW zG~_#NnRgkow8Z>qOfRlJYn>HH?#MC0dWYtk*Hw}|zx#?=@V%9R{?FX-S3a-OY7Wn# zAJq6$5%PAOCu5vT&_Y}2LTHRsJSu5!@G`hlR&r@qW4*6D%^0oQGLPwk|3SHr{F~T3 zpLSCT+Bethb;7>B{REiU8O+rM^)lTc;uUj4`cjR;MjM@#40Yx8>!T~LSC}x~H2B=ZU!0-*>$j z=L~!P)u*d?XbZ@O51U$b|G)NWpPs?nko?JE=Mt(w^L#35#{^u$w=ZQh!PnnJRuFXH z`SE?^ul4QcprSAfE!HXOnl5NgS?K*KL=9@5o797_kAZ*jN06z5wvYwH1c)Ggg5%VR zukd)~!;S%-$cPWH(FEM~T_b+Hx)*;TgnQ-B@8s7(gFz`Ck5BUc3ETo^$8}xt zM#9M(MtsE%gA(-j4q_R|LE(OGF2piGS^2B}+&)eTdcA{)*$z5=lg7{Cf*OC+%McV^ z?2XJbxOzzcp7LWfINXcsE&5saQY__7nSzn(Sq;xiyfi`gV)I8WtwG@4`x4hEeg28a zo_giRiTq=VJuS-0>7vfd@{uF!$&INtz%pcdw9n(%=JbBWOb0O@&1hKmnML&FKYo|z zDPf&E9{k~O_>GV?)r>z2C%Q6iz!z1sExj$uRTc5N+4FmjG9I_@Q5}&tLx`_7jF2xy z_6mwagSuvSA71#q^rw9CmEp-vE2)|4qGuLA!FChJnGHUrdCo@{Z}IuSLjm>qd(^p*!a)J&WF k^7>@+`gr$09b-w(HFwHtyU)7@@YD^qZt-%HZr*$RUwEfgKmY&$ literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..4b95f446fbf2e4416be95e8c97040327bd27ad1b GIT binary patch literal 4837 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 literal 0 HcmV?d00001 diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..0efa349 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,733 @@ + + + + + + +Bike-Tinder + + + + + + + + + + +
+
+
Bike-Tinder
+
—
+
+ +
+
You're all caught up.New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
+
+ +
+ + + + +
+ + + +
+
+
+ +
+
+
+
+
+
The machine
+
+
Seller's notes
+
+ Open on Kijiji ↗ +
+
+ + + +
+
+ +
+

All bikes

—
+ +
+
All
Liked
Maybe
Noped
Undecided
+
+
+
Score
Price ↕
Newest
km
+
+
+
+ +
+

Your shortlist

+
♥ Liked (0)
+
+
★ Maybe (0)
+
+
No likes yet — swipe right on the deck.
+
+ +
Trends — Phase 3The deck learns your taste as you swipe.
+
+ +
+ + + + diff --git a/app/static/manifest.json b/app/static/manifest.json new file mode 100644 index 0000000..e72d29f --- /dev/null +++ b/app/static/manifest.json @@ -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" + } + ] +} diff --git a/app/static/sw.js b/app/static/sw.js new file mode 100644 index 0000000..2241f98 --- /dev/null +++ b/app/static/sw.js @@ -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('/')) + ) + ); +});