diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 012fa2b..152959e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v6 with: node-version: 24 - - run: node --test tests/chart.test.cjs + - run: node --test tests/*.test.cjs - name: gofmt run: test -z "$(gofmt -l .)" - run: go vet ./... diff --git a/.gitignore b/.gitignore index 8bf5cab..21a8f02 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ *.log.* # live kiosk state (wallet, local watchlist). never commit. /config.json +/config.json.tmp # local env (copy from .env.example); POLYDISPLAY_ASSETS lives here /.env diff --git a/README.md b/README.md index c23b8ba..173fdf6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ Add a Polymarket public address in Settings to show positions, P/L and activity on the left and your watchlist on the right. On phones the panels stack. Clear the address to return to the full-width watchlist. +Tiles use compact spacing on tablets, with two columns in portrait mode. Drag a tile by its grip in the upper-right +corner to move it; the new custom order saves automatically on the server and +survives reloads. Scrolling elsewhere on a tile works normally. You can also +focus the grip and use arrow keys. Older iPads running iOS 10 use legacy grid +spacing and Touch Events for dragging. Settings can restore alphabetical or +traded-assets-first sorting. + Crypto prices and candles use Kraken, Coinbase or Binance. CoinGecko supplies crypto search. In Settings, choose **Commodities** under Add asset to search Gold, Silver, Copper, WTI, Brent and Natural Gas futures. Yahoo supplies these diff --git a/VERSION b/VERSION index 53d1c14..5502a04 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v22 +v23 diff --git a/auth.go b/auth.go index cfdb42c..1f501dc 100644 --- a/auth.go +++ b/auth.go @@ -241,7 +241,7 @@ func cors(w http.ResponseWriter) { func blockedStatic(p string) bool { p = strings.TrimPrefix(p, "/") switch { - case p == "config.json", p == ".env", p == "go.mod": + case p == "config.json", p == "config.json.tmp", p == ".env", p == "go.mod": return true case strings.HasPrefix(p, "polydisplay.log"), strings.HasPrefix(p, ".git"), strings.HasSuffix(p, ".go"), strings.HasSuffix(p, ".sh"): return true diff --git a/index.html b/index.html index 8837d5f..0b1da24 100644 --- a/index.html +++ b/index.html @@ -53,15 +53,31 @@ .col{flex:1 1 50%;min-width:0;display:flex;flex-direction:column;} main.assets-only .col.left{display:none;} main.assets-only .col.right{flex-basis:100%;} - #cxList{display:grid;grid-template-columns:1fr;align-content:start;gap:12px;padding:12px 16px 20px;} - #cxList .coin{margin:0;border-radius:16px;padding:16px;overflow:hidden;} + #cxList{display:grid;grid-template-columns:1fr;align-content:start;grid-gap:8px;gap:8px;padding:8px 12px 16px;} + #cxList .coin{margin:0;border-radius:12px;padding:12px;overflow:hidden;} #cxList .empty{grid-column:1/-1;} - main.assets-only #cxList{grid-template-columns:repeat(auto-fit,minmax(300px,1fr));} + main.assets-only #cxList{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));} .coin .top{flex-wrap:wrap;gap:10px;} .coin .quote{display:flex;align-items:baseline;gap:8px;} - .asset-meta{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-top:12px;font-size:11px;color:var(--muted);} - .asset-kind{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--accent);margin-bottom:8px;} + .asset-meta{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-top:8px;font-size:11px;color:var(--muted);} + .asset-kind{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--accent);margin-bottom:6px;} + .tile-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;} + .tile-head .asset-kind{margin:0;} + .tile-handle{touch-action:none;cursor:grab;border:0;background:none;color:var(--muted);font-size:20px; + padding:0 4px;min-width:32px;min-height:28px;line-height:1;border-radius:5px;} + .tile-handle:focus-visible{outline:2px solid var(--accent);} + .coin.dragging{opacity:.5;outline:2px solid var(--accent);pointer-events:none;} + #cxList.reordering .tile-handle{cursor:grabbing;} + @media(min-width:651px) and (max-width:1200px){ + #cxList{grid-gap:8px;gap:8px;padding:8px 10px 14px;} + #cxList .coin{padding:10px;} + #cxList .coin .sym{font-size:16px;} + #cxList .coin .val{font-size:19px;} + } @media(min-width:1500px){main.assets-only #cxList{grid-template-columns:repeat(3,minmax(0,1fr));}} + @media(min-width:651px) and (orientation:portrait){ + main.assets-only #cxList{grid-template-columns:repeat(2,minmax(0,1fr));} + } @media(max-width:650px){ main{display:block;overflow-y:auto;} .col{min-height:240px;} @@ -69,7 +85,7 @@ .list{overflow-y:visible;flex:none;} .col.left .list{overflow-y:auto;flex:1;} main.assets-only #cxList{grid-template-columns:1fr;} - .coin .val{font-size:26px;} + #cxList .coin .val{font-size:26px;} } .col.left{border-right:1px solid var(--line);} .col-head{display:flex;align-items:center;justify-content:space-between;padding:10px 16px 6px;flex:0 0 auto;} @@ -187,7 +203,7 @@
Connecting to server…
- + "; } +var tileDrag=null, orderSaving=false; function render(s){ + if(tileDrag || orderSaving) return; $("dashboard").className=(s.wallet||"").trim()?"":"assets-only"; if(s.version) $("appVer").textContent=s.version+" · server"; var rows=s.positions||[], html=""; @@ -699,7 +717,7 @@

Unlock pad

if((s.wallet||"").trim()){ html = pnlHtml(s.pnl) + html + activityHtml(s.activity||[]); } $("pmList").innerHTML=html; - var coins=s.coins||[], cl=""; + var coins=s.coins||[], cl="", chartHeight=window.matchMedia("(min-width:651px) and (max-width:1200px)").matches?76:84; if(!coins.length){ cl='
'+(s.loading?"Refreshing watchlist…":"No assets. Tap ⚙ to add.")+'
'; } for(var k=0;kUnlock pad var sourceName={kraken:"Kraken · USD",coinbase:"Coinbase · USD",binance:"Binance · USDT",yahoo:"Yahoo · USD"}; var quoteTime=c.quoteTime?new Date(c.quoteTime*1000).toLocaleString():""; var freshness=c.marketError?"Unavailable":(!c.quoteTime?"Waiting for quote":(Date.now()/1000-c.quoteTime>1800?"Delayed · last quote":"Delayed quote")); - cl+='
'+ - '
'+(commodity?'Commodity · futures':'Crypto')+'
'+ + cl+='
'+ + '
'+(commodity?'Commodity · futures':'Crypto')+'
'+ + '
'+ '
'+esc(c.sym)+''+esc(c.name)+'
'+ '
'+(c.price>0?fmtUsd(c.price):"—")+''+ ''+(c.chg24h!=null?fmtPct(c.chg24h)+(commodity?" session":" 24h"):"")+'
'+ - (commodity?commodityChart(c.candles,320,96,s.candleDays):chartWithAxis(c.candles,320,96,s.candleDays,c.price,c.candlesUpdated))+ + (commodity?commodityChart(c.candles,320,chartHeight,s.candleDays):chartWithAxis(c.candles,320,chartHeight,s.candleDays,c.price,c.candlesUpdated))+ '
'+esc(sourceName[c.source]||c.source||"Connecting")+(c.unit?' / '+esc(c.unit):'')+''+ (commodity?''+esc(freshness)+(quoteTime?' · '+esc(quoteTime):'')+'':'')+'
'+ (c.contract?'
'+esc(c.contract)+'
':"")+ (c.marketError?'
Market data unavailable: '+esc(c.marketError)+'
':"")+'
'; } + var focused=document.activeElement && document.activeElement.closest(".tile-handle"), focusedID=focused&&focused.closest(".coin").getAttribute("data-id"); $("cxList").innerHTML=cl; + if(focusedID){ + var tiles=$("cxList").querySelectorAll(".coin"); + for(var f=0;fUnlock pad }; $("modal").onclick=function(e){ if(e.target===this) closeSettings(); }; +/* Pointer events support both tablet touch and desktop dragging. */ +function tileIDs(){ + return Array.prototype.map.call($("cxList").querySelectorAll(".coin"),function(el){return el.getAttribute("data-id");}); +} +function moveTile(from,to){ + var list=$("cxList"), tiles=Array.prototype.slice.call(list.querySelectorAll(".coin")); + var a=tiles.indexOf(from),b=tiles.indexOf(to); + if(a<0 || b<0 || a===b) return; + list.insertBefore(from,alist.clientHeight?list:$("dashboard"); + var bounds=scroller.getBoundingClientRect(),y=tileDrag.y,oldScroll=scroller.scrollTop; + if(ybounds.bottom-45) scroller.scrollTop+=8; + if(scroller.scrollTop!==oldScroll){ + var hit=document.elementFromPoint(tileDrag.x,tileDrag.y),target=hit&&hit.closest(".coin"); + if(target && target.parentNode===list) moveTile(tileDrag.tile,target); + } + tileDrag.frame=requestAnimationFrame(scrollDrag); + } + function begin(handle,id,x,y){ + tileDrag={tile:handle.closest(".coin"),pointer:id,before:tileIDs(),x:x,y:y}; + tileDrag.tile.classList.add("dragging"); list.classList.add("reordering"); + tileDrag.frame=requestAnimationFrame(scrollDrag); + } + function move(x,y){ + tileDrag.x=x; tileDrag.y=y; + var hit=document.elementFromPoint(x,y),target=hit&&hit.closest(".coin"); + if(target && target.parentNode===list) moveTile(tileDrag.tile,target); + } + function finish(cancel){ + if(!tileDrag) return; + var drag=tileDrag; tileDrag=null; + cancelAnimationFrame(drag.frame); + drag.tile.classList.remove("dragging"); list.classList.remove("reordering"); + if(window.PointerEvent && list.hasPointerCapture(drag.pointer)) list.releasePointerCapture(drag.pointer); + if(cancel){ pullState(false); return; } + persistTileOrder(drag.before); + } + if(window.PointerEvent){ + list.onpointerdown=function(e){ + var handle=e.target.closest(".tile-handle"); + if(!handle || orderSaving || tileDrag || !e.isPrimary || e.button!==0) return; + e.preventDefault(); begin(handle,e.pointerId,e.clientX,e.clientY); + list.setPointerCapture(e.pointerId); + }; + list.onpointermove=function(e){ + if(tileDrag && e.pointerId===tileDrag.pointer) move(e.clientX,e.clientY); + }; + list.onpointerup=function(e){if(tileDrag && e.pointerId===tileDrag.pointer) finish(false);}; + list.onpointercancel=function(e){if(tileDrag && e.pointerId===tileDrag.pointer) finish(true);}; + list.onlostpointercapture=function(e){if(tileDrag && e.pointerId===tileDrag.pointer) finish(true);}; + } else { + // iOS 10 Safari has no Pointer Events or touch-action support. + list.addEventListener("touchstart",function(e){ + var handle=e.target.closest(".tile-handle"); + if(!handle || orderSaving || tileDrag || e.touches.length!==1) return; + e.preventDefault(); + var touch=e.touches[0]; begin(handle,touch.identifier,touch.clientX,touch.clientY); + },{passive:false}); + list.addEventListener("touchmove",function(e){ + if(!tileDrag) return; + e.preventDefault(); + for(var i=0;i ({add(){},remove(){}}); + const list = {children:[],classList:classes(),scrollHeight:500,clientHeight:500,scrollTop:0, + querySelectorAll(){return this.children.slice();}, + insertBefore(from,to){this.children.splice(this.children.indexOf(from),1); const at=to?this.children.indexOf(to):this.children.length; this.children.splice(at,0,from);}, + addEventListener(name,handler,options){listeners[name]={handler,options};}, + setPointerCapture(id){this.captured=id;},hasPointerCapture(id){return this.captured===id;},releasePointerCapture(){this.captured=null;}, + getBoundingClientRect(){return {top:0,bottom:500};}}; + function tile(id){ + const el={id,parentNode:list,classList:classes(),getAttribute(){return id;},closest(selector){return selector==='.coin'?el:null;}}; + Object.defineProperty(el,'nextSibling',{get(){return list.children[list.children.indexOf(el)+1]||null;}}); + el.handle={closest(selector){return selector==='.coin'?el:selector==='.tile-handle'?el.handle:null;},focus(){}}; + return el; + } + list.children=['a','b','c','d'].map(tile); + const status={}, saved=[], ctx={tileDrag:null,orderSaving:false, + window:{PointerEvent:pointer?function(){}:undefined}, + document:{elementFromPoint(){return ctx.hit;}}, + $:id => id==='cxList'||id==='dashboard'?list:status, + api(path,opts,cb){saved.push({path,ids:JSON.parse(opts.body).ids});cb({ok:true});}, + pullState(){ctx.pulls=(ctx.pulls||0)+1;},setTimeout(){}, + requestAnimationFrame(fn){ctx.frame=fn;return 1;},cancelAnimationFrame(){}, + getComputedStyle(){return {gridTemplateColumns:'200px 200px'};}}; + vm.createContext(ctx);vm.runInContext(source,ctx); + return {ctx,list,listeners,saved,status,ids:()=>list.children.map(c=>c.id)}; +} +function pointerEvent(target){return {target,isPrimary:true,button:0,pointerId:1,clientX:100,clientY:200,preventDefault(){}};} +test('pointer drag persists complete order and releases capture',()=>{ + const f=fixture(true),handle=f.list.children[0].handle; + f.list.onpointerdown(pointerEvent(handle)); + assert.ok(f.ctx.tileDrag); + f.ctx.frame(); // auto-scroll callback must also be valid + f.ctx.hit=f.list.children[2];f.list.onpointermove(pointerEvent(handle)); + f.list.onpointerup(pointerEvent(handle)); + assert.deepEqual(f.ids(),['b','c','a','d']); + assert.deepEqual(f.saved,[{path:'/api/order',ids:['b','c','a','d']}]); + assert.equal(f.ctx.tileDrag,null);assert.equal(f.list.captured,null); +}); +test('iOS 10 touch drag uses non-passive handlers and saves on touch end',()=>{ + const f=fixture(false),handle=f.list.children[0].handle; + const touch={identifier:7,clientX:100,clientY:200}; + const e={target:handle,touches:[touch],changedTouches:[touch],preventDefault(){}}; + assert.equal(f.listeners.touchstart.options.passive,false); + assert.equal(f.listeners.touchmove.options.passive,false); + f.listeners.touchstart.handler(e);f.ctx.frame(); + f.ctx.hit=f.list.children[3];f.listeners.touchmove.handler(e); + f.listeners.touchend.handler(e); + assert.deepEqual(f.ids(),['b','c','d','a']);assert.equal(f.saved.length,1); +}); +test('scrolling a tile body does not begin dragging; canceled drags never save',()=>{ + const f=fixture(false),tile=f.list.children[0],touch={identifier:7,clientX:100,clientY:200}; + const e={target:tile,touches:[touch],preventDefault(){}}; + f.listeners.touchstart.handler(e);assert.equal(f.ctx.tileDrag,null); + e.target=tile.handle;f.listeners.touchstart.handler(e); + f.ctx.hit=f.list.children[2];f.listeners.touchmove.handler(e); + f.listeners.touchcancel.handler();assert.equal(f.saved.length,0);assert.ok(f.ctx.pulls); +}); +test('keyboard down moves by one grid row',()=>{ + const f=fixture(true),handle=f.list.children[0].handle; + f.list.onkeydown({target:handle,key:'ArrowDown',preventDefault(){}}); + assert.deepEqual(f.ids(),['b','c','a','d']);assert.equal(f.saved.length,1); +}); +test('tablet grid includes iOS 10 spacing syntax',()=>{ + assert.match(html,/#cxList\{[^}]*grid-gap:8px/); +});