domma-cms 0.74.0 → 0.75.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.
- package/CLAUDE.md +3 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -0
- package/admin/js/lib/blocks-arrange.js +1 -0
- package/admin/js/lib/collections-arrange.js +1 -0
- package/admin/js/lib/components-arrange.js +1 -0
- package/admin/js/lib/context-menus-arrange.js +1 -0
- package/admin/js/lib/forms-arrange.js +1 -0
- package/admin/js/lib/media-arrange.js +1 -0
- package/admin/js/lib/menus-arrange.js +1 -0
- package/admin/js/lib/pages-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/theme-settings-arrange.js +1 -0
- package/admin/js/lib/views-arrange.js +1 -0
- package/admin/js/templates/actions-list.html +73 -14
- package/admin/js/templates/api-endpoints.html +63 -10
- package/admin/js/templates/blocks.html +76 -15
- package/admin/js/templates/collections.html +108 -52
- package/admin/js/templates/components.html +82 -15
- package/admin/js/templates/context-menus.html +55 -13
- package/admin/js/templates/forms.html +67 -14
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/pages.html +78 -23
- package/admin/js/templates/theme.html +164 -132
- package/admin/js/templates/views-list.html +63 -9
- package/admin/js/views/actions-list.js +37 -1
- package/admin/js/views/api-endpoints.js +11 -7
- package/admin/js/views/blocks.js +34 -5
- package/admin/js/views/collections.js +36 -1
- package/admin/js/views/components.js +27 -5
- package/admin/js/views/context-menus.js +10 -5
- package/admin/js/views/forms.js +57 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/pages.js +28 -8
- package/admin/js/views/theme.js +55 -1
- package/admin/js/views/views-list.js +11 -1
- package/package.json +1 -1
- package/server/routes/api/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +53 -1
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/pages.js +46 -0
- package/server/routes/api/theme.js +55 -0
- package/server/routes/api/views.js +27 -3
- package/server/server.js +16 -1
- package/server/services/contextMenus.js +41 -0
- package/server/services/views.js +38 -0
package/admin/js/views/pages.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import{api as K}from"../api.js";import{addBannerAction as be}from"../lib/plugin-chrome.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Z,settingsStore as we,sidebarCount as ke,viewLifetime as xe}from"../lib/tool-kit.js";import{filterByProject as Me,getProjectFromHash as Pe}from"../lib/project-context.js";import{arrangePages as Ee,checkPrune as Se,normaliseSettings as ee,pageRow as Ce,statusCounts as $e,treeRows as Te}from"../lib/pages-arrange.js";const te=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,Le="#/pages",P="pages.prune.draft",De=`
|
|
2
|
+
<div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
|
|
3
|
+
<span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
|
|
4
|
+
<span class="pgx-chips">
|
|
5
|
+
<span class="pgx-chip" data-bind-hidden="!visibility" title="Who can see it">{{visibility}}</span>
|
|
6
|
+
<span class="pgx-chip is-info" data-bind-hidden="!project" title="Project">{{project}}</span>
|
|
7
|
+
<span class="pgx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS">Bundled</span>
|
|
8
|
+
<span class="pgx-chip is-warn" data-bind-hidden="!plugin" title="Managed by this plugin">{{plugin}}</span>
|
|
9
|
+
<span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
|
|
10
|
+
</span>
|
|
11
|
+
<button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
12
|
+
</div>`,y=i=>String(i??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Ae(i){const l=document.createElement("template");return l.innerHTML=i.trim(),l.content.firstElementChild}const ae=we("pages.settings",ee),s=xe(te),je=i=>`#/pages/edit${i}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(i){s.reset();const l=i.get(0).querySelector(".pgx")||i.get(0),C=()=>te.test(location.hash)&&l.isConnected,d=Pe(),$=ae.read(),o=M.observable([]),N=M.observable(0),q=M.observable(!0),T=M.observable(""),p=M.observable($.status),L=M.observable($.sort),g=M.observable($.mode),H=M.observable("");s.add(M.effect(()=>ae.write(ee({status:p.value,sort:L.value,mode:g.value}))));const D=M.observable([]);s.add(M.effect(()=>{D.value=Ee(o.value,{status:p.value,query:H.value,sort:L.value})}));const U=M.observable([]);s.add(M.effect(()=>{U.value=D.value.map(e=>Ce(e))}));const ne=M.computed(()=>$e(o.value)),se=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),oe=M.computed(()=>{const e=N.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),_=e=>o.peek().find(t=>t.urlPath===e);async function re(){try{const e=await K.pages.list();if(!C())return;const t=Array.isArray(e)?e:[],a=d?Me(t,d):t;N.value=t.length-a.length,o.value=a,T.value=""}catch(e){T.value=`Could not load the pages: ${e.message||e}`}finally{q.value=!1}}const w=e=>{location.hash=je(e)},ie=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function le(e){const t=e.plugin?`"${y(e.title||e.urlPath)}" is managed by the <strong>${y(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${y(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await K.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),ke(Le)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function ce(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}let c=null;const A=new Set;let F=!1;function V(e,t=null){const a=e.versionCount||0,m=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>Se(m.value,a)),Y={keep:m,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const n=f.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},he=String(m.peek()),G=()=>String(m.peek())!==he||!!t,r=E.slideover({title:`Trim revisions - ${y(e.title||e.urlPath)}`,size:"sm",position:"right"}),h=Ae(`
|
|
13
|
+
<div class="pgx-form">
|
|
14
|
+
<p class="pgx-note" data-bind-text="intro"></p>
|
|
15
|
+
<div>
|
|
16
|
+
<label class="form-label" for="pgx-f-keep">Keep the newest
|
|
17
|
+
<span data-help="Revisions are kept each time the page is saved. Trimming removes the oldest ones for good; the page itself is not changed. 0 removes them all."></span></label>
|
|
18
|
+
<input id="pgx-f-keep" type="number" min="0" step="1" class="form-input" data-model="keep.value">
|
|
19
|
+
<small class="pgx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
|
|
20
|
+
<small class="pgx-hint" data-bind-hidden="!summary.value" data-bind-text="summary.value"></small>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="pgx-actions">
|
|
23
|
+
<span class="pgx-spacer"></span>
|
|
24
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
25
|
+
<button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
|
|
26
|
+
<span data-icon="archive" data-icon-size="13"></span> Trim</button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>`);async function J(){if(!Y.cannot.peek()){x.value=!0;try{const n=await K.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:n.kept}:b),R=!0,r.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}r.element.appendChild(h);const ge=X(h),ve=M.applyBindings(Y,h,{methods:{trim:J,cancel:()=>r.close()}}),me=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),J())};h.addEventListener("keydown",me);const Q={path:e.urlPath,dirty:G,values:()=>({keep:m.peek()})};c=Q,A.add(r),ye(r,()=>{ve.dispose(),ge(),A.delete(r),c===Q&&(c=null)});const fe=r.close.bind(r);let R=!1,B=!1;r.close=async n=>{if(!R&&n!==!0&&!F&&G()){if(B)return;B=!0;const b=await E.confirm("Discard the change you have not applied?");if(B=!1,!b)return}return R=!0,fe()},I.scan(h),r.open(),h.querySelector("#pgx-f-keep")?.select()}function de(e){const t=_(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>w(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>ce(t)},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>V(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]}const u=l.querySelector(".pgx-list");u.innerHTML=De;const pe=M.applyBindings({pages:o,loading:q,loadError:T,status:p,sort:L,mode:g,query:H,rows:U,counts:ne,emptyText:se,projectNote:oe},l,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{g.value="list"},showTree:()=>{g.value="tree"}}});s.add(()=>pe.dispose()),s.add(X(l,{position:"bottom"}));const j=l.querySelector(".pgx-tree");let k=null;s.add(M.effect(()=>{const e=g.value==="tree",t=e?Te(D.value):null;try{k?.destroy?.()}catch{}k=null,j.replaceChildren(),!(!e||!t.length)&&(k=E.treeView(j,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>w(a)}),I.scan(j))})),s.add(()=>{try{k?.destroy?.()}catch{}});const ue=E.contextMenu(u,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>de(e.dataset.path)});s.add(()=>ue?.destroy?.());const O=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){Z(a);return}w(t.dataset.path)},z=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&w(t.dataset.path))};u.addEventListener("click",O),u.addEventListener("keydown",z),s.add(()=>{u.removeEventListener("click",O),u.removeEventListener("keydown",z)}),be(i,{id:"pages-new",icon:"plus",title:"New page",onClick:ie});const W=e=>{c?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",W),s.add(()=>window.removeEventListener("beforeunload",W)),s.add(()=>{if(c?.dirty())try{S.set(P,{path:c.path,values:c.values(),at:new Date().toISOString()})}catch{}F=!0,[...A].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await re(),!C())return;let v=null;try{v=S.get(P)}catch{}if(v?.path&&v.values){try{S.remove?S.remove(P):S.set(P,null)}catch{}const e=_(v.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${y(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&C()&&V(e,v.values)}}};
|
package/admin/js/views/theme.js
CHANGED
|
@@ -1 +1,55 @@
|
|
|
1
|
-
import{api as w}from"../api.js";import{populateThemeSelect as A}from"../lib/themes.js";const he=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}];export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(s){E.tabs(s.find("#theme-tabs").get(0));const X=E.loader(s.get(0),{type:"dots"});let m,q;try{[m,q]=await Promise.all([w.theme.get(),w.theme.catalog().catch(()=>({themes:[],tokens:[]}))])}catch(e){X.destroy(),E.toast(`Could not load theme settings: ${e.message||e}`,{type:"error"});return}X.destroy();const D=q.tokens||[],Y=Array.isArray(q.custom)?q.custom:[];function x(e){if(!Y.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const n of Y){const o=document.createElement("option");o.value=n.id,o.textContent=`${n.label} (on ${n.baseTheme})`,t.appendChild(o)}e.appendChild(t)}function T(e,t){const n=e.get?e.get(0):e;if(t&&![...n.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,n.appendChild(o)}n.value=t}const M=new Map;async function H(e){if(!e)return null;if(M.has(e))return M.get(e);const t=await w.theme.themeTokens(e).catch(()=>null),n=t&&t.tokens?t.tokens:null;return M.set(e,n),n}function ae(){return(s.find("#theme-auto-enabled").prop("checked")?S.val():f.val())||m.theme}const U=["--dm-card-bg","--dm-surface"],W=[...U,"--dm-text","--dm-primary","--dm-background"];function G(e){const t=document.createElement("span");return t.style.cssText="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;border:1px solid var(--dm-border);vertical-align:middle;margin-right:.25rem;",/^#[0-9a-f]{3,8}$/i.test(e)?t.style.background=e:t.style.background="transparent",t}async function j(e){const t=e.querySelector(".ov-theme-diff");if(!t)return;for(;t.firstChild;)t.removeChild(t.firstChild);const n=e.querySelector(".ov-theme")?.value;if(!n)return;const o=ae();if(n===o){const l=document.createElement("p");l.className="form-hint text-muted",l.style.cssText="margin:.4rem 0 0;font-size:.8rem;",l.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",t.appendChild(l);return}const[a,r]=await Promise.all([H(o),H(n)]);if(!a||!r)return;const c=Object.keys(r).filter(l=>String(a[l]??"").toLowerCase()!==String(r[l]).toLowerCase()),d=U.filter(l=>c.includes(l)),i=document.createElement("div");i.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:var(--dm-radius-md);background:var(--dm-surface-raised);font-size:.8rem;";const h=document.createElement("p");if(h.style.cssText="margin:0 0 .35rem;",h.textContent=`Changes ${c.length} of ${Object.keys(r).length} tokens here.`,i.appendChild(h),!d.length){const l=document.createElement("p");l.style.cssText="margin:0 0 .35rem;color:var(--dm-warning);",l.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",i.appendChild(l)}const p=document.createElement("div");p.style.cssText="display:flex;flex-wrap:wrap;gap:.15rem .75rem;";const v=[...W.filter(l=>c.includes(l)),...c.filter(l=>!W.includes(l))];for(const l of v.slice(0,6)){const u=document.createElement("span");u.style.cssText="white-space:nowrap;color:var(--dm-text-muted);",u.appendChild(G(a[l]||""));const C=document.createElement("span");C.textContent=`${l} `,u.appendChild(C),u.appendChild(G(r[l])),p.appendChild(u)}if(v.length>6){const l=document.createElement("span");l.style.cssText="color:var(--dm-text-muted);",l.textContent=`+${v.length-6} more`,p.appendChild(l)}i.appendChild(p),t.appendChild(i)}let b=Array.isArray(m.overrides)?m.overrides.map($):[];const f=s.find("#theme-frontend"),O=s.find("#theme-admin");A(f.get(0)),A(O.get(0),{includeAdmin:!0}),x(f.get(0)),x(O.get(0)),T(f,m.theme||"charcoal-dark"),T(O,m.adminTheme||"charcoal-dark");const S=s.find("#theme-day"),I=s.find("#theme-night");A(S.get(0)),A(I.get(0)),x(S.get(0)),x(I.get(0));const g=m.autoTheme||{};T(S,g.dayTheme||"charcoal-light"),T(I,g.nightTheme||"charcoal-dark"),s.find("#theme-day-start").val(g.dayStart||"07:00"),s.find("#theme-night-start").val(g.nightStart||"19:00");const z=m.switcher||{},B=s.find("#theme-switcher-enabled"),le=s.find("#theme-switcher-fields");B.prop("checked",z.enabled===!0),s.find("#theme-switcher-position").val(z.position||"right-center"),s.find("#theme-switcher-offset-x").val(z.offsetX??32),s.find("#theme-switcher-offset-y").val(z.offsetY??32);const J=()=>{le.get(0).style.display=B.prop("checked")?"":"none"};J(),B.on("change",J);const Q=s.find("#theme-auto-enabled"),Z=s.find("#theme-auto-fields");Q.prop("checked",!!g.enabled),g.enabled&&(Z.show(),f.prop("disabled",!0)),Q.on("change",function(){Z.toggle(this.checked),f.prop("disabled",this.checked)}),s.find("#theme-font-family").val(m.font?.family||"Roboto"),s.find("#theme-font-size").val(String(m.font?.size??16));function $(e){return{id:e.id||`ov-${Date.now()}-${Math.random().toString(36).slice(2,8)}`,label:e.label||"",enabled:e.enabled!==!1,scope:e.scope||"",theme:e.theme||"",tokens:{...e.tokens||{}},css:e.css||""}}const y=document.getElementById("theme-overrides"),re=document.getElementById("theme-overrides-empty");function R(){for(;y.firstChild;)y.removeChild(y.firstChild);re.style.display=b.length?"none":"",b.forEach((e,t)=>y.appendChild(ce(e,t))),Domma.icons.scan("#theme-overrides")}function ce(e,t){const n=document.createElement("div");n.className="card mb-3",n.dataset.i=String(t);const o=document.createElement("div");o.className="card-header";const a=document.createElement("h3");a.className="m-0",a.style.fontSize="1rem",a.textContent=e.label||e.scope||`Override ${t+1}`,o.appendChild(a);const r=document.createElement("div");r.style.cssText="display:flex;gap:.5rem;align-items:center;";const c=document.createElement("label");c.style.cssText="display:flex;align-items:center;gap:.35rem;font-size:.85rem;cursor:pointer;";const d=document.createElement("input");d.type="checkbox",d.className="form-check ov-enabled",d.checked=e.enabled,c.appendChild(d),c.appendChild(document.createTextNode("Enabled")),r.appendChild(c);const i=document.createElement("button");i.type="button",i.className="btn btn-ghost btn-sm ov-remove",i.textContent="Remove",r.appendChild(i),o.appendChild(r),n.appendChild(o);const h=document.createElement("div");h.className="card-body";const p=document.createElement("div");p.className="row mb-3",p.appendChild(V("col-4","Name",ne("ov-label",e.label,"Navbar island"))),p.appendChild(V("col-8","Target selector",de(e.scope),"The region to re-theme. Everything inside it follows.")),h.appendChild(p);const v=document.createElement("div");v.className="row mb-3";const l=document.createElement("select");l.className="form-select ov-theme",A(l,{includeDefault:!0,defaultLabel:"Keep the site theme"}),x(l),T(l,e.theme||""),v.appendChild(V("col-6","Adopt a theme here",l,"Copies every token of that theme onto this region.")),h.appendChild(v);const u=document.createElement("div");u.className="ov-theme-diff",h.appendChild(u);const C=document.createElement("div");C.className="ov-tokens mb-2",Object.entries(e.tokens||{}).forEach(([ie,me])=>{C.appendChild(ee(ie,me))}),h.appendChild(te("Token overrides",C));const L=document.createElement("button");L.type="button",L.className="btn btn-ghost btn-sm ov-add-token",L.textContent="+ Add token",h.appendChild(L);const N=document.createElement("details");N.style.marginTop="1rem";const _=document.createElement("summary");_.style.cssText="cursor:pointer;font-size:.85rem;",_.textContent="Custom CSS for this override",N.appendChild(_);const k=document.createElement("textarea");k.className="form-input ov-css",k.rows=4,k.placeholder="#site-navbar .brand { letter-spacing: .04em; }",k.value=e.css||"",k.style.marginTop=".5rem",N.appendChild(k);const P=document.createElement("p");return P.className="form-hint text-muted",P.style.cssText="font-size:.8rem;margin-top:.35rem;",P.textContent="Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade, hand-written rules fight the cascade.",N.appendChild(P),h.appendChild(N),n.appendChild(h),setTimeout(()=>j(n),0),n}function ee(e,t){const n=document.createElement("div");n.className="ov-token-row",n.style.cssText="display:flex;gap:.5rem;align-items:center;margin-bottom:.5rem;";const o=document.createElement("select");if(o.className="form-select ov-token-name",o.style.flex="1 1 auto",D.length){for(const d of D){const i=document.createElement("option");i.value=d,i.textContent=d,o.appendChild(i)}o.value=e||D[0]}else{const d=document.createElement("option");d.value=e||"",d.textContent=e||"(no tokens found)",o.appendChild(d)}const a=document.createElement("input");a.type="text",a.className="form-input ov-token-value",a.style.flex="0 0 150px",a.placeholder="#0096c7",a.value=t||"";const r=document.createElement("input");r.type="color",r.className="ov-token-swatch",r.style.cssText="flex:0 0 38px;height:34px;padding:0;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);background:none;cursor:pointer;",/^#[0-9a-f]{6}$/i.test(t||"")&&(r.value=t),r.addEventListener("input",()=>{a.value=r.value,a.dispatchEvent(new Event("input",{bubbles:!0}))});const c=document.createElement("button");return c.type="button",c.className="btn btn-ghost btn-sm ov-token-remove",c.textContent="\xD7",n.append(o,a,r,c),n}function V(e,t,n,o){const a=document.createElement("div");return a.className=e,a.appendChild(te(t,n,o)),a}function te(e,t,n){const o=document.createDocumentFragment(),a=document.createElement("label");if(a.className="form-label",a.textContent=e,o.appendChild(a),o.appendChild(t),n){const r=document.createElement("p");r.className="form-hint text-muted",r.style.cssText="margin-top:.35rem;font-size:.8rem;",r.textContent=n,o.appendChild(r)}return o}function ne(e,t,n){const o=document.createElement("input");return o.type="text",o.className=`form-input ${e}`,o.value=t||"",o.placeholder=n||"",o}function de(e){const t=document.createElement("div"),n=ne("ov-scope",e,"#site-navbar");if(n.setAttribute("list","theme-scope-options"),t.appendChild(n),!document.getElementById("theme-scope-options")){const o=document.createElement("datalist");o.id="theme-scope-options";for(const a of he){const r=document.createElement("option");r.value=a.value,r.label=a.label,o.appendChild(r)}t.appendChild(o)}return t}function F(){const e=y.querySelectorAll(".card"),t=[];return e.forEach((n,o)=>{const a={};n.querySelectorAll(".ov-token-row").forEach(r=>{const c=r.querySelector(".ov-token-name")?.value?.trim(),d=r.querySelector(".ov-token-value")?.value?.trim();c&&d&&(a[c]=d)}),t.push({id:b[o]?.id,label:n.querySelector(".ov-label")?.value?.trim()||"",enabled:!!n.querySelector(".ov-enabled")?.checked,scope:n.querySelector(".ov-scope")?.value?.trim()||"",theme:n.querySelector(".ov-theme")?.value||"",tokens:a,css:n.querySelector(".ov-css")?.value||""})}),b=t,t}s.on("click",".ov-remove",function(){const e=Number(this.closest(".card").dataset.i);F(),b.splice(e,1),R()}),s.on("change",".ov-theme",function(){j(this.closest(".card"))}),s.on("change","#theme-frontend, #theme-auto-enabled, #theme-day",()=>{y.querySelectorAll(".card").forEach(j)}),s.on("click",".ov-add-token",function(){this.closest(".card").querySelector(".ov-tokens").appendChild(ee("",""))}),s.on("click",".ov-token-remove",function(){this.closest(".ov-token-row").remove()}),s.find("#theme-add-override").on("click",()=>{F(),b.push($({label:"",scope:"",enabled:!0})),R()}),R();async function K(){const e=document.getElementById("theme-css-preview"),t={...oe(),overrides:F()};try{const n=await w.theme.compile(t);e.textContent=n.css?.trim()||"(no overrides - nothing is injected)",e.style.color=""}catch(n){e.textContent=n.message||String(n),e.style.color="var(--dm-danger)"}}s.find("#theme-refresh-css").on("click",K);function oe(){const e=s.find("#theme-auto-enabled").prop("checked"),t={enabled:e,dayTheme:S.val()||"charcoal-light",nightTheme:I.val()||"charcoal-dark",dayStart:s.find("#theme-day-start").val()||"07:00",nightStart:s.find("#theme-night-start").val()||"19:00"};return{theme:e?t.dayTheme:f.val(),adminTheme:O.val(),autoTheme:t,font:{family:s.find("#theme-font-family").val()||"Roboto",size:parseInt(s.find("#theme-font-size").val(),10)||16},switcher:{enabled:B.prop("checked"),position:s.find("#theme-switcher-position").val()||"right-center",offsetX:parseInt(s.find("#theme-switcher-offset-x").val(),10)||0,offsetY:parseInt(s.find("#theme-switcher-offset-y").val(),10)||0,themes:Array.isArray(m.switcher?.themes)?m.switcher.themes:[]}}}function se(e){const t=document.getElementById("theme-errors");for(;t.firstChild;)t.removeChild(t.firstChild);if(!e||!e.length){t.style.display="none";return}t.style.display="";for(const n of e){const o=document.createElement("div");o.textContent=n,t.appendChild(o)}t.scrollIntoView({behavior:"smooth",block:"nearest"})}s.find("#theme-save-btn").on("click",async function(){se(null);const e={...oe(),overrides:F()};this.disabled=!0;try{m=await w.theme.save(e);const{refreshAdminTheme:n}=await import("../lib/admin-theme.js");await n(w),E.toast("Theme saved.",{type:"success"}),await K()}catch(t){const n=t.validation||[t.message||"Could not save the theme."];se(n),E.toast("Theme not saved.",{type:"error"})}finally{this.disabled=!1}}),await K()}};
|
|
1
|
+
import{api as N}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Pe}from"../lib/help-popover.js";import{onceClosed as lt,openMenuAt as Ke,sidebarCount as it}from"../lib/tool-kit.js";import{populateThemeSelect as Fe}from"../lib/themes.js";import{cloneOverride as Q,dayOrNight as ct,formFromConfig as X,knownThemeIds as dt,moveItem as He,overrideRow as mt,payloadFromForm as Z,snapshot as pe,swatchColour as ze,themeDiff as ht,themeLabel as ut,themeProblems as pt}from"../lib/theme-settings-arrange.js";const fe=/^\/theme(\?.*)?$/,ft="#/theme",L="theme.draft",vt=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}],bt=`
|
|
2
|
+
<div class="thx-row" data-id="{{id}}" tabindex="0" data-bind-class="off && 'is-off'">
|
|
3
|
+
<span class="thx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
|
|
4
|
+
<span class="thx-chip is-danger" data-bind-hidden="!missing" title="Its theme no longer exists">Theme gone</span>
|
|
5
|
+
<span class="thx-chip is-warn" data-bind-hidden="!empty" title="No theme, token or CSS - it changes nothing">Empty</span>
|
|
6
|
+
<span class="thx-chip" data-bind-hidden="!off">Off</span>
|
|
7
|
+
<button type="button" class="thx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
8
|
+
</div>`,ve=m=>String(m??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function yt(m){const y=document.createElement("template");return y.innerHTML=m.trim(),y.content.firstElementChild}let x=null,Be=!1;function kt(){Be||!window.R?.use||(Be=!0,R.use((m,y,u)=>!(fe.test(y?.path||"")&&!fe.test(m?.path||""))||!x?.dirty()?u():E.confirm("You have unsaved theme changes. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?u():history.replaceState(null,"","#/theme")})),M.subscribe("router:afterChange",({to:m}={})=>{if(x&&!fe.test(m?.path||"")){const y=x;x=null,y.dispose()}}))}export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(m){if(kt(),x){const e=x;x=null,e.dispose()}const y=[],u=e=>(y.push(e),e);let ee=!1;const p=m.get(0).querySelector(".thx")||m.get(0),j=()=>!ee&&p.isConnected,be=E.loader(m.get(0),{type:"dots"});let P,q;try{[P,q]=await Promise.all([N.theme.get(),N.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){be.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(be.destroy(),!p.isConnected)return;const te=q.tokens||[],K=Array.isArray(q.custom)?q.custom:[],ye=dt(q),f=M.observable(P),$=X(P),s={};for(const[e,t]of Object.entries($))s[e]=M.observable(t);const ke=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),oe=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),F=M.observable(pe($,P)),H=M.observable(!1),z=M.observable([]),ne=M.observable("(nothing yet)"),ae=M.observable(!1),_e=M.computed(()=>pe(ke(),f.value)),B=M.computed(()=>_e.value!==F.value),ge=M.computed(()=>pt(ke(),ye)),we=M.observable([]);u(M.effect(()=>{we.value=s.overrides.value.map((e,t)=>mt(e,t,{custom:K,known:ye}))}));const _=e=>ut(e,K),Ve={...s,saving:H,rows:we,cssText:ne,cssError:ae,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ge.value.length?`${ge.value.map(e=>`${e.where}: "${e.id}"`).join(", ")} - no longer available, so its base theme shows. Pick another.`:""),liveSite:M.computed(()=>{const e=f.value;if(e.autoTheme?.enabled){const t=ct(e.autoTheme)==="day"?"day":"night";return`Site: ${_(e.autoTheme.dayTheme)} by day, ${_(e.autoTheme.nightTheme)} by night (${t} now)`}return`Site: ${_(e.theme||"charcoal-dark")}`}),liveAdmin:M.computed(()=>`Admin: ${_(f.value.adminTheme||"charcoal-dark")}`),liveOverrides:M.computed(()=>{const e=f.value.overrides||[],t=e.filter(o=>o.enabled!==!1).length;return e.length?`${t} override${t===1?"":"s"} on`:""}),liveSwitcher:M.computed(()=>f.value.switcher?.enabled?"Theme switcher on":"")};function xe(e){if(!K.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of K){const a=document.createElement("option");a.value=o.id,a.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(a)}e.appendChild(t)}function Ee(e,t){if(t&&![...e.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,e.appendChild(o)}}const V=(e,t,o)=>{const a=p.querySelector(e);return Fe(a,o),xe(a),Ee(a,t),a};V("#thx-site",$.theme),V("#thx-admin",$.adminTheme,{includeAdmin:!0}),V("#thx-day",$.dayTheme),V("#thx-night",$.nightTheme);let se=null,re=0;async function Y(){const e=++re;try{const t=await N.theme.compile(Z(oe(),f.peek()));if(e!==re||!j())return;ne.value=t.css?.trim()||"(no overrides - nothing is injected)",ae.value=!1}catch(t){if(e!==re||!j())return;ne.value=t.message||String(t),ae.value=!0}}u(M.effect(()=>{s.overrides.value,clearTimeout(se),se=setTimeout(Y,400)})),u(()=>clearTimeout(se));async function le(){if(!H.peek()){H.value=!0,z.value=[];try{const e=await N.theme.save(Z(oe(),f.peek()));if(!j())return;f.value=e;const t=X(e);for(const[a,l]of Object.entries(t))s[a].value=l;F.value=pe(t,e);try{S.remove?S.remove(L):S.set(L,null)}catch{}const{refreshAdminTheme:o}=await import("../lib/admin-theme.js");await o(N),it(ft),E.toast("Theme saved.",{type:"success"}),Y()}catch(e){z.value=e.validation||[e.message||"Could not save the theme."],E.toast("Theme not saved - see the message at the top.",{type:"error"})}finally{H.value=!1}}}async function Ye(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=X(f.peek());for(const[t,o]of Object.entries(e))s[t].value=o;z.value=[]}const Se=e=>s.overrides.peek().find(t=>t.id===e),C=e=>{s.overrides.value=e},ie=(e,t)=>C(s.overrides.peek().map(o=>o.id===e?t:o)),Ue=e=>s.overrides.peek().findIndex(t=>t.id===e),ce=new Map;async function Ce(e){if(!e)return null;if(ce.has(e))return ce.get(e);const o=(await N.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ce.set(e,o),o}const Je=()=>(s.autoEnabled.peek()?s.dayTheme.peek():s.theme.peek())||"charcoal-dark",U=new Set;function J(e){const t=e?Se(e):Q({});if(!t)return;const o=!e,a=Q(t),l={label:M.observable(a.label),scope:M.observable(a.scope),enabled:M.observable(a.enabled),theme:M.observable(a.theme),css:M.observable(a.css),tokenErr:M.observable("")},c=E.slideover({title:o?"New override":`Override: ${ve(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=yt(`
|
|
9
|
+
<div class="thx-form">
|
|
10
|
+
<div class="thx-grid">
|
|
11
|
+
<div>
|
|
12
|
+
<label class="form-label" for="thx-o-label">Name</label>
|
|
13
|
+
<input id="thx-o-label" type="text" class="form-input" maxlength="80" placeholder="Navbar island" data-model="label.value">
|
|
14
|
+
</div>
|
|
15
|
+
<div class="thx-setting" style="align-self:end">
|
|
16
|
+
<label class="form-label" for="thx-o-on">On
|
|
17
|
+
<span data-help="Off keeps the override but leaves it out of the page."></span></label>
|
|
18
|
+
<input id="thx-o-on" type="checkbox" class="form-switch" data-model="enabled.value">
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div>
|
|
22
|
+
<label class="form-label" for="thx-o-scope">Target selector
|
|
23
|
+
<span data-help-title="Target selector" data-help="The region to re-theme - a CSS selector. Everything inside it follows. Pick a common region from the list or type your own (#site-navbar, .card, .page-body \u2026)."></span></label>
|
|
24
|
+
<input id="thx-o-scope" type="text" class="form-input font-mono" list="thx-scope-options" placeholder="#site-navbar" data-model="scope.value">
|
|
25
|
+
<datalist id="thx-scope-options">${vt.map(n=>`<option value="${n.value}" label="${n.label}"></option>`).join("")}</datalist>
|
|
26
|
+
</div>
|
|
27
|
+
<div>
|
|
28
|
+
<label class="form-label" for="thx-o-theme">Adopt a theme here
|
|
29
|
+
<span data-help-title="Adopt a theme" data-help="Copies every token of that theme onto this region. Two themes often share a surface colour, so the panel below says what really changes - set --dm-card-bg or --dm-surface as a token when the fill should change too."></span></label>
|
|
30
|
+
<select id="thx-o-theme" class="form-select" data-model="theme.value"></select>
|
|
31
|
+
<div class="thx-diff-host"></div>
|
|
32
|
+
</div>
|
|
33
|
+
<div>
|
|
34
|
+
<label class="form-label">Token overrides
|
|
35
|
+
<span data-help-title="Tokens" data-help="Individual --dm-* design tokens for this region, set after any adopted theme. Only tokens the shipped stylesheet defines are accepted - start typing to see them. The colour box fills in a hex value; lengths and keywords go in the text box."></span></label>
|
|
36
|
+
<div class="thx-toks"></div>
|
|
37
|
+
<datalist id="thx-token-names"></datalist>
|
|
38
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="addToken">
|
|
39
|
+
<span data-icon="plus" data-icon-size="13"></span> Add token</button>
|
|
40
|
+
<small class="thx-err" data-bind-hidden="!tokenErr.value" data-bind-text="tokenErr.value"></small>
|
|
41
|
+
</div>
|
|
42
|
+
<details class="thx-o-css"${a.css?" open":""}>
|
|
43
|
+
<summary>Custom CSS for this override</summary>
|
|
44
|
+
<textarea class="form-input font-mono" rows="4" style="margin-top:.5rem" placeholder="#site-navbar .brand { letter-spacing: .04em; }" data-model="css.value"></textarea>
|
|
45
|
+
<small class="thx-hint">Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade; hand-written rules fight the cascade.</small>
|
|
46
|
+
</details>
|
|
47
|
+
<div class="thx-actions">
|
|
48
|
+
<button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="remove"${o?" hidden":""}>
|
|
49
|
+
<span data-icon="trash" data-icon-size="13"></span> Remove</button>
|
|
50
|
+
<span class="thx-spacer"></span>
|
|
51
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
52
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="done">
|
|
53
|
+
<span data-icon="check" data-icon-size="13"></span> ${o?"Add":"Done"}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>`),de=v.querySelector("#thx-o-theme");Fe(de,{includeDefault:!0,defaultLabel:"Keep the site theme"}),xe(de),Ee(de,a.theme);const Xe=v.querySelector("#thx-token-names");for(const n of te){const i=document.createElement("option");i.value=n,Xe.appendChild(i)}const me=v.querySelector(".thx-toks");function Ne(n="",i=""){const b=document.createElement("div");b.className="thx-tok";const r=document.createElement("input");r.type="text",r.className="form-input font-mono thx-tok-name",r.placeholder="--dm-primary",r.setAttribute("list","thx-token-names"),r.value=n,r.setAttribute("aria-label","Token");const h=document.createElement("input");h.type="text",h.className="form-input thx-tok-value",h.placeholder="#0096c7",h.value=i,h.setAttribute("aria-label","Value");const k=document.createElement("input");k.type="color",k.title="Pick a colour";const g=ze(i);/^#[0-9a-f]{6}$/i.test(g)&&(k.value=g),k.addEventListener("input",()=>{h.value=k.value});const w=document.createElement("button");return w.type="button",w.className="btn btn-sm btn-ghost",w.textContent="\xD7",w.title="Remove this token",w.addEventListener("click",()=>b.remove()),b.append(r,h,k,w),b}for(const[n,i]of Object.entries(a.tokens))me.appendChild(Ne(n,i));function De(){const n={},i=[];return me.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),h=b.querySelector(".thx-tok-value").value.trim();!r||!h||(te.length&&!te.includes(r)&&i.push(r),n[r]=h)}),{tokens:n,bad:i}}const Le=()=>({...a,label:l.label.peek(),scope:l.scope.peek(),enabled:l.enabled.peek(),theme:l.theme.peek(),css:l.css.peek(),tokens:De().tokens}),Ze=JSON.stringify(Z({overrides:[a]}).overrides),et=()=>JSON.stringify(Z({overrides:[Le()]}).overrides)!==Ze,he=v.querySelector(".thx-diff-host");let Re=0;async function tt(){const n=++Re;he.replaceChildren();const i=l.theme.peek();if(!i)return;const b=Je(),r=document.createElement("div");if(r.className="thx-diff",r.style.marginTop=".5rem",i===b){const d=document.createElement("p");d.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",r.appendChild(d),he.appendChild(r);return}const[h,k]=await Promise.all([Ce(b),Ce(i)]);if(n!==Re||!h||!k)return;const g=ht(h,k),w=document.createElement("p");if(w.textContent=`Changes ${g.changed} of ${g.total} tokens here.`,r.appendChild(w),g.keepsFill){const d=document.createElement("p");d.className="is-warn",d.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",r.appendChild(d)}const G=document.createElement("div");G.className="thx-diff-list";const je=d=>{const A=document.createElement("span");return A.className="thx-sw",A.style.background=ze(d)||"transparent",A};for(const d of g.shown){const A=document.createElement("span");A.append(je(d.from),document.createTextNode(`${d.name} `),je(d.to)),G.appendChild(A)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,G.appendChild(d)}r.appendChild(G),he.appendChild(r)}const ot=M.effect(()=>{l.theme.value,tt()});let W=!1,ue=!1;const qe={addToken:()=>{const n=Ne();me.appendChild(n),n.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(s.overrides.peek().filter(n=>n.id!==t.id)),W=!0,c.close())},done(){const{bad:n}=De();if(n.length){l.tokenErr.value=`Not a token this Domma defines: ${n.join(", ")}. Pick one from the list.`;return}const i=Le();o?C([...s.overrides.peek(),i]):ie(t.id,i),W=!0,c.close()}};c.element.appendChild(v);const nt=Pe(v),at=M.applyBindings(l,v,{methods:qe}),st=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),qe.done())};v.addEventListener("keydown",st),U.add(c),lt(c,()=>{ot(),at.dispose(),nt(),U.delete(c)});const rt=c.close.bind(c);c.close=async n=>{if(!W&&n!==!0&&et()){if(ue)return;ue=!0;const i=await E.confirm("Discard your changes to this override?");if(ue=!1,!i)return}return W=!0,rt()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function We(e){const t=Se(e);if(!t)return[];const o=Ue(e),a=s.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>J(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>ie(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>ie(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const l=Q({...t,id:"",label:t.label?`${t.label} copy`:""}),c=[...s.overrides.peek()];c.splice(o+1,0,l),C(c)}},{type:"divider"},{label:"Move up",icon:"arrow-up",disabled:o<=0,action:()=>C(He(s.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=a-1,action:()=>C(He(s.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&C(s.overrides.peek().filter(l=>l.id!==e))}}]}const O=p.querySelector(".thx-list");O.innerHTML=bt,E.tabs(p.querySelector(".thx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});const Ge=M.applyBindings(Ve,p,{methods:{save:le,discard:Ye,refreshCss:Y,addOverride:()=>J(null)}});u(()=>Ge.dispose()),u(Pe(p,{position:"bottom"}));const Qe=E.contextMenu(O,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>We(e.dataset.id)});u(()=>Qe?.destroy?.());const Te=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){Ke(e.target.closest(".thx-kebab"));return}J(t.dataset.id)}},Me=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ke(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.id))};O.addEventListener("click",Te),O.addEventListener("keydown",Me),u(()=>{O.removeEventListener("click",Te),O.removeEventListener("keydown",Me)}),Ie(m,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),Ie(m,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>le()});const $e=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||U.size||(e.preventDefault(),e.stopImmediatePropagation(),le())};document.addEventListener("keydown",$e,!0),u(()=>document.removeEventListener("keydown",$e,!0));const Oe=()=>{if(B.peek())try{S.set(L,{form:oe(),base:F.peek(),at:new Date().toISOString()})}catch{}},Ae=e=>{B.peek()&&(Oe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ae),u(()=>window.removeEventListener("beforeunload",Ae)),x={dirty:()=>B.peek(),dispose(){ee=!0,Oe(),[...U].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p),Y();let T=null;try{T=S.get(L)}catch{}if(T?.form){try{S.remove?S.remove(L):S.set(L,null)}catch{}const e=T.base&&T.base!==F.peek(),t=T.at?` (${D(T.at).format("D MMM HH:mm")})`:"",o=e?`You left unsaved theme changes${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved theme changes${t}. Restore them?`;if(await E.confirm(o)&&j()){const a={...X(f.peek()),...T.form};a.overrides=(a.overrides||[]).map(l=>Q(l));for(const[l,c]of Object.entries(a))s[l]&&(s[l].value=c)}}}};
|
|
@@ -1 +1,11 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{apiRequest as m}from"../api.js";import{addBannerAction as W}from"../lib/plugin-chrome.js";import{helpPopovers as G}from"../lib/help-popover.js";import{openMenuAt as L,sidebarCount as g,viewLifetime as J}from"../lib/tool-kit.js";import{FILTERS as Y,arrangeViews as z,countViews as Q,newViewHref as X,projectOf as y,projectScope as Z,shortcodeFor as ee,viewRow as te}from"../lib/views-arrange.js";const A=/^#\/(views|projects\/[^/]+\/views)\/?(\?.*)?$/,b="#/views",f="views.filter",se=`
|
|
2
|
+
<div class="vwx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
|
|
3
|
+
<span class="vwx-main"><strong>{{title}}</strong><small>{{meta}}</small>
|
|
4
|
+
<small class="is-bad" data-bind-hidden="!broken">{{brokenText}}</small></span>
|
|
5
|
+
<span class="vwx-chips">
|
|
6
|
+
<span class="vwx-chip is-info" data-bind-hidden="!engine">{{engine}}</span>
|
|
7
|
+
<span class="vwx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
|
|
8
|
+
<span class="vwx-chip" data-bind-class="accessCls" title="Who can see it">{{access}}</span>
|
|
9
|
+
</span>
|
|
10
|
+
<button type="button" class="vwx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
11
|
+
</div>`,x=c=>String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),o=J(A);export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(c){o.reset();const r=c.get(0).querySelector(".vwx")||c.get(0),u=()=>A.test(location.hash)&&r.isConnected,a=Z(location.hash),i=M.observable([]),p=M.observable(!0),w=M.observable("");let k="all";try{k=Y.includes(S.get(f))?S.get(f):"all"}catch{}const s=M.observable(k),$=M.observable("");o.add(M.effect(()=>{const e=s.value;try{S.set(f,e)}catch{}}));const T=M.observable([]);o.add(M.effect(()=>{T.value=z(i.value,{filter:s.value,query:$.value,project:a}).map(te)}));const d=M.computed(()=>Q(i.value,{project:a}));o.add(M.effect(()=>{s.value==="broken"&&!d.value.broken&&!p.value&&(s.value="all")}));const D=M.computed(()=>{const e=i.value.length-d.value.all;return`Showing only views in ${a}.${e?` ${e} other${e===1?" is":"s are"} filed elsewhere.`:""}`}),N=M.computed(()=>d.value.all?"No views match.":"No views yet - New view in the banner makes one."),P=e=>i.peek().find(t=>t.slug===e);async function v(){try{const e=await m("/views?sources=1",{method:"GET"});if(!u())return;i.value=Array.isArray(e)?e:[],w.value=""}catch(e){w.value=`Could not load the views: ${e.message||e}`}finally{p.value=!1}}const h=e=>{location.hash=`#/views/edit/${encodeURIComponent(e)}`},U=e=>{location.hash=`#/views/${encodeURIComponent(e)}/preview`};async function q(e){const t=ee(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this: ${t}`,{type:"info",duration:8e3})}}async function O(e){const t=await E.prompt("Title of the copy",{inputValue:x(`${e.title||e.slug} copy`)});if(!(!t||!String(t).trim()||!u()))try{const{id:l,slug:oe,meta:R,sourceInfo:ae,...K}=e,H=await m("/views",{method:"POST",body:JSON.stringify({...K,title:String(t).trim(),...R?.project!=null&&{meta:{project:R.project}}})});E.toast(`Made "${H.title}".`,{type:"success"}),g(b),await v()}catch(l){E.toast(`Not duplicated: ${l.message||l}`,{type:"error"})}}async function _(e){if(await E.confirm(`Delete the view "${x(e.title||e.slug)}"? Pages that show it with [view slug="${x(e.slug)}"] will show nothing. This cannot be undone.`))try{await m(`/views/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),g(b),await v()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}function V(e){const t=P(e);return t?[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>h(t.slug)},{label:"Preview results",icon:"eye",action:()=>U(t.slug)},{label:"Copy shortcode",icon:"copy",action:()=>q(t)},...y(t)!=="core"&&!a?[{label:`Open project "${y(t)}"`,icon:"folder",action:()=>{location.hash=`#/projects/${encodeURIComponent(y(t))}/views`}}]:[],{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>O(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>_(t)}]:[]}const n=r.querySelector(".vwx-list");n.innerHTML=se;const B=M.applyBindings({views:i,loading:p,loadError:w,filter:s,query:$,rows:T,counts:d,emptyText:N,scopeText:D,project:a},r,{methods:{showAll:()=>{s.value="all"},showPublic:()=>{s.value="public"},showRestricted:()=>{s.value="restricted"},showBroken:()=>{s.value="broken"}}});o.add(()=>B.dispose()),o.add(G(r,{position:"bottom"}));const F=E.contextMenu(n,{cursor:"glyph",match:".vwx-row",inherit:!1,density:"compact",items:e=>V(e.dataset.slug)});o.add(()=>F?.destroy?.());const C=e=>{const t=e.target.closest(".vwx-row");if(!t)return;const l=e.target.closest(".vwx-kebab");if(l){L(l);return}h(t.dataset.slug)},j=e=>{const t=e.target.closest(".vwx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&h(t.dataset.slug))};n.addEventListener("click",C),n.addEventListener("keydown",j),o.add(()=>{n.removeEventListener("click",C),n.removeEventListener("keydown",j)}),W(c,{id:"views-new",icon:"plus",title:"New view",onClick:()=>{location.hash=X(a)}}),I.scan(r),await v(),u()&&g(b)}};
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Admin endpoints (authenticated + actions permission):
|
|
5
5
|
* GET /actions - List all action configs
|
|
6
|
+
* GET /actions/_count - Sidebar badge (never 503: file mode answers count 0, available: false)
|
|
6
7
|
* POST /actions - Create action config
|
|
7
8
|
* GET /actions/:slug - Get action config
|
|
8
9
|
* PUT /actions/:slug - Update action config
|
|
@@ -24,12 +25,63 @@ import {
|
|
|
24
25
|
updateAction
|
|
25
26
|
} from '../../services/actions.js';
|
|
26
27
|
import {getEntry} from '../../services/collections.js';
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
authenticate as defaultAuthenticate,
|
|
30
|
+
requirePermission as defaultRequirePermission
|
|
31
|
+
} from '../../middleware/auth.js';
|
|
28
32
|
import {getRoleLevel} from '../../services/roles.js';
|
|
29
33
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
30
34
|
import {checkEntryAccess} from '../../services/rowAccess.js';
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The sidebar badge on Actions (its countUrl): how many actions this user can
|
|
38
|
+
* see, which collections they act on, which are state-machine transitions.
|
|
39
|
+
* Plain text throughout - the popover sets it with textContent. `null` (no
|
|
40
|
+
* MongoDB) answers count 0, so the sidebar shows no badge and logs nothing;
|
|
41
|
+
* `available` lets the list screen find that out without a 503 of its own.
|
|
42
|
+
*
|
|
43
|
+
* @param {object[]|null} actions
|
|
44
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
45
|
+
*/
|
|
46
|
+
export function actionsBadge(actions) {
|
|
47
|
+
if (!Array.isArray(actions)) {
|
|
48
|
+
return {
|
|
49
|
+
count: 0, label: 'Actions need MongoDB', tone: 'info', available: false,
|
|
50
|
+
details: {title: 'Actions', rows: [], items: [],
|
|
51
|
+
empty: 'Actions are a Pro feature: they need a MongoDB connection.'}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const n = actions.length;
|
|
55
|
+
const collections = new Set(actions.map(a => a.collection).filter(Boolean));
|
|
56
|
+
const transitions = actions.filter(a => a.transition?.field).length;
|
|
57
|
+
const sorted = [...actions].sort((a, b) => String(a.title || a.slug).localeCompare(String(b.title || b.slug)));
|
|
58
|
+
return {
|
|
59
|
+
count: n,
|
|
60
|
+
label: `${n} action${n === 1 ? '' : 's'}`,
|
|
61
|
+
tone: 'info',
|
|
62
|
+
available: true,
|
|
63
|
+
details: {
|
|
64
|
+
title: 'Actions',
|
|
65
|
+
rows: [
|
|
66
|
+
{label: 'Collections', value: String(collections.size)},
|
|
67
|
+
{label: 'Transitions', value: String(transitions)}
|
|
68
|
+
],
|
|
69
|
+
items: sorted.slice(0, 8).map(a => ({
|
|
70
|
+
text: String(a.title || a.slug),
|
|
71
|
+
meta: [a.collection, `${(a.steps || []).length} step${(a.steps || []).length === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
|
|
72
|
+
})),
|
|
73
|
+
empty: 'No actions yet.'
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
80
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts] - auth as DI, so tests can stub it
|
|
81
|
+
*/
|
|
82
|
+
export async function actionsRoutes(fastify, opts = {}) {
|
|
83
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
84
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
33
85
|
const canRead = {preHandler: [authenticate, requirePermission('actions', 'read')]};
|
|
34
86
|
const canCreate = {preHandler: [authenticate, requirePermission('actions', 'create')]};
|
|
35
87
|
const canUpdate = {preHandler: [authenticate, requirePermission('actions', 'update')]};
|
|
@@ -60,6 +112,16 @@ export async function actionsRoutes(fastify) {
|
|
|
60
112
|
}
|
|
61
113
|
});
|
|
62
114
|
|
|
115
|
+
// `_count`: slugify() turns '_' into '-', so no action slug can shadow it.
|
|
116
|
+
fastify.get('/actions/_count', canRead, async (request) => {
|
|
117
|
+
try {
|
|
118
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
119
|
+
return actionsBadge((await listActions()).filter(a => canSeeArtefact(request.user, a)));
|
|
120
|
+
} catch {
|
|
121
|
+
return actionsBadge(null);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
63
125
|
// Static sub-route declared before parameterised :slug for radix-tree priority
|
|
64
126
|
fastify.get('/actions/collection/:collectionSlug', canRead, async (request, reply) => {
|
|
65
127
|
try {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* API Endpoints API (admin management of custom endpoint definitions)
|
|
3
3
|
*
|
|
4
4
|
* GET /api/api-endpoints - list definitions visible to the caller
|
|
5
|
+
* GET /api/api-endpoints/_count - the sidebar badge (ids are uuids - `_count` is never one)
|
|
5
6
|
* GET /api/api-endpoints/:id - single definition (the builder editor)
|
|
6
7
|
* POST /api/api-endpoints - create (400 validation, 409 duplicate path shape)
|
|
7
8
|
* PUT /api/api-endpoints/:id - update (project binding immutable)
|
|
@@ -42,6 +43,43 @@ async function canUseCollection(user, slug) {
|
|
|
42
43
|
return canSeeArtefact(user, schema);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The sidebar badge on API Builder (its countUrl): how many endpoints the
|
|
48
|
+
* caller can see, with the public ones called out - they answer anyone, so
|
|
49
|
+
* they are the ones worth a glance. Plain text throughout: the sidebar
|
|
50
|
+
* popover sets it with textContent.
|
|
51
|
+
*
|
|
52
|
+
* @param {object[]} endpoints - sanitised definitions
|
|
53
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
54
|
+
*/
|
|
55
|
+
export function endpointsBadge(endpoints = []) {
|
|
56
|
+
const list = [...endpoints].sort((a, b) => String(a.project).localeCompare(String(b.project))
|
|
57
|
+
|| String(a.path).localeCompare(String(b.path)));
|
|
58
|
+
const n = list.length;
|
|
59
|
+
const off = list.filter(e => e.enabled === false).length;
|
|
60
|
+
const pub = list.filter(e => e.enabled !== false && e.auth === 'public').length;
|
|
61
|
+
const url = (e) => `/api/x/${e.project}${e.path}`;
|
|
62
|
+
return {
|
|
63
|
+
count: n,
|
|
64
|
+
label: `${n} endpoint${n === 1 ? '' : 's'}${pub ? `, ${pub} public` : ''}${off ? `, ${off} disabled` : ''}`,
|
|
65
|
+
tone: 'info',
|
|
66
|
+
details: {
|
|
67
|
+
title: 'API Builder',
|
|
68
|
+
rows: [
|
|
69
|
+
{label: 'Enabled', value: String(n - off)},
|
|
70
|
+
{label: 'Public', value: String(pub)},
|
|
71
|
+
{label: 'Disabled', value: String(off)}
|
|
72
|
+
],
|
|
73
|
+
items: list.slice(0, 8).map(e => ({
|
|
74
|
+
text: `GET ${url(e)}`,
|
|
75
|
+
meta: [e.name, e.auth === 'public' ? 'public' : e.auth === 'token' ? 'API token' : `role ${e.auth}`,
|
|
76
|
+
e.enabled === false ? 'disabled' : ''].filter(Boolean).join(' · ')
|
|
77
|
+
})),
|
|
78
|
+
empty: 'No endpoints yet.'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
/**
|
|
46
84
|
* Register the api-endpoints routes.
|
|
47
85
|
*
|
|
@@ -64,6 +102,9 @@ export async function apiEndpointsRoutes(fastify, opts = {}) {
|
|
|
64
102
|
return listEndpointsSanitised(request.user);
|
|
65
103
|
});
|
|
66
104
|
|
|
105
|
+
// Before /:id in intent (Fastify matches the static segment first anyway).
|
|
106
|
+
fastify.get('/api-endpoints/_count', canRead, async (request) => endpointsBadge(await listEndpointsSanitised(request.user)));
|
|
107
|
+
|
|
67
108
|
fastify.get('/api-endpoints/:id', canRead, async (request, reply) => {
|
|
68
109
|
const def = await getEndpointSanitised(request.params.id);
|
|
69
110
|
if (!def) return reply.status(404).send({error: 'Endpoint not found'});
|
|
@@ -1,15 +1,150 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Blocks API
|
|
3
3
|
* CRUD for reusable HTML block templates stored in content/blocks/*.html.
|
|
4
|
-
* GET /api/blocks - list all blocks
|
|
4
|
+
* GET /api/blocks - list all blocks (each with hasCss, description, example;
|
|
5
|
+
* ?usage=1 adds where each is used: pages and views)
|
|
6
|
+
* GET /api/blocks/_count - the sidebar badge (`_` is outside the block-name alphabet)
|
|
5
7
|
* GET /api/blocks/:name - get block content
|
|
6
8
|
* GET /api/blocks/:name/export - download block as .dmblock.json bundle
|
|
7
9
|
* POST /api/blocks/import - import a .dmblock.json bundle
|
|
8
10
|
* PUT /api/blocks/:name - create or update block
|
|
9
11
|
* DELETE /api/blocks/:name - delete block
|
|
10
12
|
*/
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
11
15
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
12
16
|
import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
|
|
17
|
+
import {config} from '../../config.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A block's own doc comment - the one every block opens with - read for the
|
|
21
|
+
* list: its first line ("contact-card - Contact entry summary card.") gives a
|
|
22
|
+
* description, and the line after "Example:" a shortcode to copy. Plain text;
|
|
23
|
+
* a block without a comment gives neither.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} html
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @returns {{description: string, example: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function blockDoc(html, name = '') {
|
|
30
|
+
const m = /^\s*<!--([\s\S]*?)-->/.exec(String(html || ''));
|
|
31
|
+
if (!m) return {description: '', example: ''};
|
|
32
|
+
const lines = m[1].split('\n').map(l => l.trim());
|
|
33
|
+
// The first paragraph - a description may wrap over several lines.
|
|
34
|
+
const start = lines.findIndex(Boolean);
|
|
35
|
+
const para = [];
|
|
36
|
+
// …ending at a blank line or the next heading ("Example:", "Parameters (all optional):").
|
|
37
|
+
for (let i = Math.max(0, start); start >= 0 && i < lines.length && lines[i]; i++) {
|
|
38
|
+
if (i > start && /^[\w ()/-]+:$/.test(lines[i])) break;
|
|
39
|
+
para.push(lines[i]);
|
|
40
|
+
}
|
|
41
|
+
const first = para.join(' ');
|
|
42
|
+
const lead = new RegExp(`^${name.replace(/[-]/g, '\\-')}\\s*[-–:]\\s*`, 'i');
|
|
43
|
+
const description = name && lead.test(first) ? first.replace(lead, '') : (/^[\w-]+\s*[-–:]\s*/.test(first) ? first.replace(/^[\w-]+\s*[-–:]\s*/, '') : first);
|
|
44
|
+
// The example may wrap over several lines: from its `[` to the line ending `]`.
|
|
45
|
+
let example = '';
|
|
46
|
+
const at = lines.findIndex(l => /^example:?$/i.test(l));
|
|
47
|
+
if (at >= 0) {
|
|
48
|
+
const from = lines.findIndex((l, i) => i > at && l.startsWith('['));
|
|
49
|
+
if (from > 0) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
for (let i = from; i < lines.length && lines[i]; i++) {
|
|
52
|
+
parts.push(lines[i]);
|
|
53
|
+
if (lines[i].endsWith(']')) break;
|
|
54
|
+
}
|
|
55
|
+
example = parts.join(' ');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {description: description.slice(0, 200), example: example.slice(0, 300)};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape a block name for a RegExp. Names are [a-z0-9-] so only `-` needs care. */
|
|
62
|
+
const reName = (n) => n.replace(/-/g, '\\-');
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where each block is used, by reading - not rendering - the page sources and
|
|
66
|
+
* view configs: `block="x"` (collection/view/carousel/listgroup displays),
|
|
67
|
+
* `template="x"` (the [block] shortcode), the `[x …/]` short tag, and a view's
|
|
68
|
+
* `display.block`. Cheap enough for the list and the badge; a block pulled in
|
|
69
|
+
* only by a plugin's own code is not seen (so "unused" means "not used by a
|
|
70
|
+
* page or view").
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} names
|
|
73
|
+
* @param {{url: string, raw: string}[]} pages
|
|
74
|
+
* @param {{slug: string, name?: string, display?: {block?: string}}[]} views
|
|
75
|
+
* @returns {Record<string, {pages: string[], views: string[]}>}
|
|
76
|
+
*/
|
|
77
|
+
export function blockUsage(names, pages = [], views = []) {
|
|
78
|
+
const out = Object.fromEntries(names.map(n => [n, {pages: [], views: []}]));
|
|
79
|
+
for (const n of names) {
|
|
80
|
+
const re = new RegExp(`(?:\\b(?:block|template)\\s*=\\s*["']${reName(n)}["'])|(?:\\[${reName(n)}(?=[\\s/\\]]))`);
|
|
81
|
+
for (const p of pages) if (re.test(p.raw)) out[n].pages.push(p.url);
|
|
82
|
+
for (const v of views) if (v?.display?.block === n) out[n].views.push(v.slug || v.name || '');
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every page's source, with its URL - read from disk, never rendered. */
|
|
88
|
+
async function readPageSources() {
|
|
89
|
+
const root = path.join(config.content.contentDir, 'pages');
|
|
90
|
+
const out = [];
|
|
91
|
+
async function walk(dir) {
|
|
92
|
+
let entries = [];
|
|
93
|
+
try { entries = await fs.readdir(dir, {withFileTypes: true}); } catch { return; }
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
const full = path.join(dir, e.name);
|
|
96
|
+
if (e.isDirectory()) await walk(full);
|
|
97
|
+
else if (e.name.endsWith('.md')) {
|
|
98
|
+
const rel = path.relative(root, full).replace(/\.md$/, '').split(path.sep).join('/');
|
|
99
|
+
const url = rel === 'index' ? '/' : '/' + rel.replace(/\/index$/, '');
|
|
100
|
+
try { out.push({url, raw: await fs.readFile(full, 'utf8')}); } catch { /* unreadable - skip */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await walk(root);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function usageFor(names) {
|
|
109
|
+
const [pages, views] = await Promise.all([
|
|
110
|
+
readPageSources(),
|
|
111
|
+
import('../../services/views.js').then(m => m.listViews()).catch(() => [])
|
|
112
|
+
]);
|
|
113
|
+
return blockUsage(names, pages, views);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The sidebar badge on Blocks: how many there are, with the unused ones and
|
|
118
|
+
* the latest changes in the popover. A count to glance at, never a warning.
|
|
119
|
+
* Plain text throughout - the popover sets it with textContent.
|
|
120
|
+
*
|
|
121
|
+
* @param {{name: string, updatedAt?: string, hasCss?: boolean, bundled?: boolean, usage?: {pages: string[], views: string[]}}[]} blocks
|
|
122
|
+
*/
|
|
123
|
+
export function blocksBadge(blocks = []) {
|
|
124
|
+
const n = blocks.length;
|
|
125
|
+
const used = (b) => (b.usage?.pages?.length || 0) + (b.usage?.views?.length || 0);
|
|
126
|
+
const unused = blocks.filter(b => b.usage && !used(b)).length;
|
|
127
|
+
const recent = [...blocks].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, 6);
|
|
128
|
+
return {
|
|
129
|
+
count: n,
|
|
130
|
+
label: `${n} block${n === 1 ? '' : 's'}${unused ? `, ${unused} not used on any page` : ''}`,
|
|
131
|
+
tone: 'info',
|
|
132
|
+
details: {
|
|
133
|
+
title: 'Blocks',
|
|
134
|
+
rows: [
|
|
135
|
+
{label: 'Blocks', value: String(n)},
|
|
136
|
+
{label: 'With CSS', value: String(blocks.filter(b => b.hasCss).length)},
|
|
137
|
+
{label: 'Not used on a page or view', value: String(unused)},
|
|
138
|
+
{label: 'Built in', value: String(blocks.filter(b => b.bundled).length)}
|
|
139
|
+
],
|
|
140
|
+
items: recent.map(b => ({
|
|
141
|
+
text: b.name,
|
|
142
|
+
meta: [b.updatedAt ? `changed ${String(b.updatedAt).slice(0, 10)}` : '', b.usage ? `used ${used(b)} time${used(b) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')
|
|
143
|
+
})),
|
|
144
|
+
empty: 'No blocks yet.'
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
13
148
|
|
|
14
149
|
/** Current export bundle schema version. Bump when the shape changes. */
|
|
15
150
|
const BUNDLE_FORMAT = 1;
|
|
@@ -19,18 +154,45 @@ export async function blocksRoutes(fastify) {
|
|
|
19
154
|
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
20
155
|
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
|
|
21
156
|
|
|
22
|
-
|
|
23
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The blocks this user may see, each with what the list shows beyond the
|
|
159
|
+
* file: whether it has CSS, and its doc comment's description and example.
|
|
160
|
+
* getBlock was already read per block for the project check, so these come free.
|
|
161
|
+
*/
|
|
162
|
+
async function visibleBlocks(user) {
|
|
24
163
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
for (const b of all) {
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const b of await listBlocks()) {
|
|
28
166
|
// listBlocks returns metadata only; getBlock returns full record with meta
|
|
29
167
|
let full = null;
|
|
30
168
|
try { full = await getBlock(b.name); } catch { /* skip */ }
|
|
31
|
-
if (canSeeArtefact(
|
|
169
|
+
if (!canSeeArtefact(user, full)) continue;
|
|
170
|
+
out.push({...b, hasCss: Boolean(full?.css), cssSize: full?.css?.length || 0, ...blockDoc(full?.content, b.name)});
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// List all blocks (?usage=1: where each is used)
|
|
176
|
+
fastify.get('/blocks', canRead, async (request) => {
|
|
177
|
+
const blocks = await visibleBlocks(request.user);
|
|
178
|
+
if (request.query?.usage !== '1') return blocks;
|
|
179
|
+
const usage = await usageFor(blocks.map(b => b.name));
|
|
180
|
+
return blocks.map(b => ({...b, usage: usage[b.name]}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// The sidebar badge. Registered before /blocks/:name; `_` is not a valid block name.
|
|
184
|
+
// Usage reads every page's source, and the badge is asked for every minute from
|
|
185
|
+
// every admin tab: it is kept a minute (the list's ?usage=1 always reads afresh).
|
|
186
|
+
let usageCache = {at: 0, key: '', value: null};
|
|
187
|
+
fastify.get('/blocks/_count', canRead, async (request) => {
|
|
188
|
+
const blocks = await visibleBlocks(request.user);
|
|
189
|
+
const names = blocks.map(b => b.name);
|
|
190
|
+
const key = names.join('\n');
|
|
191
|
+
if (!usageCache.value || usageCache.key !== key || Date.now() - usageCache.at > 60_000) {
|
|
192
|
+
usageCache = {at: Date.now(), key, value: await usageFor(names)};
|
|
32
193
|
}
|
|
33
|
-
|
|
194
|
+
const usage = usageCache.value;
|
|
195
|
+
return blocksBadge(blocks.map(b => ({...b, usage: usage[b.name]})));
|
|
34
196
|
});
|
|
35
197
|
|
|
36
198
|
// Get single block
|