b1: frontend §11 — tap zones (sides flip clamped, center fullscreen, ⓘ wins), segment bars + band top:22 amendment, fullscreen viewer (swipe nav clamp, pinch 1-4x + double-tap zoom, ✕/pull-down close, same-position return), repeat rotation lead (last+1)%n, photo_idx on swipe commits, photo preload, sw cache v3

This commit is contained in:
Torres
2026-11-11 00:08:04 -04:00
parent 049b4fd280
commit fff9f05bae
2 changed files with 234 additions and 18 deletions

View File

@@ -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}
</style>
</head>
<body>
@@ -253,6 +268,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
</div>
</div>
<div class="vback" id="vback"></div>
<div class="viewer" id="viewer">
<div class="vgrab" id="vgrab"><i></i></div>
<button class="vclose" id="vclose">✕</button>
<div class="segments" id="vsegs"></div>
<div class="vphoto" id="vphoto"><img class="vimg" id="vimg" alt="" draggable="false"></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">
@@ -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='<div class="segments">';
for(let k=0;k<n;k++) h+=`<i${k===cur?' class="on"':''}></i>`;
return h+'</div>';
}
function phwrapHTML(src){
return `<div class="phwrap">${src?`<img class="photo" src="${src}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}</div>`;
}
function attachPhError(entry){
const wrap=entry.el.querySelector('.phwrap');
const im=wrap.querySelector('img');
if(im) im.addEventListener('error',()=>{ wrap.innerHTML='<div class="nophoto">Photo pending — thumbnail backfill queued</div>'; });
}
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<n)?[photoSrc(d,nx)]:[]; })())
: [photoSrc(d,leadFor(d))];
for(const s of targets){
if(!s) continue;
const im=new Image(); im.decoding='async'; im.src=s;
}
});
}
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>`}
const n=nPhotos(item);
const lead=leadFor(item);
if(n>=2) el.classList.add('hasSegs');
el.innerHTML=`${phwrapHTML(photoSrc(item,lead))}
<div class="grad"></div>
${bandHTML(item)}
${segHTML(n,lead)}
<div class="stamp like">LIKE</div>
<div class="stamp nope">NOPE</div>
<div class="stamp maybe">MAYBE</div>
@@ -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','<div class="nophoto">Photo pending — thumbnail backfill queued</div>');
});
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&&section&&!section.exhausted)?section.section.id:null);
setDecision(t.d.id, act, (!legacyMode&&section&&!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<w/3?-1:1);
}
/* ---------- docs/mockup core: fullscreen dossier ---------- */
let dCtx=null;
let dData=null; // B1: dossier payload (hero -> 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<vCount()){ vCtx.idx=nx; vPaint(); }
}
vTx=0; vApply();
} else if(mode==='pan'){
// keep pan position
} else if(e.type==='pointerup'){ // tap -> 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'); }

View File

@@ -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',