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:
@@ -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&§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<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'); }
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user