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 @@
-
+
@@ -212,7 +228,7 @@
Settings
Traded assets first
Symbol A–Z
- As added (custom order)
+ Custom order (drag tiles)
Watchlist assets
@@ -665,7 +681,9 @@
Unlock pad
"
";
}
+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;f
Unlock 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/);
+});