jops-core 1.0.15 → 1.1.0

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.
Files changed (30) hide show
  1. package/README.md +344 -28
  2. package/bin/init.js +138 -20
  3. package/lib/jops-core.d.ts +251 -15
  4. package/lib/jops-core.min.js +1 -1
  5. package/lib/nextjs/JopsApp.d.ts +35 -0
  6. package/lib/nextjs/JopsApp.js +50 -0
  7. package/package.json +33 -5
  8. package/templates/nextjs-jops-spa/README.md +34 -0
  9. package/templates/nextjs-jops-spa/data/quotes.json +62 -0
  10. package/templates/nextjs-jops-spa/gitignore +21 -0
  11. package/templates/nextjs-jops-spa/next.config.ts +15 -0
  12. package/templates/nextjs-jops-spa/package.json +25 -0
  13. package/templates/nextjs-jops-spa/public/jops-spa/App.js +10 -0
  14. package/templates/nextjs-jops-spa/public/jops-spa/Login.js +26 -0
  15. package/templates/nextjs-jops-spa/public/jops-spa/Quotes.js +102 -0
  16. package/templates/nextjs-jops-spa/public/jops-spa/api.js +15 -0
  17. package/templates/nextjs-jops-spa/public/jops-spa/badge.js +8 -0
  18. package/templates/nextjs-jops-spa/public/jops-spa/format.js +5 -0
  19. package/templates/nextjs-jops-spa/public/jops-spa/layout/login.html +34 -0
  20. package/templates/nextjs-jops-spa/public/jops-spa/layout/main.html +17 -0
  21. package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes.html +50 -0
  22. package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes_row.html +8 -0
  23. package/templates/nextjs-jops-spa/public/jops-spa/skeleton.js +7 -0
  24. package/templates/nextjs-jops-spa/src/app/about/page.tsx +59 -0
  25. package/templates/nextjs-jops-spa/src/app/api/quotes/route.ts +11 -0
  26. package/templates/nextjs-jops-spa/src/app/globals.css +6 -0
  27. package/templates/nextjs-jops-spa/src/app/jops-spa/page.tsx +11 -0
  28. package/templates/nextjs-jops-spa/src/app/layout.tsx +27 -0
  29. package/templates/nextjs-jops-spa/src/app/page.tsx +5 -0
  30. package/templates/nextjs-jops-spa/tsconfig.json +21 -0
@@ -1 +1 @@
1
- var R=Object.defineProperty;var d=(o,t)=>R(o,"name",{value:t,configurable:!0});var v=new URL(import.meta.url).origin!==location.origin,w=new Map;function f(o){if(!o)return o;if(v){if(o.startsWith("/"))return"."+o}else if(o.startsWith("./"))return o.slice(1);return o}d(f,"normalizePath");(()=>{console.log("JOPS initialized");let o=document.querySelector("script[data-app]"),t=o?.dataset.app??"./src/App.js";import(new URL(f(t),document.baseURI).href).then(e=>new e.default).catch(e=>console.error("JOPS: App.js not found or failed to load",e)),o?.dataset.nativeBack!=="off"&&V()})();function V(){document.addEventListener("deviceready",()=>{let o=window.Capacitor;o?.isNativePlatform?.()&&o.getPlatform()==="android"&&!o.isPluginAvailable("App")&&console.warn("JOPS: install @capacitor/app so the Android back button navigates instead of closing the app.");let t=0;document.addEventListener("backbutton",()=>{let e=Date.now();if(e-t<300)return;t=e;let s=m.singleton();s?.canGoBack()?s.back():!s&&window.navigation?.canGoBack?history.back():navigator.app.exitApp()})},{once:!0})}d(V,"installNativeBackButton");var p=class{static{d(this,"RawHTML")}constructor(t){this.html=t}},E=d(o=>new p(o),"raw");function P(o,...t){return o.reduce((e,s,i)=>{if(i>=t.length)return e+s;let n=t[i];return n==null?e+s:n instanceof p?e+s+n.html:e+s+String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},"")}d(P,"layout");var y=class{static{d(this,"Element")}constructor(t,e){this.element=t?.id===e?t:t.querySelector(`#${e}`)}set(t){this.element.innerHTML=t}value(){return this.element.innerHTML}},b=class o{static{d(this,"View")}#t=!1;#e=!1;constructor(t){this.loadLayout(t),this.subViews=[]}async loadLayout(t){if(t)if(t.endsWith(".js"))try{let e=await import(new URL(f(t),document.baseURI).href);this.layout=e.default}catch(e){console.error("JOPS: failed to load layout:",t,e)}else if(t.endsWith(".html")||t.endsWith(".htm"))try{let e=new URL(f(t),document.baseURI).href;if(w.has(e))this.layout=w.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let i=await s.text();w.set(e,i),this.layout=i}}catch(e){console.error("JOPS: failed to load layout:",t,e)}else this.layout=t}setStyle(t){if(t)if(this.style=t,t.endsWith(".css")){if(document.querySelector(`link[href="${t}"]`))return;let e=document.createElement("link");e.rel="stylesheet",e.href=t,document.head.appendChild(e)}else{if(document.querySelector(`style[data-jops="${t}"]`))return;let e=document.createElement("style");e.setAttribute("data-jops",t),e.textContent=t,document.head.appendChild(e)}}get(t){return new y(this.domNode,t)}bindEvents(){if(!this.domNode)return;let t=[...this.domNode.querySelectorAll("[jops-event-bind], [type='jops-event-bind']")];(this.domNode.hasAttribute("jops-event-bind")||this.domNode.getAttribute("type")==="jops-event-bind")&&t.unshift(this.domNode);for(let e of t)for(let s of e.attributes){if(!s.name.startsWith("on"))continue;let i=s.value.trim();typeof this[i]=="function"&&(e[s.name]=n=>this[i](n))}}inflate(){if(!this.layout||this.#t)return;this.#t=!0;let t=document.createElement("template");t.innerHTML=this.layout;let s=t.content.cloneNode(!0).firstElementChild;this.domNode=s}async addSubView(t,e){this.subViews.push(e),e.domNode||(await e.loadLayout(),e.inflate()),this.domNode.querySelector(`#${t}`).appendChild(e.domNode),await e?.onLayout?.(),await e?.onResume?.(),e?.bindEvents?.()}hide(){this.domNode.style.display="none"}show(){this.domNode.style.display=""}find(t){let e=[];for(let s of this.subViews)t(s)&&e.push(s),e.push(...s.find?.(t)??[]);return e}destroy(){this.subViews.forEach(t=>t.destroy?.()),this.subViews=[],this.domNode?.remove(),this.domNode=null,this.layout=null}removeSubView(t){t.destroy?.(),this.subViews=this.subViews.filter(e=>e!==t)}async insertSubView(t,e){t.domNode||(await t.loadLayout(),t.inflate());let s=this.domNode.children[e]??null;this.domNode.insertBefore(t.domNode,s),this.subViews.splice(e,0,t),await t?.onLayout?.(),await t?.onResume?.(),t?.bindEvents?.()}async render(){if(!this.layout||this.#e)return;this.#e=!0;let t=this.domNode??document.body;t===document.body&&(t.innerHTML=this.layout);let e=Array.from(t.querySelectorAll("[jops], [type='jops']")).filter(s=>s.tagName==="ROUTER"||!s.closest("router")||s.closest("router")===t);e.length!==0&&await Promise.all(e.map(async s=>{if(s.tagName==="ROUTER"){let c=new m(s);await c.render(),await c.onLayout();return}let i=s.getAttribute("layout"),n=s.getAttribute("src");if(!n&&!i)return;let r;if(n){let l=(await import(new URL(f(n),document.baseURI).href)).default;r=new l}else r=new o;this.subViews.push(r),await r.loadLayout(i);let a=document.createElement("template");a.innerHTML=r.layout??"";let h=a.content.cloneNode(!0);if(s.children.length>0){let c=h.firstElementChild??h;Array.from(s.children).forEach(l=>c.appendChild(l))}let u=h.firstElementChild;r.routePath=s.getAttribute("path"),s.replaceWith(h),r.domNode=u,await r.render(),await r?.onLayout?.(),await r?.onResume?.(),r?.bindEvents?.()}))}},_=class o{static{d(this,"EventBus")}static#t={};static subscribe(t,e){o.#t[t]||(o.#t[t]=[]),o.#t[t].push(e)}static send(t,e){o.#t[t]?.forEach(s=>s(e))}static unsubscribe(t,e){o.#t[t]&&(o.#t[t]=o.#t[t].filter(s=>s!==e))}},S=class o{static{d(this,"Store")}static#t=null;static singleton(){return o.#t||(o.#t=new o),o.#t}constructor(){this._state={}}get(t){return t.split(".").reduce((e,s)=>e?.[s],this._state)}set(t,e){let s=t.split("."),i=s.pop(),n=s.reduce((r,a)=>(r[a]||(r[a]={}),r[a]),this._state);n[i]=e}clear(){this._state={}}},m=class o extends b{static{d(this,"Router")}static#t=null;static singleton(){return o.#t}constructor(t){super(),o.#t=this,this.rootNode=t.parentNode,this.routerDomNode=t,this.domNode=t,this.layout=t.innerHTML,this.animation=t.getAttribute("animation")??"none"}async onLayout(){let t=document.createDocumentFragment();for(;this.routerDomNode.firstChild;)t.appendChild(this.routerDomNode.firstChild);this.routerDomNode.replaceWith(t),this.domNode=this.rootNode,this.initRouter()}initRouter(){this.routes={};for(let r of this.subViews)if(r.routePath){let a=r.routePath.slice(1).split("?")[0];this.routes[a]=r}this._navStack=[],this._baseView=null;for(let r of Object.values(this.routes))r.domNode.hidden=!0;let t=Object.keys(this.routes)[0],e=window.location.hash.slice(1),s=e.split("?")[0],i=s&&this.routes[s]?e:t;this._idx=history.state?.jopsIdx??0,history.replaceState({...history.state,jopsIdx:this._idx},"","#"+i);let n=d(()=>this._onHistoryChange(),"onHistoryChange");window.addEventListener("popstate",n),window.addEventListener("hashchange",n),this._switchTo(i)}_onHistoryChange(){let t=history.state?.jopsIdx;if(t===this._idx)return;if(t===void 0){this._idx+=1,history.replaceState({...history.state,jopsIdx:this._idx},"",location.href),this._onForward();return}let e=t<this._idx;this._idx=t,e?this._onBack():this._onForward()}_onBack(){if(this._navStack.length>0){let e=this._navStack.pop(),s=this._navStack.at(-1)??this._baseView;e?.onBackPressed?.(),this._animateTransition(e,s,"back").then(()=>e.destroy());return}let t=window.location.hash.slice(1).split("?")[0];if(!this.routes[t]){history.back();return}this._currentView?.onBackPressed?.(),this._switchToHash()}_onForward(){this._navStack.length>0&&(this._navStack.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null),this._switchToHash()}_switchToHash(){let t=window.location.hash.slice(1),e=t.split("?")[0];e&&this.routes[e]&&this._switchTo(t)}async _animateTransition(t,e,s){this.animation==="slide"?await this._slideTransition(t,e,s):this.animation==="fade"?await this._fadeTransition(t,e):(t.domNode.hidden=!0,e.domNode.hidden=!1,this._currentView=e,e?.onResume?.())}async _slideTransition(t,e,s){let i=this.domNode,n=s==="forward",r=i.getBoundingClientRect(),a=t.domNode.getBoundingClientRect(),h=a.left-r.left,u=a.top-r.top,c=a.width,l=i.offsetWidth-h,N=`position:absolute;left:${h}px;top:${u}px;width:${c}px`;i.style.cssText=`position:relative;overflow:hidden;height:${i.offsetHeight}px`,t.domNode.style.cssText=`${N};z-index:${n?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${N};z-index:${n?2:1}`,e.domNode.hidden=!1;let T=n?e.domNode:t.domNode,g=n?`${l}px`:"0px",L=n?"0px":`${l}px`,x=T.animate([{transform:`translateX(${g})`},{transform:`translateX(${L})`}],{duration:300,easing:"ease",fill:"forwards"});await x.finished,x.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",i.style.cssText="",this._currentView=e,e?.onResume?.()}async _fadeTransition(t,e){let s=this.domNode,i=s.getBoundingClientRect(),n=t.domNode.getBoundingClientRect(),r=n.left-i.left,a=n.top-i.top,h=n.width,u=`position:absolute;left:${r}px;top:${a}px;width:${h}px`;s.style.cssText=`position:relative;overflow:hidden;height:${s.offsetHeight}px`,t.domNode.style.cssText=u,t.domNode.hidden=!1,e.domNode.style.cssText=u,e.domNode.hidden=!1;let c=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),l=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([c.finished,l.finished]),c.cancel(),l.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",s.style.cssText="",this._currentView=e,e?.onResume?.()}_switchTo(t){let e=t.split("?")[0];for(let[s,i]of Object.entries(this.routes)){let n=s===e;i.domNode.hidden=!n,n&&(this._currentView=i,i?.onResume?.())}}back(){history.back()}canGoBack(){return this._idx>0}getParams(){let t=window.location.hash.split("?")[1];return t?Object.fromEntries(new URLSearchParams(t)):{}}async navigate(t,e){let[s,i]=t.replace(/^\//,"").split("?"),n=s,r=e??this.routes[n]?.constructor;if(!r){console.error(`JOPS Router: no class found for path "${t}"`);return}this._navStack.length===0&&(this._baseView=this._currentView);let a=new r;await a.loadLayout(),a.inflate(),this.domNode.appendChild(a.domNode),history.pushState({jopsNav:!0,jopsIdx:++this._idx},"","#"+n+(i?"?"+i:"")),await a.onLayout?.(),await a.onResume?.(),a.bindEvents?.(),this._navStack.push(a),this._animateTransition(this._currentView,a,"forward")}async replace(t,e){this._navStack.forEach(a=>a.destroy?.()),this._navStack=[];let[s,i]=t.replace(/^\//,"").split("?"),n=s,r="#"+n+(i?"?"+i:"");if(this.routes[n])history.replaceState({jopsIdx:this._idx},"",r),this._switchTo(n);else if(e){let a=new e;await a.loadLayout(),a.inflate(),this.domNode.appendChild(a.domNode),await a.onLayout?.(),await a.onResume?.(),a.bindEvents?.(),this._currentView&&(this._currentView.domNode.hidden=!0),this._currentView=a,history.replaceState({jopsIdx:this._idx},"",r)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},k=class{static{d(this,"Thread")}#t=null;run(t){}onMessage(t){}start(t){if(typeof Worker>"u"){console.warn("JOPS Thread: Web Workers are not supported in this environment.");return}let e=this.run.toString();!e.startsWith("function")&&!e.startsWith("(")&&!e.startsWith("async")&&(e="function "+e);let s=new Blob([`const run = ${e};`,"self.onmessage = (e) => { run(e.data); };"],{type:"application/javascript"}),i=URL.createObjectURL(s);this.#t=new Worker(i),URL.revokeObjectURL(i),this.#t.onmessage=n=>this.onMessage(n),this.#t.postMessage(t)}terminate(){if(!this.#t){console.warn("JOPS Thread: no active worker to terminate.");return}this.#t.terminate(),this.#t=null}};export{_ as EventBus,m as Router,S as Store,k as Thread,b as View,P as layout,E as raw};
1
+ var W=Object.defineProperty;var h=(o,t)=>W(o,"name",{value:t,configurable:!0});var B=new URL(import.meta.url).origin!==location.origin,k=new Map;function b(o){if(!o)return o;if(B){if(o.startsWith("/"))return"."+o}else if(o.startsWith("./"))return o.slice(1);return o}h(b,"normalizePath");var u=null,S=!1,m=0,v=new Set,_=null,I=new Set(["jops","src","layout","path","jops-event-bind"]);function H(o){let t={};for(let{name:e,value:s}of o.attributes)I.has(e)||e==="type"&&s==="jops"||(t[e.replace(/-([a-z])/g,(n,i)=>i.toUpperCase())]=s);return t}h(H,"tagProps");var j=new Set,O=!1;(()=>{if(console.log("JOPS initialized"),Array.from(document.querySelectorAll("script[src]")).find(s=>s.src===import.meta.url)?.dataset.host!==void 0){S=!0,window.JOPS={mount:D,unmount:V},window.dispatchEvent(new Event("jops:ready"));return}let t=document.querySelector("script[data-app]"),e=t?.dataset.app??"./src/App.js";t?.dataset.root&&(u=document.getElementById(t.dataset.root),u||console.warn(`JOPS: data-root element #${t.dataset.root} not found, rendering into <body>`)),import(new URL(b(e),document.baseURI).href).then(s=>new s.default).catch(s=>console.error("JOPS: App.js not found or failed to load",s)),t?.dataset.nativeBack!=="off"&&K()})();var T=!1,N=!1,C={},$={};function M(){if(!N){N=!0;for(let o of["pushState","replaceState"]){let t=history[o],e=h(function(...s){let n=t.apply(this,s);return T||queueMicrotask(()=>y.singleton()?._onHostNavigation()),n},"wrapper");C[o]=t,$[o]=e,history[o]=e}}}h(M,"installHostHistoryHook");function q(){if(!N)return;let o=!0;for(let t of["pushState","replaceState"])history[t]===$[t]?history[t]=C[t]:o=!1;o&&(N=!1)}h(q,"uninstallHostHistoryHook");async function D({app:o,root:t}){if(!(t instanceof HTMLElement))throw new TypeError("JOPS mount(): root must be an HTMLElement");u=t,M();let e=m,s=await import(new URL(b(o),document.baseURI).href);return e!==m?null:new s.default}h(D,"mount");function V(){m+=1,y.singleton()?.destroy(),v.forEach(o=>o.destroy()),v.clear(),u&&(u.innerHTML=""),u=null,P.singleton().clear(),L.clear(),q()}h(V,"unmount");function K(){document.addEventListener("deviceready",()=>{let o=window.Capacitor;o?.isNativePlatform?.()&&o.getPlatform()==="android"&&!o.isPluginAvailable("App")&&console.warn("JOPS: install @capacitor/app so the Android back button navigates instead of closing the app.");let t=0;document.addEventListener("backbutton",()=>{let e=Date.now();if(e-t<300)return;t=e;let s=y.singleton();s?.canGoBack()?s.back():!s&&window.navigation?.canGoBack?history.back():navigator.app.exitApp()})},{once:!0})}h(K,"installNativeBackButton");var x=class{static{h(this,"RawHTML")}constructor(t){this.html=t}},z=h(o=>new x(o),"raw");function G(o,...t){return o.reduce((e,s,n)=>{if(n>=t.length)return e+s;let i=t[n];return i==null?e+s:i instanceof x?e+s+i.html:e+s+String(i).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},"")}h(G,"layout");var g=class o{static{h(this,"Element")}constructor(t,e){this.id=e,this.inert=t.length===0,this.element=null;for(let s of t)if(this.element=s.id===e?s:s.querySelector(`[id="${CSS.escape(e)}"]`),this.element)break}static from(t,e){let s=new o([],e);return s.inert=!1,s.element=t,s}set(t){return this.element?(this.element.innerHTML=t,this):this.#t("set")}attr(t,e){return this.element?(e==null?this.element.removeAttribute(t):this.element.setAttribute(t,String(e)),this):this.#t("attr")}value(){return this.element?this.element.innerHTML:(this.#t("value"),"")}#t(t){return this.inert||console.warn(`JOPS: ${t}() on #${this.id}: no such element in the view`),this}},R=class o{static{h(this,"View")}#t=!1;#e=!1;#o=!1;#s=null;#i;constructor(t,e){this._generation=m;let s=_&&_.ClassDef===new.target?_.props:null;this.#i=Object.freeze({...e??s??{}}),this.loadLayout(t),this.subViews=[]}get props(){return this.#i}async loadLayout(t){if(this.#s)return this.#s;if(!(!t||this.#o))return this.#s=this.#n(t).finally(()=>{this.#o=this.layout!=null,this.#s=null}),this.#s}async#n(t){if(t.endsWith(".js"))try{let e=await import(new URL(b(t),document.baseURI).href);this.layout=e.default}catch(e){console.error("JOPS: failed to load layout:",t,e)}else if(t.endsWith(".html")||t.endsWith(".htm"))try{let e=new URL(b(t),document.baseURI).href;if(k.has(e))this.layout=k.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let n=await s.text();k.set(e,n),this.layout=n}}catch(e){console.error("JOPS: failed to load layout:",t,e)}else this.layout=t}setStyle(t){if(t){if(this.style=t,S){O||(O=!0,console.info("JOPS: setStyle() is ignored in hosted mode; the host provides the CSS."));return}if(t.endsWith(".css")){if(document.querySelector(`link[href="${CSS.escape(t)}"]`))return;let e=document.createElement("link");e.rel="stylesheet",e.href=t,document.head.appendChild(e)}else{if(j.has(t))return;j.add(t);let e=document.createElement("style");e.setAttribute("data-jops",""),e.textContent=t,document.head.appendChild(e)}}}get(t){return new g(this._roots(),t)}_roots(){return this.domNodes??(this.domNode?[this.domNode]:[])}_setHidden(t){for(let e of this._roots())e.hidden=t}bindEvents(){let t=this._roots().flatMap(e=>[...e.hasAttribute("jops-event-bind")||e.getAttribute("type")==="jops-event-bind"?[e]:[],...e.querySelectorAll("[jops-event-bind], [type='jops-event-bind']")]);for(let e of t)for(let s of e.attributes){if(!s.name.startsWith("on"))continue;let n=s.value.trim();typeof this[n]=="function"&&(e[s.name]=i=>this[n](i))}}inflate(){if(!this.layout||this.#t)return;this.#t=!0;let t=document.createElement("template");t.innerHTML=this.layout;let e=t.content.cloneNode(!0);this.domNodes=[...e.children],this.domNode=this.domNodes[0]}async addSubView(t,e){if(!this.domNode)return;if(e._destroyed){console.warn("JOPS: addSubView(): this view was destroyed; create a new instance");return}this.subViews.push(e),e.domNode||(await e.loadLayout(),e.inflate());let s=this.domNode?.querySelector(`#${t}`);if(!e.domNode||!s){this.subViews=this.subViews.filter(n=>n!==e),this.domNode&&console.warn(e.domNode?`JOPS: addSubView(): no element #${t} in the view`:"JOPS: addSubView(): the view has no layout");return}s.append(...e._roots()),await e?.onLayout?.(),await e?.onResume?.(),e?.bindEvents?.()}hide(){for(let t of this._roots())t.style.display="none"}show(){for(let t of this._roots())t.style.display=""}find(t){if(typeof t=="string")return this._roots().flatMap(s=>[...s.id===t?[s]:[],...s.querySelectorAll(`[id="${CSS.escape(t)}"]`)]).map(s=>g.from(s,t));let e=[];for(let s of this.subViews)t(s)&&e.push(s),e.push(...s.find?.(t)??[]);return e}destroy(){this._destroyed=!0,L.unsubscribeAll(this),this.subViews.forEach(e=>e.destroy?.()),this.subViews=[],this._borrowedDomNode||this.domNode&&(this.domNode===u||this.domNode===document.body)?this.domNode.innerHTML="":this._roots().forEach(e=>e.remove()),this.domNode=null,this.domNodes=null,this.layout=null}removeSubView(t){t.destroy?.(),this.subViews=this.subViews.filter(e=>e!==t)}async insertSubView(t,e){if(!this.domNode)return;if(t._destroyed){console.warn("JOPS: insertSubView(): this view was destroyed; create a new instance");return}if(t.domNode||(await t.loadLayout(),t.inflate()),!this.domNode)return;if(!t.domNode){console.warn("JOPS: insertSubView(): the view has no layout");return}let s=this.domNode.children[e]??null;for(let n of t._roots())this.domNode.insertBefore(n,s);this.subViews.splice(e,0,t),await t?.onLayout?.(),await t?.onResume?.(),t?.bindEvents?.()}async _startSubView(t){await t?.onResume?.(),t?.bindEvents?.()}async render(){if(!this.layout||this.#e||this._generation!==m)return;let t=!this.domNode;if(t&&S&&!u)return;this.#e=!0;let e=this.domNode??u??document.body;t&&(e.innerHTML=this.layout,v.add(this),this.domNode=e,this._borrowedDomNode=!0);let s=h(()=>this._generation!==m,"ended"),n=Array.from(e.querySelectorAll("[jops], [type='jops']")).filter(i=>i.tagName==="ROUTER"||!i.closest("router")||i.closest("router")===e);if(n.length>0&&await this.#r(n,e,s),!s()&&t){if(await this.onLayout?.(),s()||(await this.onResume?.(),s()))return;this.bindEvents()}}async#r(t,e,s){let n=new Array(t.length);await Promise.all(t.map(async(i,a)=>{if(i.tagName==="ROUTER"){let l=new y(i);if(await l.render(),s())return;await l.onLayout();return}let r=i.getAttribute("layout"),f=i.getAttribute("src");if(!f&&!r)return;let c;if(f){let l=await import(new URL(b(f),document.baseURI).href);if(s())return;let w=l.default;_={ClassDef:w,props:H(i)};try{c=new w}finally{_=null}}else c=new o(void 0,H(i));if(n[a]=c,await c.loadLayout(r),s())return;let p=document.createElement("template");p.innerHTML=c.layout??"";let d=p.content.cloneNode(!0);if(i.children.length>0){let l=d.firstElementChild??d;Array.from(i.children).forEach(w=>l.appendChild(w))}c.domNodes=[...d.children],c.routePath=i.getAttribute("path"),i.replaceWith(d),c.domNode=c.domNodes[0],await c.render(),!s()&&(await c?.onLayout?.(),!s()&&await this._startSubView(c))})),!s()&&this.subViews.push(...n.filter(Boolean))}},L=class o{static{h(this,"EventBus")}static#t={};static subscribe(...t){let[e,s,n]=typeof t[0]=="string"?[null,...t]:t;(o.#t[s]??=[]).push({callback:n,owner:e})}static send(t,e){let s=o.#t[t];if(s){o.#t[t]=s.filter(n=>!n.owner?._destroyed);for(let n of[...o.#t[t]])n.callback(e)}}static unsubscribe(t,e){o.#t[t]&&(o.#t[t]=o.#t[t].filter(s=>s.callback!==e))}static unsubscribeAll(t){for(let e of Object.keys(o.#t))o.#t[e]=o.#t[e].filter(s=>s.owner!==t)}static clear(){o.#t={}}},P=class o{static{h(this,"Store")}static#t=null;static singleton(){return o.#t||(o.#t=new o),o.#t}constructor(){this._state={}}get(t){return t.split(".").reduce((e,s)=>e?.[s],this._state)}set(t,e){let s=t.split("."),n=s.pop(),i=s.reduce((a,r)=>(a[r]||(a[r]={}),a[r]),this._state);i[n]=e}clear(){this._state={}}},y=class o extends R{static{h(this,"Router")}static#t=null;static#e=!1;static singleton(){return o.#t}constructor(t){super(),o.#t=this,this.rootNode=t.parentNode,this.routerDomNode=t,this.domNode=t,this.layout=t.innerHTML,this.animation=t.getAttribute("animation")??"none"}async onLayout(){let t=document.createDocumentFragment();for(;this.routerDomNode.firstChild;)t.appendChild(this.routerDomNode.firstChild);this.routerDomNode.replaceWith(t),this.domNode=this.rootNode,this.initRouter()}async _startSubView(t){t?.bindEvents?.()}initRouter(){if(this._generation!==m)return;this.routes={};for(let i of this.subViews)if(i.routePath){let a=i.routePath.slice(1).split("?")[0];this.routes[a]=i}this._navStack=[],this._baseView=null;for(let i of Object.values(this.routes))i._setHidden(!0);let t=Object.keys(this.routes)[0];this._defaultPath=t,this._pathname=location.pathname;let e=window.location.hash.slice(1),s=e.split("?")[0],n=s&&this.routes[s]?e:t;this._idx=history.state?.jopsIdx??0,this._history("replaceState",{...history.state,jopsIdx:this._idx},"#"+n),this._historyListener=()=>this._onHistoryChange(),window.addEventListener("popstate",this._historyListener),window.addEventListener("hashchange",this._historyListener),this._switchTo(n)}_onHistoryChange(){if(this._destroyed)return;if(location.pathname!==this._pathname){S&&V();return}let t=history.state?.jopsIdx;if(t===this._idx)return;if(t===void 0){this._idx+=1,this._history("replaceState",{...history.state,jopsIdx:this._idx},location.href),this._onForward();return}let e=t<this._idx;this._idx=t,e?this._onBack():this._onForward()}_onBack(){if(this._navStack.length>0){let e=this._navStack.pop(),s=this._navStack.at(-1)??this._baseView;e?.onBackPressed?.(),this._animateTransition(e,s,"back").then(()=>e.destroy());return}let t=window.location.hash.slice(1).split("?")[0];if(!this.routes[t]){history.back();return}this._currentView?.onBackPressed?.(),this._switchToHash()}_onForward(){this._closeStack(),this._switchToHash()}_closeStack(){this._navStack.length!==0&&(this._navStack.includes(this._currentView)&&(this._currentView=null),this._navStack.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null)}_pauseCurrent(t){this._currentView&&this._currentView!==t&&this._currentView.onPause?.()}_switchToHash(){let t=window.location.hash.slice(1),e=t.split("?")[0];e?this.routes[e]&&this._switchTo(t):this._switchToDefault()}_switchToDefault(){this._history("replaceState",{...history.state,jopsIdx:this._idx},"#"+this._defaultPath),this._switchTo(this._defaultPath)}destroy(){this._destroyed||(this._destroyed=!0,this._historyListener&&(window.removeEventListener("popstate",this._historyListener),window.removeEventListener("hashchange",this._historyListener),this._historyListener=null),this._navStack?.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null,this._currentView=null,this.routes={},o.#t===this&&(o.#t=null),super.destroy())}_history(t,e,s){T=!0;try{history[t](e,"",s)}finally{T=!1}}_onHostNavigation(){if(this._destroyed||!this.routes)return;if(location.pathname!==this._pathname){V();return}let t=location.hash.slice(1).split("?")[0],e=history.state?.jopsIdx===void 0;t?e&&this.routes[t]&&(this._idx+=1,this._history("replaceState",{...history.state,jopsIdx:this._idx},location.href),this._onForward()):(e&&(this._idx+=1),this._closeStack(),this._switchToDefault())}async _animateTransition(t,e,s){let n=s==="forward",i=t._roots().length>1||e._roots().length>1;i&&this.animation!=="none"&&!o.#e&&(o.#e=!0,console.info("JOPS: Router animations need single-root views; views with several root elements switch without animation.")),this.animation==="slide"&&!i?await this._slideTransition(t,e,s):this.animation==="fade"&&!i?await this._fadeTransition(t,e):(t._setHidden(!0),e._setHidden(!1),this._currentView=e),n||e?.onResume?.()}async _slideTransition(t,e,s){let n=this.domNode,i=s==="forward",a=n.getBoundingClientRect(),r=t.domNode.getBoundingClientRect(),f=r.left-a.left,c=r.top-a.top,p=r.width,d=n.offsetWidth-f,l=`position:absolute;left:${f}px;top:${c}px;width:${p}px`;n.style.cssText=`position:relative;overflow:hidden;height:${n.offsetHeight}px`,t.domNode.style.cssText=`${l};z-index:${i?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${l};z-index:${i?2:1}`,e.domNode.hidden=!1;let w=i?e.domNode:t.domNode,J=i?`${d}px`:"0px",U=i?"0px":`${d}px`,E=w.animate([{transform:`translateX(${J})`},{transform:`translateX(${U})`}],{duration:300,easing:"ease",fill:"forwards"});await E.finished,E.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",n.style.cssText="",this._currentView=e}async _fadeTransition(t,e){let s=this.domNode,n=s.getBoundingClientRect(),i=t.domNode.getBoundingClientRect(),a=i.left-n.left,r=i.top-n.top,f=i.width,c=`position:absolute;left:${a}px;top:${r}px;width:${f}px`;s.style.cssText=`position:relative;overflow:hidden;height:${s.offsetHeight}px`,t.domNode.style.cssText=c,t.domNode.hidden=!1,e.domNode.style.cssText=c,e.domNode.hidden=!1;let p=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),d=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([p.finished,d.finished]),p.cancel(),d.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",s.style.cssText="",this._currentView=e}_switchTo(t){let e=t.split("?")[0],s=this.routes[e];s&&this._pauseCurrent(s);for(let[n,i]of Object.entries(this.routes)){let a=n===e;i._setHidden(!a),a&&(this._currentView=i,i?.onResume?.())}}back(){history.back()}canGoBack(){return this._idx>0}getParams(){let t=window.location.hash.split("?")[1];return t?Object.fromEntries(new URLSearchParams(t)):{}}async navigate(t,e){let[s,n]=t.replace(/^\//,"").split("?"),i=s,a=e??this.routes[i]?.constructor;if(!a){console.error(`JOPS Router: no class found for path "${t}"`);return}this._navStack.length===0&&(this._baseView=this._currentView);let r=new a;await r.loadLayout(),r.inflate(),this.domNode.append(...r._roots()),this._history("pushState",{jopsNav:!0,jopsIdx:++this._idx},"#"+i+(n?"?"+n:"")),this._pauseCurrent(r),await r.onLayout?.(),await r.onResume?.(),r.bindEvents?.(),this._navStack.push(r),this._animateTransition(this._currentView,r,"forward")}async replace(t,e){this._closeStack();let[s,n]=t.replace(/^\//,"").split("?"),i=s,a="#"+i+(n?"?"+n:"");if(this.routes[i])this._history("replaceState",{...history.state,jopsIdx:this._idx},a),this._switchTo(i);else if(e){let r=new e;await r.loadLayout(),r.inflate(),this.domNode.append(...r._roots()),this._pauseCurrent(r),await r.onLayout?.(),await r.onResume?.(),r.bindEvents?.(),this._currentView&&this._currentView._setHidden(!0),this._currentView=r,this._history("replaceState",{...history.state,jopsIdx:this._idx},a)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},A=class{static{h(this,"Thread")}#t=null;run(t){}onMessage(t){}start(t){if(typeof Worker>"u"){console.warn("JOPS Thread: Web Workers are not supported in this environment.");return}let e=this.run.toString();!e.startsWith("function")&&!e.startsWith("(")&&!e.startsWith("async")&&(e="function "+e);let s=new Blob([`const run = ${e};`,"self.onmessage = (e) => { run(e.data); };"],{type:"application/javascript"}),n=URL.createObjectURL(s);this.#t=new Worker(n),URL.revokeObjectURL(n),this.#t.onmessage=i=>this.onMessage(i),this.#t.postMessage(t)}terminate(){if(!this.#t){console.warn("JOPS Thread: no active worker to terminate.");return}this.#t.terminate(),this.#t=null}};export{L as EventBus,y as Router,P as Store,A as Thread,R as View,G as layout,D as mount,z as raw,V as unmount};
@@ -0,0 +1,35 @@
1
+ export type JopsAppProps = {
2
+ /** URL of the JOPS app's entry module, e.g. "/quotes/App.js". */
3
+ app: string;
4
+ /** id for the root <div> (CSS, tests, debugging). Does not choose the mount target. */
5
+ id?: string;
6
+ /** className for the root <div>. */
7
+ className?: string;
8
+ /** jops-core library URL; also used for the import map. Defaults to "/lib/jops-core.min.js". */
9
+ lib?: string;
10
+ };
11
+ type JopsGlobal = {
12
+ mount(options: {
13
+ app: string;
14
+ root: HTMLElement;
15
+ }): Promise<unknown>;
16
+ /** Ends the app completely (Router, views, Store, EventBus, history hook). */
17
+ unmount(): void;
18
+ };
19
+ declare global {
20
+ interface Window {
21
+ JOPS?: JopsGlobal;
22
+ }
23
+ }
24
+ /**
25
+ * Hosts a JOPS SPA inside a Next.js page. Renders an empty <div> that becomes
26
+ * the JOPS root; React never renders children into it, so JOPS owns its DOM.
27
+ *
28
+ * @example
29
+ * import JopsApp from "jops-core/nextjs";
30
+ * export default function Page() {
31
+ * return <JopsApp app="/quotes/App.js" id="jops-quotes-root" />;
32
+ * }
33
+ */
34
+ export default function JopsApp({ app, id, className, lib }: JopsAppProps): import("react").JSX.Element;
35
+ export {};
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from "react";
4
+ const DEFAULT_LIB = "/lib/jops-core.min.js";
5
+ const SCRIPT_ID = "jops-core";
6
+ const IMPORT_MAP_ID = "jops-core-importmap";
7
+ function ensureImportMap(lib) {
8
+ if (document.getElementById(IMPORT_MAP_ID)) return;
9
+ const map = document.createElement("script");
10
+ map.type = "importmap";
11
+ map.id = IMPORT_MAP_ID;
12
+ map.textContent = JSON.stringify({ imports: { "jops-core": lib } });
13
+ document.head.appendChild(map);
14
+ }
15
+ function loadJops(lib) {
16
+ if (window.JOPS) return Promise.resolve(window.JOPS);
17
+ return new Promise((resolve, reject) => {
18
+ window.addEventListener("jops:ready", () => resolve(window.JOPS), { once: true });
19
+ if (!document.getElementById(SCRIPT_ID)) {
20
+ ensureImportMap(lib);
21
+ const script = document.createElement("script");
22
+ script.type = "module";
23
+ script.src = lib;
24
+ script.id = SCRIPT_ID;
25
+ script.dataset.host = "next";
26
+ script.onerror = () => reject(new Error(`JOPS: failed to load ${lib}`));
27
+ document.head.appendChild(script);
28
+ }
29
+ });
30
+ }
31
+ function JopsApp({ app, id, className, lib = DEFAULT_LIB }) {
32
+ const rootRef = useRef(null);
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ let jops = null;
36
+ loadJops(lib).then((loaded) => {
37
+ if (cancelled || !rootRef.current) return;
38
+ jops = loaded;
39
+ return loaded.mount({ app, root: rootRef.current });
40
+ }).catch((err) => console.error(err));
41
+ return () => {
42
+ cancelled = true;
43
+ jops?.unmount();
44
+ };
45
+ }, [app, lib]);
46
+ return /* @__PURE__ */ jsx("div", { ref: rootRef, id, className, "data-jops-root": "", style: { position: "relative" } });
47
+ }
48
+ export {
49
+ JopsApp as default
50
+ };
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "jops-core",
3
- "version": "1.0.15",
3
+ "version": "1.1.0",
4
4
  "description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
5
5
  "type": "module",
6
- "exports": "./lib/jops-core.min.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./lib/jops-core.d.ts",
9
+ "default": "./lib/jops-core.min.js"
10
+ },
11
+ "./nextjs": {
12
+ "types": "./lib/nextjs/JopsApp.d.ts",
13
+ "default": "./lib/nextjs/JopsApp.js"
14
+ }
15
+ },
7
16
  "browser": "./lib/jops-core.min.js",
8
17
  "types": "./lib/jops-core.d.ts",
9
18
  "bin": {
@@ -12,11 +21,13 @@
12
21
  "files": [
13
22
  "lib/jops-core.min.js",
14
23
  "lib/jops-core.d.ts",
24
+ "lib/nextjs/",
15
25
  "bin/init.js",
16
26
  "templates/"
17
27
  ],
18
28
  "scripts": {
19
- "build": "esbuild src/jops-core.js --bundle --format=esm --minify --keep-names --outfile=lib/jops-core.min.js && tsc -p tsconfig.types.json && npm pack"
29
+ "build:nextjs": "esbuild jops-nextjs/JopsApp.tsx --format=esm --jsx=automatic --target=es2022 --outdir=lib/nextjs && tsc -p tsconfig.nextjs.json",
30
+ "build": "esbuild src/jops-core.js --bundle --format=esm --minify --keep-names --outfile=lib/jops-core.min.js && tsc -p tsconfig.types.json && npm run build:nextjs && npm pack"
20
31
  },
21
32
  "homepage": "https://cdn.ui-db.com",
22
33
  "repository": {
@@ -52,13 +63,30 @@
52
63
  "ios",
53
64
  "hybrid-app",
54
65
  "phonegap",
55
- "hybrid-mobile"
66
+ "hybrid-mobile",
67
+ "nextjs",
68
+ "next-js",
69
+ "next.js",
70
+ "ssr"
56
71
  ],
57
72
  "author": "arik@ui-db.com",
58
73
  "license": "MIT",
59
74
  "devDependencies": {
75
+ "@types/react": "^19.3.0",
60
76
  "esbuild": "^0.25.12",
61
77
  "typescript": "^7.0.2"
62
78
  },
63
- "packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943"
79
+ "packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943",
80
+ "peerDependencies": {
81
+ "react": ">=18",
82
+ "next": ">=15"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "react": {
86
+ "optional": true
87
+ },
88
+ "next": {
89
+ "optional": true
90
+ }
91
+ }
64
92
  }
@@ -0,0 +1,34 @@
1
+ # Next.js + JOPS embedded SPA
2
+
3
+ A Next.js (App Router) project that hosts a [JOPS](https://github.com/uidb-dev/jops-core) single-page app on one of its pages. Created by `npx jops-core init` (template 3) in the current folder: in an empty folder, or merged into a fresh npm project (an existing `package.json` keeps its entries, an installed `jops-core` included).
4
+
5
+ ```bash
6
+ npm run dev
7
+ ```
8
+
9
+ Open http://localhost:3000/about and http://localhost:3000/jops-spa.
10
+
11
+ ## What's inside
12
+
13
+ | Path | What it is |
14
+ |---|---|
15
+ | `src/app/about/page.tsx` | A React-only page (no JOPS). |
16
+ | `src/app/jops-spa/page.tsx` | Renders `<JopsApp app="/jops-spa/App.js" />` from `jops-core/nextjs`, which launches the JOPS SPA. |
17
+ | `src/app/api/quotes/route.ts` | `GET /api/quotes[?status=…]`: the SPA's backend, reading `data/quotes.json`. |
18
+ | `public/jops-spa/` | The JOPS SPA: plain ES modules and HTML layouts, served as-is. A Router with `#login` (mock login, no request) and `#quotes` (quotes list with status filters). |
19
+ | `public/lib/jops-core.min.js` | The JOPS library, vendored. |
20
+
21
+ ## Conventions
22
+
23
+ - **Absolute paths** for everything JOPS loads (`/jops-spa/Quotes.js`, `/jops-spa/layout/quotes.html`).
24
+ - **Vendored library:** `public/lib/jops-core.min.js`, which must match the `lib` URL used by `JopsApp` (default `/lib/jops-core.min.js`). After updating the `jops-core` package, run `npm run jops:update` to copy the new version into `public/lib/`.
25
+ - **One stylesheet:** Tailwind is generated by Next.js and also covers the classes used in `public/jops-spa/`. `setStyle()` has no effect in an embedded JOPS app.
26
+ - **Lifecycle:** leaving `/jops-spa` (a link or Back) ends the JOPS app completely; returning launches it again. The mock login is kept in the JOPS `Store`, so it is cleared too.
27
+
28
+ ## Deploying
29
+
30
+ This is a standard Next.js app: `npm run build` produces `.next/` (not a static `dist/` folder), and it runs on a Node.js server because of the Route Handler (`/api/quotes`). Deploy it like any Next.js app; see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying). Three points specific to the embedded JOPS app:
31
+
32
+ - **Deploy `public/` with the app.** The whole JOPS SPA and the library live there. With `output: "standalone"`, Next.js leaves `public/` and `.next/static/` out of `.next/standalone/` on purpose; copy both in, or the JOPS app is missing.
33
+ - **Caching:** files in `public/` are not content-hashed, so a CDN or the browser can keep serving an old `/jops-spa/*.js` or `/lib/jops-core.min.js` after a deploy. Use short cache headers for those paths, or versioned paths.
34
+ - **Library version:** after updating the `jops-core` package, run `npm run jops:update` before `npm run build`, so `public/lib/jops-core.min.js` matches the installed version.
@@ -0,0 +1,62 @@
1
+ [
2
+ {
3
+ "id": "q4",
4
+ "number": "Q-2026-0004",
5
+ "companyName": "Greenfield Foods",
6
+ "dealTitle": "Onboarding package",
7
+ "status": "rejected",
8
+ "createdAt": "2026-09-17T00:00:00.000Z",
9
+ "totals": {
10
+ "gross": 9600.0,
11
+ "discount": 0.0,
12
+ "subtotal": 9600.0,
13
+ "tax": 1632.0,
14
+ "total": 11232.0
15
+ }
16
+ },
17
+ {
18
+ "id": "q3",
19
+ "number": "Q-2026-0003",
20
+ "companyName": "Copperleaf Software",
21
+ "dealTitle": "Fleet expansion",
22
+ "status": "accepted",
23
+ "createdAt": "2026-09-13T00:00:00.000Z",
24
+ "totals": {
25
+ "gross": 36000.0,
26
+ "discount": 3320.0,
27
+ "subtotal": 32680.0,
28
+ "tax": 5555.6,
29
+ "total": 38235.6
30
+ }
31
+ },
32
+ {
33
+ "id": "q2",
34
+ "number": "Q-2026-0002",
35
+ "companyName": "Blue Harbor Health",
36
+ "dealTitle": "Support renewal",
37
+ "status": "sent",
38
+ "createdAt": "2026-09-09T00:00:00.000Z",
39
+ "totals": {
40
+ "gross": 15700.0,
41
+ "discount": 480.0,
42
+ "subtotal": 15220.0,
43
+ "tax": 2587.4,
44
+ "total": 17807.4
45
+ }
46
+ },
47
+ {
48
+ "id": "q1",
49
+ "number": "Q-2026-0001",
50
+ "companyName": "Acme Robotics",
51
+ "dealTitle": "Pilot project",
52
+ "status": "draft",
53
+ "createdAt": "2026-09-05T00:00:00.000Z",
54
+ "totals": {
55
+ "gross": 10800.0,
56
+ "discount": 0.0,
57
+ "subtotal": 10800.0,
58
+ "tax": 1836.0,
59
+ "total": 12636.0
60
+ }
61
+ }
62
+ ]
@@ -0,0 +1,21 @@
1
+ # dependencies
2
+ /node_modules
3
+
4
+ # next.js
5
+ /.next/
6
+ /out/
7
+
8
+ # production
9
+ /build
10
+
11
+ # misc
12
+ .DS_Store
13
+ *.pem
14
+ *.tsbuildinfo
15
+ next-env.d.ts
16
+
17
+ # debug
18
+ npm-debug.log*
19
+
20
+ # env files
21
+ .env*
@@ -0,0 +1,15 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ cacheComponents: true,
5
+ turbopack: {
6
+ rules: {
7
+ "*.css": {
8
+ loaders: ["@tailwindcss/turbopack"],
9
+ as: "*.css",
10
+ },
11
+ },
12
+ },
13
+ };
14
+
15
+ export default nextConfig;
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "nextjs-jops-spa",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "jops:update": "jops-core update"
10
+ },
11
+ "dependencies": {
12
+ "jops-core": "^1.1.0",
13
+ "next": "16.4.2",
14
+ "react": "19.3.0",
15
+ "react-dom": "19.3.0"
16
+ },
17
+ "devDependencies": {
18
+ "@tailwindcss/turbopack": "^4",
19
+ "@types/node": "^20",
20
+ "@types/react": "^19",
21
+ "@types/react-dom": "^19",
22
+ "tailwindcss": "^4",
23
+ "typescript": "^5"
24
+ }
25
+ }
@@ -0,0 +1,10 @@
1
+ import { View } from "jops-core";
2
+
3
+ // Entry module launched by <JopsApp app="/jops-spa/App.js"> on the /jops-spa
4
+ // page. The root view renders layout/main.html into JopsApp's <div>.
5
+ export default class App {
6
+ constructor() {
7
+ const root = new View();
8
+ root.loadLayout("/jops-spa/layout/main.html").then(() => root.render());
9
+ }
10
+ }
@@ -0,0 +1,26 @@
1
+ import { View, Router, Store } from "jops-core";
2
+
3
+ // #login: a mock login. The user is kept in the JOPS Store only (no request);
4
+ // the Store is cleared when the app ends, so leaving the page logs out.
5
+ export default class Login extends View {
6
+ constructor() {
7
+ super();
8
+ }
9
+
10
+ onResume() {
11
+ // Already signed in: go straight to the quotes. Deferred, so it does not
12
+ // run inside the Router's own route switch.
13
+ if (Store.singleton().get("user")) {
14
+ queueMicrotask(() => Router.singleton().replace("/quotes"));
15
+ }
16
+ }
17
+
18
+ // The browser has checked the required fields before the submit event.
19
+ login(event) {
20
+ event.preventDefault();
21
+ const name = new FormData(event.target).get("username");
22
+ Store.singleton().set("user", { name });
23
+ event.target.reset();
24
+ location.hash = "#quotes";
25
+ }
26
+ }
@@ -0,0 +1,102 @@
1
+ import { View, Router, Store, layout } from "jops-core";
2
+ import { getQuotes } from "./api.js";
3
+ import { formatDate, formatMoney } from "./format.js";
4
+ import { BADGE } from "./badge.js";
5
+ import { tableSkeleton } from "./skeleton.js";
6
+
7
+ // Full class names, so Tailwind finds them in this file.
8
+ const TAB = {
9
+ active: "rounded-md px-3 py-1.5 text-sm capitalize bg-indigo-600 text-white",
10
+ inactive:
11
+ "rounded-md px-3 py-1.5 text-sm capitalize bg-white text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50",
12
+ };
13
+
14
+ // #quotes: the quotes list with status filter tabs. Only for a signed-in user.
15
+ export default class Quotes extends View {
16
+ constructor() {
17
+ super();
18
+ this.status = "";
19
+ }
20
+
21
+ onResume() {
22
+ const user = Store.singleton().get("user");
23
+ if (!user) {
24
+ // Not signed in: back to the login. Deferred, so it does not run inside
25
+ // the Router's own route switch.
26
+ queueMicrotask(() => Router.singleton().replace("/login"));
27
+ return;
28
+ }
29
+ this.get("user_name").set(layout`${user.name}`);
30
+ this.loadQuotes();
31
+ }
32
+
33
+ // Filter tab click: highlight the tab, then load that status.
34
+ filterQuotes(event) {
35
+ this.status = event.target.dataset.status;
36
+ for (const tab of this.get("tabs").element.children) {
37
+ tab.className = tab === event.target ? TAB.active : TAB.inactive;
38
+ }
39
+ this.loadQuotes();
40
+ }
41
+
42
+ async loadQuotes() {
43
+ const rows = this.get("quote-rows");
44
+ const table = this.get("quotes-table");
45
+ const empty = this.get("quotes-empty");
46
+ const loading = this.get("quotes-loading");
47
+ const request = (this.request = {}); // a later click wins
48
+
49
+ loading.set(tableSkeleton());
50
+ table.attr("hidden", "");
51
+ empty.attr("hidden", "");
52
+ try {
53
+ const quotes = await getQuotes(this.status);
54
+ if (this.request !== request) return;
55
+ this.subViews
56
+ .filter((v) => v instanceof QuoteRow)
57
+ .forEach((v) => this.removeSubView(v));
58
+ // Awaited one by one, so the rows are added in the API's order.
59
+ for (const quote of quotes) await this.addSubView("quote-rows", new QuoteRow(quote));
60
+ empty.attr("hidden", quotes.length ? "" : null);
61
+ } catch (err) {
62
+ if (this.request !== request) return;
63
+ rows.set(
64
+ layout`<tr><td colspan="6" class="px-4 py-6 text-center text-red-600">Could not load quotes: ${err.message}</td></tr>`,
65
+ );
66
+ } finally {
67
+ if (this.request === request) {
68
+ loading.set("");
69
+ table.attr("hidden", null);
70
+ }
71
+ }
72
+ }
73
+
74
+ logout() {
75
+ Store.singleton().set("user", null);
76
+ Router.singleton().replace("/login");
77
+ }
78
+ }
79
+
80
+ // One table row, created in code and added with addSubView().
81
+ class QuoteRow extends View {
82
+ constructor(quote) {
83
+ super();
84
+ this.quote = quote;
85
+ }
86
+
87
+ loadLayout() {
88
+ return super.loadLayout("/jops-spa/layout/quotes_row.html");
89
+ }
90
+
91
+ onLayout() {
92
+ const q = this.quote;
93
+ this.get("quote_number").set(layout`${q.number}`);
94
+ this.get("company_name").set(layout`${q.companyName ?? "—"}`);
95
+ this.get("deal_title").set(layout`${q.dealTitle ?? "—"}`);
96
+ this.get("quote_status").set(
97
+ layout`<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${BADGE[q.status] ?? BADGE.draft}">${q.status}</span>`,
98
+ );
99
+ this.get("created_at").set(layout`${formatDate(q.createdAt)}`);
100
+ this.get("total").set(layout`${formatMoney(q.totals.total)}`);
101
+ }
102
+ }
@@ -0,0 +1,15 @@
1
+ // Calls to the Next.js backend (Route Handlers in src/app/api/). Same origin,
2
+ // so cookies would be sent automatically if you add authentication.
3
+
4
+ async function request(url) {
5
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
6
+ const data = await res.json().catch(() => ({}));
7
+ if (!res.ok) throw new Error(data.error ?? `Request failed (${res.status})`);
8
+ return data;
9
+ }
10
+
11
+ // GET /api/quotes[?status=…] → the quotes, newest number first.
12
+ export async function getQuotes(status) {
13
+ const qs = status ? `?status=${encodeURIComponent(status)}` : "";
14
+ return (await request(`/api/quotes${qs}`)).data;
15
+ }
@@ -0,0 +1,8 @@
1
+ // Status badge colours. Full class names (not built from parts), so Tailwind
2
+ // finds them when it scans this file.
3
+ export const BADGE = {
4
+ draft: "bg-slate-100 text-slate-700 ring-slate-300",
5
+ sent: "bg-sky-50 text-sky-700 ring-sky-200",
6
+ accepted: "bg-emerald-50 text-emerald-700 ring-emerald-200",
7
+ rejected: "bg-red-50 text-red-700 ring-red-200",
8
+ };
@@ -0,0 +1,5 @@
1
+ const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
2
+ export const formatMoney = (value) => money.format(value);
3
+
4
+ const date = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
5
+ export const formatDate = (iso) => date.format(new Date(iso));
@@ -0,0 +1,34 @@
1
+ <div class="mx-auto mt-8 max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
2
+ <h1 class="text-xl font-semibold text-slate-900">Sign in</h1>
3
+ <p class="mt-1 text-sm text-slate-500">
4
+ Mock login: any username and password work. Nothing is sent to a server.
5
+ </p>
6
+ <form id="login_form" jops-event-bind onsubmit="login" class="mt-6 space-y-4">
7
+ <label class="block text-sm">
8
+ <span class="font-medium text-slate-700">Username</span>
9
+ <input
10
+ id="username"
11
+ name="username"
12
+ required
13
+ autocomplete="username"
14
+ class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
15
+ />
16
+ </label>
17
+ <label class="block text-sm">
18
+ <span class="font-medium text-slate-700">Password</span>
19
+ <input
20
+ name="password"
21
+ type="password"
22
+ required
23
+ autocomplete="current-password"
24
+ class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
25
+ />
26
+ </label>
27
+ <button
28
+ type="submit"
29
+ class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
30
+ >
31
+ Sign in
32
+ </button>
33
+ </form>
34
+ </div>
@@ -0,0 +1,17 @@
1
+ <div class="jops-spa">
2
+ <!-- The first route is the default one: a fresh visit opens the login. -->
3
+ <Router jops>
4
+ <Login
5
+ jops
6
+ path="/login"
7
+ src="/jops-spa/Login.js"
8
+ layout="/jops-spa/layout/login.html"
9
+ ></Login>
10
+ <Quotes
11
+ jops
12
+ path="/quotes"
13
+ src="/jops-spa/Quotes.js"
14
+ layout="/jops-spa/layout/quotes.html"
15
+ ></Quotes>
16
+ </Router>
17
+ </div>