@pramen/admin 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>pramen · admin</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="./main.dm6bgf51.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,185 @@
1
+ var id,W,Pd,ya,C,Md,_d,Ld,wd,u,s,Rd,Wd,Yd,Zd,ka,pd={},rd=[],wa=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,bd=Array.isArray;function P(d,a){for(var p in a)d[p]=a[p];return d}function Xd(d){d&&d.parentNode&&d.parentNode.removeChild(d)}function Ya(d,a,p){var b,i,r,v={};for(r in a)r=="key"?b=a[r]:r=="ref"?i=a[r]:v[r]=a[r];if(arguments.length>2&&(v.children=arguments.length>3?id.call(arguments,2):p),typeof d=="function"&&d.defaultProps!=null)for(r in d.defaultProps)v[r]===void 0&&(v[r]=d.defaultProps[r]);return dd(d,v,b,i,null)}function dd(d,a,p,b,i){var r={type:d,props:a,key:p,ref:b,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i==null?++Pd:i,__i:-1,__u:0};return i==null&&W.vnode!=null&&W.vnode(r),r}function _(d){return d.children}function ad(d,a){this.props=d,this.context=a}function S(d,a){if(a==null)return d.__?S(d.__,d.__i+1):null;for(var p;a<d.__k.length;a++)if((p=d.__k[a])!=null&&p.__e!=null)return p.__e;return typeof d.type=="function"?S(d):null}function Za(d){if(d.__P&&d.__d){var a=d.__v,p=a.__e,b=[],i=[],r=P({},a);r.__v=a.__v+1,W.vnode&&W.vnode(r),fd(d.__P,r,a,d.__n,d.__P.namespaceURI,32&a.__u?[p]:null,b,p==null?S(a):p,!!(32&a.__u),i),r.__v=a.__v,r.__.__k[r.__i]=r,cd(b,r,i),a.__e=a.__=null,r.__e!=p&&$d(r)}}function $d(d){if((d=d.__)!=null&&d.__c!=null)return d.__e=d.__c.base=null,d.__k.some(function(a){if(a!=null&&a.__e!=null)return d.__e=d.__c.base=a.__e}),$d(d)}function Bd(d){(!d.__d&&(d.__d=!0)&&C.push(d)&&!od.__r++||Md!=W.debounceRendering)&&((Md=W.debounceRendering)||_d)(od)}function od(){try{for(var d,a=1;C.length;)C.length>a&&C.sort(Ld),d=C.shift(),a=C.length,Za(d)}finally{C.length=od.__r=0}}function Cd(d,a,p,b,i,r,v,z,k,m,w){var o,Y,y,f,q,I,Z,Q=b&&b.__k||rd,O=a.length;for(k=Qa(p,a,Q,k,O),o=0;o<O;o++)(y=p.__k[o])!=null&&(Y=y.__i!=-1&&Q[y.__i]||pd,y.__i=o,I=fd(d,y,Y,i,r,v,z,k,m,w),f=y.__e,y.ref&&Y.ref!=y.ref&&(Y.ref&&Kd(Y.ref,null,y),w.push(y.ref,y.__c||f,y)),q==null&&f!=null&&(q=f),(Z=!!(4&y.__u))||Y.__k===y.__k?(k=Ad(y,k,d,Z),Z&&Y.__e&&(Y.__e=null)):typeof y.type=="function"&&I!==void 0?k=I:f&&(k=f.nextSibling),y.__u&=-7);return p.__e=q,k}function Qa(d,a,p,b,i){var r,v,z,k,m,w=p.length,o=w,Y=0;for(d.__k=Array(i),r=0;r<i;r++)(v=a[r])!=null&&typeof v!="boolean"&&typeof v!="function"?(typeof v=="string"||typeof v=="number"||typeof v=="bigint"||v.constructor==String?v=d.__k[r]=dd(null,v,null,null,null):bd(v)?v=d.__k[r]=dd(_,{children:v},null,null,null):v.constructor===void 0&&v.__b>0?v=d.__k[r]=dd(v.type,v.props,v.key,v.ref?v.ref:null,v.__v):d.__k[r]=v,k=r+Y,v.__=d,v.__b=d.__b+1,z=null,(m=v.__i=Wa(v,p,k,o))!=-1&&(o--,(z=p[m])&&(z.__u|=2)),z==null||z.__v==null?(m==-1&&(i>w?Y--:i<w&&Y++),typeof v.type!="function"&&(v.__u|=4)):m!=k&&(m==k-1?Y--:m==k+1?Y++:(m>k?Y--:Y++,v.__u|=4))):d.__k[r]=null;if(o)for(r=0;r<w;r++)(z=p[r])!=null&&(2&z.__u)==0&&(z.__e==b&&(b=S(z)),Td(z,z));return b}function Ad(d,a,p,b){var i,r;if(typeof d.type=="function"){for(i=d.__k,r=0;i&&r<i.length;r++)i[r]&&(i[r].__=d,a=Ad(i[r],a,p,b));return a}d.__e!=a&&(b&&(a&&d.type&&!a.parentNode&&(a=S(d)),p.insertBefore(d.__e,a||null)),a=d.__e);do a=a&&a.nextSibling;while(a!=null&&a.nodeType==8);return a}function Wa(d,a,p,b){var i,r,v,z=d.key,k=d.type,m=a[p],w=m!=null&&(2&m.__u)==0;if(m===null&&z==null||w&&z==m.key&&k==m.type)return p;if(b>(w?1:0)){for(i=p-1,r=p+1;i>=0||r<a.length;)if((m=a[v=i>=0?i--:r++])!=null&&(2&m.__u)==0&&z==m.key&&k==m.type)return v}return-1}function Nd(d,a,p){a[0]=="-"?d.setProperty(a,p==null?"":p):d[a]=p==null?"":typeof p!="number"||wa.test(a)?p:p+"px"}function n(d,a,p,b,i){var r,v;d:if(a=="style")if(typeof p=="string")d.style.cssText=p;else{if(typeof b=="string"&&(d.style.cssText=b=""),b)for(a in b)p&&a in p||Nd(d.style,a,"");if(p)for(a in p)b&&p[a]==b[a]||Nd(d.style,a,p[a])}else if(a[0]=="o"&&a[1]=="n")r=a!=(a=a.replace(Rd,"$1")),v=a.toLowerCase(),a=v in d||a=="onFocusOut"||a=="onFocusIn"?v.slice(2):a.slice(2),d.l||(d.l={}),d.l[a+r]=p,p?b?p[s]=b[s]:(p[s]=Wd,d.addEventListener(a,r?Zd:Yd,r)):d.removeEventListener(a,r?Zd:Yd,r);else{if(i=="http://www.w3.org/2000/svg")a=a.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(a!="width"&&a!="height"&&a!="href"&&a!="list"&&a!="form"&&a!="tabIndex"&&a!="download"&&a!="rowSpan"&&a!="colSpan"&&a!="role"&&a!="popover"&&a in d)try{d[a]=p==null?"":p;break d}catch(z){}typeof p=="function"||(p==null||p===!1&&a[4]!="-"?d.removeAttribute(a):d.setAttribute(a,a=="popover"&&p==1?"":p))}}function Ud(d){return function(a){if(this.l){var p=this.l[a.type+d];if(a[u]==null)a[u]=Wd++;else if(a[u]<p[s])return;return p(W.event?W.event(a):a)}}}function fd(d,a,p,b,i,r,v,z,k,m){var w,o,Y,y,f,q,I,Z,Q,O,N,U,R,F,B,G=a.type;if(a.constructor!==void 0)return null;128&p.__u&&(k=!!(32&p.__u),r=[z=a.__e=p.__e]),(w=W.__b)&&w(a);d:if(typeof G=="function")try{if(Z=a.props,Q=G.prototype&&G.prototype.render,O=(w=G.contextType)&&b[w.__c],N=w?O?O.props.value:w.__:b,p.__c?I=(o=a.__c=p.__c).__=o.__E:(Q?a.__c=o=new G(Z,N):(a.__c=o=new ad(Z,N),o.constructor=G,o.render=fa),O&&O.sub(o),o.state||(o.state={}),o.__n=b,Y=o.__d=!0,o.__h=[],o._sb=[]),Q&&o.__s==null&&(o.__s=o.state),Q&&G.getDerivedStateFromProps!=null&&(o.__s==o.state&&(o.__s=P({},o.__s)),P(o.__s,G.getDerivedStateFromProps(Z,o.__s))),y=o.props,f=o.state,o.__v=a,Y)Q&&G.getDerivedStateFromProps==null&&o.componentWillMount!=null&&o.componentWillMount(),Q&&o.componentDidMount!=null&&o.__h.push(o.componentDidMount);else{if(Q&&G.getDerivedStateFromProps==null&&Z!==y&&o.componentWillReceiveProps!=null&&o.componentWillReceiveProps(Z,N),a.__v==p.__v||!o.__e&&o.shouldComponentUpdate!=null&&o.shouldComponentUpdate(Z,o.__s,N)===!1){a.__v!=p.__v&&(o.props=Z,o.state=o.__s,o.__d=!1),a.__e=p.__e,a.__k=p.__k,a.__k.some(function(H){H&&(H.__=a)}),rd.push.apply(o.__h,o._sb),o._sb=[],o.__h.length&&v.push(o);break d}o.componentWillUpdate!=null&&o.componentWillUpdate(Z,o.__s,N),Q&&o.componentDidUpdate!=null&&o.__h.push(function(){o.componentDidUpdate(y,f,q)})}if(o.context=N,o.props=Z,o.__P=d,o.__e=!1,U=W.__r,R=0,Q)o.state=o.__s,o.__d=!1,U&&U(a),w=o.render(o.props,o.state,o.context),rd.push.apply(o.__h,o._sb),o._sb=[];else do o.__d=!1,U&&U(a),w=o.render(o.props,o.state,o.context),o.state=o.__s;while(o.__d&&++R<25);o.state=o.__s,o.getChildContext!=null&&(b=P(P({},b),o.getChildContext())),Q&&!Y&&o.getSnapshotBeforeUpdate!=null&&(q=o.getSnapshotBeforeUpdate(y,f)),F=w!=null&&w.type===_&&w.key==null?Sd(w.props.children):w,z=Cd(d,bd(F)?F:[F],a,p,b,i,r,v,z,k,m),o.base=a.__e,a.__u&=-161,o.__h.length&&v.push(o),I&&(o.__E=o.__=null)}catch(H){if(a.__v=null,k||r!=null)if(H.then){for(a.__u|=k?160:128;z&&z.nodeType==8&&z.nextSibling;)z=z.nextSibling;r[r.indexOf(z)]=null,a.__e=z}else{for(B=r.length;B--;)Xd(r[B]);Qd(a)}else a.__e=p.__e,a.__k=p.__k,H.then||Qd(a);W.__e(H,a,p)}else r==null&&a.__v==p.__v?(a.__k=p.__k,a.__e=p.__e):z=a.__e=Xa(p.__e,a,p,b,i,r,v,k,m);return(w=W.diffed)&&w(a),128&a.__u?void 0:z}function Qd(d){d&&(d.__c&&(d.__c.__e=!0),d.__k&&d.__k.some(Qd))}function cd(d,a,p){for(var b=0;b<p.length;b++)Kd(p[b],p[++b],p[++b]);W.__c&&W.__c(a,d),d.some(function(i){try{d=i.__h,i.__h=[],d.some(function(r){r.call(i)})}catch(r){W.__e(r,i.__v)}})}function Sd(d){return typeof d!="object"||d==null||d.__b>0?d:bd(d)?d.map(Sd):d.constructor!==void 0?null:P({},d)}function Xa(d,a,p,b,i,r,v,z,k){var m,w,o,Y,y,f,q,I=p.props||pd,Z=a.props,Q=a.type;if(Q=="svg"?i="http://www.w3.org/2000/svg":Q=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),r!=null){for(m=0;m<r.length;m++)if((y=r[m])&&"setAttribute"in y==!!Q&&(Q?y.localName==Q:y.nodeType==3)){d=y,r[m]=null;break}}if(d==null){if(Q==null)return document.createTextNode(Z);d=document.createElementNS(i,Q,Z.is&&Z),z&&(W.__m&&W.__m(a,r),z=!1),r=null}if(Q==null)I===Z||z&&d.data==Z||(d.data=Z);else{if(r=Q=="textarea"&&Z.defaultValue!=null?null:r&&id.call(d.childNodes),!z&&r!=null)for(I={},m=0;m<d.attributes.length;m++)I[(y=d.attributes[m]).name]=y.value;for(m in I)y=I[m],m=="dangerouslySetInnerHTML"?o=y:m=="children"||(m in Z)||m=="value"&&("defaultValue"in Z)||m=="checked"&&("defaultChecked"in Z)||n(d,m,null,y,i);for(m in Z)y=Z[m],m=="children"?Y=y:m=="dangerouslySetInnerHTML"?w=y:m=="value"?f=y:m=="checked"?q=y:z&&typeof y!="function"||I[m]===y||n(d,m,y,I[m],i);if(w)z||o&&(w.__html==o.__html||w.__html==d.innerHTML)||(d.innerHTML=w.__html),a.__k=[];else if(o&&(d.innerHTML=""),Cd(a.type=="template"?d.content:d,bd(Y)?Y:[Y],a,p,b,Q=="foreignObject"?"http://www.w3.org/1999/xhtml":i,r,v,r?r[0]:p.__k&&S(p,0),z,k),r!=null)for(m=r.length;m--;)Xd(r[m]);z&&Q!="textarea"||(m="value",Q=="progress"&&f==null?d.removeAttribute("value"):f!=null&&(f!==d[m]||Q=="progress"&&!f||Q=="option"&&f!=I[m])&&n(d,m,f,I[m],i),m="checked",q!=null&&q!=d[m]&&n(d,m,q,I[m],i))}return d}function Kd(d,a,p){try{if(typeof d=="function"){var b=typeof d.__u=="function";b&&d.__u(),b&&a==null||(d.__u=d(a))}else d.current=a}catch(i){W.__e(i,p)}}function Td(d,a,p){var b,i;if(W.unmount&&W.unmount(d),(b=d.ref)&&(b.current&&b.current!=d.__e||Kd(b,null,a)),(b=d.__c)!=null){if(b.componentWillUnmount)try{b.componentWillUnmount()}catch(r){W.__e(r,a)}b.base=b.__P=null}if(b=d.__k)for(i=0;i<b.length;i++)b[i]&&Td(b[i],a,p||typeof d.type!="function");p||Xd(d.__e),d.__c=d.__=d.__e=void 0}function fa(d,a,p){return this.constructor(d,p)}function hd(d,a,p){var b,i,r,v;a==document&&(a=document.documentElement),W.__&&W.__(d,a),i=(b=typeof p=="function")?null:p&&p.__k||a.__k,r=[],v=[],fd(a,d=(!b&&p||a).__k=Ya(_,null,[d]),i||pd,pd,a.namespaceURI,!b&&p?[p]:i?null:a.firstChild?id.call(a.childNodes):null,r,!b&&p?p:i?i.__e:a.firstChild,b,v),cd(r,d,v)}id=rd.slice,W={__e:function(d,a,p,b){for(var i,r,v;a=a.__;)if((i=a.__c)&&!i.__)try{if((r=i.constructor)&&r.getDerivedStateFromError!=null&&(i.setState(r.getDerivedStateFromError(d)),v=i.__d),i.componentDidCatch!=null&&(i.componentDidCatch(d,b||{}),v=i.__d),v)return i.__E=i}catch(z){d=z}throw d}},Pd=0,ya=function(d){return d!=null&&d.constructor===void 0},ad.prototype.setState=function(d,a){var p;p=this.__s!=null&&this.__s!=this.state?this.__s:this.__s=P({},this.state),typeof d=="function"&&(d=d(P({},p),this.props)),d&&P(p,d),d!=null&&this.__v&&(a&&this._sb.push(a),Bd(this))},ad.prototype.forceUpdate=function(d){this.__v&&(this.__e=!0,d&&this.__h.push(d),Bd(this))},ad.prototype.render=_,C=[],_d=typeof Promise=="function"?Promise.prototype.then.bind(Promise.resolve()):setTimeout,Ld=function(d,a){return d.__v.__b-a.__v.__b},od.__r=0,wd=Math.random().toString(8),u="__d"+wd,s="__a"+wd,Rd=/(PointerCapture)$|Capture$/i,Wd=0,Yd=Ud(!1),Zd=Ud(!0),ka=0;var j,K,Id,ed,V=0,nd=[],J=W,sd=J.__b,jd=J.__r,Vd=J.diffed,ld=J.__c,Dd=J.unmount,Ed=J.__;function qd(d,a){J.__h&&J.__h(K,d,V||a),V=0;var p=K.__H||(K.__H={__:[],__h:[]});return d>=p.__.length&&p.__.push({}),p.__[d]}function M(d){return V=1,Ka(aa,d)}function Ka(d,a,p){var b=qd(j++,2);if(b.t=d,!b.__c&&(b.__=[p?p(a):aa(void 0,a),function(z){var k=b.__N?b.__N[0]:b.__[0],m=b.t(k,z);k!==m&&(b.__N=[m,b.__[1]],b.__c.setState({}))}],b.__c=K,!K.__f)){var i=function(z,k,m){if(!b.__c.__H)return!0;var w=b.__c.__H.__.filter(function(Y){return Y.__c});if(w.every(function(Y){return!Y.__N}))return!r||r.call(this,z,k,m);var o=b.__c.props!==z;return w.some(function(Y){if(Y.__N){var y=Y.__[0];Y.__=Y.__N,Y.__N=void 0,y!==Y.__[0]&&(o=!0)}}),r&&r.call(this,z,k,m)||o};K.__f=!0;var{shouldComponentUpdate:r,componentWillUpdate:v}=K;K.componentWillUpdate=function(z,k,m){if(this.__e){var w=r;r=void 0,i(z,k,m),r=w}v&&v.call(this,z,k,m)},K.shouldComponentUpdate=i}return b.__N||b.__}function md(d,a){var p=qd(j++,3);!J.__s&&da(p.__H,a)&&(p.__=d,p.u=a,K.__H.__h.push(p))}function ud(d){return V=5,l(function(){return{current:d}},[])}function l(d,a){var p=qd(j++,7);return da(p.__H,a)&&(p.__=d(),p.__H=a,p.__h=d),p.__}function T(d,a){return V=8,l(function(){return d},a)}function Ia(){for(var d;d=nd.shift();){var a=d.__H;if(d.__P&&a)try{a.__h.some(gd),a.__h.some(Jd),a.__h=[]}catch(p){a.__h=[],J.__e(p,d.__v)}}}J.__b=function(d){K=null,sd&&sd(d)},J.__=function(d,a){d&&a.__k&&a.__k.__m&&(d.__m=a.__k.__m),Ed&&Ed(d,a)},J.__r=function(d){jd&&jd(d),j=0;var a=(K=d.__c).__H;a&&(Id===K?(a.__h=[],K.__h=[],a.__.some(function(p){p.__N&&(p.__=p.__N),p.u=p.__N=void 0})):(a.__h.some(gd),a.__h.some(Jd),a.__h=[],j=0)),Id=K},J.diffed=function(d){Vd&&Vd(d);var a=d.__c;a&&a.__H&&(a.__H.__h.length&&(nd.push(a)!==1&&ed===J.requestAnimationFrame||((ed=J.requestAnimationFrame)||Ja)(Ia)),a.__H.__.some(function(p){p.u&&(p.__H=p.u),p.u=void 0})),Id=K=null},J.__c=function(d,a){a.some(function(p){try{p.__h.some(gd),p.__h=p.__h.filter(function(b){return!b.__||Jd(b)})}catch(b){a.some(function(i){i.__h&&(i.__h=[])}),a=[],J.__e(b,p.__v)}}),ld&&ld(d,a)},J.unmount=function(d){Dd&&Dd(d);var a,p=d.__c;p&&p.__H&&(p.__H.__.some(function(b){try{gd(b)}catch(i){a=i}}),p.__H=void 0,a&&J.__e(a,p.__v))};var td=typeof requestAnimationFrame=="function";function Ja(d){var a,p=function(){clearTimeout(b),td&&cancelAnimationFrame(a),setTimeout(d)},b=setTimeout(p,35);td&&(a=requestAnimationFrame(p))}function gd(d){var a=K,p=d.__c;typeof p=="function"&&(d.__c=void 0,p()),K=a}function Jd(d){var a=K;d.__c=d.__(),K=a}function da(d,a){return!d||d.length!==a.length||a.some(function(p,b){return p!==d[b]})}function aa(d,a){return typeof a=="function"?a(d):a}class c extends Error{code;status;constructor(d,a,p){super(d);this.code=a;this.status=p;this.name="ApiError"}}async function A(d,a,p){let b=d.baseUrl.replace(/\/+$/,""),i;try{i=await fetch(`${b}${a}`,{...p,headers:{...p?.body?{"content-type":"application/json"}:{},authorization:`Bearer ${d.token}`,...p?.headers??{}}})}catch(v){throw new c(`network error reaching ${b} — is pramen running and is CORS_ORIGINS set?`,"network",0)}let r=null;try{r=await i.json()}catch{}if(!i.ok||!r||r.ok===!1){let v=r?.code??String(i.status),z=r?.error??`request failed (${i.status})`;if(i.status===403)z=`not an admin token — ${z}`;throw new c(z,v,i.status)}return r.result}var L={tenants:(d)=>A(d,"/tenants"),schema:(d,a)=>A(d,`/admin/schema?tenant=${encodeURIComponent(a)}`),list:(d,a,p,b,i)=>A(d,"/admin/data",{method:"POST",body:JSON.stringify({tenant:a,table:p,op:"list",limit:b,offset:i})}),count:(d,a,p)=>A(d,"/admin/data",{method:"POST",body:JSON.stringify({tenant:a,table:p,op:"count"})}),get:(d,a,p,b)=>A(d,"/admin/data",{method:"POST",body:JSON.stringify({tenant:a,table:p,op:"get",id:b})}),create:(d,a,p,b)=>A(d,"/admin/data",{method:"POST",body:JSON.stringify({tenant:a,table:p,op:"create",values:b})}),update:(d,a,p,b,i)=>A(d,"/admin/data",{method:"POST",body:JSON.stringify({tenant:a,table:p,op:"update",id:b,patch:i})}),remove:(d,a,p,b)=>A(d,"/admin/data",{method:"POST",body:JSON.stringify({tenant:a,table:p,op:"delete",id:b})})};var qa=0;function g(d,a,p,b,i,r){a||(a={});var v,z,k=a;if("ref"in k)for(z in k={},a)z=="ref"?v=a[z]:k[z]=a[z];var m={type:d,props:k,key:p,ref:v,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--qa,__i:-1,__u:0,__source:i,__self:r};if(typeof d=="function"&&(v=d.defaultProps))for(z in v)k[z]===void 0&&(k[z]=v[z]);return W.vnode&&W.vnode(m),m}var Fd={base:"pramen.admin.baseUrl",token:"pramen.admin.token",tenant:"pramen.admin.tenant"},h=25,Od=(d,a)=>{let[p,b]=M(()=>localStorage.getItem(d)??a),i=T((r)=>{b(r),localStorage.setItem(d,r)},[d]);return[p,i]};function Fa(d){if(d===null||d===void 0)return g("span",{class:"cell null",children:"null"},void 0,!1,void 0,this);if(typeof d==="boolean")return g("span",{class:"cell bool",children:String(d)},void 0,!1,void 0,this);if(typeof d==="object"){let p=JSON.stringify(d);return g("span",{class:"cell json",title:p,children:p},void 0,!1,void 0,this)}let a=String(d);return g("span",{class:"cell",title:a,children:a},void 0,!1,void 0,this)}function pa(){let[d,a]=Od(Fd.base,"http://localhost:8787"),[p,b]=Od(Fd.token,""),[i,r]=Od(Fd.tenant,"main"),v=l(()=>({baseUrl:d,token:p}),[d,p]),[z,k]=M([]),[m,w]=M(null),[o,Y]=M(null),[y,f]=M(null),[q,I]=M(null),[Z,Q]=M(0),[O,N]=M(!1),[U,R]=M(null),[F,B]=M(null),[G,H]=M(null),[vd,e]=M(!1),$=T((x)=>{if(x instanceof c)R({message:x.message,code:x.code});else R({message:x instanceof Error?x.message:String(x),code:"unknown"})},[]),ia=T(async()=>{if(!p)return;try{k(await L.tenants(v))}catch(x){if(x instanceof c&&x.status===403)$(x)}},[v,p,$]),ba=T(async()=>{if(!p)return;R(null);try{let x=await L.schema(v,i);w(x),Y((X)=>X&&(X in x.tables)?X:Object.keys(x.tables)[0]??null)}catch(x){w(null),Y(null),$(x)}},[v,i,p,$]),D=T(async()=>{if(!p||!o)return;N(!0),R(null);try{let[x,X]=await Promise.all([L.list(v,i,o,h,Z),L.count(v,i,o)]);f(x),I(X)}catch(x){f(null),I(null),$(x)}finally{N(!1)}},[v,i,o,Z,p,$]),xd=ud();md(()=>{return clearTimeout(xd.current),xd.current=setTimeout(()=>{ia(),ba()},250),()=>clearTimeout(xd.current)},[d,p,i]),md(()=>{Q(0)},[o,i]),md(()=>{D()},[o,i,Z,d,p]);let zd=l(()=>(o&&m?m.tables[o]:null)??[],[o,m]),ga=()=>{H(null);let x={};for(let X of zd)if(X!=="id")x[X]=null;B({mode:"create",original:null,text:JSON.stringify(x,null,2)})},ma=async(x)=>{H(null);try{let X=await L.get(v,i,o,x.id)??x;B({mode:"edit",original:X,text:JSON.stringify(X,null,2)})}catch(X){$(X)}},va=async()=>{if(!F||!o)return;let x;try{if(x=JSON.parse(F.text),typeof x!=="object"||x===null||Array.isArray(x))throw Error("must be a JSON object")}catch(X){H(X instanceof Error?X.message:"invalid JSON");return}e(!0),H(null);try{if(F.mode==="create")await L.create(v,i,o,x);else{let X=F.original??{},t=X.id,yd={};for(let[kd,Hd]of Object.entries(x)){if(kd==="id")continue;if(JSON.stringify(Hd)!==JSON.stringify(X[kd]))yd[kd]=Hd}if(Object.keys(yd).length===0){H("no changes to save"),e(!1);return}await L.update(v,i,o,t,yd)}B(null),await D()}catch(X){H(X instanceof c?`${X.message} (${X.code})`:String(X))}finally{e(!1)}},xa=async(x)=>{if(!o)return;if(!confirm(`Delete row id=${JSON.stringify(x.id)} from "${o}"? This cannot be undone.`))return;e(!0);try{await L.remove(v,i,o,x.id),await D()}catch(X){$(X)}finally{e(!1)}},E=m?Object.keys(m.tables):[],za=Math.floor(Z/h)+1,Gd=q!=null?Math.max(1,Math.ceil(q/h)):null;return g(_,{children:[g("div",{class:"bar",children:[g("div",{class:"brand",children:[g("span",{class:"mark",children:"pramen"},void 0,!1,void 0,this),g("span",{class:"sub",children:"admin"},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"field url",children:[g("label",{children:"base url"},void 0,!1,void 0,this),g("input",{value:d,onInput:(x)=>a(x.target.value),placeholder:"http://localhost:8787",spellcheck:!1},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"field token",children:[g("label",{children:"admin token"},void 0,!1,void 0,this),g("input",{type:"password",value:p,onInput:(x)=>b(x.target.value),placeholder:'paste an admin JWT (roles include "admin")',spellcheck:!1},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"hint",children:'Must be an admin token — every endpoint checks roles for "admin".'},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"layout",children:[g("aside",{class:"side",children:[g("p",{class:"sec-title",children:"Tenant"},void 0,!1,void 0,this),g("div",{class:"tenant-row",children:[z.length>0?g("select",{value:z.includes(i)?i:"",onChange:(x)=>r(x.target.value),children:[!z.includes(i)&&g("option",{value:"",children:[i," (manual)"]},void 0,!0,void 0,this),z.map((x)=>g("option",{value:x,children:x},x,!1,void 0,this))]},void 0,!0,void 0,this):g("input",{value:i,onInput:(x)=>r(x.target.value),placeholder:"main",spellcheck:!1},void 0,!1,void 0,this),g("button",{class:"ghost sm",title:"Edit tenant name manually",onClick:()=>{let x=prompt("Tenant name",i);if(x!=null&&x.trim())r(x.trim())},children:"edit"},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("p",{class:"sec-title",children:"Schema"},void 0,!1,void 0,this),m?g("div",{class:`hashchip${m.hash?"":" none"}`,title:"applied schema hash",children:[g("span",{class:"dot"},void 0,!1,void 0,this),m.hash?m.hash.slice(0,12):"no schema hash"]},void 0,!0,void 0,this):g("div",{class:"muted",style:"margin-bottom:14px",children:p?"—":"enter a token"},void 0,!1,void 0,this),g("p",{class:"sec-title",children:["Tables",E.length?` · ${E.length}`:""]},void 0,!0,void 0,this),E.length>0?g("ul",{class:"tables",children:E.map((x)=>g("li",{children:g("button",{class:x===o?"active":"",onClick:()=>Y(x),children:[g("span",{class:"tname",children:x},void 0,!1,void 0,this),g("span",{class:"cols",children:[m.tables[x].length," cols"]},void 0,!0,void 0,this)]},void 0,!0,void 0,this)},x,!1,void 0,this))},void 0,!1,void 0,this):g("div",{class:"muted",children:p?"no tables":""},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("main",{class:"main",children:[U&&g("div",{class:"banner",children:[g("div",{children:[U.message," ",g("code",{children:U.code},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("button",{class:"ghost sm",onClick:()=>R(null),children:"dismiss"},void 0,!1,void 0,this)]},void 0,!0,void 0,this),!o?g("div",{class:"placeholder",children:[g("div",{class:"big",children:p?"select a table":"paste an admin token to begin"},void 0,!1,void 0,this),g("div",{children:["The dashboard talks to the pramen Worker over HTTP. Tenant defaults to ",g("code",{style:"color:var(--amber)",children:"main"},void 0,!1,void 0,this),"."]},void 0,!0,void 0,this)]},void 0,!0,void 0,this):g(_,{children:[g("div",{class:"main-head",children:[g("h2",{children:o},void 0,!1,void 0,this),g("span",{class:"countchip",children:q==null?"…":`${q} row${q===1?"":"s"}`},void 0,!1,void 0,this),g("span",{class:"spacer"},void 0,!1,void 0,this),g("button",{class:"ghost sm",onClick:()=>void D(),disabled:O,children:"refresh"},void 0,!1,void 0,this),g("button",{class:"primary",onClick:ga,children:"+ new row"},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"toolbar",children:g("div",{class:"pager",children:[g("button",{class:"sm",disabled:Z===0||O,onClick:()=>Q(Math.max(0,Z-h)),children:"‹ prev"},void 0,!1,void 0,this),g("span",{children:["page ",za,Gd!=null?` / ${Gd}`:""]},void 0,!0,void 0,this),g("button",{class:"sm",disabled:O||q!=null&&Z+h>=q,onClick:()=>Q(Z+h),children:"next ›"},void 0,!1,void 0,this)]},void 0,!0,void 0,this)},void 0,!1,void 0,this),O&&!y?g("div",{class:"loading",children:"loading…"},void 0,!1,void 0,this):y&&y.length===0?g("div",{class:"empty",children:"no rows on this page"},void 0,!1,void 0,this):y?g("div",{class:"tablewrap",children:g("table",{class:"grid",children:[g("thead",{children:g("tr",{children:[zd.map((x)=>g("th",{children:x},x,!1,void 0,this)),g("th",{style:"text-align:right",children:"actions"},void 0,!1,void 0,this)]},void 0,!0,void 0,this)},void 0,!1,void 0,this),g("tbody",{children:y.map((x,X)=>g("tr",{children:[zd.map((t)=>g("td",{children:Fa(x[t])},t,!1,void 0,this)),g("td",{children:g("div",{class:"rowactions",children:[g("button",{class:"ghost sm",onClick:()=>void ma(x),children:"edit"},void 0,!1,void 0,this),g("button",{class:"danger sm",onClick:()=>void xa(x),disabled:vd,children:"del"},void 0,!1,void 0,this)]},void 0,!0,void 0,this)},void 0,!1,void 0,this)]},x.id??X,!0,void 0,this))},void 0,!1,void 0,this)]},void 0,!0,void 0,this)},void 0,!1,void 0,this):null]},void 0,!0,void 0,this)]},void 0,!0,void 0,this)]},void 0,!0,void 0,this),F&&g("div",{class:"scrim",onClick:(x)=>x.target===x.currentTarget&&B(null),children:g("div",{class:"modal",children:[g("div",{class:"modal-head",children:[g("h3",{children:F.mode==="create"?`new ${o} row`:`edit ${o} · id=${JSON.stringify(F.original?.id)}`},void 0,!1,void 0,this),g("span",{class:"spacer"},void 0,!1,void 0,this),g("button",{class:"ghost sm",onClick:()=>B(null),children:"✕"},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"modal-body",children:[g("label",{children:["row json ",F.mode==="edit"?"(only changed fields are sent as the patch)":"(sent as values)"]},void 0,!0,void 0,this),g("textarea",{value:F.text,spellcheck:!1,onInput:(x)=>B({...F,text:x.target.value})},void 0,!1,void 0,this),G&&g("div",{class:"jsonerr",style:"margin-top:8px",children:G},void 0,!1,void 0,this)]},void 0,!0,void 0,this),g("div",{class:"modal-foot",children:[g("span",{class:"muted",children:F.mode==="edit"?"id is never modified":"id is assigned by the server unless provided"},void 0,!1,void 0,this),g("span",{class:"spacer"},void 0,!1,void 0,this),g("button",{class:"ghost",onClick:()=>B(null),children:"cancel"},void 0,!1,void 0,this),g("button",{class:"primary",onClick:()=>void va(),disabled:vd,children:vd?"saving…":F.mode==="create"?"create":"save changes"},void 0,!1,void 0,this)]},void 0,!0,void 0,this)]},void 0,!0,void 0,this)},void 0,!1,void 0,this)]},void 0,!0,void 0,this)}var ra=`
2
+ :root {
3
+ --bg: #0e1116;
4
+ --surface: #141921;
5
+ --surface-2: #181e28;
6
+ --raise: #1d2531;
7
+ --border: #232c3a;
8
+ --border-2: #2e3a4d;
9
+ --ink: #e6edf3;
10
+ --ink-2: #aab7c7;
11
+ --ink-3: #6b7a8d;
12
+ --spring: #3fd6c0;
13
+ --spring-dim:#1c8d80;
14
+ --spring-bg: #0f2723;
15
+ --amber: #e0a458;
16
+ --danger: #e0606a;
17
+ --danger-bg: #2a161a;
18
+ --radius: 7px;
19
+ --radius-sm: 5px;
20
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Menlo", monospace;
21
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
22
+ }
23
+
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; height: 100%; }
26
+ body {
27
+ background: var(--bg);
28
+ color: var(--ink);
29
+ font-family: var(--sans);
30
+ font-size: 14px;
31
+ line-height: 1.5;
32
+ -webkit-font-smoothing: antialiased;
33
+ }
34
+ #app { min-height: 100%; }
35
+
36
+ button {
37
+ font-family: var(--sans);
38
+ font-size: 13px;
39
+ cursor: pointer;
40
+ border: 1px solid var(--border-2);
41
+ background: var(--raise);
42
+ color: var(--ink);
43
+ padding: 6px 11px;
44
+ border-radius: var(--radius-sm);
45
+ transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
46
+ }
47
+ button:hover:not(:disabled) { background: var(--surface-2); border-color: #3a485e; }
48
+ button:active:not(:disabled) { background: var(--surface); }
49
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
50
+ button:focus-visible { outline: 2px solid var(--spring-dim); outline-offset: 1px; }
51
+
52
+ button.primary {
53
+ background: var(--spring-bg);
54
+ border-color: var(--spring-dim);
55
+ color: var(--spring);
56
+ }
57
+ button.primary:hover:not(:disabled) { background: #123029; border-color: var(--spring); }
58
+ button.danger { color: var(--danger); border-color: #4a2a2f; background: var(--danger-bg); }
59
+ button.danger:hover:not(:disabled) { border-color: var(--danger); background: #361a1f; }
60
+ button.ghost { background: transparent; border-color: transparent; color: var(--ink-2); padding: 4px 7px; }
61
+ button.ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
62
+ button.sm { padding: 3px 8px; font-size: 12px; }
63
+
64
+ input, textarea {
65
+ font-family: var(--mono);
66
+ font-size: 13px;
67
+ background: var(--bg);
68
+ color: var(--ink);
69
+ border: 1px solid var(--border-2);
70
+ border-radius: var(--radius-sm);
71
+ padding: 7px 9px;
72
+ }
73
+ input::placeholder, textarea::placeholder { color: var(--ink-3); }
74
+ input:focus, textarea:focus { outline: none; border-color: var(--spring-dim); }
75
+ label { font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-3); display: block; margin-bottom: 4px; }
76
+
77
+ /* config bar */
78
+ .bar {
79
+ position: sticky; top: 0; z-index: 10;
80
+ display: flex; align-items: flex-end; gap: 14px;
81
+ padding: 12px 18px;
82
+ background: var(--surface);
83
+ border-bottom: 1px solid var(--border);
84
+ }
85
+ .brand { display: flex; align-items: baseline; gap: 9px; margin-right: 6px; padding-bottom: 5px; }
86
+ .brand .mark { color: var(--spring); font-family: var(--mono); font-weight: 600; letter-spacing: -0.02em; font-size: 16px; }
87
+ .brand .sub { color: var(--ink-3); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; }
88
+ .bar .field { display: flex; flex-direction: column; }
89
+ .bar .field.url input { width: 220px; }
90
+ .bar .field.token { flex: 1; min-width: 200px; }
91
+ .bar .field.token input { width: 100%; }
92
+ .bar .hint { font-size: 11px; color: var(--ink-3); padding-bottom: 7px; max-width: 180px; line-height: 1.35; }
93
+
94
+ .layout { display: grid; grid-template-columns: 270px 1fr; min-height: calc(100vh - 60px); }
95
+ .side { border-right: 1px solid var(--border); background: var(--surface-2); padding: 16px 14px; }
96
+ .main { padding: 20px 24px; min-width: 0; }
97
+
98
+ .sec-title { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 9px; }
99
+ .tenant-row { display: flex; gap: 6px; margin-bottom: 18px; }
100
+ .tenant-row select, .tenant-row input { flex: 1; min-width: 0; }
101
+
102
+ select {
103
+ font-family: var(--mono); font-size: 13px;
104
+ background: var(--bg); color: var(--ink);
105
+ border: 1px solid var(--border-2); border-radius: var(--radius-sm);
106
+ padding: 7px 9px;
107
+ }
108
+ select:focus { outline: none; border-color: var(--spring-dim); }
109
+
110
+ .hashchip {
111
+ display: inline-flex; align-items: center; gap: 6px;
112
+ font-family: var(--mono); font-size: 11px;
113
+ color: var(--ink-2); background: var(--raise);
114
+ border: 1px solid var(--border); border-radius: 20px;
115
+ padding: 3px 10px; margin-bottom: 14px;
116
+ }
117
+ .hashchip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--spring); }
118
+ .hashchip.none .dot { background: var(--ink-3); }
119
+
120
+ .tables { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
121
+ .tables li button {
122
+ width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
123
+ display: flex; justify-content: space-between; align-items: center; gap: 8px;
124
+ padding: 7px 9px; border-radius: var(--radius-sm); color: var(--ink-2);
125
+ }
126
+ .tables li button:hover { background: var(--raise); color: var(--ink); }
127
+ .tables li button.active { background: var(--spring-bg); border-color: var(--spring-dim); color: var(--spring); }
128
+ .tables .tname { font-family: var(--mono); font-size: 13px; }
129
+ .tables .cols { font-size: 11px; color: var(--ink-3); }
130
+
131
+ .main-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
132
+ .main-head h2 { margin: 0; font-family: var(--mono); font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
133
+ .countchip {
134
+ font-family: var(--mono); font-size: 12px; color: var(--ink-2);
135
+ border: 1px solid var(--border); border-radius: 20px; padding: 2px 10px;
136
+ }
137
+ .spacer { flex: 1; }
138
+
139
+ .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
140
+ .pager { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--ink-2); }
141
+
142
+ .tablewrap { border: 1px solid var(--border); border-radius: var(--radius); overflow: auto; background: var(--surface); }
143
+ table.grid { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
144
+ table.grid th {
145
+ position: sticky; top: 0; background: var(--surface-2);
146
+ text-align: left; font-weight: 600; color: var(--ink-2);
147
+ padding: 8px 12px; border-bottom: 1px solid var(--border); white-space: nowrap;
148
+ }
149
+ table.grid td { padding: 7px 12px; border-bottom: 1px solid var(--border); color: var(--ink); vertical-align: top; max-width: 360px; }
150
+ table.grid tr:last-child td { border-bottom: none; }
151
+ table.grid tbody tr:hover { background: var(--surface-2); }
152
+ .cell { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
153
+ .cell.json { color: var(--amber); }
154
+ .cell.null { color: var(--ink-3); font-style: italic; }
155
+ .cell.bool { color: var(--spring); }
156
+ .rowactions { display: flex; gap: 4px; white-space: nowrap; }
157
+
158
+ .banner {
159
+ display: flex; align-items: flex-start; gap: 10px;
160
+ border: 1px solid #4a2a2f; background: var(--danger-bg);
161
+ color: var(--danger); border-radius: var(--radius);
162
+ padding: 10px 13px; margin-bottom: 14px; font-size: 13px;
163
+ }
164
+ .banner code { font-family: var(--mono); background: rgba(0,0,0,0.25); padding: 1px 5px; border-radius: 3px; color: var(--amber); }
165
+ .banner button { margin-left: auto; }
166
+
167
+ .empty, .loading { color: var(--ink-3); padding: 40px 0; text-align: center; font-size: 13px; }
168
+ .loading { color: var(--ink-2); }
169
+
170
+ /* modal */
171
+ .scrim { position: fixed; inset: 0; background: rgba(5,8,12,0.66); display: flex; align-items: flex-start; justify-content: center; padding: 56px 20px; z-index: 50; overflow: auto; }
172
+ .modal { width: 100%; max-width: 640px; background: var(--surface); border: 1px solid var(--border-2); border-radius: var(--radius); }
173
+ .modal-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
174
+ .modal-head h3 { margin: 0; font-family: var(--mono); font-size: 15px; font-weight: 600; }
175
+ .modal-head .spacer { flex: 1; }
176
+ .modal-body { padding: 16px 18px; }
177
+ .modal-body textarea { width: 100%; min-height: 280px; resize: vertical; line-height: 1.55; tab-size: 2; }
178
+ .modal-foot { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--border); }
179
+ .modal-foot .spacer { flex: 1; }
180
+ .jsonerr { color: var(--danger); font-family: var(--mono); font-size: 12px; }
181
+ .muted { color: var(--ink-3); font-size: 12px; }
182
+
183
+ .placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60vh; color: var(--ink-3); gap: 8px; }
184
+ .placeholder .big { font-family: var(--mono); color: var(--ink-2); font-size: 15px; }
185
+ `;var oa=document.createElement("style");oa.textContent=ra;document.head.appendChild(oa);hd(g(pa,{},void 0,!1,void 0,this),document.getElementById("app"));
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@pramen/admin",
3
+ "version": "0.0.2",
4
+ "description": "pramen admin dashboard — a Preact single-page browser client for the pramen admin HTTP API (tenants, schema, and generic row browse/edit).",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/netvarec/pramen.git",
10
+ "directory": "packages/admin"
11
+ },
12
+ "homepage": "https://github.com/netvarec/pramen#readme",
13
+ "bugs": "https://github.com/netvarec/pramen/issues",
14
+ "type": "module",
15
+ "files": ["dist", "src", "scripts"],
16
+ "scripts": {
17
+ "build": "bun run scripts/build.ts",
18
+ "dev": "bun run scripts/build.ts --watch"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "preact": "^10.25.0"
25
+ }
26
+ }
@@ -0,0 +1,71 @@
1
+ // Build the admin SPA: bundle src/main.tsx -> dist/ and emit dist/index.html.
2
+ // This is an app, not a library, so `bun build` is the right tool here (it only
3
+ // mangles pure re-export barrels; app code bundles fine). Pass --watch to rebuild
4
+ // on change and serve a preview on http://localhost:5174.
5
+
6
+ import { rm, mkdir, writeFile } from "node:fs/promises";
7
+
8
+ const root = new URL("..", import.meta.url).pathname;
9
+ const dist = `${root}dist`;
10
+ const entry = `${root}src/main.tsx`;
11
+ const watch = process.argv.includes("--watch");
12
+
13
+ const html = (jsName: string) => `<!doctype html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8" />
17
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
18
+ <title>pramen · admin</title>
19
+ </head>
20
+ <body>
21
+ <div id="app"></div>
22
+ <script type="module" src="./${jsName}"></script>
23
+ </body>
24
+ </html>
25
+ `;
26
+
27
+ async function build(): Promise<void> {
28
+ const out = await Bun.build({
29
+ entrypoints: [entry],
30
+ outdir: dist,
31
+ target: "browser",
32
+ minify: !watch,
33
+ sourcemap: watch ? "linked" : "none",
34
+ naming: "[dir]/[name].[hash].[ext]",
35
+ });
36
+ if (!out.success) {
37
+ for (const log of out.logs) console.error(log);
38
+ throw new Error("build failed");
39
+ }
40
+ const js = out.outputs.find((o) => o.path.endsWith(".js"));
41
+ if (!js) throw new Error("no js output");
42
+ const jsName = js.path.split("/").pop()!;
43
+ await writeFile(`${dist}/index.html`, html(jsName));
44
+ console.log(`built dist/${jsName} + index.html`);
45
+ }
46
+
47
+ await rm(dist, { recursive: true, force: true });
48
+ await mkdir(dist, { recursive: true });
49
+ await build();
50
+
51
+ if (watch) {
52
+ const { watch: fsWatch } = await import("node:fs");
53
+ let pending = false;
54
+ fsWatch(`${root}src`, { recursive: true }, () => {
55
+ if (pending) return;
56
+ pending = true;
57
+ setTimeout(() => {
58
+ pending = false;
59
+ build().catch((e) => console.error(e));
60
+ }, 80);
61
+ });
62
+ Bun.serve({
63
+ port: 5174,
64
+ fetch(req) {
65
+ const url = new URL(req.url);
66
+ const path = url.pathname === "/" ? "/index.html" : url.pathname;
67
+ return new Response(Bun.file(`${dist}${path}`));
68
+ },
69
+ });
70
+ console.log("watching src/ — preview on http://localhost:5174");
71
+ }
package/src/api.ts ADDED
@@ -0,0 +1,112 @@
1
+ // Thin browser client for pramen's admin HTTP API. Every call carries the admin
2
+ // bearer token; failures surface as an ApiError that the UI renders verbatim
3
+ // (error + code), with a friendlier hint for a 403 (not an admin token).
4
+
5
+ export type Row = Record<string, unknown>;
6
+
7
+ export interface SchemaResult {
8
+ hash: string | null;
9
+ tables: Record<string, string[]>;
10
+ }
11
+
12
+ export class ApiError extends Error {
13
+ constructor(
14
+ message: string,
15
+ readonly code: string,
16
+ readonly status: number,
17
+ ) {
18
+ super(message);
19
+ this.name = "ApiError";
20
+ }
21
+ }
22
+
23
+ export interface Config {
24
+ baseUrl: string;
25
+ token: string;
26
+ }
27
+
28
+ interface Envelope<T> {
29
+ ok: boolean;
30
+ result?: T;
31
+ error?: string;
32
+ code?: string;
33
+ }
34
+
35
+ async function call<T>(cfg: Config, path: string, init?: RequestInit): Promise<T> {
36
+ const base = cfg.baseUrl.replace(/\/+$/, "");
37
+ let res: Response;
38
+ try {
39
+ res = await fetch(`${base}${path}`, {
40
+ ...init,
41
+ headers: {
42
+ ...(init?.body ? { "content-type": "application/json" } : {}),
43
+ authorization: `Bearer ${cfg.token}`,
44
+ ...(init?.headers ?? {}),
45
+ },
46
+ });
47
+ } catch (e) {
48
+ throw new ApiError(
49
+ `network error reaching ${base} — is pramen running and is CORS_ORIGINS set?`,
50
+ "network",
51
+ 0,
52
+ );
53
+ }
54
+
55
+ let body: Envelope<T> | null = null;
56
+ try {
57
+ body = (await res.json()) as Envelope<T>;
58
+ } catch {
59
+ // non-JSON (e.g. the plain-text root help page or an upstream 500)
60
+ }
61
+
62
+ if (!res.ok || !body || body.ok === false) {
63
+ const code = body?.code ?? String(res.status);
64
+ let message = body?.error ?? `request failed (${res.status})`;
65
+ if (res.status === 403) message = `not an admin token — ${message}`;
66
+ throw new ApiError(message, code, res.status);
67
+ }
68
+ return body.result as T;
69
+ }
70
+
71
+ export const api = {
72
+ tenants: (cfg: Config) => call<string[]>(cfg, "/tenants"),
73
+
74
+ schema: (cfg: Config, tenant: string) =>
75
+ call<SchemaResult>(cfg, `/admin/schema?tenant=${encodeURIComponent(tenant)}`),
76
+
77
+ list: (cfg: Config, tenant: string, table: string, limit: number, offset: number) =>
78
+ call<Row[]>(cfg, "/admin/data", {
79
+ method: "POST",
80
+ body: JSON.stringify({ tenant, table, op: "list", limit, offset }),
81
+ }),
82
+
83
+ count: (cfg: Config, tenant: string, table: string) =>
84
+ call<number>(cfg, "/admin/data", {
85
+ method: "POST",
86
+ body: JSON.stringify({ tenant, table, op: "count" }),
87
+ }),
88
+
89
+ get: (cfg: Config, tenant: string, table: string, id: unknown) =>
90
+ call<Row | null>(cfg, "/admin/data", {
91
+ method: "POST",
92
+ body: JSON.stringify({ tenant, table, op: "get", id }),
93
+ }),
94
+
95
+ create: (cfg: Config, tenant: string, table: string, values: Row) =>
96
+ call<Row>(cfg, "/admin/data", {
97
+ method: "POST",
98
+ body: JSON.stringify({ tenant, table, op: "create", values }),
99
+ }),
100
+
101
+ update: (cfg: Config, tenant: string, table: string, id: unknown, patch: Row) =>
102
+ call<Row | null>(cfg, "/admin/data", {
103
+ method: "POST",
104
+ body: JSON.stringify({ tenant, table, op: "update", id, patch }),
105
+ }),
106
+
107
+ remove: (cfg: Config, tenant: string, table: string, id: unknown) =>
108
+ call<boolean>(cfg, "/admin/data", {
109
+ method: "POST",
110
+ body: JSON.stringify({ tenant, table, op: "delete", id }),
111
+ }),
112
+ };
package/src/app.tsx ADDED
@@ -0,0 +1,380 @@
1
+ // The pramen admin dashboard. A single focused inspector: config bar -> tenant
2
+ // vessel -> schema strata (tables) -> row browser, with view/edit/create/delete
3
+ // over the /admin/data primitives. Editing is a robust JSON textarea (no schema
4
+ // guessing). All API failures render verbatim (error + code) in a banner.
5
+
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
7
+ import { api, ApiError, type Config, type Row, type SchemaResult } from "./api";
8
+
9
+ const LS = { base: "pramen.admin.baseUrl", token: "pramen.admin.token", tenant: "pramen.admin.tenant" };
10
+ const PAGE = 25;
11
+
12
+ const useLocal = (key: string, initial: string): [string, (v: string) => void] => {
13
+ const [v, setV] = useState(() => localStorage.getItem(key) ?? initial);
14
+ const set = useCallback(
15
+ (next: string) => {
16
+ setV(next);
17
+ localStorage.setItem(key, next);
18
+ },
19
+ [key],
20
+ );
21
+ return [v, set];
22
+ };
23
+
24
+ function renderCell(v: unknown) {
25
+ if (v === null || v === undefined) return <span class="cell null">null</span>;
26
+ if (typeof v === "boolean") return <span class="cell bool">{String(v)}</span>;
27
+ if (typeof v === "object") {
28
+ const s = JSON.stringify(v);
29
+ return (
30
+ <span class="cell json" title={s}>
31
+ {s}
32
+ </span>
33
+ );
34
+ }
35
+ const s = String(v);
36
+ return (
37
+ <span class="cell" title={s}>
38
+ {s}
39
+ </span>
40
+ );
41
+ }
42
+
43
+ type Editor = { mode: "create" | "edit"; original: Row | null; text: string } | null;
44
+
45
+ export function App() {
46
+ const [baseUrl, setBaseUrl] = useLocal(LS.base, "http://localhost:8787");
47
+ const [token, setToken] = useLocal(LS.token, "");
48
+ const [tenant, setTenant] = useLocal(LS.tenant, "main");
49
+
50
+ const cfg: Config = useMemo(() => ({ baseUrl, token }), [baseUrl, token]);
51
+
52
+ const [tenants, setTenants] = useState<string[]>([]);
53
+ const [schema, setSchema] = useState<SchemaResult | null>(null);
54
+ const [table, setTable] = useState<string | null>(null);
55
+ const [rows, setRows] = useState<Row[] | null>(null);
56
+ const [count, setCount] = useState<number | null>(null);
57
+ const [offset, setOffset] = useState(0);
58
+ const [loading, setLoading] = useState(false);
59
+ const [err, setErr] = useState<{ message: string; code: string } | null>(null);
60
+ const [editor, setEditor] = useState<Editor>(null);
61
+ const [editErr, setEditErr] = useState<string | null>(null);
62
+ const [busy, setBusy] = useState(false);
63
+
64
+ const report = useCallback((e: unknown) => {
65
+ if (e instanceof ApiError) setErr({ message: e.message, code: e.code });
66
+ else setErr({ message: e instanceof Error ? e.message : String(e), code: "unknown" });
67
+ }, []);
68
+
69
+ // Load tenant list (best-effort: a fresh deploy may have none; manual entry still works).
70
+ const loadTenants = useCallback(async () => {
71
+ if (!token) return;
72
+ try {
73
+ setTenants(await api.tenants(cfg));
74
+ } catch (e) {
75
+ // Surface only if it's auth — a missing list is fine.
76
+ if (e instanceof ApiError && e.status === 403) report(e);
77
+ }
78
+ }, [cfg, token, report]);
79
+
80
+ const loadSchema = useCallback(async () => {
81
+ if (!token) return;
82
+ setErr(null);
83
+ try {
84
+ const s = await api.schema(cfg, tenant);
85
+ setSchema(s);
86
+ setTable((cur) => (cur && cur in s.tables ? cur : (Object.keys(s.tables)[0] ?? null)));
87
+ } catch (e) {
88
+ setSchema(null);
89
+ setTable(null);
90
+ report(e);
91
+ }
92
+ }, [cfg, tenant, token, report]);
93
+
94
+ const loadRows = useCallback(async () => {
95
+ if (!token || !table) return;
96
+ setLoading(true);
97
+ setErr(null);
98
+ try {
99
+ const [r, c] = await Promise.all([api.list(cfg, tenant, table, PAGE, offset), api.count(cfg, tenant, table)]);
100
+ setRows(r);
101
+ setCount(c);
102
+ } catch (e) {
103
+ setRows(null);
104
+ setCount(null);
105
+ report(e);
106
+ } finally {
107
+ setLoading(false);
108
+ }
109
+ }, [cfg, tenant, table, offset, token, report]);
110
+
111
+ // tenants + schema react to config/tenant; rows react to table/page.
112
+ const debounce = useRef<ReturnType<typeof setTimeout>>();
113
+ useEffect(() => {
114
+ clearTimeout(debounce.current);
115
+ debounce.current = setTimeout(() => {
116
+ void loadTenants();
117
+ void loadSchema();
118
+ }, 250);
119
+ return () => clearTimeout(debounce.current);
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ }, [baseUrl, token, tenant]);
122
+
123
+ useEffect(() => {
124
+ setOffset(0);
125
+ }, [table, tenant]);
126
+
127
+ useEffect(() => {
128
+ void loadRows();
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [table, tenant, offset, baseUrl, token]);
131
+
132
+ const columns = useMemo(() => (table && schema ? schema.tables[table] : null) ?? [], [table, schema]);
133
+
134
+ const openCreate = () => {
135
+ setEditErr(null);
136
+ const skeleton: Row = {};
137
+ for (const c of columns) if (c !== "id") skeleton[c] = null;
138
+ setEditor({ mode: "create", original: null, text: JSON.stringify(skeleton, null, 2) });
139
+ };
140
+
141
+ const openEdit = async (row: Row) => {
142
+ setEditErr(null);
143
+ try {
144
+ const full = (await api.get(cfg, tenant, table!, row.id)) ?? row;
145
+ setEditor({ mode: "edit", original: full, text: JSON.stringify(full, null, 2) });
146
+ } catch (e) {
147
+ report(e);
148
+ }
149
+ };
150
+
151
+ const saveEditor = async () => {
152
+ if (!editor || !table) return;
153
+ let parsed: Row;
154
+ try {
155
+ parsed = JSON.parse(editor.text) as Row;
156
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("must be a JSON object");
157
+ } catch (e) {
158
+ setEditErr(e instanceof Error ? e.message : "invalid JSON");
159
+ return;
160
+ }
161
+ setBusy(true);
162
+ setEditErr(null);
163
+ try {
164
+ if (editor.mode === "create") {
165
+ await api.create(cfg, tenant, table, parsed);
166
+ } else {
167
+ const orig = editor.original ?? {};
168
+ const id = orig.id;
169
+ // Send only changed fields as the patch; never send id back.
170
+ const patch: Row = {};
171
+ for (const [k, v] of Object.entries(parsed)) {
172
+ if (k === "id") continue;
173
+ if (JSON.stringify(v) !== JSON.stringify(orig[k])) patch[k] = v;
174
+ }
175
+ if (Object.keys(patch).length === 0) {
176
+ setEditErr("no changes to save");
177
+ setBusy(false);
178
+ return;
179
+ }
180
+ await api.update(cfg, tenant, table, id, patch);
181
+ }
182
+ setEditor(null);
183
+ await loadRows();
184
+ } catch (e) {
185
+ setEditErr(e instanceof ApiError ? `${e.message} (${e.code})` : String(e));
186
+ } finally {
187
+ setBusy(false);
188
+ }
189
+ };
190
+
191
+ const del = async (row: Row) => {
192
+ if (!table) return;
193
+ if (!confirm(`Delete row id=${JSON.stringify(row.id)} from "${table}"? This cannot be undone.`)) return;
194
+ setBusy(true);
195
+ try {
196
+ await api.remove(cfg, tenant, table, row.id);
197
+ await loadRows();
198
+ } catch (e) {
199
+ report(e);
200
+ } finally {
201
+ setBusy(false);
202
+ }
203
+ };
204
+
205
+ const tableNames = schema ? Object.keys(schema.tables) : [];
206
+ const page = Math.floor(offset / PAGE) + 1;
207
+ const totalPages = count != null ? Math.max(1, Math.ceil(count / PAGE)) : null;
208
+
209
+ return (
210
+ <>
211
+ <div class="bar">
212
+ <div class="brand">
213
+ <span class="mark">pramen</span>
214
+ <span class="sub">admin</span>
215
+ </div>
216
+ <div class="field url">
217
+ <label>base url</label>
218
+ <input value={baseUrl} onInput={(e) => setBaseUrl((e.target as HTMLInputElement).value)} placeholder="http://localhost:8787" spellcheck={false} />
219
+ </div>
220
+ <div class="field token">
221
+ <label>admin token</label>
222
+ <input type="password" value={token} onInput={(e) => setToken((e.target as HTMLInputElement).value)} placeholder="paste an admin JWT (roles include &quot;admin&quot;)" spellcheck={false} />
223
+ </div>
224
+ <div class="hint">Must be an admin token — every endpoint checks roles for &quot;admin&quot;.</div>
225
+ </div>
226
+
227
+ <div class="layout">
228
+ <aside class="side">
229
+ <p class="sec-title">Tenant</p>
230
+ <div class="tenant-row">
231
+ {tenants.length > 0 ? (
232
+ <select value={tenants.includes(tenant) ? tenant : ""} onChange={(e) => setTenant((e.target as HTMLSelectElement).value)}>
233
+ {!tenants.includes(tenant) && <option value="">{tenant} (manual)</option>}
234
+ {tenants.map((t) => (
235
+ <option key={t} value={t}>
236
+ {t}
237
+ </option>
238
+ ))}
239
+ </select>
240
+ ) : (
241
+ <input value={tenant} onInput={(e) => setTenant((e.target as HTMLInputElement).value)} placeholder="main" spellcheck={false} />
242
+ )}
243
+ <button class="ghost sm" title="Edit tenant name manually" onClick={() => {
244
+ const next = prompt("Tenant name", tenant);
245
+ if (next != null && next.trim()) setTenant(next.trim());
246
+ }}>edit</button>
247
+ </div>
248
+
249
+ <p class="sec-title">Schema</p>
250
+ {schema ? (
251
+ <div class={`hashchip${schema.hash ? "" : " none"}`} title="applied schema hash">
252
+ <span class="dot" />
253
+ {schema.hash ? schema.hash.slice(0, 12) : "no schema hash"}
254
+ </div>
255
+ ) : (
256
+ <div class="muted" style="margin-bottom:14px">{token ? "—" : "enter a token"}</div>
257
+ )}
258
+
259
+ <p class="sec-title">Tables{tableNames.length ? ` · ${tableNames.length}` : ""}</p>
260
+ {tableNames.length > 0 ? (
261
+ <ul class="tables">
262
+ {tableNames.map((t) => (
263
+ <li key={t}>
264
+ <button class={t === table ? "active" : ""} onClick={() => setTable(t)}>
265
+ <span class="tname">{t}</span>
266
+ <span class="cols">{schema!.tables[t].length} cols</span>
267
+ </button>
268
+ </li>
269
+ ))}
270
+ </ul>
271
+ ) : (
272
+ <div class="muted">{token ? "no tables" : ""}</div>
273
+ )}
274
+ </aside>
275
+
276
+ <main class="main">
277
+ {err && (
278
+ <div class="banner">
279
+ <div>
280
+ {err.message} <code>{err.code}</code>
281
+ </div>
282
+ <button class="ghost sm" onClick={() => setErr(null)}>dismiss</button>
283
+ </div>
284
+ )}
285
+
286
+ {!table ? (
287
+ <div class="placeholder">
288
+ <div class="big">{token ? "select a table" : "paste an admin token to begin"}</div>
289
+ <div>The dashboard talks to the pramen Worker over HTTP. Tenant defaults to <code style="color:var(--amber)">main</code>.</div>
290
+ </div>
291
+ ) : (
292
+ <>
293
+ <div class="main-head">
294
+ <h2>{table}</h2>
295
+ <span class="countchip">{count == null ? "…" : `${count} row${count === 1 ? "" : "s"}`}</span>
296
+ <span class="spacer" />
297
+ <button class="ghost sm" onClick={() => void loadRows()} disabled={loading}>refresh</button>
298
+ <button class="primary" onClick={openCreate}>+ new row</button>
299
+ </div>
300
+
301
+ <div class="toolbar">
302
+ <div class="pager">
303
+ <button class="sm" disabled={offset === 0 || loading} onClick={() => setOffset(Math.max(0, offset - PAGE))}>‹ prev</button>
304
+ <span>
305
+ page {page}
306
+ {totalPages != null ? ` / ${totalPages}` : ""}
307
+ </span>
308
+ <button class="sm" disabled={loading || (count != null && offset + PAGE >= count)} onClick={() => setOffset(offset + PAGE)}>next ›</button>
309
+ </div>
310
+ </div>
311
+
312
+ {loading && !rows ? (
313
+ <div class="loading">loading…</div>
314
+ ) : rows && rows.length === 0 ? (
315
+ <div class="empty">no rows on this page</div>
316
+ ) : rows ? (
317
+ <div class="tablewrap">
318
+ <table class="grid">
319
+ <thead>
320
+ <tr>
321
+ {columns.map((c) => (
322
+ <th key={c}>{c}</th>
323
+ ))}
324
+ <th style="text-align:right">actions</th>
325
+ </tr>
326
+ </thead>
327
+ <tbody>
328
+ {rows.map((row, i) => (
329
+ <tr key={(row.id as string) ?? i}>
330
+ {columns.map((c) => (
331
+ <td key={c}>{renderCell(row[c])}</td>
332
+ ))}
333
+ <td>
334
+ <div class="rowactions">
335
+ <button class="ghost sm" onClick={() => void openEdit(row)}>edit</button>
336
+ <button class="danger sm" onClick={() => void del(row)} disabled={busy}>del</button>
337
+ </div>
338
+ </td>
339
+ </tr>
340
+ ))}
341
+ </tbody>
342
+ </table>
343
+ </div>
344
+ ) : null}
345
+ </>
346
+ )}
347
+ </main>
348
+ </div>
349
+
350
+ {editor && (
351
+ <div class="scrim" onClick={(e) => e.target === e.currentTarget && setEditor(null)}>
352
+ <div class="modal">
353
+ <div class="modal-head">
354
+ <h3>{editor.mode === "create" ? `new ${table} row` : `edit ${table} · id=${JSON.stringify(editor.original?.id)}`}</h3>
355
+ <span class="spacer" />
356
+ <button class="ghost sm" onClick={() => setEditor(null)}>✕</button>
357
+ </div>
358
+ <div class="modal-body">
359
+ <label>row json {editor.mode === "edit" ? "(only changed fields are sent as the patch)" : "(sent as values)"}</label>
360
+ <textarea
361
+ value={editor.text}
362
+ spellcheck={false}
363
+ onInput={(e) => setEditor({ ...editor, text: (e.target as HTMLTextAreaElement).value })}
364
+ />
365
+ {editErr && <div class="jsonerr" style="margin-top:8px">{editErr}</div>}
366
+ </div>
367
+ <div class="modal-foot">
368
+ <span class="muted">{editor.mode === "edit" ? "id is never modified" : "id is assigned by the server unless provided"}</span>
369
+ <span class="spacer" />
370
+ <button class="ghost" onClick={() => setEditor(null)}>cancel</button>
371
+ <button class="primary" onClick={() => void saveEditor()} disabled={busy}>
372
+ {busy ? "saving…" : editor.mode === "create" ? "create" : "save changes"}
373
+ </button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ )}
378
+ </>
379
+ );
380
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,11 @@
1
+ // SPA entry: inject the inline stylesheet and mount the admin dashboard.
2
+
3
+ import { render } from "preact";
4
+ import { App } from "./app";
5
+ import { css } from "./styles";
6
+
7
+ const style = document.createElement("style");
8
+ style.textContent = css;
9
+ document.head.appendChild(style);
10
+
11
+ render(<App />, document.getElementById("app")!);
package/src/styles.ts ADDED
@@ -0,0 +1,190 @@
1
+ // Inline stylesheet for the admin SPA. Borders-only depth, a single spring-teal
2
+ // accent (the "pramen" / source motif), semantic amber+red only. Tokens are named
3
+ // for the product's world: ink/slate for structure, spring for the live accent,
4
+ // strata for the schema panel. Mono everywhere data lives.
5
+
6
+ export const css = `
7
+ :root {
8
+ --bg: #0e1116;
9
+ --surface: #141921;
10
+ --surface-2: #181e28;
11
+ --raise: #1d2531;
12
+ --border: #232c3a;
13
+ --border-2: #2e3a4d;
14
+ --ink: #e6edf3;
15
+ --ink-2: #aab7c7;
16
+ --ink-3: #6b7a8d;
17
+ --spring: #3fd6c0;
18
+ --spring-dim:#1c8d80;
19
+ --spring-bg: #0f2723;
20
+ --amber: #e0a458;
21
+ --danger: #e0606a;
22
+ --danger-bg: #2a161a;
23
+ --radius: 7px;
24
+ --radius-sm: 5px;
25
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Menlo", monospace;
26
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+ html, body { margin: 0; height: 100%; }
31
+ body {
32
+ background: var(--bg);
33
+ color: var(--ink);
34
+ font-family: var(--sans);
35
+ font-size: 14px;
36
+ line-height: 1.5;
37
+ -webkit-font-smoothing: antialiased;
38
+ }
39
+ #app { min-height: 100%; }
40
+
41
+ button {
42
+ font-family: var(--sans);
43
+ font-size: 13px;
44
+ cursor: pointer;
45
+ border: 1px solid var(--border-2);
46
+ background: var(--raise);
47
+ color: var(--ink);
48
+ padding: 6px 11px;
49
+ border-radius: var(--radius-sm);
50
+ transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
51
+ }
52
+ button:hover:not(:disabled) { background: var(--surface-2); border-color: #3a485e; }
53
+ button:active:not(:disabled) { background: var(--surface); }
54
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
55
+ button:focus-visible { outline: 2px solid var(--spring-dim); outline-offset: 1px; }
56
+
57
+ button.primary {
58
+ background: var(--spring-bg);
59
+ border-color: var(--spring-dim);
60
+ color: var(--spring);
61
+ }
62
+ button.primary:hover:not(:disabled) { background: #123029; border-color: var(--spring); }
63
+ button.danger { color: var(--danger); border-color: #4a2a2f; background: var(--danger-bg); }
64
+ button.danger:hover:not(:disabled) { border-color: var(--danger); background: #361a1f; }
65
+ button.ghost { background: transparent; border-color: transparent; color: var(--ink-2); padding: 4px 7px; }
66
+ button.ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
67
+ button.sm { padding: 3px 8px; font-size: 12px; }
68
+
69
+ input, textarea {
70
+ font-family: var(--mono);
71
+ font-size: 13px;
72
+ background: var(--bg);
73
+ color: var(--ink);
74
+ border: 1px solid var(--border-2);
75
+ border-radius: var(--radius-sm);
76
+ padding: 7px 9px;
77
+ }
78
+ input::placeholder, textarea::placeholder { color: var(--ink-3); }
79
+ input:focus, textarea:focus { outline: none; border-color: var(--spring-dim); }
80
+ label { font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-3); display: block; margin-bottom: 4px; }
81
+
82
+ /* config bar */
83
+ .bar {
84
+ position: sticky; top: 0; z-index: 10;
85
+ display: flex; align-items: flex-end; gap: 14px;
86
+ padding: 12px 18px;
87
+ background: var(--surface);
88
+ border-bottom: 1px solid var(--border);
89
+ }
90
+ .brand { display: flex; align-items: baseline; gap: 9px; margin-right: 6px; padding-bottom: 5px; }
91
+ .brand .mark { color: var(--spring); font-family: var(--mono); font-weight: 600; letter-spacing: -0.02em; font-size: 16px; }
92
+ .brand .sub { color: var(--ink-3); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; }
93
+ .bar .field { display: flex; flex-direction: column; }
94
+ .bar .field.url input { width: 220px; }
95
+ .bar .field.token { flex: 1; min-width: 200px; }
96
+ .bar .field.token input { width: 100%; }
97
+ .bar .hint { font-size: 11px; color: var(--ink-3); padding-bottom: 7px; max-width: 180px; line-height: 1.35; }
98
+
99
+ .layout { display: grid; grid-template-columns: 270px 1fr; min-height: calc(100vh - 60px); }
100
+ .side { border-right: 1px solid var(--border); background: var(--surface-2); padding: 16px 14px; }
101
+ .main { padding: 20px 24px; min-width: 0; }
102
+
103
+ .sec-title { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 9px; }
104
+ .tenant-row { display: flex; gap: 6px; margin-bottom: 18px; }
105
+ .tenant-row select, .tenant-row input { flex: 1; min-width: 0; }
106
+
107
+ select {
108
+ font-family: var(--mono); font-size: 13px;
109
+ background: var(--bg); color: var(--ink);
110
+ border: 1px solid var(--border-2); border-radius: var(--radius-sm);
111
+ padding: 7px 9px;
112
+ }
113
+ select:focus { outline: none; border-color: var(--spring-dim); }
114
+
115
+ .hashchip {
116
+ display: inline-flex; align-items: center; gap: 6px;
117
+ font-family: var(--mono); font-size: 11px;
118
+ color: var(--ink-2); background: var(--raise);
119
+ border: 1px solid var(--border); border-radius: 20px;
120
+ padding: 3px 10px; margin-bottom: 14px;
121
+ }
122
+ .hashchip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--spring); }
123
+ .hashchip.none .dot { background: var(--ink-3); }
124
+
125
+ .tables { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
126
+ .tables li button {
127
+ width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
128
+ display: flex; justify-content: space-between; align-items: center; gap: 8px;
129
+ padding: 7px 9px; border-radius: var(--radius-sm); color: var(--ink-2);
130
+ }
131
+ .tables li button:hover { background: var(--raise); color: var(--ink); }
132
+ .tables li button.active { background: var(--spring-bg); border-color: var(--spring-dim); color: var(--spring); }
133
+ .tables .tname { font-family: var(--mono); font-size: 13px; }
134
+ .tables .cols { font-size: 11px; color: var(--ink-3); }
135
+
136
+ .main-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
137
+ .main-head h2 { margin: 0; font-family: var(--mono); font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
138
+ .countchip {
139
+ font-family: var(--mono); font-size: 12px; color: var(--ink-2);
140
+ border: 1px solid var(--border); border-radius: 20px; padding: 2px 10px;
141
+ }
142
+ .spacer { flex: 1; }
143
+
144
+ .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
145
+ .pager { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--ink-2); }
146
+
147
+ .tablewrap { border: 1px solid var(--border); border-radius: var(--radius); overflow: auto; background: var(--surface); }
148
+ table.grid { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
149
+ table.grid th {
150
+ position: sticky; top: 0; background: var(--surface-2);
151
+ text-align: left; font-weight: 600; color: var(--ink-2);
152
+ padding: 8px 12px; border-bottom: 1px solid var(--border); white-space: nowrap;
153
+ }
154
+ table.grid td { padding: 7px 12px; border-bottom: 1px solid var(--border); color: var(--ink); vertical-align: top; max-width: 360px; }
155
+ table.grid tr:last-child td { border-bottom: none; }
156
+ table.grid tbody tr:hover { background: var(--surface-2); }
157
+ .cell { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158
+ .cell.json { color: var(--amber); }
159
+ .cell.null { color: var(--ink-3); font-style: italic; }
160
+ .cell.bool { color: var(--spring); }
161
+ .rowactions { display: flex; gap: 4px; white-space: nowrap; }
162
+
163
+ .banner {
164
+ display: flex; align-items: flex-start; gap: 10px;
165
+ border: 1px solid #4a2a2f; background: var(--danger-bg);
166
+ color: var(--danger); border-radius: var(--radius);
167
+ padding: 10px 13px; margin-bottom: 14px; font-size: 13px;
168
+ }
169
+ .banner code { font-family: var(--mono); background: rgba(0,0,0,0.25); padding: 1px 5px; border-radius: 3px; color: var(--amber); }
170
+ .banner button { margin-left: auto; }
171
+
172
+ .empty, .loading { color: var(--ink-3); padding: 40px 0; text-align: center; font-size: 13px; }
173
+ .loading { color: var(--ink-2); }
174
+
175
+ /* modal */
176
+ .scrim { position: fixed; inset: 0; background: rgba(5,8,12,0.66); display: flex; align-items: flex-start; justify-content: center; padding: 56px 20px; z-index: 50; overflow: auto; }
177
+ .modal { width: 100%; max-width: 640px; background: var(--surface); border: 1px solid var(--border-2); border-radius: var(--radius); }
178
+ .modal-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
179
+ .modal-head h3 { margin: 0; font-family: var(--mono); font-size: 15px; font-weight: 600; }
180
+ .modal-head .spacer { flex: 1; }
181
+ .modal-body { padding: 16px 18px; }
182
+ .modal-body textarea { width: 100%; min-height: 280px; resize: vertical; line-height: 1.55; tab-size: 2; }
183
+ .modal-foot { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--border); }
184
+ .modal-foot .spacer { flex: 1; }
185
+ .jsonerr { color: var(--danger); font-family: var(--mono); font-size: 12px; }
186
+ .muted { color: var(--ink-3); font-size: 12px; }
187
+
188
+ .placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60vh; color: var(--ink-3); gap: 8px; }
189
+ .placeholder .big { font-family: var(--mono); color: var(--ink-2); font-size: 15px; }
190
+ `;