domma-cms 0.75.0 → 0.77.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 +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/action-editor-arrange.js +1 -0
- package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/block-editor-arrange.js +2 -0
- package/admin/js/lib/collection-editor-arrange.js +3 -0
- package/admin/js/lib/collection-entries-arrange.js +1 -0
- package/admin/js/lib/component-editor-arrange.js +27 -0
- package/admin/js/lib/context-menu-editor-arrange.js +1 -0
- package/admin/js/lib/dashboard-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/form-editor-arrange.js +1 -0
- package/admin/js/lib/form-submissions-arrange.js +2 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +2 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/page-editor-arrange.js +1 -0
- package/admin/js/lib/plugin-code-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/project-page-arrange.js +1 -0
- package/admin/js/lib/role-editor-arrange.js +3 -0
- package/admin/js/lib/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/user-editor-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/view-editor-arrange.js +14 -0
- package/admin/js/templates/action-editor.html +223 -147
- package/admin/js/templates/api-endpoint-editor.html +172 -117
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/block-editor.html +194 -203
- package/admin/js/templates/collection-editor.html +191 -113
- package/admin/js/templates/collection-entries.html +99 -36
- package/admin/js/templates/component-editor.html +128 -126
- package/admin/js/templates/context-menu-editor.html +208 -148
- package/admin/js/templates/dashboard.html +184 -22
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/form-editor.html +41 -26
- package/admin/js/templates/form-submissions.html +78 -26
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/menu-editor.html +57 -17
- package/admin/js/templates/menu-locations.html +54 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/page-editor.html +47 -26
- package/admin/js/templates/plugin-code.html +91 -73
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/project-detail.html +83 -145
- package/admin/js/templates/project-settings.html +88 -53
- package/admin/js/templates/role-editor.html +158 -83
- package/admin/js/templates/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/user-editor.html +150 -14
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/view-editor.html +203 -180
- package/admin/js/templates/view-preview.html +45 -45
- package/admin/js/views/action-editor.js +7 -1
- package/admin/js/views/api-endpoint-editor.js +2 -3
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/block-editor.js +14 -8
- package/admin/js/views/collection-editor.js +64 -5
- package/admin/js/views/collection-entries.js +50 -1
- package/admin/js/views/component-editor.js +2 -28
- package/admin/js/views/context-menu-editor.js +54 -55
- package/admin/js/views/dashboard.js +7 -1
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +11 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +21 -43
- package/admin/js/views/menu-locations.js +16 -1
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/plugin-code.js +23 -4
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/project-detail.js +9 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +11 -3
- package/admin/js/views/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/collections.js +13 -1
- package/server/routes/api/dashboard.js +32 -4
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +104 -13
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/users.js +65 -3
- package/server/routes/api/views.js +55 -1
- package/server/server.js +12 -0
- package/server/services/components.js +9 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +16 -2
- package/server/services/views.js +14 -0
- package/admin/js/templates/dashboard/activity-feed.html +0 -3
- package/admin/js/templates/dashboard/cache.html +0 -32
- package/admin/js/templates/dashboard/health-detail.html +0 -2
- package/admin/js/templates/dashboard/journeys.html +0 -17
- package/admin/js/templates/dashboard/kpi-strip.html +0 -34
- package/admin/js/templates/dashboard/spike-feed.html +0 -3
- package/admin/js/templates/dashboard/top-pages.html +0 -3
- package/admin/js/templates/dashboard/traffic-chart.html +0 -3
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/block-editor-enhance.js +0 -1
- package/admin/js/views/dashboard/lib/escape.js +0 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
- package/admin/js/views/dashboard/widgets/cache.js +0 -1
- package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
- package/admin/js/views/dashboard/widgets/journeys.js +0 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
- package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
- package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
- package/admin/js/views/plugin-marketplace.js +0 -1
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import{api as u}from"../api.js";import{reportRestart as ze}from"../lib/server-restart.js";import{createSimpleEditor as He}from"../lib/simple-editor.js";import{languageForFile as Ee}from"../lib/syntax-highlight.js";import{addBannerAction as K}from"../lib/plugin-chrome.js";import{helpPopovers as Se}from"../lib/help-popover.js";import{onceClosed as Ue,openMenuAt as Z}from"../lib/tool-kit.js";import{REQUIRED_FILES as ee,checkPath as Ve,draftOf as Ye,fileRows as We,fileScope as te,restorePlan as _e}from"../lib/plugin-code-arrange.js";const Qe=/^\/plugins\/code\/[a-z0-9-]+\/?(\?.*)?$/,L=r=>`plugin-code.draft.${r}`,Ge=`
|
|
2
|
+
<div class="pcx-item" data-key="{{key}}">
|
|
3
|
+
<div class="pcx-dir" data-bind-hidden="!isDir">{{label}}</div>
|
|
4
|
+
<div class="pcx-file" data-bind-hidden="isDir" data-bind-class="cls" data-path="{{path}}" tabindex="0" title="{{path}} - {{meta}}">
|
|
5
|
+
<span class="pcx-name">{{label}}</span>
|
|
6
|
+
<span class="pcx-dirty" data-bind-hidden="!dirty" title="Unsaved changes">\u25CF</span>
|
|
7
|
+
<button type="button" class="pcx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>`,T=r=>String(r??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Je(r){const d=document.createElement("template");return d.innerHTML=r.trim(),d.content.firstElementChild}function $e(r,d){const s=r.querySelector(".dm-plugin-banner-name");if(!s)return;const p=document.createElement("span");p.className="dm-plugin-banner-parent",p.textContent="Marketplace";const v=document.createElement("span");v.className="dm-plugin-banner-sep",v.setAttribute("aria-hidden","true"),v.textContent="\u203A",s.replaceChildren(p,v,document.createTextNode(d))}let f=null,Me=!1;function Xe(){Me||!window.R?.use||(Me=!0,R.use((r,d,s)=>{const v=Qe.test(d?.path||"")&&(r?.path||"")!==(d?.path||"")&&f?f.dirtyCount():0;return v?E.confirm(`${v===1?"A file has":`${v} files have`} unsaved changes. Leave anyway? They are kept as a draft for your next visit to this plugin's code.`).then(c=>{c?s():f&&history.replaceState(null,"",f.hash)}):s()}),M.subscribe("router:afterChange",({to:r}={})=>{if(f&&(r?.path||"")!==f.path){const d=f;f=null,d.dispose()}}))}export const pluginCodeView={templateUrl:"/admin/js/templates/plugin-code.html",async onMount(r){if(Xe(),f){const e=f;f=null,e.dispose()}const d=window.location.hash.match(/\/plugins\/code\/([a-z0-9-]+)/),s=d?d[1]:null;if(!s){E.toast("No plugin specified.",{type:"error"}),R.navigate("/plugins");return}const p=r.get(0).querySelector(".pcx")||r.get(0),v=[],c=e=>(v.push(e),e);let z=!1;const m=()=>!z&&p.isConnected,b=M.observable([]),ne=M.observable(!0),H=M.observable(""),ae=M.observable(""),o=M.observable(null),U=M.observable(0),w=M.observable(!1),A=M.observable("Ctrl+S saves the open file, Ctrl+Shift+S every unsaved file."),j=M.observable(""),V=M.observable(!1),i=new Map,k=()=>{U.value=U.peek()+1},F=e=>i.has(e)&&i.get(e).content!==i.get(e).saved,N=()=>[...i.keys()].filter(F),q=M.computed(()=>(U.value,new Set(N()))),se=M.observable([]);c(M.effect(()=>{se.value=We(b.value,{openPath:o.value,dirty:q.value,query:ae.value})}));const Ce={files:b,loading:ne,loadError:H,query:ae,openPath:o,saving:w,status:A,cursor:j,restartPending:V,rows:se,currentDirty:M.computed(()=>!!o.value&&q.value.has(o.value)),dirtyCount:M.computed(()=>q.value.size),dirtyText:M.computed(()=>q.value.size===1?"1 unsaved file":`${q.value.size} unsaved files`),currentText:M.computed(()=>{const e=o.value;return e?e+(Ee(e)?"":" (plain text)"):"Select a file"}),scopeLabel:M.computed(()=>o.value?te(o.value).label:""),scopeHint:M.computed(()=>o.value?te(o.value).hint:""),scopeCls:M.computed(()=>o.value&&te(o.value).kind==="server"?"is-warn":"is-info"),emptyText:M.computed(()=>b.value.length?"No files match.":"This plugin has no editable files.")};async function x(){try{const e=await u.plugins.files(s);if(!m())return;b.value=e.files||[],H.value=""}catch(e){H.value=`Could not list the files: ${e.message||e}`}finally{ne.value=!1}}const Y=p.querySelector("#pcode-editor");let h=null;c(()=>{try{h?.destroy()}catch{}h=null});function Re(e,t){try{h?.destroy()}catch{}Y.replaceChildren(),h=He(Y,{initialValue:t,language:Ee(e),autoIndent:!0,onChange:n=>{const a=i.get(e);if(!a)return;const l=a.content!==a.saved;a.content=n,a.content!==a.saved!==l&&k()},onCursor:({line:n,col:a})=>{j.value=`Ln ${n}, Col ${a}`}}),h.focus()}async function g(e){if(!(!e||e===o.peek())){if(!i.has(e))try{const t=await u.plugins.readFile(s,e);i.set(e,{content:t.content,saved:t.content})}catch(t){E.toast(`Could not open ${e}: ${t.message||t}`,{type:"error"});return}m()&&(o.value=e,j.value="Ln 1, Col 1",Re(e,i.get(e).content),k())}}async function oe(e){const t=i.get(e),n=t.content,a=await u.plugins.writeFile(s,e,n);return t.saved=n,!!a?.restartRequired}function re(e,t){e&&(V.value=!0),A.value=e?`Saved ${t} - server code: restart the server to apply.`:`Saved ${t} - refresh the admin tab to see it.`,k(),x()}async function W(e=o.peek()){if(!(!e||!F(e)||w.peek())){w.value=!0;try{const t=await oe(e);E.toast(t?"Saved. Restart the server to apply.":"Saved.",{type:t?"warning":"success"}),re(t,e)}catch(t){E.toast(`Not saved: ${t.message||t}`,{type:"error"})}finally{w.value=!1}}}async function ie(){const e=N();if(!e.length||w.peek())return;w.value=!0;let t=!1;const n=[];for(const a of e)try{t=await oe(a)||t}catch(l){n.push(`${a}: ${l.message||l}`)}w.value=!1,n.length?E.toast(`Some files were not saved - ${n.join("; ")}`,{type:"error",duration:8e3}):E.toast(`Saved ${e.length===1?"1 file":`${e.length} files`}.`,{type:t?"warning":"success"}),re(t,e.length===1?e[0]:`${e.length-n.length} files`)}async function le(e=o.peek()){if(!e||!F(e)||!await E.confirm(`Throw away your changes to ${T(e)}? It goes back to what is saved.`))return;const t=i.get(e);t.content=t.saved,e===o.peek()&&h?.setValue(t.saved),k()}const _=new Set;c(()=>[..._].forEach(e=>{try{e.close(!0)}catch{}}));function Q({title:e,initial:t="",from:n="",submitText:a,help:l}){return new Promise(Pe=>{const P=M.observable(t),ge=M.computed(()=>Ve(P.value,{existing:b.value.map(D=>D.path),from:n})),Ae=M.computed(()=>n&&P.value.trim()===n),be={value:P,err:ge,cannot:M.computed(()=>!!ge.value||Ae.value),submitText:a,help:l},y=E.slideover({title:T(e),size:"sm",position:"right"}),$=Je(`
|
|
10
|
+
<div class="pcx-form">
|
|
11
|
+
<div>
|
|
12
|
+
<label class="form-label" for="pcx-f-path">File path
|
|
13
|
+
<span data-help="A path inside the plugin, e.g. admin/views/cart.js. Folders are made as needed. Only .js, .json, .md, .html, .css, .txt and .svg files can be edited here."></span></label>
|
|
14
|
+
<input id="pcx-f-path" type="text" class="form-input font-mono" spellcheck="false" autocomplete="off" data-model="value.value">
|
|
15
|
+
<small class="pcx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
|
|
16
|
+
<small class="pcx-hint" data-bind-text="help"></small>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="pcx-actions">
|
|
19
|
+
<span class="pcx-spacer"></span>
|
|
20
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
21
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="ok" data-bind-disabled="cannot.value" data-bind-text="submitText"></button>
|
|
22
|
+
</div>
|
|
23
|
+
</div>`);let we=null;const Ie=M.applyBindings(be,$,{methods:{ok:()=>{be.cannot.peek()||(we=P.peek().trim(),J=!0,y.close())},cancel:()=>y.close()}});y.element.appendChild($);const je=Se($);_.add(y),Ue(y,()=>{Ie.dispose(),je(),_.delete(y),Pe(we)});const Be=y.close.bind(y);let J=!1,X=!1;y.close=async D=>{if(!J&&D!==!0&&P.peek().trim()!==t.trim()){if(X)return;X=!0;const Ke=await E.confirm("Discard the path you typed?");if(X=!1,!Ke)return}return J=!0,Be()},$.querySelector("#pcx-f-path").addEventListener("keydown",D=>{D.key==="Enter"&&(D.preventDefault(),$.querySelector('[data-on-click="ok"]').click())}),I.scan($),y.open();const ke=$.querySelector("#pcx-f-path");ke.focus();const xe=t.lastIndexOf(".");xe>t.lastIndexOf("/")&&ke.setSelectionRange(t.lastIndexOf("/")+1,xe)})}async function G(e=""){const t=await Q({title:"New file",initial:e?`${e}/`:"admin/",submitText:"Create",help:"Created empty and opened. An admin/ file applies on a refresh; anything else on a restart."});if(!(!t||!m()))try{await u.plugins.writeFile(s,t,""),i.set(t,{content:"",saved:""}),await x(),await g(t)}catch(n){E.toast(`Could not create ${t}: ${n.message||n}`,{type:"error"})}}async function De(e){if(ee.includes(e))return;const t=await Q({title:`Rename ${e}`,initial:e,from:e,submitText:"Rename",help:"Your unsaved changes to it come along."});if(!t||t===e||!m())return;const n=i.get(e)||{content:(await u.plugins.readFile(s,e)).content,saved:null};try{await u.plugins.writeFile(s,t,n.content),await u.plugins.deleteFile(s,e),i.delete(e),i.set(t,{content:n.content,saved:n.content});const a=o.peek()===e;a&&(o.value=null),await x(),a&&await g(t),k(),E.toast(`Renamed to ${t}.`,{type:"success"})}catch(a){E.toast(`Rename failed: ${a.message||a}`,{type:"error"}),x()}}async function Le(e){const t=e.lastIndexOf("."),n=t>e.lastIndexOf("/")?`${e.slice(0,t)}-copy${e.slice(t)}`:`${e}-copy`,a=await Q({title:`Duplicate ${e}`,initial:n,submitText:"Duplicate",help:"The copy is saved with the text shown in the editor now."});if(!(!a||!m()))try{const l=i.get(e)?.content??(await u.plugins.readFile(s,e)).content;await u.plugins.writeFile(s,a,l),i.set(a,{content:l,saved:l}),await x(),await g(a)}catch(l){E.toast(`Could not duplicate: ${l.message||l}`,{type:"error"})}}async function Te(e){if(ee.includes(e))return;const t=F(e)?" Your unsaved changes to it go too.":"";if(await E.confirm(`Delete ${T(e)}? This cannot be undone.${t}`))try{if(await u.plugins.deleteFile(s,e),i.delete(e),o.peek()===e){o.value=null;try{h?.destroy()}catch{}h=null,Y.replaceChildren(),j.value=""}E.toast(`Deleted ${e}.`,{type:"success"}),k(),await x()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}}async function Fe(e){try{await navigator.clipboard.writeText(e),E.toast("Path copied.",{type:"success"})}catch{E.toast(e,{type:"info"})}}async function ce(){const e=N().length;if(await E.confirm((e?`${e===1?"A file has":`${e} files have`} unsaved changes - they will NOT be included. `:"")+"Restart the server now? Server-side plugin code applies when it starts. The admin is unreachable for a few seconds.",{title:"Restart server",confirmText:"Restart"}))try{const n=await u.plugins.restart();n.supervised||E.toast("No supervisor detected - if the server does not come back, start it by hand.",{type:"warning"}),await ze(n,{onStatus:a=>{A.value=a}}),V.value=!1}catch(n){E.toast(`Restart failed: ${n.message||n}`,{type:"error"})}}function ue(e){if(!e)return[];const t=ee.includes(e),n=F(e),a=e.includes("/")?e.slice(0,e.lastIndexOf("/")):"";return[{header:e},{label:"Open",icon:"file-text",disabled:o.peek()===e,action:()=>g(e)},{label:"Save",icon:"save",disabled:!n,action:()=>W(e)},{label:"Revert changes\u2026",icon:"rotate-ccw",disabled:!n,action:()=>le(e)},{type:"divider"},{label:"Copy path",icon:"copy",action:()=>Fe(e)},{label:`New file in ${a?`${a}/`:"the plugin"}\u2026`,icon:"plus",action:()=>G(a)},{label:"Duplicate\u2026",icon:"copy",action:()=>Le(e)},{label:t?"Rename - required file":"Rename\u2026",icon:"edit-3",disabled:t,action:()=>De(e)},{type:"divider"},{label:t?"Delete - required file":"Delete\u2026",icon:"trash",danger:!0,disabled:t,action:()=>Te(e)}]}const C=p.querySelector(".pcx-list");C.innerHTML=Ge;const Ne=M.applyBindings(Ce,p,{methods:{save:()=>W(),revert:()=>le(),newFile:()=>G(),restart:()=>ce()}});c(()=>Ne.dispose()),c(Se(p,{position:"bottom"}));const qe=E.contextMenu(C,{cursor:"glyph",match:".pcx-file",inherit:!1,density:"compact",items:e=>ue(e.dataset.path)});c(()=>qe?.destroy?.());const B=p.querySelector(".pcx-more"),Oe=E.contextMenu(B.parentElement,{cursor:"glyph",match:".pcx-more",inherit:!1,density:"compact",items:()=>ue(o.peek())});c(()=>Oe?.destroy?.());const de=()=>Z(B);B.addEventListener("click",de),c(()=>B.removeEventListener("click",de));const pe=e=>{const t=e.target.closest(".pcx-file");if(!t)return;const n=e.target.closest(".pcx-kebab");if(n){Z(n);return}g(t.dataset.path)},fe=e=>{const t=e.target.closest(".pcx-file");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.path))};C.addEventListener("click",pe),C.addEventListener("keydown",fe),c(()=>{C.removeEventListener("click",pe),C.removeEventListener("keydown",fe)});const ve=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!m()||e.target?.closest?.(".pcx-form")||(e.preventDefault(),e.stopPropagation(),e.shiftKey?ie():W())};document.addEventListener("keydown",ve,!0),c(()=>document.removeEventListener("keydown",ve,!0)),K(r,{id:"pcode-back",icon:"arrow-left",title:"Back to the Marketplace",onClick:()=>{location.hash="#/plugins"}}),K(r,{id:"pcode-new",icon:"plus",title:"New file",onClick:()=>G()}),K(r,{id:"pcode-saveall",icon:"save",title:"Save all (Ctrl+Shift+S)",onClick:()=>ie()}),K(r,{id:"pcode-restart",icon:"refresh-cw",title:"Restart server",onClick:()=>ce()});const me=()=>{const e=Ye(i);try{e?S.set(L(s),{...e,at:new Date().toISOString()}):S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}},ye=e=>{N().length&&(me(),e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ye),c(()=>window.removeEventListener("beforeunload",ye)),f={path:location.hash.replace(/^#/,"").split("?")[0]||`/plugins/code/${s}`,hash:location.hash,dirtyCount:()=>N().length,dispose(){z||(me(),z=!0,v.splice(0).reverse().forEach(e=>{try{e()}catch{}}))}},document.title=`${s} code - Domma CMS`,$e(r.get(0),s),u.plugins.list().then(e=>{const t=(Array.isArray(e)?e:[]).find(n=>n.name===s);t?.displayName&&m()&&($e(r.get(0),t.displayName),document.title=`${t.displayName} code - Domma CMS`)}).catch(()=>{}),I.scan(p),await x(),!m())return;let O=null;try{O=S.get(L(s))}catch{}if(O?.files&&Object.keys(O.files).length){try{S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}const e={};if(await Promise.all(Object.keys(O.files).map(async n=>{e[n]=await u.plugins.readFile(s,n).then(a=>a.content).catch(()=>null)})),!m())return;const t=_e(O,e);if(t.restore.length){const n=t.restore.length,a=[t.changed.length?` Changed on the server since: ${t.changed.map(T).join(", ")} - restoring puts your version back over it (unsaved until you save).`:"",t.gone.length?` No longer there: ${t.gone.map(T).join(", ")} (left out).`:""].join("");if(await E.confirm(`You left ${n===1?"a file":`${n} files`} unsaved here (${t.restore.map(l=>T(l.path)).join(", ")}). Restore ${n===1?"it":"them"}?${a}`)&&m()){for(const l of t.restore)i.set(l.path,{content:l.content,saved:l.saved});k(),await g(t.restore[0].path),A.value=`Restored ${n===1?"1 unsaved file":`${n} unsaved files`} - save to keep them.`;return}}}const he=b.peek().find(e=>e.path==="plugin.js")||b.peek()[0];he&&await g(he.path)}};
|
|
@@ -1,43 +1,101 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as Te}from"../lib/help-popover.js";import{onceClosed as He,openMenuAt as Ie,sidebarCount as Ye,viewLifetime as Je}from"../lib/tool-kit.js";import{pluginAccent as Ne}from"../lib/plugin-accent.js";import{reportRestart as Qe}from"../lib/server-restart.js";import{FILTERS as Ge,arrangeCatalogue as Xe,arrangePlugins as Ze,catalogueAction as oe,catalogueRow as et,checkScaffold as tt,fmtReleased as at,needsAttention as nt,pluginRow as st,pluginStatus as Ue,readSettings as lt,settingsFields as Oe,slugifyPlugin as it,updateFor as Ae}from"../lib/plugins-arrange.js";const Le=/^#\/plugins\/?(\?.*)?$/,ot="#/plugins",re="plugins.filter",X="plugins.draft",rt=`
|
|
2
|
+
<div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
|
|
3
|
+
<span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
|
|
4
|
+
<span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
|
|
5
|
+
<span class="plx-chips">
|
|
6
|
+
<span class="plx-chip is-warn" data-bind-hidden="!update" data-bind-text="updateText"></span>
|
|
7
|
+
<span class="plx-chip" data-bind-hidden="!entText" data-bind-class="entCls" data-bind-text="entText" title="{{entTitle}}"></span>
|
|
8
|
+
<span class="plx-chip is-info" data-bind-hidden="!licensed" title="Licensed - its source is not viewable here">Licensed</span>
|
|
9
|
+
<span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
|
|
10
|
+
</span>
|
|
11
|
+
<button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
|
|
12
|
+
</div>`,ct=`
|
|
13
|
+
<div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
|
|
14
|
+
<span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
|
|
15
|
+
<span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
|
|
16
|
+
<span class="plx-chips">
|
|
17
|
+
<span class="plx-chip" data-bind-hidden="!licText" data-bind-class="licCls" data-bind-text="licText"></span>
|
|
18
|
+
<span class="plx-chip">{{price}}</span>
|
|
19
|
+
</span>
|
|
20
|
+
<button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
|
|
21
|
+
<button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
|
|
22
|
+
</div>`,r=O=>String(O??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function ce(O){const p=document.createElement("template");return p.innerHTML=O.trim(),p.content.firstElementChild}const h=Je(Le);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(O){h.reset();const p=O.get(0).querySelector(".plx")||O.get(0),k=()=>Le.test(location.hash)&&p.isConnected,L=M.observable([]),b=M.observable({available:null,site:null,items:[]}),de=M.observable(!0),Z=M.observable(""),m=M.observable("");let ue="all";try{ue=Ge.includes(S.get(re))?S.get(re):"all"}catch{}const C=M.observable(ue),K=M.observable(""),R=M.observable("all"),pe=M.observable("");h.add(M.effect(()=>{const e=C.value;try{S.set(re,e)}catch{}}));const V=M.computed(()=>new Map((b.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(b.peek().items||[]).find(t=>t.slug===e),ee=M.observable([]);h.add(M.effect(()=>{const e=V.value;ee.value=Ze(L.value,{filter:C.value,query:K.value,latest:e}).map(t=>({...st(t,e),accent:Ne(t.name)}))}));const Re=M.computed(()=>{const e=L.value,t=V.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>nt(n,t)).length}}),_=M.computed(()=>(b.value.items||[]).filter(e=>oe(e)==="update").length),je=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),te=M.observable([]);h.add(M.effect(()=>{te.value=Xe(b.value.items,{filter:R.value,query:pe.value}).map(e=>{const t=et(e),a=t.action==="update"||t.action==="install";return{...t,accent:Ne(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const Fe=M.computed(()=>{const e=b.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>!t.installed&&t.entitled).length,updates:_.value}}),H=e=>L.peek().find(t=>t.name===e);async function ae(){try{const e=await U.plugins.list();if(!k())return;L.value=Array.isArray(e)?e:[],Z.value=""}catch(e){Z.value=`Could not load the plugins: ${e.message||e}`}finally{de.value=!1}}async function be(){try{const e=await U.marketplace.catalogue();if(!k())return;b.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{b.value={available:!1,site:null,items:[],failed:!0}}}const B=async()=>{await Promise.all([ae(),be()]),Ye(ot)};async function ne(e,t){if(e.core)return;const a=t?"on":"off";if(await E.confirm(`Switch ${r(e.displayName||e.name)} ${a}? The server restarts to apply it, which takes a few seconds.`)&&!m.peek()){m.value=`Switching ${e.displayName||e.name} ${a}\u2026`;try{const n=await U.plugins.update(e.name,{enabled:t});await Qe(n,{onStatus:l=>{m.value=l}}),E.toast(`${e.displayName||e.name} is ${a}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(n){E.toast(`Not switched ${a}: ${n.message||n}`,{type:"error"})}finally{m.value="",k()&&await B()}}}async function j(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await U.marketplace.install(e.slug,e.version,{update:t}),v=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${v} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await B()}}async function me(e,t){if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await U.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}k()&&await B()}}async function ve(e){try{await U.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const fe=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},q=p.querySelector(".plx-file");async function De(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await U.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",k()&&await B()}}const ge=async()=>{const e=q.files?.[0];q.value="",e&&await De(e)};q.addEventListener("change",ge),h.add(()=>q.removeEventListener("change",ge));const Y=new Set;let A=null,he=!1;function ye({title:e,size:t="md",panel:a,model:n,methods:l,dirty:v=()=>!1,form:u=null,cleanup:c=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const $=Te(a),x=M.applyBindings(n,a,{methods:l});u&&(A=u),Y.add(i),He(i,()=>{c(),x.dispose(),$(),Y.delete(i),A===u&&(A=null)});const w=i.close.bind(i);let f=!1,T=!1;return i.close=async F=>{if(!f&&F!==!0&&v()){if(T)return;T=!0;const D=await E.confirm("Discard the changes you have not saved?");if(T=!1,!D)return}return f=!0,w()},I.scan(a),i.open(),i}function se(e,t=null){const a=H(e);if(!a)return;const n=Ue(a),l=Oe(a),v=W(a.name),u=Ae(a,V.peek()),c=!!(u&&v?.entitled),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(d=>d.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>lt(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),T=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${u?` (v${u} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",at(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),D=(s,d)=>{const N=`plx-set-${d}`,Q=s.help?` <span data-help="${r(s.help)}"></span>`:"",z=`<label class="form-label" for="${N}">${r(s.label)}${Q}</label>`,P=`data-model="v${d}.value"`,G=`<small class="plx-err" data-bind-hidden="!err${d}.value" data-bind-text="err${d}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${z}<input id="${N}" type="checkbox" class="form-switch" ${P}></div>`;if(s.type==="select")return`<div>${z}<select id="${N}" class="form-input" ${P}>${s.options.map(Me=>`<option value="${r(Me.value)}">${r(Me.label)}</option>`).join("")}</select>${G}</div>`;if(s.type==="json")return`<div>${z}<textarea id="${N}" class="form-input plx-json" rows="5" spellcheck="false" ${P}></textarea>${G}</div>`;if(s.type==="textarea")return`<div>${z}<textarea id="${N}" class="form-input" rows="3" ${P}></textarea>${G}</div>`;const Ve=s.type==="number"?"number":s.secret?"password":"text",We=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${z}<input id="${N}" type="${Ve}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${We} ${P}>${G}</div>`},o={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,d)=>[[`v${d}`,x[s.key]],[`err${d}`,M.computed(()=>w.value.errors[s.key]||"")]]))},g=ce(`
|
|
23
|
+
<div class="plx-form">
|
|
24
|
+
<p class="plx-desc"></p>
|
|
25
|
+
<dl class="plx-facts"></dl>
|
|
26
|
+
<div data-if="hasFields">
|
|
27
|
+
<p class="plx-sub">Settings
|
|
28
|
+
<span data-help-title="Settings" data-help="Saved to config/plugins.json for this site. Only what you change is stored - anything you leave keeps the plugin's own default, so a later version can improve it. Most plugins read their settings as they run; a few only at start-up."></span></p>
|
|
29
|
+
<div class="plx-form">${l.map(D).join("")}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<p class="plx-hint" data-if="!hasFields">This plugin has no settings to change here.</p>
|
|
32
|
+
<div class="plx-actions">
|
|
33
|
+
${a.core?"":`<button type="button" class="btn btn-sm btn-ghost" data-on-click="toggle"><span data-icon="${a.enabled?"pause":"play"}" data-icon-size="13"></span> ${a.enabled?"Switch off":"Switch on"}</button>`}
|
|
34
|
+
${c?`<button type="button" class="btn btn-sm btn-ghost" data-on-click="update"><span data-icon="download" data-icon-size="13"></span> Update to v${r(u)}</button>`:""}
|
|
35
|
+
${a.sourceEditable!==!1?'<button type="button" class="btn btn-sm btn-ghost" data-on-click="source"><span data-icon="code" data-icon-size="13"></span> Source</button>':""}
|
|
36
|
+
${a.closedSource?"":'<button type="button" class="btn btn-sm btn-ghost" data-on-click="exportIt" title="Download as a .dcmsplugin file"><span data-icon="download" data-icon-size="13"></span> Export</button>'}
|
|
37
|
+
<span class="plx-spacer"></span>
|
|
38
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
|
|
39
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
|
|
40
|
+
<span data-icon="check" data-icon-size="13"></span> Save settings</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>`);g.querySelector(".plx-desc").textContent=a.description||"";const Ke=g.querySelector(".plx-facts");for(const[s,d]of F){const N=document.createElement("dt");N.textContent=s;const Q=document.createElement("dd");Q.textContent=d,Ke.append(N,Q)}async function Se(){if(o.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await U.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),J.close(!0),k()&&await ae()}catch(d){E.toast(`Not saved: ${d.message||d}`,{type:"error"})}finally{f.value=!1}}const ie=s=>async()=>{await J.close(),Y.has(J)||s()},J=ye({title:a.displayName||a.name,size:"md",panel:g,model:o,dirty:T,form:l.length?{kind:"settings",name:a.name,dirty:T,values:()=>{const s={};for(const d of l)d.secret||(s[d.key]=x[d.key].peek());return s}}:null,methods:{save:Se,cancel:()=>J.close(),toggle:ie(()=>ne(a,!a.enabled)),update:ie(()=>j(v,{update:!0})),source:ie(()=>fe(a)),exportIt:()=>ve(a)}});g.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Se())})}function xe(e=null){const t=_e(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.peek()])),v=()=>JSON.stringify(l())!==n||!!e;let u=!!e?.slug;const c=M.effect(()=>{const o=a.displayName.value;u||(a.slug.value=it(o))}),i=M.observable(!1),$=M.computed(()=>tt(Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.value])),L.value.map(o=>o.name))),x=o=>M.computed(()=>$.value.errors[o]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys($.value.errors).length>0)},f=ce(`
|
|
43
|
+
<div class="plx-form">
|
|
44
|
+
<div>
|
|
45
|
+
<label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
|
|
46
|
+
<input id="plx-np-name" type="text" class="form-input" maxlength="60" placeholder="Event Manager" data-model="displayName.value">
|
|
47
|
+
<small class="plx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
|
|
48
|
+
</div>
|
|
49
|
+
<div>
|
|
50
|
+
<label class="form-label" for="plx-np-slug">Slug
|
|
51
|
+
<span data-help="The plugin's folder under plugins/ and its id everywhere - lowercase letters, digits and hyphens, starting with a letter. Made from the name as you type."></span></label>
|
|
52
|
+
<input id="plx-np-slug" type="text" class="form-input font-mono" maxlength="40" data-model="slug.value">
|
|
53
|
+
<small class="plx-err" data-bind-hidden="!slugErr.value" data-bind-text="slugErr.value"></small>
|
|
16
54
|
</div>
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
<div>
|
|
56
|
+
<label class="form-label" for="plx-np-desc">Description</label>
|
|
57
|
+
<input id="plx-np-desc" type="text" class="form-input" maxlength="300" placeholder="What does this plugin do?" data-model="description.value">
|
|
58
|
+
<small class="plx-err" data-bind-hidden="!descErr.value" data-bind-text="descErr.value"></small>
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<label class="form-label" for="plx-np-author">Author</label>
|
|
62
|
+
<input id="plx-np-author" type="text" class="form-input" maxlength="80" data-model="author.value">
|
|
63
|
+
<small class="plx-err" data-bind-hidden="!authorErr.value" data-bind-text="authorErr.value"></small>
|
|
64
|
+
</div>
|
|
65
|
+
<div>
|
|
66
|
+
<label class="form-label" for="plx-np-icon">Icon
|
|
67
|
+
<span data-help="A Domma icon name, as used in data-icon - box, calendar, users\u2026"></span></label>
|
|
68
|
+
<input id="plx-np-icon" type="text" class="form-input font-mono" maxlength="40" data-model="icon.value">
|
|
69
|
+
<small class="plx-err" data-bind-hidden="!iconErr.value" data-bind-text="iconErr.value"></small>
|
|
70
|
+
</div>
|
|
71
|
+
<p class="plx-hint">Made from the built-in template - server routes, an admin view and a CLAUDE.md - and switched on. Restart the server to activate it. Ctrl+Enter creates.</p>
|
|
72
|
+
<div class="plx-actions">
|
|
73
|
+
<span class="plx-spacer"></span>
|
|
74
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
75
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
|
|
76
|
+
<span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>`);async function T(){if(w.cannot.peek())return;i.value=!0;const{payload:o}=$.peek();try{await U.plugins.scaffold(o),E.toast(`"${o.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await B()}catch(g){E.toast(g.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const F=ye({title:"New plugin",size:"sm",panel:f,model:w,dirty:v,form:{kind:"new",name:null,dirty:v,values:l},cleanup:()=>c(),methods:{create:T,cancel:()=>F.close()}}),D=f.querySelector("#plx-np-slug");D.addEventListener("input",()=>{u=!!D.value}),f.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),T())}),f.querySelector("#plx-np-name")?.focus()}function Be(e){const t=H(e);if(!t)return[];const a=W(t.name),n=Ae(t,V.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Oe(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>se(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>ne(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>ne(t,!0)}],...n?[{label:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled,action:()=>j(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>fe(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ve(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>me(t.name,t.displayName)}]]}function qe(e){const t=W(e);if(!t)return[];const a=oe(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>j(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>j(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{K.value=t.slug,C.value="all",$e.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>me(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[]]}function ze(e){const t=W(e);if(!t)return;const a=oe(t);a==="install"?j(t):a==="update"?j(t,{update:!0}):t.installed&&(K.value=t.slug,C.value="all",$e.activate?.(0))}function we(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:c=>a(c.dataset[t])});h.add(()=>l?.destroy?.());const v=c=>{const i=c.target.closest(".plx-row");if(!i)return;const $=c.target.closest(".plx-kebab");if($){Ie($);return}if(c.target.closest(".plx-act")){c.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},u=c=>{const i=c.target.closest(".plx-row");i&&(c.key==="F10"&&c.shiftKey?(c.preventDefault(),Ie(i)):c.key==="Enter"&&c.target===i&&n(i.dataset[t]))};e.addEventListener("click",v),e.addEventListener("keydown",u),h.add(()=>{e.removeEventListener("click",v),e.removeEventListener("keydown",u)})}const le=p.querySelector(".plx-browse");le.appendChild(ce(`
|
|
79
|
+
<div class="plx-card">
|
|
80
|
+
<p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
|
|
81
|
+
<p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
|
|
82
|
+
<div data-bind-hidden="browseState.value !== 'file'">
|
|
83
|
+
<p style="margin:0 0 .4rem"><strong>This site installs plugins from a file.</strong></p>
|
|
84
|
+
<p class="plx-note">Buy a plugin and you are sent a single <code>.dcmsplugin</code> file - Install from file in the banner installs it, and the same button takes an update later. Sites managed for you browse and install here instead, over their manager connection.</p>
|
|
20
85
|
</div>
|
|
21
|
-
<div
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
</button>`}
|
|
86
|
+
<div data-bind-hidden="browseState.value !== 'list'">
|
|
87
|
+
<div class="plx-bar">
|
|
88
|
+
<div class="plx-seg" role="group" aria-label="Show">
|
|
89
|
+
<button type="button" data-on-click="bAll" data-bind-class="browseFilter.value === 'all' && 'is-on'">All <b data-bind-text="browseCounts.value.all"></b></button>
|
|
90
|
+
<button type="button" data-on-click="bAvailable" data-bind-class="browseFilter.value === 'available' && 'is-on'">Available <b data-bind-text="browseCounts.value.available"></b></button>
|
|
91
|
+
<button type="button" data-on-click="bInstalled" data-bind-class="browseFilter.value === 'installed' && 'is-on'">Installed <b data-bind-text="browseCounts.value.installed"></b></button>
|
|
92
|
+
<button type="button" data-on-click="bUpdates" data-bind-class="browseFilter.value === 'updates' && 'is-on'" data-bind-hidden="!browseCounts.value.updates">Updates <b data-bind-text="browseCounts.value.updates"></b></button>
|
|
93
|
+
</div>
|
|
94
|
+
<input type="search" class="form-input plx-search" placeholder="Search the catalogue\u2026" aria-label="Search the catalogue" data-model="browseQuery.value">
|
|
95
|
+
<span data-help-title="Browse" data-help="What this site's manager offers it. Install brings a plugin in licensed to this site; Update replaces the installed version and keeps its data. One not licensed yet is listed so you can see it exists - ask for it to be added. Either takes effect after a server restart."></span>
|
|
32
96
|
</div>
|
|
97
|
+
<p class="plx-note" data-bind-text="siteText.value" data-bind-hidden="!siteText.value"></p>
|
|
98
|
+
<p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
|
|
99
|
+
<div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
|
|
33
100
|
</div>
|
|
34
|
-
</div
|
|
35
|
-
<label class="form-label">${r(a.label)}</label>
|
|
36
|
-
<select class="form-control" name="${r(a.key)}">${b}</select>
|
|
37
|
-
</div>`}return a.type==="number"?`<div class="form-group">
|
|
38
|
-
<label class="form-label">${r(a.label)}</label>
|
|
39
|
-
<input class="form-control" type="number" name="${r(a.key)}" value="${r(String(u))}"${a.min!=null?` min="${a.min}"`:""}${a.max!=null?` max="${a.max}"`:""}>
|
|
40
|
-
</div>`:`<div class="form-group">
|
|
41
|
-
<label class="form-label">${r(a.label)}</label>
|
|
42
|
-
<input class="form-control" type="text" name="${r(a.key)}" value="${r(String(u))}">
|
|
43
|
-
</div>`}).join("")}</form>`;if(!await E.confirm(n,{title:`${r(t.displayName)} Settings`,confirmText:"Save",cancelText:"Cancel",html:!0}))return;const l=document.getElementById("plugin-settings-form");if(!l)return;const p={};t.settingsSchema.forEach(a=>{const u=l.elements[a.key];u&&(p[a.key]=a.type==="number"?Number(u.value):u.value)});try{await f.plugins.update(e,{settings:p}),t.settings={...t.settings,...p},E.toast("Settings saved.",{type:"success"})}catch(a){E.toast(`Failed to save: ${a.message}`,{type:"error"})}}),o.on("click",".btn-toggle-plugin",async function(){const e=$(this),t=e.data("name"),i=e.data("enabled")!==1&&e.data("enabled")!=="1";e.prop("disabled",!0).text(i?"Enabling\u2026":"Disabling\u2026");try{const n=await f.plugins.update(t,{enabled:i});E.toast(`Plugin ${i?"enabled":"disabled"}.`,{type:"success"}),await N(n,{onStatus:l=>e.text(l.slice(0,24))});const d=await f.plugins.list();y(s,d)}catch(n){E.toast(`Failed: ${n.message}`,{type:"error"}),e.prop("disabled",!1)}})}function r(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}
|
|
101
|
+
</div>`)),le.querySelector(".plx-catalogue").innerHTML=ct;const ke=p.querySelector(".plx-installed");ke.innerHTML=rt;const $e=E.tabs(p.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Pe=M.applyBindings({plugins:L,catalogue:b,loading:de,loadError:Z,restarting:m,filter:C,query:K,rows:ee,counts:Re,emptyText:je,updateCount:_,updateCountText:M.computed(()=>`${_.value} update${_.value===1?"":"s"}`),browseFilter:R,browseQuery:pe,browseRows:te,browseCounts:Fe,siteText:M.computed(()=>b.value.site?`Licensed to ${b.value.site}.`:""),browseState:M.computed(()=>b.value.available===null?"loading":b.value.failed?"error":b.value.available?"list":"file")},p,{methods:{showAll:()=>{C.value="all"},showOn:()=>{C.value="on"},showOff:()=>{C.value="off"},showAttention:()=>{C.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});h.add(()=>Pe.dispose()),h.add(Te(p,{position:"bottom"})),h.add(M.effect(()=>{ee.value,te.value,requestAnimationFrame(()=>{p.isConnected&&I.scan(p)})})),we(ke,"name",Be,e=>se(e)),we(le.querySelector(".plx-catalogue"),"slug",qe,ze),Ce(O,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>q.click()}),Ce(O,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>xe()});const Ee=e=>{(m.peek()||A?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ee),h.add(()=>window.removeEventListener("beforeunload",Ee)),h.add(()=>{if(A?.dirty())try{S.set(X,{kind:A.kind,name:A.name,values:A.values(),at:new Date().toISOString()})}catch{}he=!0,[...Y].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(p),await Promise.all([ae(),be()]),!k())return;let y=null;try{y=S.get(X)}catch{}if(y?.values&&!he){try{S.remove?S.remove(X):S.set(X,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(H(y.name)?.displayName||y.name)}`;(y.kind==="new"||H(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(y.kind==="new"?xe(y.values):se(y.name,y.values))}}};
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
import{api as b}from"../api.js";import{openQuickCreate as
|
|
1
|
+
import{api as b}from"../api.js";import{openQuickCreate as z,INLINE_TYPES as K}from"../lib/project-quick-create.js";import{addBannerAction as j}from"../lib/plugin-chrome.js";import{helpPopovers as V}from"../lib/help-popover.js";import{openMenuAt as U,viewLifetime as Q}from"../lib/tool-kit.js";import{countsOf as N,projectFromPath as Y,relTime as _,tilesFor as G}from"../lib/project-page-arrange.js";const P=/^#\/projects\/(?!new(?:[/?#]|$))[^/?#]+\/?(\?.*)?$/,c=Q(P),J=`
|
|
2
|
+
<div class="pdx-tile" data-key="{{key}}" tabindex="0" data-bind-class="cls" style="--c: {{tone}}">
|
|
3
|
+
<span class="pdx-tile-chip" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="20"></span></span>
|
|
4
|
+
<span class="pdx-tile-body"><strong data-bind-text="countText"></strong><small>{{label}}</small></span>
|
|
5
|
+
<span class="pdx-tile-actions">
|
|
6
|
+
<button type="button" class="pdx-add" title="New {{singular}}" aria-label="New {{singular}}">+</button>
|
|
7
|
+
<button type="button" class="pdx-kebab" title="Actions" aria-label="Actions for {{label}}">\u22EE</button>
|
|
8
|
+
</span>
|
|
9
|
+
</div>`;export function setBannerSection(s,o){const a=s.querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(!a)return;const l=a.nextSibling;l&&l.nodeType===3?l.nodeValue=o:a.after(document.createTextNode(o))}export const projectDetailView={templateUrl:"/admin/js/templates/project-detail.html",async onMount(s){c.reset();const o=Y(location.hash);if(!o){location.hash="#/projects";return}const r=s.get(0).querySelector(".pdx")||s.get(0),a=()=>P.test(location.hash)&&r.isConnected&&Y(location.hash)===o,l=encodeURIComponent(o),h=M.observable(null),y=M.observable({}),v=M.observable(!0),g=M.observable(""),d=M.observable(!1),p=M.observable([]),w=M.observable("");c.add(M.effect(()=>{const e=G(y.value,{slug:o,inline:K});p.value=e.tiles,w.value=v.value?"":e.totalText}));const n=()=>h.value||{},q={loading:v,loadError:g,busy:d,tiles:p,totalText:w,name:M.computed(()=>n().name||o),description:M.computed(()=>n().description||""),off:M.computed(()=>h.value&&n().enabled===!1),builtIn:M.computed(()=>n().protected===!0),rootUrl:M.computed(()=>n().rootUrl||""),rootText:M.computed(()=>n().rootUrl?`Pages under ${n().rootUrl}`:"No root URL - pages are filed by hand"),created:M.computed(()=>n().createdAt?D(n().createdAt).format("D MMM YYYY"):""),creator:M.computed(()=>n().createdBy||""),updated:M.computed(()=>_(n().updatedAt)),updatedTitle:M.computed(()=>n().updatedAt?D(n().updatedAt).format("D MMM YYYY, HH:mm"):"")},C=()=>{try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}};async function u(){try{const[e,t]=await Promise.all([b.projects.get(o),b.projects.artefacts(o).catch(()=>({}))]);if(!a())return;h.value=e,y.value=N(t),g.value="",setBannerSection(s.get(0),e?.name||o);const m=r.querySelector(".pdx-hero-icon");if(m){m.replaceChildren();const k=document.createElement("span");k.setAttribute("data-icon",e?.icon||"folder"),k.setAttribute("data-icon-size","32"),m.appendChild(k),I.scan(m)}}catch(e){if(!a())return;if(/not found|404/i.test(String(e?.message||e))){E.toast("Project not found.",{type:"error"}),location.hash="#/projects";return}g.value=`Could not load the project: ${e.message||e}`}finally{v.value=!1}}async function $(){const e=await b.projects.artefacts(o).catch(()=>null);!a()||!e||(y.value=N(e),C())}async function H(){if(!d.peek()){d.value=!0;try{await b.projects.update(o,{enabled:!0}),E.toast(`"${n().name||o}" enabled - its public pages and API are back.`,{type:"success"}),C(),a()&&await u()}catch(e){E.toast(`Not enabled: ${e.message||e}`,{type:"error"})}finally{d.value=!1}}}const x=e=>p.peek().find(t=>t.key===e),B=()=>({slug:o,name:n().name||o,rootUrl:n().rootUrl||""});function f(e,{inEditor:t=!1}={}){if(e){if(e.inline&&!t){z({type:e.key,project:B(),onCreated:$});return}location.hash=e.newHash}}const A=e=>{e?.has&&(location.hash=e.listHash)},T=e=>{const t=x(e);t&&(t.has?A(t):f(t))};function F(e){const t=x(e);return t?[{header:`${t.label} \xB7 ${t.count}`},{label:"Open the list",icon:"list",disabled:!t.has,action:()=>A(t)},{label:`New ${t.singular}\u2026`,icon:"plus",action:()=>f(t)},...t.inline?[{label:`New ${t.singular} in its editor`,icon:"edit",action:()=>f(t,{inEditor:!0})}]:[]]:[]}const i=r.querySelector(".pdx-tiles");i.innerHTML=J;const O=M.applyBindings(q,r,{methods:{enable:H,retry:()=>u()}});c.add(()=>O.dispose()),c.add(V(r,{position:"bottom"})),c.add(M.effect(()=>{p.value,requestAnimationFrame(()=>{i.isConnected&&I.scan(i)})}));const R=E.contextMenu(i,{cursor:"glyph",match:".pdx-tile",inherit:!1,density:"compact",items:e=>F(e.dataset.key)});c.add(()=>R?.destroy?.());const L=e=>{const t=e.target.closest(".pdx-tile");if(t){if(e.target.closest(".pdx-kebab")){U(e.target.closest(".pdx-kebab"));return}if(e.target.closest(".pdx-add")){f(x(t.dataset.key));return}T(t.dataset.key)}},S=e=>{const t=e.target.closest(".pdx-tile");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),U(t)):e.key==="Enter"&&e.target===t&&T(t.dataset.key))};i.addEventListener("click",L),i.addEventListener("keydown",S),c.add(()=>{i.removeEventListener("click",L),i.removeEventListener("keydown",S)}),j(s,{id:"project-overview-settings",icon:"settings",title:"Project settings",onClick:()=>{location.hash=`#/projects/${l}/settings`}}),j(s,{id:"project-overview-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>u()}),j(s,{id:"project-overview-all",icon:"folder",title:"All projects",onClick:()=>{location.hash="#/projects"}}),I.scan(r),await u(),a()&&I.scan(r)}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as m}from"../api.js";import{makeIconInput as me}from"../lib/shortcode-modal.js";import{addBannerAction as B}from"../lib/plugin-chrome.js";import{helpPopovers as he}from"../lib/help-popover.js";import{sidebarCount as ve}from"../lib/tool-kit.js";import{countsOf as ne,draftKey as U,projectFromPath as ge,settingsForm as K,settingsPayload as ye,settingsRules as be,settingsSnapshot as re,untagSummary as ke}from"../lib/project-page-arrange.js";import{setBannerSection as ae}from"./project-detail.js";const N=n=>String(n??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),C=n=>String(n||"").replace(/^#/,"").split("?")[0].replace(/\/+$/,"");let c=null,se=!1;function je(){se||!window.R?.use||(se=!0,R.use((n,t,h)=>!c||C(t?.path)!==c.path||C(n?.path)===c.path||!c.dirty()?h():E.confirm("You have unsaved changes to this project. Leave anyway? They are kept as a draft for your next visit.").then($=>{$?h():history.replaceState(null,"",`#${t.path}`)})),M.subscribe("router:afterChange",({to:n}={})=>{if(c&&C(n?.path)!==c.path){const t=c;c=null,t.dispose()}}))}export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(n){if(je(),c){const e=c;c=null,e.dispose()}const t=ge(location.hash,{settings:!0});if(!t){location.hash="#/projects";return}const h=encodeURIComponent(t),$=`/projects/${h}/settings`,H=[],v=e=>(H.push(e),e);let Y=!1;const p=n.get(0).querySelector(".psx")||n.get(0),O=()=>!Y&&p.isConnected,V=E.loader(n.get(0),{type:"dots"});let g,W={};try{[g,W]=await Promise.all([m.projects.get(t),m.projects.artefacts(t).then(ne).catch(()=>({}))])}catch(e){V.destroy(),E.toast(`Could not load the project: ${e.message||e}`,{type:"error"}),location.hash="#/projects";return}if(V.destroy(),!p.isConnected)return;const r=M.observable(g),d=be(g),z=K(g),a=Object.fromEntries(Object.entries(z).map(([e,o])=>[e,M.observable(o)])),G=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),J=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),y=M.observable(z),x=M.computed(()=>re(y.value)),j=M.computed(()=>re(G())!==x.value),P=M.computed(()=>ye(G(),r.value)),Q=e=>M.computed(()=>P.value.errors[e]||""),b=M.observable(!1),l=M.observable(!1),w=M.observable(""),L=M.observable(W),k=M.computed(()=>ke(L.value)),ce={...a,saving:b,busy:l,dirty:j,errorText:w,slug:t,rootLocked:d.rootLocked,canToggle:d.canToggle,canDelete:d.canDelete,canUntag:d.canUntag,dangerShown:d.canDelete||d.canUntag,nameErr:Q("name"),rootErr:Q("rootUrl"),cannot:M.computed(()=>b.value||Object.keys(P.value.errors).length>0),filedText:M.computed(()=>{const e=k.value;if(!e.total)return"Nothing is filed under this project.";const o=r.value.rootUrl&&(L.value.pages||0)>0?` Pages under ${r.value.rootUrl} belong here by their address - untagging does not move them; clear the root URL (and save) for that.`:"";return`${e.total} filed here: ${e.text}.${o}`}),noneFiled:M.computed(()=>k.value.total===0),deleteBlocked:M.computed(()=>k.value.total>0||l.value),deleteHint:M.computed(()=>k.value.total?"Untag what is filed here first - a project can only be deleted once nothing is filed under it.":"Deleting removes the project record. It cannot be undone.")},T=()=>{try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}ve("#/projects")},F=()=>{try{S.remove?S.remove(U(t)):S.set(U(t),null)}catch{}};async function q(){if(b.peek())return;const{errors:e,payload:o}=P.peek();if(Object.keys(e).length){E.toast("Fix the marked fields first.",{type:"warning"});return}b.value=!0,w.value="";try{const s=await m.projects.update(t,o);if(!O())return;r.value=s||{...r.peek(),...o},y.value=K(r.peek());for(const[f,A]of Object.entries(y.peek()))a[f].value=A;F(),ae(n.get(0),r.peek().name||t),T(),E.toast("Project saved.",{type:"success"})}catch(s){w.value=s.message||"Could not save the project.",E.toast("Not saved - see the message at the top.",{type:"error"})}finally{b.value=!1}}async function ie(){await E.confirm("Throw away your unsaved changes to this project?")&&(X(y.peek()),w.value="")}function X(e){for(const[o,s]of Object.entries(e))a[o]&&(a[o].value=s);i&&i.input.value!==String(a.icon.peek())&&(i.input.value=String(a.icon.peek()))}async function Z(){const e=await m.projects.artefacts(t).then(ne).catch(()=>null);O()&&e&&(L.value=e)}async function le(){if(l.peek())return;const e=k.peek();if(!e.total)return;const o=N(r.peek().name||t);if(await E.confirm(`Untag everything filed under "${o}" (${N(e.text)})? It all stays where it is and belongs to Core from then on. The project itself is kept.`)){l.value=!0;try{const s=await m.projects.untagAll(t),f=Object.values(s?.untagged||{}).reduce((A,fe)=>A+(Number(fe)||0),0);E.toast(`Untagged ${f} artefact${f===1?"":"s"}.${r.peek().rootUrl?` Pages under ${r.peek().rootUrl} stay - they belong here by their address.`:""}`,{type:"success",duration:6e3}),T(),await Z()}catch(s){E.toast(`Untag failed: ${s.message||s}`,{type:"error"})}finally{l.value=!1}}}async function ue(){if(l.peek())return;const e=N(r.peek().name||t);if(await E.confirm(`Delete the project "${e}"? This cannot be undone.`)){l.value=!0;try{await m.projects.remove(t),F(),y.value=J(),E.toast(`Deleted "${r.peek().name||t}".`,{type:"success"}),T(),location.hash="#/projects"}catch(o){E.toast(`Not deleted: ${o.message||o}`,{type:"error",duration:7e3}),await Z()}finally{l.value=!1}}}const pe=p.querySelector(".psx-icon-mount"),i=me("folder, users, box\u2026",a.icon.peek());i.input.id="psx-icon",i.input.classList.add("form-input");const _=()=>{a.icon.value=i.input.value};i.input.addEventListener("input",_),v(()=>i.input.removeEventListener("input",_)),pe.appendChild(i.el),p.querySelector("#psx-slug").value=t;const de=M.applyBindings(ce,p,{methods:{save:q,discard:ie,untagAll:le,remove:ue}});v(()=>de.dispose()),v(he(p,{position:"bottom"})),ae(n.get(0),g.name||t),B(n,{id:"project-settings-overview",icon:"eye",title:"Project overview",onClick:()=>{location.hash=`#/projects/${h}`}}),B(n,{id:"project-settings-all",icon:"folder",title:"All projects",onClick:()=>{location.hash="#/projects"}}),B(n,{id:"project-settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>q()});const ee=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),q())};document.addEventListener("keydown",ee,!0),v(()=>document.removeEventListener("keydown",ee,!0));const te=()=>{if(j.peek())try{S.set(U(t),{form:J(),base:x.peek(),at:new Date().toISOString()})}catch{}},oe=e=>{j.peek()&&(te(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",oe),v(()=>window.removeEventListener("beforeunload",oe)),c={path:C($),dirty:()=>j.peek(),dispose(){Y=!0,te(),H.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p);let u=null;try{u=S.get(U(t))}catch{}if(u?.form){F();const e=u.base&&u.base!==x.peek(),o=u.at?` (${D(u.at).format("D MMM HH:mm")})`:"",s=e?`You left unsaved changes to this project${o}, but it has been changed since. Put your changes back over the current settings?`:`You left unsaved changes to this project${o}. Pick up where you left off?`;if(await E.confirm(s)&&O()){const f=K({...r.peek(),...u.form,enabled:u.form.enabled!==!1});X(f)}}}};
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import{api as O,apiRequest as Ue,getUser as Be}from"../api.js";import{addBannerAction as ke}from"../lib/plugin-chrome.js";import{helpPopovers as He}from"../lib/help-popover.js";import{openMenuAt as Se,sidebarCount as Pe}from"../lib/tool-kit.js";import{BADGE_OPTIONS as xe}from"../lib/roles-arrange.js";import{checkRole as Ke,expandPerms as Ge,formFromRole as z,matrixRows as _e,payloadFromForm as Ve,roleFacts as Ye,selfRisks as Je,setActions as Ee,snapshot as F,summaryLines as We,toggleAction as ze}from"../lib/role-editor-arrange.js";const Qe=/^\/roles\/edit\/[^/?#]+(\?.*)?$/,P=o=>String(o||"").split("?")[0],T=o=>`role-editor.draft.${o}`,w=["name","label","level","badgeClass","project","adminAllow","adminHome"],Xe=`
|
|
2
|
+
<div class="rex-row" data-bind-class="kind === 'group' ? 'is-group' : ''" data-id="{{id}}" data-kind="{{kind}}" data-key="{{key}}" data-group="{{group}}" tabindex="0">
|
|
3
|
+
<span class="rex-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="15"></span></span>
|
|
4
|
+
<span class="rex-main"><strong>{{label}}</strong><small>{{description}}</small></span>
|
|
5
|
+
<span class="rex-acts">{{#each actions key=key}}<button type="button" class="rex-act" data-act="{{key}}" aria-pressed="{{on}}" title="{{title}}">{{label}}</button>{{/each}}</span>
|
|
6
|
+
<button type="button" class="rex-full" data-state="{{state}}" data-bind-hidden="kind === 'group'" data-bind-disabled="locked" title="Every action in this area">Full</button>
|
|
7
|
+
<span class="rex-state">{{meta}}</span>
|
|
8
|
+
<button type="button" class="rex-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
|
|
9
|
+
</div>`,Ze="<li>{{text}}</li>",et='<div class="rex-sum"><strong>{{title}}</strong><span>{{text}}</span></div>',we=o=>String(o??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),Q=(o,c,p=`${c}s`)=>`${o} ${o===1?c:p}`;function Me(o,c){const p=o.querySelector(".dm-plugin-banner-name");if(!p)return;const d=p.querySelector(".dm-plugin-banner-parent"),C=p.querySelector(".dm-plugin-banner-sep");d&&C&&p.replaceChildren(d,C,document.createTextNode(c))}let v=null,Re=!1;function tt(){Re||!window.R?.use||(Re=!0,R.use((o,c,p)=>!(Qe.test(c?.path||"")&&P(o?.path)!==P(c?.path))||!v?.dirty()?p():E.confirm("This role has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(C=>{C?p():v&&history.replaceState(null,"",v.hash)})),M.subscribe("router:afterChange",({to:o}={})=>{if(v&&P(o?.path)!==v.path){const c=v;v=null,c.dispose()}}))}export const roleEditorView={templateUrl:"/admin/js/templates/role-editor.html",async onMount(o){if(tt(),v){const e=v;v=null,e.dispose()}const c=Be();if(!c){R.navigate("/");return}const p=location.hash.match(/^#\/roles\/edit\/([^/?#]+)/),d=p?decodeURIComponent(p[1]):"";if(!d){R.navigate("/roles");return}const C=[],f=e=>(C.push(e),e);let X=!1;const m=o.get(0).querySelector(".rex")||o.get(0),N=()=>!X&&m.isConnected,Z=E.loader(o.get(0),{type:"dots"});let ee,K,te,j,G;try{[ee,K,te,j,G]=await Promise.all([O.get(`/collections/roles/entries/${encodeURIComponent(d)}`),O.get("/auth/permissions-registry"),O.projects.list().catch(()=>[]),O.get("/collections/roles/entries?limit=1000").catch(()=>({entries:[]})),Ue("/roles/_holders",{method:"GET"}).catch(()=>null)])}catch(e){Z.destroy(),E.toast(`Could not load the role: ${e.message||e}`,{type:"error"}),R.navigate("/roles");return}if(Z.destroy(),!m.isConnected)return;const h=K.resources||[],oe=K.groups||[];let a=ee.data||{};const r=Ye(a),ae=j?.entries||(Array.isArray(j)?j:[]),Ce=ae.map(e=>e.data?.name).filter(Boolean),_=e=>Number(G?.counts?.[e]||0),ne=[c.role,...Array.isArray(c.additionalRoles)?c.additionalRoles:[]].filter(Boolean),re=ae.map(e=>e.data||{}).filter(e=>ne.includes(e.name)&&e.name!==a.name),se={holds:ne.includes(a.name),otherRoot:re.some(e=>e.level===0),otherGranted:[...new Set(re.flatMap(e=>Ge(e.permissions,h)))]},$=z(a,h),l={};for(const e of w)l[e]=M.observable($[e]);const n=M.observable($.granted),U=()=>({...Object.fromEntries(w.map(e=>[e,l[e].value])),granted:n.value}),le=()=>({...Object.fromEntries(w.map(e=>[e,l[e].peek()])),granted:n.peek()}),L=M.observable(F($)),b=M.observable(a.name||""),ie=M.observable(a.level),B=M.computed(()=>F(U())!==L.value),H=(e,t)=>JSON.stringify(t.map(s=>JSON.parse(e)[s])),$e=M.computed(()=>H(F(U()),w)!==H(L.value,w)),Le=M.computed(()=>H(F(U()),["granted"])!==H(L.value,["granted"])),A=M.observable(!1),V=M.computed(()=>Ke(U(),{facts:r,taken:Ce,originalName:b.value})),Te=M.computed(()=>A.value||Object.keys(V.value).length>0),ce=M.observable(""),q=M.observable("all"),Y=M.observable([]);f(M.effect(()=>{Y.value=_e(h,oe,n.value,{query:ce.value,show:q.value,locked:r.isRoot})}));const k=m.querySelector(".rex-matrix");f(M.effect(()=>{Y.value,requestAnimationFrame(()=>{k.isConnected&&I.scan(k)})}));const Ae=h.reduce((e,t)=>e+(t.actions||[]).length,0),J=M.computed(()=>Je({...se,oldLevel:ie.value,newLevel:Number.parseInt(l.level.value,10),granted:n.value,isRoot:r.isRoot})),de=M.observable([]);f(M.effect(()=>{de.value=J.value.map((e,t)=>({id:String(t),text:e}))}));const ue=M.observable([]);f(M.effect(()=>{const e=String(l.adminAllow.value||"").split(`
|
|
10
|
+
`).map(t=>t.trim()).filter(Boolean);ue.value=We(n.value,h,oe,{isRoot:r.isRoot,admin:{allow:e,home:String(l.adminHome.value||"").trim()}})}));const qe={...l,granted:n,dirty:B,generalDirty:$e,permsDirty:Le,errors:V,cannot:Te,saving:A,query:ce,show:q,rows:Y,risks:J,riskRows:de,summary:ue,isRoot:r.isRoot,nameLockedText:r.nameLocked,pluginText:r.plugin?`From ${r.plugin}`:"",previewLabel:M.computed(()=>String(l.label.value||"").trim()||l.name.value||"This role"),holdersText:M.computed(()=>G?`Held by ${Q(_(b.value),"user")}`:""),renameNote:M.computed(()=>{const e=_(b.value),t=String(l.name.value||"").trim();return t&&t!==b.value&&e?`${Q(e,"user")} ${e===1?"holds":"hold"} "${b.value}": renamed, they are on a role that no longer exists until you move them under Users.`:""}),scopeText:M.computed(()=>{const e=String(l.adminAllow.value||"").split(`
|
|
11
|
+
`).map(t=>t.trim()).filter(Boolean);return e.length?e.length===1?`Confined to ${e[0]}`:`Confined to ${e.length} screens`:""}),grantedCount:M.computed(()=>r.isRoot?"all":String(n.value.length)),grantedText:M.computed(()=>r.isRoot?"Every permission (root)":`${n.value.length} of ${Ae} actions granted`)},pe=m.querySelector("#rex-badge");for(const e of xe){const t=document.createElement("option");t.value=e.value,t.textContent=e.label,pe.appendChild(t)}if($.badgeClass&&!xe.some(e=>e.value===$.badgeClass)){const e=document.createElement("option");e.value=$.badgeClass,e.textContent=$.badgeClass,pe.appendChild(e)}const Oe=m.querySelector("#rex-project");for(const e of te||[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,Oe.appendChild(t)}async function W(){if(A.peek())return;const e=Object.values(V.peek());if(e.length){E.toast(e[0],{type:"error"});return}const t=[...J.peek()],s=String(l.name.peek()||"").trim(),u=_(b.peek());if(!r.nameLocked&&s!==b.peek()&&u&&t.push(`${Q(u,"user")} ${u===1?"holds":"hold"} "${b.peek()}" - after renaming they are on a role that no longer exists until you move them.`),t.length&&!await E.confirm(`${t.map(y=>we(y)).join("<br><br>")}<br><br>Save anyway?`)||!N())return;A.value=!0;const i=Ve(le(),a,{facts:r,resources:h});try{if(await O.put(`/collections/roles/entries/${encodeURIComponent(d)}`,{data:i}),!N())return;a=i;const y=z(a,h);for(const be of w)l[be].value=y[be];n.value=y.granted,L.value=F(y),b.value=a.name,ie.value=a.level;try{S.remove?S.remove(T(d)):S.set(T(d),null)}catch{}if(Me(o.get(0),a.label||a.name),Pe("#/roles"),se.holds){try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}try{document.dispatchEvent(new CustomEvent("dm:admin-home-changed"))}catch{}}E.toast(`Saved "${a.label||a.name}".`,{type:"success"})}catch(y){E.toast(`Not saved: ${y.message||y}`,{type:"error"})}finally{A.value=!1}}async function De(){if(!await E.confirm("Throw away the changes you have not saved?")||!N())return;const e=z(a,h);for(const t of w)l[t].value=e[t];n.value=e.granted}const me=e=>e?.closest?.(".rex-row");function x(e,t){if(r.isRoot||!e)return;const{kind:s,key:u,group:i}=e.dataset;s==="group"?n.value=Ee(n.peek(),h,{group:i,...t}):n.value=Ee(n.peek(),h,{resource:u,...t})}const Fe=e=>{const{kind:t,key:s,group:u}=e.dataset,i=r.isRoot,y=e.querySelector(".rex-main strong")?.textContent||s||u;return t==="group"?[{header:`${u} - every area`},{label:"Grant everything",icon:"check-circle",disabled:i,action:()=>x(e,{to:!0})},{label:"View only",icon:"eye",disabled:i,action:()=>x(e,{only:"read"})},{label:"Grant nothing",icon:"x-circle",disabled:i,action:()=>x(e,{to:!1})}]:[{header:y},{label:"Full access",icon:"check-circle",disabled:i,action:()=>x(e,{to:!0})},{label:"View only",icon:"eye",disabled:i,action:()=>x(e,{only:"read"})},{label:"No access",icon:"x-circle",disabled:i,action:()=>x(e,{to:!1})},{type:"divider"},{label:"Copy permission key",icon:"copy",action:()=>{navigator.clipboard?.writeText(s).then(()=>E.toast(`Copied "${s}".`,{type:"success"}),()=>E.toast(s,{type:"info",duration:6e3}))}}]};k.innerHTML=Xe,m.querySelector(".rex-risks").innerHTML=Ze,m.querySelector(".rex-summary").innerHTML=et;const Ie=E.tabs(m.querySelector(".rex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Ne=M.applyBindings(qe,m,{methods:{save:()=>W(),discard:()=>De(),showAll:()=>{q.value="all"},showOn:()=>{q.value="on"},showOff:()=>{q.value="off"}}});f(()=>Ne.dispose()),f(He(m,{position:"bottom"}));const je=E.contextMenu(k,{cursor:"glyph",match:".rex-row",inherit:!1,density:"compact",items:Fe});f(()=>je?.destroy?.());const ve=e=>{const t=me(e.target);if(!t)return;const s=e.target.closest(".rex-kebab");if(s){Se(s);return}if(r.isRoot)return;const u=e.target.closest(".rex-act");if(u){n.value=ze(n.peek(),`${t.dataset.key}.${u.dataset.act}`);return}e.target.closest(".rex-full")&&x(t,{to:e.target.closest(".rex-full").dataset.state!=="all"})},fe=e=>{const t=me(e.target);if(t){if(e.key==="F10"&&e.shiftKey){e.preventDefault(),Se(t);return}e.key==="Enter"&&e.target===t&&t.dataset.kind==="res"&&!r.isRoot&&(e.preventDefault(),x(t,{to:t.querySelector(".rex-full")?.dataset.state!=="all"}))}};k.addEventListener("click",ve),k.addEventListener("keydown",fe),f(()=>{k.removeEventListener("click",ve),k.removeEventListener("keydown",fe)}),Me(o.get(0),a.label||a.name||"Edit"),ke(o,{id:"role-editor-back",icon:"arrow-left",title:"Back to roles",onClick:()=>{location.hash="#/roles"}}),ke(o,{id:"role-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>W()});const he=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),W())};document.addEventListener("keydown",he,!0),f(()=>document.removeEventListener("keydown",he,!0));const ge=()=>{if(B.peek())try{S.set(T(d),{form:le(),base:L.peek(),at:new Date().toISOString()})}catch{}},ye=e=>{B.peek()&&(ge(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ye),f(()=>window.removeEventListener("beforeunload",ye)),v={path:P(location.hash.replace(/^#/,"")),hash:location.hash,dirty:()=>B.peek(),dispose(){X=!0,ge(),C.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(m);let g=null;try{g=S.get(T(d))}catch{}if(g?.form){try{S.remove?S.remove(T(d)):S.set(T(d),null)}catch{}const e=g.base&&g.base!==L.peek(),t=g.at?` (${D(g.at).format("D MMM HH:mm")})`:"",s=we(a.label||a.name),u=e?`You left unsaved changes to "${s}"${t}, but the role has been saved since. Restore your version anyway?`:`You left unsaved changes to "${s}"${t}. Restore them?`;if(await E.confirm(u)&&N()){for(const i of w)i in g.form&&(l[i].value=String(g.form[i]??""));Array.isArray(g.form.granted)&&(n.value=[...g.form.granted].sort())}}}};
|
package/admin/js/views/roles.js
CHANGED
|
@@ -1,4 +1,49 @@
|
|
|
1
|
-
import{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import{apiRequest as f,getUser as xe}from"../api.js";import{addBannerAction as Ee}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ke,openMenuAt as ae,sidebarCount as Me,viewLifetime as Se}from"../lib/tool-kit.js";import{BADGE_OPTIONS as Te,FILTERS as $e,arrangeRoles as Ce,checkNewRole as Le,deleteBlock as le,duplicateOf as Oe,filterCounts as Ne,slugify as Ie}from"../lib/roles-arrange.js";const ne=/^#\/(projects\/[^/?#]+\/)?roles\/?(\?.*)?$/,je="#/roles",x="roles.draft",A="roles.filter",Re=`
|
|
2
|
+
<div class="rlx-row" data-id="{{id}}" tabindex="0">
|
|
3
|
+
<span class="rlx-badge"><span class="badge" data-bind-class="badgeClass">{{label}}</span></span>
|
|
4
|
+
<span class="rlx-main"><strong class="font-mono">{{name}}</strong><small>{{meta}}</small></span>
|
|
5
|
+
<span class="rlx-chips">
|
|
6
|
+
<span class="rlx-chip">{{levelText}}</span>
|
|
7
|
+
<span class="rlx-chip" data-bind-hidden="!holdersText" data-bind-class="unheld ? 'is-warn' : 'is-ok'" title="Users holding this role, as their main role or an extra one">{{holdersText}}</span>
|
|
8
|
+
<span class="rlx-chip is-info" data-bind-hidden="!plugin" title="Brought by a plugin">{{plugin}}</span>
|
|
9
|
+
<span class="rlx-chip is-warn" data-bind-hidden="!scope" title="Its holders see only these admin screens">{{scope}}</span>
|
|
10
|
+
<span class="rlx-chip" data-bind-hidden="!builtIn" title="Seeded by the CMS - cannot be deleted">Built in</span>
|
|
11
|
+
</span>
|
|
12
|
+
<button type="button" class="rlx-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
|
|
13
|
+
</div>`,De=s=>String(s??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Be(s){const n=document.createElement("template");return n.innerHTML=s.trim(),n.content.firstElementChild}const se=(s,n,p=`${n}s`)=>`${s} ${s===1?n:p}`,i=Se(ne);export const rolesView={templateUrl:"/admin/js/templates/roles.html",async onMount(s){if(!xe()){R.navigate("/");return}i.reset();const n=s.get(0).querySelector(".rlx")||s.get(0),p=()=>ne.test(location.hash)&&n.isConnected,y=(location.hash.match(/^#\/projects\/([^/?#]+)\/roles/)||[])[1]?decodeURIComponent(location.hash.match(/^#\/projects\/([^/?#]+)\/roles/)[1]):"",c=M.observable([]),k=M.observable({}),q=M.observable([]),P=M.observable(!0),T=M.observable("");let K="all";try{K=$e.includes(S.get(A))?S.get(A):"all"}catch{}const d=M.observable(K),_=M.observable("");i.add(M.effect(()=>{const e=d.value;try{S.set(A,e)}catch{}}));const $=M.computed(()=>y?c.value.filter(e=>(e?.data?.meta?.project||"core")===y):c.value),F=M.observable([]);i.add(M.effect(()=>{F.value=Ce($.value,{filter:d.value,query:_.value,holders:k.value})}));const oe=M.computed(()=>Ne($.value)),re=M.computed(()=>$.value.length?"No roles match.":"No roles here."),ie=M.computed(()=>{const e=q.value;if(!e.length)return"";const t=[...new Set(e.map(a=>a.role))].map(a=>`"${a}"`).join(", ");return`${se(e.length,"user")} ${e.length===1?"holds":"hold"} a role that no longer exists (${t}) - edit them under Users.`}),ce=e=>{const t=c.peek().find(a=>a.id===e);return t?{id:t.id,...t.data||{}}:null};async function J(){try{const[e,t]=await Promise.all([f("/collections/roles/entries?limit=1000",{method:"GET"}),f("/roles/_holders",{method:"GET"}).catch(()=>null)]);if(!p())return;c.value=e?.entries||[],k.value=t?.counts||{},q.value=t?.orphans||[],T.value=""}catch(e){T.value=`Could not load the roles: ${e.message||e}`}finally{P.value=!1}}const h=e=>{location.hash=`#/roles/edit/${encodeURIComponent(e)}`},C=()=>Me(je);async function de(e){try{await navigator.clipboard.writeText(e.name),E.toast(`Copied "${e.name}".`,{type:"success"})}catch{E.toast(`Role name: ${e.name}`,{type:"info",duration:6e3})}}async function ue(e){const t=Oe(e,c.peek().map(a=>a.data?.name));try{const a=await f("/collections/roles/entries",{method:"POST",body:JSON.stringify({data:t})});E.toast(`Made "${t.label}" with the same permissions.`,{type:"success"}),C(),a?.id&&p()&&h(a.id)}catch(a){E.toast(`Not duplicated: ${a.message||a}`,{type:"error"})}}async function pe(e){const t=le(e);if(t){E.toast(t,{type:"warning"});return}const a=k.peek()[e.name]||0,g=a?` ${se(a,"user")} ${a===1?"holds":"hold"} it and will need another role.`:"";if(await E.confirm(`Delete the role "${De(e.label||e.name)}"?${g} This cannot be undone.`))try{await f(`/collections/roles/entries/${encodeURIComponent(e.id)}`,{method:"DELETE"}),E.toast(`Deleted "${e.label||e.name}".`,{type:"success"}),C(),await J()}catch(v){E.toast(`Not deleted: ${v.message||v}`,{type:"error"})}}let m=null;const L=new Set;let G=!1;function H(e=null){const t={label:"",name:"",level:"5",badgeClass:"badge-secondary",...e||{}},a=Object.fromEntries(Object.entries(t).map(([l,r])=>[l,M.observable(String(r))]));let g=!!e?.name;const v=()=>Object.fromEntries(Object.keys(a).map(l=>[l,a[l].peek()])),ve=JSON.stringify(t),W=()=>JSON.stringify(v())!==ve||!!e,w=M.observable(!1),N=M.computed(()=>Le(Object.fromEntries(Object.keys(a).map(l=>[l,a[l].value])),{taken:c.value.map(l=>l.data?.name),project:y})),j=l=>M.computed(()=>N.value.errors[l]||""),Q={...a,saving:w,nameErr:j("name"),labelErr:j("label"),levelErr:j("level"),previewText:M.computed(()=>a.label.value.trim()||"New role"),cannot:M.computed(()=>w.value||Object.keys(N.value.errors).length>0)},fe=M.effect(()=>{const l=a.label.value;g||(a.name.value=Ie(l))}),o=E.slideover({title:"New role",size:"sm",position:"right"}),u=Be(`
|
|
14
|
+
<div class="rlx-form">
|
|
15
|
+
<div>
|
|
16
|
+
<label class="form-label" for="rlx-f-label">Label <span class="text-danger">*</span></label>
|
|
17
|
+
<input id="rlx-f-label" type="text" class="form-input" maxlength="60" placeholder="Moderator" data-model="label.value">
|
|
18
|
+
<small class="rlx-err" data-bind-hidden="!labelErr.value" data-bind-text="labelErr.value"></small>
|
|
19
|
+
</div>
|
|
20
|
+
<div>
|
|
21
|
+
<label class="form-label" for="rlx-f-name">Name
|
|
22
|
+
<span data-help="The role's id - what a user record and a page's visibility hold. Made from the label as you type."></span></label>
|
|
23
|
+
<input id="rlx-f-name" type="text" class="form-input font-mono" maxlength="60" data-model="name.value">
|
|
24
|
+
<small class="rlx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
|
|
25
|
+
</div>
|
|
26
|
+
<div>
|
|
27
|
+
<label class="form-label" for="rlx-f-level">Level
|
|
28
|
+
<span data-help-title="Level" data-help="Lower outranks higher: level 1 is an admin, 2 the ordinary user. Someone can only manage users on a higher number than their own, and 'visibility: this-role' on a page also lets in every role on the same or a lower number."></span></label>
|
|
29
|
+
<input id="rlx-f-level" type="number" min="1" class="form-input" data-model="level.value">
|
|
30
|
+
<small class="rlx-err" data-bind-hidden="!levelErr.value" data-bind-text="levelErr.value"></small>
|
|
31
|
+
</div>
|
|
32
|
+
<div>
|
|
33
|
+
<label class="form-label" for="rlx-f-badge">Badge colour</label>
|
|
34
|
+
<div class="rlx-preview">
|
|
35
|
+
<select id="rlx-f-badge" class="form-input" data-model="badgeClass.value">
|
|
36
|
+
${Te.map(l=>`<option value="${l.value}">${l.label}</option>`).join("")}
|
|
37
|
+
</select>
|
|
38
|
+
<span class="badge" data-bind-class="badgeClass.value" data-bind-text="previewText.value"></span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<small class="rlx-hint">It starts with no permissions - the role editor opens next to set them.</small>
|
|
42
|
+
<div class="rlx-actions">
|
|
43
|
+
<small class="rlx-hint">Ctrl+Enter creates.</small>
|
|
44
|
+
<span class="rlx-spacer"></span>
|
|
45
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
46
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
|
|
47
|
+
<span data-icon="plus" data-icon-size="13"></span> Create role</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>`);let D=!1;async function X(){if(Q.cannot.peek())return;w.value=!0;const{payload:l}=N.peek();try{const r=await f("/collections/roles/entries",{method:"POST",body:JSON.stringify({data:l})});E.toast(`Created "${l.label}" - now set its permissions.`,{type:"success"}),D=!0,o.close(),C(),r?.id&&h(r.id)}catch(r){E.toast(`Not created: ${r.message||r}`,{type:"error"})}finally{w.value=!1}}o.element.appendChild(u);const ye=te(u),ge=M.applyBindings(Q,u,{methods:{save:X,cancel:()=>o.close()}}),B=u.querySelector("#rlx-f-name"),Z=()=>{g=!!B.value};B.addEventListener("input",Z),u.addEventListener("keydown",l=>{(l.ctrlKey||l.metaKey)&&l.key==="Enter"&&(l.preventDefault(),X())});const ee={dirty:W,values:v};m=ee,L.add(o),ke(o,()=>{fe(),ge.dispose(),ye(),B.removeEventListener("input",Z),L.delete(o),m===ee&&(m=null)});const we=o.close.bind(o);let U=!1;o.close=async l=>{if(!D&&!G&&l!==!0&&W()){if(U)return;U=!0;const r=await E.confirm("Discard the new role you have not created?");if(U=!1,!r)return}return D=!0,we()},I.scan(u),o.open(),u.querySelector("#rlx-f-label")?.focus()}function me(e){const t=ce(e);if(!t)return[];const a=le(t);return[{header:t.label||t.name},{label:"Edit permissions",icon:"edit",action:()=>h(t.id)},{label:"Duplicate",icon:"copy",action:()=>ue(t)},{label:"Copy name",icon:"clipboard",action:()=>de(t)},{type:"divider"},a?{label:t.plugin?`Delete - from ${t.plugin}`:"Delete - built in",icon:"trash",disabled:!0,title:a}:{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>pe(t)}]}const b=n.querySelector(".rlx-list");b.innerHTML=Re;const be=M.applyBindings({projectSlug:y,entries:c,loading:P,loadError:T,filter:d,query:_,rows:F,counts:oe,emptyText:re,orphanText:ie},n,{methods:{showAll:()=>{d.value="all"},showBuiltIn:()=>{d.value="built-in"},showCustom:()=>{d.value="custom"},showPlugins:()=>{d.value="plugins"}}});i.add(()=>be.dispose()),i.add(te(n,{position:"bottom"}));const he=E.contextMenu(b,{cursor:"glyph",match:".rlx-row",inherit:!1,density:"compact",items:e=>me(e.dataset.id)});i.add(()=>he?.destroy?.());const Y=e=>{const t=e.target.closest(".rlx-row");if(!t)return;const a=e.target.closest(".rlx-kebab");if(a){ae(a);return}h(t.dataset.id)},z=e=>{const t=e.target.closest(".rlx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&h(t.dataset.id))};b.addEventListener("click",Y),b.addEventListener("keydown",z),i.add(()=>{b.removeEventListener("click",Y),b.removeEventListener("keydown",z)}),Ee(s,{id:"roles-new",icon:"plus",title:"New role",onClick:()=>H()});const V=e=>{m?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",V),i.add(()=>window.removeEventListener("beforeunload",V)),i.add(()=>{if(m?.dirty())try{S.set(x,{values:m.values(),at:new Date().toISOString()})}catch{}G=!0,[...L].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(n),await J(),!p())return;I.scan(n);let O=null;try{O=S.get(x)}catch{}if(O?.values){try{S.remove?S.remove(x):S.set(x,null)}catch{}await E.confirm("You left a new role unfinished. Pick up where you left off?")&&p()&&H(O.values)}}};
|