diff --git a/app/static/index.html b/app/static/index.html index c4ad187..c677791 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -181,6 +181,21 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .trow span{cursor:pointer} .trow i{color:#4a4a55;font-style:normal;cursor:default} .tmuted{font-size:12px;color:#6f6f7a;margin:10px 18px 20px} +/* B1 §11 — photo galleries: segments, tap zones, fullscreen viewer */ +.segments{position:absolute;top:8px;left:50%;transform:translateX(-50%);display:flex;gap:4px;z-index:4;pointer-events:none} +.segments i{width:22px;height:3px;border-radius:2px;background:rgba(255,255,255,.35);transition:background .15s} +.segments i.on{background:#fff} +.card.hasSegs .band{top:22px} /* §11.2 amendment: bands drop below the bars */ +.vback{position:absolute;inset:0;z-index:71;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .28s} +.vback.on{opacity:1;pointer-events:auto} +.viewer{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:72;background:#0b0b0f;display:flex;flex-direction:column;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.5);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);overflow:hidden} +.viewer.on{transform:translateY(0)} +.vgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none} +.vgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block} +.vclose{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:16px;z-index:6;cursor:pointer;display:flex;align-items:center;justify-content:center} +.vphoto{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none} +.vimg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transform-origin:center center} +.viewer .segments{top:34px} @@ -253,6 +268,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items +
+
+
+ +
+
+
+

All bikes

—
@@ -332,12 +355,18 @@ async function getJSON(url){ /* 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, secId, fails, warned} +const pend=new Map(); // listing_id -> {act, secId, photoIdx, fails, warned} let qRunning=false, qTimer=null, qWarned=false; -function enqueue(listingId, act, secId=null){ // act: 'like'|'nope'|'maybe'|null (delete) - pend.set(listingId,{act,secId,fails:0}); +function enqueue(listingId, act, secId=null, photoIdx=null){ // act: 'like'|'nope'|'maybe'|null (delete) + pend.set(listingId,{act,secId,photoIdx,fails:0}); scheduleQueue(); } +function swipeBody(id, act, secId, photoIdx){ + const b={listing_id:id,action:act}; + if(secId!=null) b.section_id=secId; + if(photoIdx!=null) b.photo_idx=photoIdx; // B1 §11.5: photo on screen at strike + return b; +} 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; @@ -355,7 +384,7 @@ async function runQueue(){ 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(p.secId?{listing_id:id,action:p.act,section_id:p.secId}:{listing_id:id,action:p.act})}); + : await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(swipeBody(id,p.act,p.secId,p.photoIdx))}); 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){ @@ -371,10 +400,10 @@ async function runQueue(){ progress stays correct while the persistence queue is mid-flight. */ const localDecisions=new Map(); // listing_id -> action|null let lastResyncAt=0; -function setDecision(id, act, secId=null){ +function setDecision(id, act, secId=null, photoIdx=null){ localDecisions.set(id, act); updateRowDecision(id, act); // optimistic paint - enqueue(id, act, secId); + enqueue(id, act, secId, photoIdx); } /* ---------- payload -> display helpers ---------- */ @@ -458,12 +487,75 @@ function bandHTML(item){ // P3 system bands — the ONLY card-fa } return ''; } +/* ---------- B1 §11: photo galleries ---------- */ +function nPhotos(it){ // payload n_photos; tolerant of old APIs + const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0); + return Math.max(0, Math.trunc(n)); +} +function leadFor(it){ // §11.5 repeat rotation: (last+1) % n + const n=nPhotos(it); + if(n<2) return 0; + const li=it.last_photo_idx; + if(!Number.isInteger(li)||li<0) return 0; + return (li+1)%n; +} +function photoSrc(it,i){ if(!it||!it.img) return null; return i>0?('/img/'+it.id+'?i='+i):it.img; } +function segHTML(n,cur){ + if(!n||n<2) return ''; + let h='
'; + for(let k=0;k`; + return h+'
'; +} +function phwrapHTML(src){ + return `
${src?``:`
Photo pending — thumbnail backfill queued
`}
`; +} +function attachPhError(entry){ + const wrap=entry.el.querySelector('.phwrap'); + const im=wrap.querySelector('img'); + if(im) im.addEventListener('error',()=>{ wrap.innerHTML='
Photo pending — thumbnail backfill queued
'; }); +} +function paintSegs(entry){ + const segs=entry.el.querySelector('.segments'); + if(segs) [...segs.children].forEach((x,k)=>x.classList.toggle('on',k===(entry.cur||0))); +} +/* §11.2 flip — instant, clamped at the ends (no wrap), commits nothing */ +function flipCard(entry,dir){ + const n=nPhotos(entry.d); + if(n<2) return; + const cur=(entry.cur||0)+dir; + if(cur<0||cur>=n) return; + entry.cur=cur; + const wrap=entry.el.querySelector('.phwrap'); + wrap.innerHTML= phwrapHTML(photoSrc(entry.d,cur)); + attachPhError(entry); + paintSegs(entry); + preloadPhotos(); +} +/* §11.6 preload: top card's next photo + next card's lead photo, off-path */ +function preloadPhotos(){ + pile.slice(0,2).forEach((entry,pos)=>{ + const d=entry.d; + if(!d) return; + const targets= pos===0 + ? ((()=>{ const n=nPhotos(d); const nx=(entry.cur||0)+1; + return (n>1&&nx`:`
Photo pending — thumbnail backfill queued
`} + const n=nPhotos(item); + const lead=leadFor(item); + if(n>=2) el.classList.add('hasSegs'); + el.innerHTML=`${phwrapHTML(photoSrc(item,lead))}
${bandHTML(item)} + ${segHTML(n,lead)}
NOPE
MAYBE
@@ -474,11 +566,8 @@ function cardNode(item){ 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','
Photo pending — thumbnail backfill queued
'); - }); + const entry={d:item,el,cur:lead}; + attachPhError(entry); el.addEventListener('pointerdown',down); el.addEventListener('pointermove',move); el.addEventListener('pointerup',upH); @@ -486,7 +575,7 @@ function cardNode(item){ 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}; + return entry; } function layer(el,d,anim){ el.classList.toggle('topcard',d===0); @@ -511,6 +600,7 @@ function fillPile(){ layer(entry.el,d,true); } relayer(); + preloadPhotos(); } /* deck feed: P3 sections (pinned order) with a legacy /api/deck fallback */ async function loadDeck(){ // legacy cursor feed (pre-P3 API) @@ -586,7 +676,8 @@ function commit(el,dir){ const t=pile.shift(); hist.push(t.d); const act= dir==='maybe'?'maybe':dir; - setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null); + setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null, + nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike sectDecided++; paintSect(); flyOut(el,dir); @@ -620,6 +711,7 @@ function undo(){ if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); buffer.unshift(drop.d); } void entry.el.offsetWidth; relayer(); + preloadPhotos(); $('empty').classList.remove('on'); $('report').classList.remove('on'); } @@ -654,13 +746,25 @@ function upH(e){ 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'}); } + else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); } +} +/* §11.2/§11.3/§11.4 tap zones: sides flip (clamped), center = fullscreen. + The old tap-anywhere -> dossier gesture is retired (ⓘ stays the door). */ +function tapZone(entry,e){ + const n=nPhotos(entry.d); + if(n<1 || !entry.d.img) return; + const r=stage.getBoundingClientRect(); + const rel=e.clientX-r.left, w=r.width||360; + if(rel>=w/3 && rel<=w*2/3) openViewer(entry.d, entry.cur||0, {type:'card',entry}); + else flipCard(entry, rel viewer) function openDetail(data,ctx){ dCtx=ctx||null; + dData=data; const ph=$('dphoto'); if(data.img){ ph.src=data.img; ph.classList.remove('ph'); } else { ph.removeAttribute('src'); ph.classList.add('ph'); } @@ -721,7 +825,12 @@ function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e 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=''; } } + else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; + /* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at photo 0 */ + if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList + && e.currentTarget.classList.contains('dhero') && dData&&dData.img){ + openViewer(dData,0,{type:'dossier'}); + } } } [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(); @@ -736,6 +845,113 @@ function dAction(a){ } $('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like'); +/* ---------- B1 §11.3: fullscreen viewer (swipe nav, pinch/double-tap zoom, + ✕ + pull-down close) — returns to the same card & photo position ---------- */ +let vCtx=null; // {item, idx, back} — deck cards untouched +let vScale=1, vTx=0, vTy=0; +let vDrag=null; // single-pointer gesture state +const vPtrs=new Map(); // pointerId -> {x,y} +let vPinch=null; // {d0, scale0} +let vLastTap=0; +function vCount(){ return vCtx&&vCtx.item?nPhotos(vCtx.item):0; } +function vApply(){ if(!vCtx) return; if(vScale<=1){ vTx=0; vTy=0; } $('vimg').style.transform=`translate(${vTx}px,${vTy}px) scale(${vScale})`; } +function vPaint(){ + const item=vCtx.item, i=vCtx.idx; + $('vsegs').innerHTML=segHTML(vCount(),i); + const im=$('vimg'), s=photoSrc(item,i); + if(s){ im.src=s; im.style.opacity=1; } + else { im.removeAttribute('src'); im.style.opacity=.25; } + im.onerror=()=>{ im.style.opacity=.25; }; + vApply(); +} +function openViewer(item, idx, back){ + const n=nPhotos(item); + if(!n||!item||!item.img) return; + vCtx={item, idx:Math.max(0,Math.min(idx||0,n-1)), back:back||null}; + vScale=1; vTx=0; vTy=0; + vPaint(); + $('viewer').classList.add('on'); $('vback').classList.add('on'); +} +function closeViewer(){ + const v=$('viewer'); + v.classList.remove('on'); $('vback').classList.remove('on'); + v.style.transition=''; v.style.transform=''; + vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null; +} +$('vback').addEventListener('click',closeViewer); +$('vclose').onclick=closeViewer; +$('vclose').addEventListener('pointerdown',e=>e.stopPropagation()); +const vph=$('vphoto'); +function vDist(){ + const pts=[...vPtrs.values()]; + return pts.length>=2?Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y):null; +} +vph.addEventListener('pointerdown',e=>{ + if(!vCtx) return; + vPtrs.set(e.pointerId,{x:e.clientX,y:e.clientY}); + try{ vph.setPointerCapture(e.pointerId); }catch(_){} + if(vPtrs.size===1){ vDrag={sx:e.clientX,sy:e.clientY,px:vTx,py:vTy,scale0:vScale,mode:null}; } + else if(vPtrs.size===2){ vDrag=null; vPinch={d0:vDist()||1,scale0:vScale}; } +}); +vph.addEventListener('pointermove',e=>{ + if(!vCtx) return; + const pt=vPtrs.get(e.pointerId); if(pt){ pt.x=e.clientX; pt.y=e.clientY; } + if(vPtrs.size>=2&&vPinch){ + const dd=vDist(); if(!dd) return; + vScale=Math.max(1,Math.min(4,vPinch.scale0*dd/vPinch.d0)); // 1×–4× + vApply(); return; + } + if(!vDrag) return; + const dx=e.clientX-vDrag.sx, dy=e.clientY-vDrag.sy; + if(!vDrag.mode){ + if(Math.abs(dx)<=6&&Math.abs(dy)<=6) return; + vDrag.mode= vScale>1?'pan':(Math.abs(dx)>=Math.abs(dy)?'nav':'pull'); + } + if(vDrag.mode==='pull'){ + const v=$('viewer'); v.style.transition='none'; + v.style.transform=`translateY(${Math.max(0,dy)}px)`; + } else if(vDrag.mode==='nav'){ + vTx=Math.max(-70,Math.min(70,dx)); vApply(); // live edge-feedback + } else { vTx=vDrag.px+dx; vTy=vDrag.py+dy; vApply(); } // pan while zoomed +}); +function vUp(e){ + const wasDrag=vDrag, dx=wasDrag?e.clientX-wasDrag.sx:0, dy=wasDrag?e.clientY-wasDrag.sy:0; + if(wasDrag&&vPtrs.size<=1){ + const mode=wasDrag.mode; + if(mode==='pull'){ + if(dy>110){ closeViewer(); } + else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; } + } else if(mode==='nav'){ + if(Math.abs(dx)>=50&&vCtx){ + const nx=vCtx.idx+(dx>0?-1:1); // clamp at the ends + if(nx>=0&&nx double-tap zoom toggle + const now=Date.now(); + if(now-vLastTap<300){ vLastTap=0; vScale= vScale>1?1:2.5; vApply(); } + else vLastTap=now; + } + vDrag=null; + } + vPtrs.delete(e.pointerId); vPinch=null; +} +vph.addEventListener('pointerup',vUp); +vph.addEventListener('pointercancel',vUp); +/* pull-down close from the grabber (the §3 idiom) */ +let vgY=null; +function vgStart(e){ vgY=e.clientY; $('viewer').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} } +function vgMove(e){ if(vgY===null) return; $('viewer').style.transform=`translateY(${Math.max(0,e.clientY-vgY)}px)`; } +function vgEnd(e){ if(vgY===null) return; const dy=Math.max(0,e.clientY-vgY); vgY=null; + if(dy>110){ closeViewer(); } + else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; } } +$('vgrab').addEventListener('pointerdown',vgStart); +$('vgrab').addEventListener('pointermove',vgMove); +$('vgrab').addEventListener('pointerup',vgEnd); +$('vgrab').addEventListener('pointercancel',vgEnd); + /* ---------- list screen (real filters + search) ---------- */ let listItems=[]; const listCtl={decision:'all',sort:'score',q:'',priceDir:1}; @@ -1043,7 +1259,7 @@ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ /* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */ document.addEventListener('keydown',e=>{ - if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')) return; + if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')||$('viewer').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'); } diff --git a/app/static/sw.js b/app/static/sw.js index 3c26a6c..10c252c 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -6,7 +6,7 @@ offline once seen). - Everything else (shell/navigation): network-first, fall back to cache. */ -const VERSION = 'bike-tinder-v2'; +const VERSION = 'bike-tinder-v3'; const SHELL = [ '/', '/manifest.json',