domma-cms 0.75.0 → 0.76.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/admin/js/app.js +4 -4
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -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/users-arrange.js +1 -0
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/plugins.html +100 -28
- 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/users.html +69 -9
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/plugins.js +97 -39
- 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/users.js +13 -4
- 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/effects.js +68 -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 +53 -1
- package/server/server.js +12 -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 +14 -0
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/plugin-marketplace.js +0 -1
|
@@ -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))}}};
|
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)}}};
|
package/admin/js/views/search.js
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as N}from"../api.js";import{addBannerAction as ee}from"../lib/plugin-chrome.js";import{helpPopovers as me}from"../lib/help-popover.js";import{openMenuAt as te,sidebarCount as fe}from"../lib/tool-kit.js";import{POSITION_LABEL as be,clampNotes as he,formFromSettings as O,liveChips as ye,maxRadius as ge,payloadFromForm as c,previewState as se,radiusForSize as ke,resultRow as Se,snapshot as U}from"../lib/search-settings-arrange.js";const K=/^\/search(\?.*)?$/,we="#/search",b="search.draft",xe='<span class="ssx-chip" data-bind-class="cls">{{text}}</span>',Me=`
|
|
2
|
+
<div class="ssx-row" data-url="{{url}}" tabindex="0">
|
|
3
|
+
<span class="ssx-rank">{{rank}}</span>
|
|
4
|
+
<span class="ssx-main"><strong>{{title}}</strong><small>{{meta}}</small><span class="ssx-snippet">{{snippet}}</span></span>
|
|
5
|
+
<span class="ssx-chip">{{score}}</span>
|
|
6
|
+
<button type="button" class="ssx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
7
|
+
</div>`;let u=null,oe=!1;function Ee(){oe||!window.R?.use||(oe=!0,R.use((i,v,o)=>!(K.test(v?.path||"")&&!K.test(i?.path||""))||!u?.dirty()?o():E.confirm("You have unsaved search settings. Leave anyway? They are kept as a draft for your next visit.").then(r=>{r?o():history.replaceState(null,"","#/search")})),M.subscribe("router:afterChange",({to:i}={})=>{if(u&&!K.test(i?.path||"")){const v=u;u=null,v.dispose()}}))}export const searchView={templateUrl:"/admin/js/templates/search.html",async onMount(i){if(Ee(),u){const e=u;u=null,e.dispose()}const v=[],o=e=>(v.push(e),e);let P=!1;const r=i.get(0).querySelector(".ssx")||i.get(0),$=()=>!P&&r.isConnected,H=E.loader(i.get(0),{type:"dots"});let j,A=null;try{[j,A]=await Promise.all([N("/search/settings"),N("/search/_count").catch(()=>null)])}catch(e){H.destroy(),E.toast(`Could not load the search settings: ${e.message||e}`,{type:"error"});return}if(H.destroy(),!r.isConnected)return;const w=M.observable(j),B=O(j),s={};for(const[e,t]of Object.entries(B))s[e]=M.observable(t);const h=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),p=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),x=M.observable(U(B)),L=M.computed(()=>U(p())!==x.value),T=M.observable(!1),Y=M.observable(A?{searchable:A.count}:null),V=M.observable([]);o(M.effect(()=>{V.value=ye(w.value,Y.value).map(e=>({text:e.text,cls:e.tone?`is-${e.tone}`:""}))}));let m=Number(s.iconSize.peek())||52;o(M.effect(()=>{const e=Number(s.iconSize.value)||52;if(e===m)return;const t=ke(s.iconRadius.peek(),m,e);m=e,String(t)!==String(s.iconRadius.peek())&&(s.iconRadius.value=String(t))}));const ne=M.computed(()=>ge(s.iconSize.value)),Q=r.querySelector("#ssx-radius");o(M.effect(()=>{Q&&(Q.max=String(ne.value))}));const y=M.observable(""),g=M.observable([]),z=M.observable(""),C=M.observable(""),ae=M.computed(()=>se(y.value,s.minQueryLength.value).note);let _=0,q=null;async function re(){const e=se(y.peek(),s.minQueryLength.peek()),t=++_;if(!e.run){g.value=[],C.value="",z.value="";return}const a=c(h()).maxResults;try{const n=await N(`/search/preview?q=${encodeURIComponent(y.peek().trim())}&max=${a}`);if(t!==_||!$())return;g.value=(n.results||[]).map(Se),z.value="";const l=g.peek().length;C.value=l?`${l} result${l===1?"":"s"} (showing up to ${n.maxResults}) from ${n.coverage?.searchable??"?"} searchable pages.`:`Nothing found in ${n.coverage?.searchable??"?"} searchable pages.`,n.coverage&&(Y.value=n.coverage)}catch(n){if(t!==_)return;g.value=[],C.value="",z.value=`Search failed: ${n.message||n}`}}o(M.effect(()=>{y.value,s.maxResults.value,clearTimeout(q),q=setTimeout(re,c(h()).debounceMs)})),o(()=>clearTimeout(q));async function F(){if(!T.peek()){T.value=!0;try{const e=await N("/search/settings",{method:"PUT",body:JSON.stringify(c(h()))});if(!$())return;const t=e?.settings||c(h());w.value=t;const a=O(t);m=Number(a.iconSize)||52;for(const[n,l]of Object.entries(a))s[n].value=l;x.value=U(a);try{S.remove?S.remove(b):S.set(b,null)}catch{}fe(we),E.toast(t.enabled?"Search settings saved.":"Saved - search is off on the public site.",{type:"success"})}catch(e){E.toast(`Not saved: ${e.message||e}`,{type:"error"})}finally{T.value=!1}}}function ie(){const e=O(w.peek());m=Number(e.iconSize)||52;for(const[t,a]of Object.entries(e))s[t].value=a}r.querySelector(".ssx-chips").innerHTML=xe;const f=r.querySelector(".ssx-list");f.innerHTML=Me;const k=e=>`${e}px`,ce={...s,dirty:L,saving:T,chips:V,query:y,results:g,previewNote:ae,previewError:z,previewMeta:C,clampText:M.computed(()=>he(p()).join(" ")),sizeText:M.computed(()=>k(c(p()).iconSize)),radiusText:M.computed(()=>k(c(p()).iconRadius)),sizePx:M.computed(()=>k(c(p()).iconSize)),radiusPx:M.computed(()=>k(c(p()).iconRadius)),glyphPx:M.computed(()=>k(Math.round(c(p()).iconSize*20/52))),positionText:M.computed(()=>be[s.position.value]||s.position.value)},le=E.tabs(r.querySelector(".ssx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),ue=M.applyBindings(ce,r,{methods:{save:()=>F(),discard:()=>ie(),useNavbar:()=>{s.displayMode.value="navbar"},useFloating:()=>{s.displayMode.value="floating"},setPos:e=>{s.position.value=e}}});o(()=>ue.dispose()),o(me(r,{position:"bottom"}));const pe=e=>[{header:e},{label:"Open on the site",icon:"external-link",action:()=>window.open(e,"_blank","noopener")},{label:"Edit the page",icon:"edit",action:()=>{location.hash=`#/pages/edit${e}`}},{label:"Copy address",icon:"copy",action:()=>{const t=new URL(e,location.origin).href;navigator.clipboard?.writeText(t).then(()=>E.toast("Address copied.",{type:"success"}),()=>E.toast(t,{type:"info",duration:8e3}))}}],de=E.contextMenu(f,{cursor:"glyph",match:".ssx-row",inherit:!1,density:"compact",items:e=>pe(e.dataset.url)});o(()=>de?.destroy?.());const J=e=>{const t=e.target.closest(".ssx-row");if(!t)return;const a=e.target.closest(".ssx-kebab");if(a){te(a);return}window.open(t.dataset.url,"_blank","noopener")},W=e=>{const t=e.target.closest(".ssx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),te(t)):e.key==="Enter"&&e.target===t&&window.open(t.dataset.url,"_blank","noopener"))};f.addEventListener("click",J),f.addEventListener("keydown",W),o(()=>{f.removeEventListener("click",J),f.removeEventListener("keydown",W)}),ee(i,{id:"search-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),ee(i,{id:"search-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>F()});const G=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),F())};document.addEventListener("keydown",G,!0),o(()=>document.removeEventListener("keydown",G,!0));const X=()=>{if(L.peek())try{S.set(b,{form:h(),base:x.peek(),at:new Date().toISOString()})}catch{}},Z=e=>{L.peek()&&(X(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Z),o(()=>window.removeEventListener("beforeunload",Z)),u={dirty:()=>L.peek(),dispose(){P=!0,X(),v.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(r);let d=null;try{d=S.get(b)}catch{}if(d?.form){try{S.remove?S.remove(b):S.set(b,null)}catch{}const e=d.base&&d.base!==x.peek(),t=d.at?` (${D(d.at).format("D MMM HH:mm")})`:"",a=e?`You left unsaved search settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved search settings${t}. Restore them?`;if(await E.confirm(a)&&$()){const n={...O(w.peek()),...d.form};m=Number(n.iconSize)||52;for(const[l,ve]of Object.entries(n))s[l]&&(s[l].value=ve)}}}};
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import{api as G,apiRequest as w}from"../api.js";import{placeTools as Re}from"../lib/sidebar-grouping.js";import{addBannerAction as fe}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{openMenuAt as Q,sidebarCount as he}from"../lib/tool-kit.js";import{changedTabs as He,describeCacheKey as X,draftOf as Pe,expiresIn as $e,formFromSettings as De,homeValue as je,payloadFromForm as Ke,settingsProblems as _e,snapshot as ve,TAB_FIELDS as Ie}from"../lib/site-settings-arrange.js";const Z=/^\/settings(\?.*)?$/,be="#/settings",T="settings.draft",Be=`
|
|
2
|
+
<div class="stx-problem">
|
|
3
|
+
<span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
|
|
4
|
+
<span>{{text}}</span>
|
|
5
|
+
<button type="button" class="btn btn-sm btn-ghost" data-go="{{tab}}">Go there</button>
|
|
6
|
+
</div>`,qe=`
|
|
7
|
+
<div class="stx-row" data-id="{{id}}" tabindex="0">
|
|
8
|
+
<span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
|
|
9
|
+
<span class="stx-chip">{{expires}}</span>
|
|
10
|
+
<button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
|
|
11
|
+
</div>`,Fe=["general","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function ze(l,p){const[r,O]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),u=(C,m)=>{for(const o of C||[]){if(!o||o.hidden||o.restricted)continue;const f=typeof o.url=="string"?o.url:"";f.startsWith("#/")&&f.length>2&&!a.has(f.slice(1))&&a.set(f.slice(1),`${m?`${m} \u203A `:""}${o.text||f}`),u(o.items,o.url?m:o.text||m)}};u(Re(r?.items||[],O||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),l.replaceChildren();for(const[C,m]of a){const o=document.createElement("option");o.value=C,o.textContent=m,l.appendChild(o)}}let y=null,ye=!1;function Ne(){ye||!window.R?.use||(ye=!0,R.use((l,p,r)=>!(Z.test(p?.path||"")&&!Z.test(l?.path||""))||!y?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(a=>{a?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(y&&!Z.test(l?.path||"")){const p=y;y=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Ne(),y){const e=y;y=null,e.dispose()}const p=[],r=e=>(p.push(e),e);let O=!1;const a=l.get(0).querySelector(".stx")||l.get(0),u=()=>!O&&a.isConnected,C=E.loader(l.get(0),{type:"dots"});let m,o;try{[m,o]=await Promise.all([G.settings.get(),w("/settings/custom-css").then(e=>e?.css||"").catch(()=>null)])}catch(e){C.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(C.destroy(),!a.isConnected)return;const f=o!==null,ee=M.observable(m||{}),te=De(m||{},o||""),n={};for(const[e,t]of Object.entries(te))n[e]=M.observable(t);const B=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),se=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),g=M.observable(te),q=M.computed(()=>ve(g.value)),L=M.computed(()=>ve(B())!==q.value),A=M.observable(!1),P=M.observable(""),F=M.observable(!1),ne=M.computed(()=>{const e=new Set(He(B(),g.value));return Object.fromEntries(Object.keys(Ie).map(t=>[t,e.has(t)]))}),z=M.observable([]);r(M.effect(()=>{z.value=_e(B()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ae=M.observable(""),$=M.observable(!1),j=M.observable(""),K=M.observable(""),h=M.observable(null),N=M.observable([]),V=M.observable(""),U=M.observable(!0),W=M.observable(""),oe=M.observable([]);r(M.effect(()=>{const e=V.value.trim().toLowerCase(),t=Date.now();oe.value=[...N.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(i=>String(i).toLowerCase().includes(e))).map(s=>{const i=X(s.key);return{id:s.key,path:i.path,meta:[i.page?`as ${i.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:$e(s.expiresAt,t)}})}));const ge={...n,saving:A,dirty:L,tabs:ne,problems:z,testTo:ae,testing:$,testResult:j,testTone:K,cacheQuery:V,cacheLoading:U,cacheError:W,cacheRows:oe,errorText:P,problemsText:M.computed(()=>z.value.length?"yes":""),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),dirtyEmail:M.computed(()=>ne.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cssSize:M.computed(()=>{if(!f)return"Could not load content/custom.css - reload to try again. It will not be saved until it loads.";const e=new Blob([String(n.css.value||"")]).size/1024;return`${e.toFixed(1)} KB of 100 KB${e>100?" - too big to save":""}`}),cacheOn:M.computed(()=>h.value?.enabled===!0),cacheState:M.computed(()=>{const e=h.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=h.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=h.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>h.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>V.value.trim()?"Nothing matches.":h.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function Y(){if(A.peek())return;const e=se(),t=g.peek(),s=String(e.css)!==String(t.css);if(s&&!f){E.toast("The custom CSS did not load, so it cannot be saved - reload the screen first.",{type:"error"});return}A.value=!0,P.value="";const i=String(e.title).trim()!==String(t.title).trim();try{await G.settings.save(Ke(e,ee.peek(),{adminHomeReady:F.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const c=await G.settings.get().catch(()=>null);if(!u())return;c&&(ee.value=c);const b={...e,smtpPass:""};n.smtpPass.value="",g.value=b;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),i&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),he(be),E.toast("Settings saved.",{type:"success"})}catch(c){P.value=c.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{A.value=!1}}async function ke(){await E.confirm("Throw away your unsaved settings?")&&(re(g.peek()),P.value="")}function re(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);d&&d.getValue()!==String(n.css.peek())?d.setValue(String(n.css.peek())):!d&&v&&(v.value=String(n.css.peek()))}async function we(){if(!$.peek()){$.value=!0,K.value="",j.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:ae.peek().trim()||void 0})});if(!u())return;j.value=e?.message||"Test email sent.",K.value="is-ok"}catch(e){if(!u())return;j.value=e.message||"Could not send the test email.",K.value="is-bad"}finally{$.value=!1}}}async function _(){U.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!u())return;h.value=e,N.value=Array.isArray(t?.entries)?t.entries:[],W.value=""}catch(e){if(!u())return;W.value=`Could not read the cache: ${e.message||e}`}finally{U.value=!1}}async function Ee(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await w("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}u()&&await _()}}async function Se(){const e=h.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{h.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),he(be)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}u()&&await _()}}const ie=e=>N.peek().find(t=>t.key===e);function Me(e){const t=ie(e);if(!t)return[];const s=X(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}a.querySelector(".stx-problems").innerHTML=Be;const x=a.querySelector(".stx-list");x.innerHTML=qe;const Ce=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),xe=M.applyBindings(ge,a,{methods:{save:Y,discard:ke,sendTest:we,refreshCache:_,clearCache:Ee,toggleCache:Se,corner:e=>{n.bcPosition.value=e}}});r(()=>xe.dispose()),r(Ae(a,{position:"bottom"}));const Te=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Fe.indexOf(t.dataset.go);s>=0&&Ce.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Te);const J=a.querySelector("#stx-admin-home");ze(J,je(n.adminHome.peek())).then(()=>{u()&&(J.value=n.adminHome.peek(),F.value=!0)}).catch(()=>{});const v=a.querySelector(".stx-css");v.value=String(n.css.peek());let d=null;if(!f)v.disabled=!0;else if(E.editor)try{d=E.editor(v,{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0,onChange:e=>{n.css.value=e}})}catch{d=null}if(d)d._editorEl&&d._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=d._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,i=t.selectionStart,c=s.lastIndexOf(`
|
|
12
|
+
`,i-1)+1,b=s.indexOf(`
|
|
13
|
+
`,i),Le=b===-1?s.slice(c):s.slice(c,b+1);navigator.clipboard?.writeText(Le).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,c)+(b===-1?"":s.slice(b+1)),t.selectionStart=t.selectionEnd=c,t.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})):E.toast("Line copied",{type:"info",duration:1500})});else{const e=()=>{n.css.value=v.value};v.addEventListener("input",e),r(()=>v.removeEventListener("input",e))}r(()=>{try{d?.destroy?.()}catch{}});const Oe=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Me(e.dataset.id)});r(()=>Oe?.destroy?.());const ce=e=>{const t=ie(e.dataset.id),s=t&&X(t.key);s?.page?window.open(s.path,"_blank","noopener"):Q(e)},le=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Q(s);return}ce(t)},ue=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Q(t)):e.key==="Enter"&&e.target===t&&ce(t))};x.addEventListener("click",le),x.addEventListener("keydown",ue),r(()=>{x.removeEventListener("click",le),x.removeEventListener("keydown",ue)}),fe(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),fe(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>Y()});const de=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),Y())};document.addEventListener("keydown",de,!0),r(()=>document.removeEventListener("keydown",de,!0));const pe=()=>{if(L.peek())try{S.set(T,{form:Pe(se()),base:q.peek(),at:new Date().toISOString()})}catch{}},me=e=>{L.peek()&&(pe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",me),r(()=>window.removeEventListener("beforeunload",me)),y={dirty:()=>L.peek(),dispose(){O=!0,pe(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),_();let k=null;try{k=S.get(T)}catch{}if(k?.form){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=k.base&&k.base!==q.peek(),t=k.at?` (${D(k.at).format("D MMM HH:mm")})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&u()){const i={...g.peek()};for(const[c,b]of Object.entries(k.form))c in i&&c!=="smtpPass"&&(i[c]=b);f||(i.css=g.peek().css),re(i),F.peek()&&(J.value=n.adminHome.peek())}}}};
|
package/admin/js/views/users.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import{api as N,apiRequest as U,getUser as X}from"../api.js";import{addBannerAction as Z}from"../lib/plugin-chrome.js";import{helpPopovers as ee}from"../lib/help-popover.js";import{openMenuAt as L,settingsStore as te,sidebarCount as q,viewLifetime as se}from"../lib/tool-kit.js";import{filterByProject as oe}from"../lib/project-context.js";import{FILTERS as ae,SORTS as ne,arrangeUsers as ie,canManage as P,roleIndex as re,userRow as le}from"../lib/users-arrange.js";const Y=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,B="#/users",ce=`
|
|
2
|
+
<div class="usx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
|
|
3
|
+
<span class="usx-avatar" aria-hidden="true">{{initials}}</span>
|
|
4
|
+
<span class="usx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
|
|
5
|
+
<span class="usx-chips">
|
|
6
|
+
<span class="usx-chip is-ok" data-bind-hidden="!self">You</span>
|
|
7
|
+
<span class="usx-chip" data-bind-class="roleTone">{{roleLabel}}</span>
|
|
8
|
+
<span class="usx-chip is-outline" data-bind-hidden="!extraRoles" title="Additional roles">+ {{extraRoles}}</span>
|
|
9
|
+
<span class="usx-chip" data-bind-hidden="!scope" title="Project access - sees only these (and Core)">{{scope}}</span>
|
|
10
|
+
<span class="usx-chip is-warn" data-bind-hidden="!inactive" title="Cannot sign in">Inactive</span>
|
|
11
|
+
</span>
|
|
12
|
+
<button type="button" class="usx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
13
|
+
</div>`,O=o=>String(o??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),F=te("users.list",o=>({filter:ae.includes(o?.filter)?o.filter:"all",sort:ne.includes(o?.sort)?o.sort:"name"})),n=se(Y);function de(){const o=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!o)return null;try{return decodeURIComponent(o[1])}catch{return o[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(o){n.reset();const i=o.get(0).querySelector(".usx")||o.get(0),p=()=>Y.test(location.hash)&&i.isConnected,m=X(),r=de(),w=F.read(),l=M.observable([]),u=M.observable({}),x=M.observable(!0),h=M.observable(""),c=M.observable(w.filter),v=M.observable(w.sort),f=M.observable(""),k=M.observable(""),b=M.observable(0);n.add(M.effect(()=>{F.write({filter:c.value,sort:v.value})}));const R=M.observable([]);n.add(M.effect(()=>{const e=u.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():D(s).format("D MMM YYYY HH:mm")}catch{return s}};R.value=ie(l.value,{filter:c.value,role:f.value,query:k.value,sort:v.value}).map(s=>le(s,{roles:e,me:m,when:t}))}));const H=M.computed(()=>{const e=l.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),K=M.computed(()=>l.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),_=M.computed(()=>r?`Showing only users in the project "${r}"${b.value?` - ${b.value} more elsewhere`:""}.`:""),$=e=>l.peek().find(t=>t.id===e);async function y(){try{const[e,t]=await Promise.all([N.users.list(),U("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]);if(!p())return;let s=Array.isArray(e)?e:[];if(r){const a=oe(s,r);b.value=s.length-a.length,s=a}u.value=re(t?.entries||[]),G(),l.value=s,h.value=""}catch(e){h.value=`Could not load the users: ${e.message||e}`}finally{x.value=!1}}function G(){const e=i.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=f.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(u.peek()).sort((s,a)=>s[1].level-a[1].level).forEach(([s,a])=>{const g=document.createElement("option");g.value=s,g.textContent=a.label,e.appendChild(g)}),e.value=t}const A=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function C(e,t){if(!(!t&&!await E.confirm(`Make ${O(e.name||e.email)} inactive? They will not be able to sign in until the account is made active again. Their work stays.`)))try{await U(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),q(B),p()&&await y()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function J(e){if(await E.confirm(`Delete ${O(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await N.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),q(B),p()&&await y()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function V(e){try{await navigator.clipboard.writeText(e.email||""),E.toast("Email copied.",{type:"success"})}catch{E.toast(e.email||"No email",{type:"info",duration:6e3})}}function W(e){const t=$(e);if(!t)return[];const s=P(m,t,u.peek()),a=s.self?"not on your own account":"your role is not senior to theirs";return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${a}`,icon:"edit",disabled:!s.edit,action:()=>A(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>V(t)},{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${a}`,icon:"play",disabled:!s.other,action:()=>C(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${a}`,icon:"pause",disabled:!s.other,action:()=>C(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${a}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>J(t)}]}const d=i.querySelector(".usx-list");d.innerHTML=ce;const z=M.applyBindings({users:l,loading:x,loadError:h,filter:c,sort:v,role:f,query:k,rows:R,counts:H,emptyText:K,projectNote:_},i,{methods:{showAll:()=>{c.value="all"},showActive:()=>{c.value="active"},showInactive:()=>{c.value="inactive"}}});n.add(()=>z.dispose()),n.add(ee(i,{position:"bottom"}));const Q=E.contextMenu(d,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>W(e.dataset.id)});n.add(()=>Q?.destroy?.());const S=e=>{const t=$(e);t&&(P(m,t,u.peek()).edit?A(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},T=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){L(s);return}S(t.dataset.id)},j=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&S(t.dataset.id))};d.addEventListener("click",T),d.addEventListener("keydown",j),n.add(()=>{d.removeEventListener("click",T),d.removeEventListener("keydown",j)}),Z(o,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=r?`#/users/new?project=${encodeURIComponent(r)}`:"#/users/new"}}),I.scan(i),await y()}};
|