domma-cms 0.74.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/CLAUDE.md +3 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/blocks-arrange.js +1 -0
- package/admin/js/lib/collections-arrange.js +1 -0
- package/admin/js/lib/components-arrange.js +1 -0
- package/admin/js/lib/context-menus-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/forms-arrange.js +1 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/media-arrange.js +1 -0
- package/admin/js/lib/menus-arrange.js +1 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/pages-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 +3 -3
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/theme-settings-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/views-arrange.js +1 -0
- package/admin/js/templates/actions-list.html +73 -14
- package/admin/js/templates/api-endpoints.html +63 -10
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/blocks.html +76 -15
- package/admin/js/templates/collections.html +108 -52
- package/admin/js/templates/components.html +82 -15
- package/admin/js/templates/context-menus.html +55 -13
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/forms.html +67 -14
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/pages.html +78 -23
- 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/theme.html +164 -132
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/views-list.html +63 -9
- package/admin/js/views/actions-list.js +37 -1
- package/admin/js/views/api-endpoints.js +11 -7
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/blocks.js +34 -5
- package/admin/js/views/collections.js +36 -1
- package/admin/js/views/components.js +27 -5
- package/admin/js/views/context-menus.js +10 -5
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/forms.js +57 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/pages.js +28 -8
- 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/theme.js +55 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/views-list.js +11 -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/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +66 -2
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/pages.js +46 -0
- 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/theme.js +55 -0
- package/server/routes/api/users.js +53 -1
- package/server/routes/api/views.js +27 -3
- package/server/server.js +28 -1
- package/server/services/contextMenus.js +41 -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/server/services/views.js +38 -0
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/plugin-marketplace.js +0 -1
package/admin/js/views/theme.js
CHANGED
|
@@ -1 +1,55 @@
|
|
|
1
|
-
import{api as w}from"../api.js";import{populateThemeSelect as A}from"../lib/themes.js";const he=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}];export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(s){E.tabs(s.find("#theme-tabs").get(0));const X=E.loader(s.get(0),{type:"dots"});let m,q;try{[m,q]=await Promise.all([w.theme.get(),w.theme.catalog().catch(()=>({themes:[],tokens:[]}))])}catch(e){X.destroy(),E.toast(`Could not load theme settings: ${e.message||e}`,{type:"error"});return}X.destroy();const D=q.tokens||[],Y=Array.isArray(q.custom)?q.custom:[];function x(e){if(!Y.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const n of Y){const o=document.createElement("option");o.value=n.id,o.textContent=`${n.label} (on ${n.baseTheme})`,t.appendChild(o)}e.appendChild(t)}function T(e,t){const n=e.get?e.get(0):e;if(t&&![...n.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,n.appendChild(o)}n.value=t}const M=new Map;async function H(e){if(!e)return null;if(M.has(e))return M.get(e);const t=await w.theme.themeTokens(e).catch(()=>null),n=t&&t.tokens?t.tokens:null;return M.set(e,n),n}function ae(){return(s.find("#theme-auto-enabled").prop("checked")?S.val():f.val())||m.theme}const U=["--dm-card-bg","--dm-surface"],W=[...U,"--dm-text","--dm-primary","--dm-background"];function G(e){const t=document.createElement("span");return t.style.cssText="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;border:1px solid var(--dm-border);vertical-align:middle;margin-right:.25rem;",/^#[0-9a-f]{3,8}$/i.test(e)?t.style.background=e:t.style.background="transparent",t}async function j(e){const t=e.querySelector(".ov-theme-diff");if(!t)return;for(;t.firstChild;)t.removeChild(t.firstChild);const n=e.querySelector(".ov-theme")?.value;if(!n)return;const o=ae();if(n===o){const l=document.createElement("p");l.className="form-hint text-muted",l.style.cssText="margin:.4rem 0 0;font-size:.8rem;",l.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",t.appendChild(l);return}const[a,r]=await Promise.all([H(o),H(n)]);if(!a||!r)return;const c=Object.keys(r).filter(l=>String(a[l]??"").toLowerCase()!==String(r[l]).toLowerCase()),d=U.filter(l=>c.includes(l)),i=document.createElement("div");i.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:var(--dm-radius-md);background:var(--dm-surface-raised);font-size:.8rem;";const h=document.createElement("p");if(h.style.cssText="margin:0 0 .35rem;",h.textContent=`Changes ${c.length} of ${Object.keys(r).length} tokens here.`,i.appendChild(h),!d.length){const l=document.createElement("p");l.style.cssText="margin:0 0 .35rem;color:var(--dm-warning);",l.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",i.appendChild(l)}const p=document.createElement("div");p.style.cssText="display:flex;flex-wrap:wrap;gap:.15rem .75rem;";const v=[...W.filter(l=>c.includes(l)),...c.filter(l=>!W.includes(l))];for(const l of v.slice(0,6)){const u=document.createElement("span");u.style.cssText="white-space:nowrap;color:var(--dm-text-muted);",u.appendChild(G(a[l]||""));const C=document.createElement("span");C.textContent=`${l} `,u.appendChild(C),u.appendChild(G(r[l])),p.appendChild(u)}if(v.length>6){const l=document.createElement("span");l.style.cssText="color:var(--dm-text-muted);",l.textContent=`+${v.length-6} more`,p.appendChild(l)}i.appendChild(p),t.appendChild(i)}let b=Array.isArray(m.overrides)?m.overrides.map($):[];const f=s.find("#theme-frontend"),O=s.find("#theme-admin");A(f.get(0)),A(O.get(0),{includeAdmin:!0}),x(f.get(0)),x(O.get(0)),T(f,m.theme||"charcoal-dark"),T(O,m.adminTheme||"charcoal-dark");const S=s.find("#theme-day"),I=s.find("#theme-night");A(S.get(0)),A(I.get(0)),x(S.get(0)),x(I.get(0));const g=m.autoTheme||{};T(S,g.dayTheme||"charcoal-light"),T(I,g.nightTheme||"charcoal-dark"),s.find("#theme-day-start").val(g.dayStart||"07:00"),s.find("#theme-night-start").val(g.nightStart||"19:00");const z=m.switcher||{},B=s.find("#theme-switcher-enabled"),le=s.find("#theme-switcher-fields");B.prop("checked",z.enabled===!0),s.find("#theme-switcher-position").val(z.position||"right-center"),s.find("#theme-switcher-offset-x").val(z.offsetX??32),s.find("#theme-switcher-offset-y").val(z.offsetY??32);const J=()=>{le.get(0).style.display=B.prop("checked")?"":"none"};J(),B.on("change",J);const Q=s.find("#theme-auto-enabled"),Z=s.find("#theme-auto-fields");Q.prop("checked",!!g.enabled),g.enabled&&(Z.show(),f.prop("disabled",!0)),Q.on("change",function(){Z.toggle(this.checked),f.prop("disabled",this.checked)}),s.find("#theme-font-family").val(m.font?.family||"Roboto"),s.find("#theme-font-size").val(String(m.font?.size??16));function $(e){return{id:e.id||`ov-${Date.now()}-${Math.random().toString(36).slice(2,8)}`,label:e.label||"",enabled:e.enabled!==!1,scope:e.scope||"",theme:e.theme||"",tokens:{...e.tokens||{}},css:e.css||""}}const y=document.getElementById("theme-overrides"),re=document.getElementById("theme-overrides-empty");function R(){for(;y.firstChild;)y.removeChild(y.firstChild);re.style.display=b.length?"none":"",b.forEach((e,t)=>y.appendChild(ce(e,t))),Domma.icons.scan("#theme-overrides")}function ce(e,t){const n=document.createElement("div");n.className="card mb-3",n.dataset.i=String(t);const o=document.createElement("div");o.className="card-header";const a=document.createElement("h3");a.className="m-0",a.style.fontSize="1rem",a.textContent=e.label||e.scope||`Override ${t+1}`,o.appendChild(a);const r=document.createElement("div");r.style.cssText="display:flex;gap:.5rem;align-items:center;";const c=document.createElement("label");c.style.cssText="display:flex;align-items:center;gap:.35rem;font-size:.85rem;cursor:pointer;";const d=document.createElement("input");d.type="checkbox",d.className="form-check ov-enabled",d.checked=e.enabled,c.appendChild(d),c.appendChild(document.createTextNode("Enabled")),r.appendChild(c);const i=document.createElement("button");i.type="button",i.className="btn btn-ghost btn-sm ov-remove",i.textContent="Remove",r.appendChild(i),o.appendChild(r),n.appendChild(o);const h=document.createElement("div");h.className="card-body";const p=document.createElement("div");p.className="row mb-3",p.appendChild(V("col-4","Name",ne("ov-label",e.label,"Navbar island"))),p.appendChild(V("col-8","Target selector",de(e.scope),"The region to re-theme. Everything inside it follows.")),h.appendChild(p);const v=document.createElement("div");v.className="row mb-3";const l=document.createElement("select");l.className="form-select ov-theme",A(l,{includeDefault:!0,defaultLabel:"Keep the site theme"}),x(l),T(l,e.theme||""),v.appendChild(V("col-6","Adopt a theme here",l,"Copies every token of that theme onto this region.")),h.appendChild(v);const u=document.createElement("div");u.className="ov-theme-diff",h.appendChild(u);const C=document.createElement("div");C.className="ov-tokens mb-2",Object.entries(e.tokens||{}).forEach(([ie,me])=>{C.appendChild(ee(ie,me))}),h.appendChild(te("Token overrides",C));const L=document.createElement("button");L.type="button",L.className="btn btn-ghost btn-sm ov-add-token",L.textContent="+ Add token",h.appendChild(L);const N=document.createElement("details");N.style.marginTop="1rem";const _=document.createElement("summary");_.style.cssText="cursor:pointer;font-size:.85rem;",_.textContent="Custom CSS for this override",N.appendChild(_);const k=document.createElement("textarea");k.className="form-input ov-css",k.rows=4,k.placeholder="#site-navbar .brand { letter-spacing: .04em; }",k.value=e.css||"",k.style.marginTop=".5rem",N.appendChild(k);const P=document.createElement("p");return P.className="form-hint text-muted",P.style.cssText="font-size:.8rem;margin-top:.35rem;",P.textContent="Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade, hand-written rules fight the cascade.",N.appendChild(P),h.appendChild(N),n.appendChild(h),setTimeout(()=>j(n),0),n}function ee(e,t){const n=document.createElement("div");n.className="ov-token-row",n.style.cssText="display:flex;gap:.5rem;align-items:center;margin-bottom:.5rem;";const o=document.createElement("select");if(o.className="form-select ov-token-name",o.style.flex="1 1 auto",D.length){for(const d of D){const i=document.createElement("option");i.value=d,i.textContent=d,o.appendChild(i)}o.value=e||D[0]}else{const d=document.createElement("option");d.value=e||"",d.textContent=e||"(no tokens found)",o.appendChild(d)}const a=document.createElement("input");a.type="text",a.className="form-input ov-token-value",a.style.flex="0 0 150px",a.placeholder="#0096c7",a.value=t||"";const r=document.createElement("input");r.type="color",r.className="ov-token-swatch",r.style.cssText="flex:0 0 38px;height:34px;padding:0;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);background:none;cursor:pointer;",/^#[0-9a-f]{6}$/i.test(t||"")&&(r.value=t),r.addEventListener("input",()=>{a.value=r.value,a.dispatchEvent(new Event("input",{bubbles:!0}))});const c=document.createElement("button");return c.type="button",c.className="btn btn-ghost btn-sm ov-token-remove",c.textContent="\xD7",n.append(o,a,r,c),n}function V(e,t,n,o){const a=document.createElement("div");return a.className=e,a.appendChild(te(t,n,o)),a}function te(e,t,n){const o=document.createDocumentFragment(),a=document.createElement("label");if(a.className="form-label",a.textContent=e,o.appendChild(a),o.appendChild(t),n){const r=document.createElement("p");r.className="form-hint text-muted",r.style.cssText="margin-top:.35rem;font-size:.8rem;",r.textContent=n,o.appendChild(r)}return o}function ne(e,t,n){const o=document.createElement("input");return o.type="text",o.className=`form-input ${e}`,o.value=t||"",o.placeholder=n||"",o}function de(e){const t=document.createElement("div"),n=ne("ov-scope",e,"#site-navbar");if(n.setAttribute("list","theme-scope-options"),t.appendChild(n),!document.getElementById("theme-scope-options")){const o=document.createElement("datalist");o.id="theme-scope-options";for(const a of he){const r=document.createElement("option");r.value=a.value,r.label=a.label,o.appendChild(r)}t.appendChild(o)}return t}function F(){const e=y.querySelectorAll(".card"),t=[];return e.forEach((n,o)=>{const a={};n.querySelectorAll(".ov-token-row").forEach(r=>{const c=r.querySelector(".ov-token-name")?.value?.trim(),d=r.querySelector(".ov-token-value")?.value?.trim();c&&d&&(a[c]=d)}),t.push({id:b[o]?.id,label:n.querySelector(".ov-label")?.value?.trim()||"",enabled:!!n.querySelector(".ov-enabled")?.checked,scope:n.querySelector(".ov-scope")?.value?.trim()||"",theme:n.querySelector(".ov-theme")?.value||"",tokens:a,css:n.querySelector(".ov-css")?.value||""})}),b=t,t}s.on("click",".ov-remove",function(){const e=Number(this.closest(".card").dataset.i);F(),b.splice(e,1),R()}),s.on("change",".ov-theme",function(){j(this.closest(".card"))}),s.on("change","#theme-frontend, #theme-auto-enabled, #theme-day",()=>{y.querySelectorAll(".card").forEach(j)}),s.on("click",".ov-add-token",function(){this.closest(".card").querySelector(".ov-tokens").appendChild(ee("",""))}),s.on("click",".ov-token-remove",function(){this.closest(".ov-token-row").remove()}),s.find("#theme-add-override").on("click",()=>{F(),b.push($({label:"",scope:"",enabled:!0})),R()}),R();async function K(){const e=document.getElementById("theme-css-preview"),t={...oe(),overrides:F()};try{const n=await w.theme.compile(t);e.textContent=n.css?.trim()||"(no overrides - nothing is injected)",e.style.color=""}catch(n){e.textContent=n.message||String(n),e.style.color="var(--dm-danger)"}}s.find("#theme-refresh-css").on("click",K);function oe(){const e=s.find("#theme-auto-enabled").prop("checked"),t={enabled:e,dayTheme:S.val()||"charcoal-light",nightTheme:I.val()||"charcoal-dark",dayStart:s.find("#theme-day-start").val()||"07:00",nightStart:s.find("#theme-night-start").val()||"19:00"};return{theme:e?t.dayTheme:f.val(),adminTheme:O.val(),autoTheme:t,font:{family:s.find("#theme-font-family").val()||"Roboto",size:parseInt(s.find("#theme-font-size").val(),10)||16},switcher:{enabled:B.prop("checked"),position:s.find("#theme-switcher-position").val()||"right-center",offsetX:parseInt(s.find("#theme-switcher-offset-x").val(),10)||0,offsetY:parseInt(s.find("#theme-switcher-offset-y").val(),10)||0,themes:Array.isArray(m.switcher?.themes)?m.switcher.themes:[]}}}function se(e){const t=document.getElementById("theme-errors");for(;t.firstChild;)t.removeChild(t.firstChild);if(!e||!e.length){t.style.display="none";return}t.style.display="";for(const n of e){const o=document.createElement("div");o.textContent=n,t.appendChild(o)}t.scrollIntoView({behavior:"smooth",block:"nearest"})}s.find("#theme-save-btn").on("click",async function(){se(null);const e={...oe(),overrides:F()};this.disabled=!0;try{m=await w.theme.save(e);const{refreshAdminTheme:n}=await import("../lib/admin-theme.js");await n(w),E.toast("Theme saved.",{type:"success"}),await K()}catch(t){const n=t.validation||[t.message||"Could not save the theme."];se(n),E.toast("Theme not saved.",{type:"error"})}finally{this.disabled=!1}}),await K()}};
|
|
1
|
+
import{api as N}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Pe}from"../lib/help-popover.js";import{onceClosed as lt,openMenuAt as Ke,sidebarCount as it}from"../lib/tool-kit.js";import{populateThemeSelect as Fe}from"../lib/themes.js";import{cloneOverride as Q,dayOrNight as ct,formFromConfig as X,knownThemeIds as dt,moveItem as He,overrideRow as mt,payloadFromForm as Z,snapshot as pe,swatchColour as ze,themeDiff as ht,themeLabel as ut,themeProblems as pt}from"../lib/theme-settings-arrange.js";const fe=/^\/theme(\?.*)?$/,ft="#/theme",L="theme.draft",vt=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}],bt=`
|
|
2
|
+
<div class="thx-row" data-id="{{id}}" tabindex="0" data-bind-class="off && 'is-off'">
|
|
3
|
+
<span class="thx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
|
|
4
|
+
<span class="thx-chip is-danger" data-bind-hidden="!missing" title="Its theme no longer exists">Theme gone</span>
|
|
5
|
+
<span class="thx-chip is-warn" data-bind-hidden="!empty" title="No theme, token or CSS - it changes nothing">Empty</span>
|
|
6
|
+
<span class="thx-chip" data-bind-hidden="!off">Off</span>
|
|
7
|
+
<button type="button" class="thx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
8
|
+
</div>`,ve=m=>String(m??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function yt(m){const y=document.createElement("template");return y.innerHTML=m.trim(),y.content.firstElementChild}let x=null,Be=!1;function kt(){Be||!window.R?.use||(Be=!0,R.use((m,y,u)=>!(fe.test(y?.path||"")&&!fe.test(m?.path||""))||!x?.dirty()?u():E.confirm("You have unsaved theme changes. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?u():history.replaceState(null,"","#/theme")})),M.subscribe("router:afterChange",({to:m}={})=>{if(x&&!fe.test(m?.path||"")){const y=x;x=null,y.dispose()}}))}export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(m){if(kt(),x){const e=x;x=null,e.dispose()}const y=[],u=e=>(y.push(e),e);let ee=!1;const p=m.get(0).querySelector(".thx")||m.get(0),j=()=>!ee&&p.isConnected,be=E.loader(m.get(0),{type:"dots"});let P,q;try{[P,q]=await Promise.all([N.theme.get(),N.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){be.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(be.destroy(),!p.isConnected)return;const te=q.tokens||[],K=Array.isArray(q.custom)?q.custom:[],ye=dt(q),f=M.observable(P),$=X(P),s={};for(const[e,t]of Object.entries($))s[e]=M.observable(t);const ke=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),oe=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),F=M.observable(pe($,P)),H=M.observable(!1),z=M.observable([]),ne=M.observable("(nothing yet)"),ae=M.observable(!1),_e=M.computed(()=>pe(ke(),f.value)),B=M.computed(()=>_e.value!==F.value),ge=M.computed(()=>pt(ke(),ye)),we=M.observable([]);u(M.effect(()=>{we.value=s.overrides.value.map((e,t)=>mt(e,t,{custom:K,known:ye}))}));const _=e=>ut(e,K),Ve={...s,saving:H,rows:we,cssText:ne,cssError:ae,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ge.value.length?`${ge.value.map(e=>`${e.where}: "${e.id}"`).join(", ")} - no longer available, so its base theme shows. Pick another.`:""),liveSite:M.computed(()=>{const e=f.value;if(e.autoTheme?.enabled){const t=ct(e.autoTheme)==="day"?"day":"night";return`Site: ${_(e.autoTheme.dayTheme)} by day, ${_(e.autoTheme.nightTheme)} by night (${t} now)`}return`Site: ${_(e.theme||"charcoal-dark")}`}),liveAdmin:M.computed(()=>`Admin: ${_(f.value.adminTheme||"charcoal-dark")}`),liveOverrides:M.computed(()=>{const e=f.value.overrides||[],t=e.filter(o=>o.enabled!==!1).length;return e.length?`${t} override${t===1?"":"s"} on`:""}),liveSwitcher:M.computed(()=>f.value.switcher?.enabled?"Theme switcher on":"")};function xe(e){if(!K.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of K){const a=document.createElement("option");a.value=o.id,a.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(a)}e.appendChild(t)}function Ee(e,t){if(t&&![...e.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,e.appendChild(o)}}const V=(e,t,o)=>{const a=p.querySelector(e);return Fe(a,o),xe(a),Ee(a,t),a};V("#thx-site",$.theme),V("#thx-admin",$.adminTheme,{includeAdmin:!0}),V("#thx-day",$.dayTheme),V("#thx-night",$.nightTheme);let se=null,re=0;async function Y(){const e=++re;try{const t=await N.theme.compile(Z(oe(),f.peek()));if(e!==re||!j())return;ne.value=t.css?.trim()||"(no overrides - nothing is injected)",ae.value=!1}catch(t){if(e!==re||!j())return;ne.value=t.message||String(t),ae.value=!0}}u(M.effect(()=>{s.overrides.value,clearTimeout(se),se=setTimeout(Y,400)})),u(()=>clearTimeout(se));async function le(){if(!H.peek()){H.value=!0,z.value=[];try{const e=await N.theme.save(Z(oe(),f.peek()));if(!j())return;f.value=e;const t=X(e);for(const[a,l]of Object.entries(t))s[a].value=l;F.value=pe(t,e);try{S.remove?S.remove(L):S.set(L,null)}catch{}const{refreshAdminTheme:o}=await import("../lib/admin-theme.js");await o(N),it(ft),E.toast("Theme saved.",{type:"success"}),Y()}catch(e){z.value=e.validation||[e.message||"Could not save the theme."],E.toast("Theme not saved - see the message at the top.",{type:"error"})}finally{H.value=!1}}}async function Ye(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=X(f.peek());for(const[t,o]of Object.entries(e))s[t].value=o;z.value=[]}const Se=e=>s.overrides.peek().find(t=>t.id===e),C=e=>{s.overrides.value=e},ie=(e,t)=>C(s.overrides.peek().map(o=>o.id===e?t:o)),Ue=e=>s.overrides.peek().findIndex(t=>t.id===e),ce=new Map;async function Ce(e){if(!e)return null;if(ce.has(e))return ce.get(e);const o=(await N.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ce.set(e,o),o}const Je=()=>(s.autoEnabled.peek()?s.dayTheme.peek():s.theme.peek())||"charcoal-dark",U=new Set;function J(e){const t=e?Se(e):Q({});if(!t)return;const o=!e,a=Q(t),l={label:M.observable(a.label),scope:M.observable(a.scope),enabled:M.observable(a.enabled),theme:M.observable(a.theme),css:M.observable(a.css),tokenErr:M.observable("")},c=E.slideover({title:o?"New override":`Override: ${ve(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=yt(`
|
|
9
|
+
<div class="thx-form">
|
|
10
|
+
<div class="thx-grid">
|
|
11
|
+
<div>
|
|
12
|
+
<label class="form-label" for="thx-o-label">Name</label>
|
|
13
|
+
<input id="thx-o-label" type="text" class="form-input" maxlength="80" placeholder="Navbar island" data-model="label.value">
|
|
14
|
+
</div>
|
|
15
|
+
<div class="thx-setting" style="align-self:end">
|
|
16
|
+
<label class="form-label" for="thx-o-on">On
|
|
17
|
+
<span data-help="Off keeps the override but leaves it out of the page."></span></label>
|
|
18
|
+
<input id="thx-o-on" type="checkbox" class="form-switch" data-model="enabled.value">
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div>
|
|
22
|
+
<label class="form-label" for="thx-o-scope">Target selector
|
|
23
|
+
<span data-help-title="Target selector" data-help="The region to re-theme - a CSS selector. Everything inside it follows. Pick a common region from the list or type your own (#site-navbar, .card, .page-body \u2026)."></span></label>
|
|
24
|
+
<input id="thx-o-scope" type="text" class="form-input font-mono" list="thx-scope-options" placeholder="#site-navbar" data-model="scope.value">
|
|
25
|
+
<datalist id="thx-scope-options">${vt.map(n=>`<option value="${n.value}" label="${n.label}"></option>`).join("")}</datalist>
|
|
26
|
+
</div>
|
|
27
|
+
<div>
|
|
28
|
+
<label class="form-label" for="thx-o-theme">Adopt a theme here
|
|
29
|
+
<span data-help-title="Adopt a theme" data-help="Copies every token of that theme onto this region. Two themes often share a surface colour, so the panel below says what really changes - set --dm-card-bg or --dm-surface as a token when the fill should change too."></span></label>
|
|
30
|
+
<select id="thx-o-theme" class="form-select" data-model="theme.value"></select>
|
|
31
|
+
<div class="thx-diff-host"></div>
|
|
32
|
+
</div>
|
|
33
|
+
<div>
|
|
34
|
+
<label class="form-label">Token overrides
|
|
35
|
+
<span data-help-title="Tokens" data-help="Individual --dm-* design tokens for this region, set after any adopted theme. Only tokens the shipped stylesheet defines are accepted - start typing to see them. The colour box fills in a hex value; lengths and keywords go in the text box."></span></label>
|
|
36
|
+
<div class="thx-toks"></div>
|
|
37
|
+
<datalist id="thx-token-names"></datalist>
|
|
38
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="addToken">
|
|
39
|
+
<span data-icon="plus" data-icon-size="13"></span> Add token</button>
|
|
40
|
+
<small class="thx-err" data-bind-hidden="!tokenErr.value" data-bind-text="tokenErr.value"></small>
|
|
41
|
+
</div>
|
|
42
|
+
<details class="thx-o-css"${a.css?" open":""}>
|
|
43
|
+
<summary>Custom CSS for this override</summary>
|
|
44
|
+
<textarea class="form-input font-mono" rows="4" style="margin-top:.5rem" placeholder="#site-navbar .brand { letter-spacing: .04em; }" data-model="css.value"></textarea>
|
|
45
|
+
<small class="thx-hint">Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade; hand-written rules fight the cascade.</small>
|
|
46
|
+
</details>
|
|
47
|
+
<div class="thx-actions">
|
|
48
|
+
<button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="remove"${o?" hidden":""}>
|
|
49
|
+
<span data-icon="trash" data-icon-size="13"></span> Remove</button>
|
|
50
|
+
<span class="thx-spacer"></span>
|
|
51
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
52
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="done">
|
|
53
|
+
<span data-icon="check" data-icon-size="13"></span> ${o?"Add":"Done"}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>`),de=v.querySelector("#thx-o-theme");Fe(de,{includeDefault:!0,defaultLabel:"Keep the site theme"}),xe(de),Ee(de,a.theme);const Xe=v.querySelector("#thx-token-names");for(const n of te){const i=document.createElement("option");i.value=n,Xe.appendChild(i)}const me=v.querySelector(".thx-toks");function Ne(n="",i=""){const b=document.createElement("div");b.className="thx-tok";const r=document.createElement("input");r.type="text",r.className="form-input font-mono thx-tok-name",r.placeholder="--dm-primary",r.setAttribute("list","thx-token-names"),r.value=n,r.setAttribute("aria-label","Token");const h=document.createElement("input");h.type="text",h.className="form-input thx-tok-value",h.placeholder="#0096c7",h.value=i,h.setAttribute("aria-label","Value");const k=document.createElement("input");k.type="color",k.title="Pick a colour";const g=ze(i);/^#[0-9a-f]{6}$/i.test(g)&&(k.value=g),k.addEventListener("input",()=>{h.value=k.value});const w=document.createElement("button");return w.type="button",w.className="btn btn-sm btn-ghost",w.textContent="\xD7",w.title="Remove this token",w.addEventListener("click",()=>b.remove()),b.append(r,h,k,w),b}for(const[n,i]of Object.entries(a.tokens))me.appendChild(Ne(n,i));function De(){const n={},i=[];return me.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),h=b.querySelector(".thx-tok-value").value.trim();!r||!h||(te.length&&!te.includes(r)&&i.push(r),n[r]=h)}),{tokens:n,bad:i}}const Le=()=>({...a,label:l.label.peek(),scope:l.scope.peek(),enabled:l.enabled.peek(),theme:l.theme.peek(),css:l.css.peek(),tokens:De().tokens}),Ze=JSON.stringify(Z({overrides:[a]}).overrides),et=()=>JSON.stringify(Z({overrides:[Le()]}).overrides)!==Ze,he=v.querySelector(".thx-diff-host");let Re=0;async function tt(){const n=++Re;he.replaceChildren();const i=l.theme.peek();if(!i)return;const b=Je(),r=document.createElement("div");if(r.className="thx-diff",r.style.marginTop=".5rem",i===b){const d=document.createElement("p");d.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",r.appendChild(d),he.appendChild(r);return}const[h,k]=await Promise.all([Ce(b),Ce(i)]);if(n!==Re||!h||!k)return;const g=ht(h,k),w=document.createElement("p");if(w.textContent=`Changes ${g.changed} of ${g.total} tokens here.`,r.appendChild(w),g.keepsFill){const d=document.createElement("p");d.className="is-warn",d.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",r.appendChild(d)}const G=document.createElement("div");G.className="thx-diff-list";const je=d=>{const A=document.createElement("span");return A.className="thx-sw",A.style.background=ze(d)||"transparent",A};for(const d of g.shown){const A=document.createElement("span");A.append(je(d.from),document.createTextNode(`${d.name} `),je(d.to)),G.appendChild(A)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,G.appendChild(d)}r.appendChild(G),he.appendChild(r)}const ot=M.effect(()=>{l.theme.value,tt()});let W=!1,ue=!1;const qe={addToken:()=>{const n=Ne();me.appendChild(n),n.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(s.overrides.peek().filter(n=>n.id!==t.id)),W=!0,c.close())},done(){const{bad:n}=De();if(n.length){l.tokenErr.value=`Not a token this Domma defines: ${n.join(", ")}. Pick one from the list.`;return}const i=Le();o?C([...s.overrides.peek(),i]):ie(t.id,i),W=!0,c.close()}};c.element.appendChild(v);const nt=Pe(v),at=M.applyBindings(l,v,{methods:qe}),st=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),qe.done())};v.addEventListener("keydown",st),U.add(c),lt(c,()=>{ot(),at.dispose(),nt(),U.delete(c)});const rt=c.close.bind(c);c.close=async n=>{if(!W&&n!==!0&&et()){if(ue)return;ue=!0;const i=await E.confirm("Discard your changes to this override?");if(ue=!1,!i)return}return W=!0,rt()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function We(e){const t=Se(e);if(!t)return[];const o=Ue(e),a=s.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>J(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>ie(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>ie(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const l=Q({...t,id:"",label:t.label?`${t.label} copy`:""}),c=[...s.overrides.peek()];c.splice(o+1,0,l),C(c)}},{type:"divider"},{label:"Move up",icon:"arrow-up",disabled:o<=0,action:()=>C(He(s.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=a-1,action:()=>C(He(s.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&C(s.overrides.peek().filter(l=>l.id!==e))}}]}const O=p.querySelector(".thx-list");O.innerHTML=bt,E.tabs(p.querySelector(".thx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});const Ge=M.applyBindings(Ve,p,{methods:{save:le,discard:Ye,refreshCss:Y,addOverride:()=>J(null)}});u(()=>Ge.dispose()),u(Pe(p,{position:"bottom"}));const Qe=E.contextMenu(O,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>We(e.dataset.id)});u(()=>Qe?.destroy?.());const Te=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){Ke(e.target.closest(".thx-kebab"));return}J(t.dataset.id)}},Me=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ke(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.id))};O.addEventListener("click",Te),O.addEventListener("keydown",Me),u(()=>{O.removeEventListener("click",Te),O.removeEventListener("keydown",Me)}),Ie(m,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),Ie(m,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>le()});const $e=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||U.size||(e.preventDefault(),e.stopImmediatePropagation(),le())};document.addEventListener("keydown",$e,!0),u(()=>document.removeEventListener("keydown",$e,!0));const Oe=()=>{if(B.peek())try{S.set(L,{form:oe(),base:F.peek(),at:new Date().toISOString()})}catch{}},Ae=e=>{B.peek()&&(Oe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ae),u(()=>window.removeEventListener("beforeunload",Ae)),x={dirty:()=>B.peek(),dispose(){ee=!0,Oe(),[...U].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p),Y();let T=null;try{T=S.get(L)}catch{}if(T?.form){try{S.remove?S.remove(L):S.set(L,null)}catch{}const e=T.base&&T.base!==F.peek(),t=T.at?` (${D(T.at).format("D MMM HH:mm")})`:"",o=e?`You left unsaved theme changes${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved theme changes${t}. Restore them?`;if(await E.confirm(o)&&j()){const a={...X(f.peek()),...T.form};a.overrides=(a.overrides||[]).map(l=>Q(l));for(const[l,c]of Object.entries(a))s[l]&&(s[l].value=c)}}}};
|
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()}};
|
|
@@ -1 +1,11 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{apiRequest as m}from"../api.js";import{addBannerAction as W}from"../lib/plugin-chrome.js";import{helpPopovers as G}from"../lib/help-popover.js";import{openMenuAt as L,sidebarCount as g,viewLifetime as J}from"../lib/tool-kit.js";import{FILTERS as Y,arrangeViews as z,countViews as Q,newViewHref as X,projectOf as y,projectScope as Z,shortcodeFor as ee,viewRow as te}from"../lib/views-arrange.js";const A=/^#\/(views|projects\/[^/]+\/views)\/?(\?.*)?$/,b="#/views",f="views.filter",se=`
|
|
2
|
+
<div class="vwx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
|
|
3
|
+
<span class="vwx-main"><strong>{{title}}</strong><small>{{meta}}</small>
|
|
4
|
+
<small class="is-bad" data-bind-hidden="!broken">{{brokenText}}</small></span>
|
|
5
|
+
<span class="vwx-chips">
|
|
6
|
+
<span class="vwx-chip is-info" data-bind-hidden="!engine">{{engine}}</span>
|
|
7
|
+
<span class="vwx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
|
|
8
|
+
<span class="vwx-chip" data-bind-class="accessCls" title="Who can see it">{{access}}</span>
|
|
9
|
+
</span>
|
|
10
|
+
<button type="button" class="vwx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
11
|
+
</div>`,x=c=>String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),o=J(A);export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(c){o.reset();const r=c.get(0).querySelector(".vwx")||c.get(0),u=()=>A.test(location.hash)&&r.isConnected,a=Z(location.hash),i=M.observable([]),p=M.observable(!0),w=M.observable("");let k="all";try{k=Y.includes(S.get(f))?S.get(f):"all"}catch{}const s=M.observable(k),$=M.observable("");o.add(M.effect(()=>{const e=s.value;try{S.set(f,e)}catch{}}));const T=M.observable([]);o.add(M.effect(()=>{T.value=z(i.value,{filter:s.value,query:$.value,project:a}).map(te)}));const d=M.computed(()=>Q(i.value,{project:a}));o.add(M.effect(()=>{s.value==="broken"&&!d.value.broken&&!p.value&&(s.value="all")}));const D=M.computed(()=>{const e=i.value.length-d.value.all;return`Showing only views in ${a}.${e?` ${e} other${e===1?" is":"s are"} filed elsewhere.`:""}`}),N=M.computed(()=>d.value.all?"No views match.":"No views yet - New view in the banner makes one."),P=e=>i.peek().find(t=>t.slug===e);async function v(){try{const e=await m("/views?sources=1",{method:"GET"});if(!u())return;i.value=Array.isArray(e)?e:[],w.value=""}catch(e){w.value=`Could not load the views: ${e.message||e}`}finally{p.value=!1}}const h=e=>{location.hash=`#/views/edit/${encodeURIComponent(e)}`},U=e=>{location.hash=`#/views/${encodeURIComponent(e)}/preview`};async function q(e){const t=ee(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this: ${t}`,{type:"info",duration:8e3})}}async function O(e){const t=await E.prompt("Title of the copy",{inputValue:x(`${e.title||e.slug} copy`)});if(!(!t||!String(t).trim()||!u()))try{const{id:l,slug:oe,meta:R,sourceInfo:ae,...K}=e,H=await m("/views",{method:"POST",body:JSON.stringify({...K,title:String(t).trim(),...R?.project!=null&&{meta:{project:R.project}}})});E.toast(`Made "${H.title}".`,{type:"success"}),g(b),await v()}catch(l){E.toast(`Not duplicated: ${l.message||l}`,{type:"error"})}}async function _(e){if(await E.confirm(`Delete the view "${x(e.title||e.slug)}"? Pages that show it with [view slug="${x(e.slug)}"] will show nothing. This cannot be undone.`))try{await m(`/views/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),g(b),await v()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}function V(e){const t=P(e);return t?[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>h(t.slug)},{label:"Preview results",icon:"eye",action:()=>U(t.slug)},{label:"Copy shortcode",icon:"copy",action:()=>q(t)},...y(t)!=="core"&&!a?[{label:`Open project "${y(t)}"`,icon:"folder",action:()=>{location.hash=`#/projects/${encodeURIComponent(y(t))}/views`}}]:[],{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>O(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>_(t)}]:[]}const n=r.querySelector(".vwx-list");n.innerHTML=se;const B=M.applyBindings({views:i,loading:p,loadError:w,filter:s,query:$,rows:T,counts:d,emptyText:N,scopeText:D,project:a},r,{methods:{showAll:()=>{s.value="all"},showPublic:()=>{s.value="public"},showRestricted:()=>{s.value="restricted"},showBroken:()=>{s.value="broken"}}});o.add(()=>B.dispose()),o.add(G(r,{position:"bottom"}));const F=E.contextMenu(n,{cursor:"glyph",match:".vwx-row",inherit:!1,density:"compact",items:e=>V(e.dataset.slug)});o.add(()=>F?.destroy?.());const C=e=>{const t=e.target.closest(".vwx-row");if(!t)return;const l=e.target.closest(".vwx-kebab");if(l){L(l);return}h(t.dataset.slug)},j=e=>{const t=e.target.closest(".vwx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&h(t.dataset.slug))};n.addEventListener("click",C),n.addEventListener("keydown",j),o.add(()=>{n.removeEventListener("click",C),n.removeEventListener("keydown",j)}),W(c,{id:"views-new",icon:"plus",title:"New view",onClick:()=>{location.hash=X(a)}}),I.scan(r),await v(),u()&&g(b)}};
|
package/package.json
CHANGED
|
@@ -72,6 +72,7 @@ import {applyIdentity, findIdentity, identityFrom, swapSignature} from './identi
|
|
|
72
72
|
import {describeWhen, quickTimes} from './scheduling.js';
|
|
73
73
|
import {applyTemplate, expandTemplate} from './templates.js';
|
|
74
74
|
import {makeResizable} from './resizable.js';
|
|
75
|
+
import {whenClosed} from './when-closed.js';
|
|
75
76
|
import {
|
|
76
77
|
collectGroups,
|
|
77
78
|
currentFragment,
|
|
@@ -509,16 +510,6 @@ export function openCompose({
|
|
|
509
510
|
}
|
|
510
511
|
form.appendChild(bodyWrap);
|
|
511
512
|
|
|
512
|
-
// What the window opened showing, read back out of the thing showing it.
|
|
513
|
-
//
|
|
514
|
-
// NOT `draft.body`: the editor is seeded from `draft.html` and derives its
|
|
515
|
-
// own text from that, so the two already differed on every HTML reply -
|
|
516
|
-
// which is why discarding an untouched reply asked whether to lose
|
|
517
|
-
// changes nobody had made. A signature makes that worse, since it means
|
|
518
|
-
// even a new message opens "changed". Comparing against what was actually
|
|
519
|
-
// put in front of the person is both the fix and the only baseline that
|
|
520
|
-
// stays true once anything else seeds the body.
|
|
521
|
-
const openedText = editor ? editor.getText() : bodyInput.value;
|
|
522
513
|
|
|
523
514
|
// Attachments
|
|
524
515
|
// Seeded from the draft, so reopening one keeps the files that were
|
|
@@ -653,7 +644,13 @@ export function openCompose({
|
|
|
653
644
|
const saveDraftBtn = document.createElement('button');
|
|
654
645
|
saveDraftBtn.className = 'btn btn-sm btn-ghost';
|
|
655
646
|
saveDraftBtn.textContent = 'Save draft';
|
|
656
|
-
|
|
647
|
+
/**
|
|
648
|
+
* Save this message to the mailbox's Drafts folder - the button, and what
|
|
649
|
+
* leaving the screen does with a message that has changed.
|
|
650
|
+
*
|
|
651
|
+
* @returns {Promise<object|null>} the server's answer, or null if it failed
|
|
652
|
+
*/
|
|
653
|
+
async function saveDraft() {
|
|
657
654
|
saveDraftBtn.disabled = true;
|
|
658
655
|
say('Saving\u2026');
|
|
659
656
|
try {
|
|
@@ -663,47 +660,56 @@ export function openCompose({
|
|
|
663
660
|
replaces: savedUid
|
|
664
661
|
});
|
|
665
662
|
savedUid = result.uid ?? savedUid;
|
|
663
|
+
// Saved is the new baseline: closing now loses nothing.
|
|
664
|
+
savedSnapshot = snapshot();
|
|
666
665
|
say(`Saved to ${result.savedTo}.`, 'ok');
|
|
667
666
|
// The Drafts folder now holds a different set of UIDs, so a list
|
|
668
667
|
// showing it is out of date whether or not the window closes.
|
|
669
668
|
onSaved(result);
|
|
669
|
+
return result;
|
|
670
670
|
} catch (err) {
|
|
671
671
|
say(err.message, 'bad');
|
|
672
|
+
return null;
|
|
672
673
|
} finally {
|
|
673
674
|
saveDraftBtn.disabled = false;
|
|
674
675
|
}
|
|
675
|
-
}
|
|
676
|
+
}
|
|
677
|
+
saveDraftBtn.addEventListener('click', () => { saveDraft(); });
|
|
676
678
|
|
|
677
679
|
const cancel = document.createElement('button');
|
|
678
680
|
cancel.className = 'btn btn-sm btn-ghost';
|
|
679
681
|
cancel.textContent = 'Discard';
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
// the only place that text exists until it is saved.
|
|
682
|
+
/** What the message says now - compared with the last saved (or opened) state. */
|
|
683
|
+
function snapshot() {
|
|
683
684
|
const current = editor ? editor.getText() : bodyInput.value;
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
685
|
+
return JSON.stringify([subjectInput.value.trim(), current.trim(), toInput.value.trim(),
|
|
686
|
+
ccInput.value.trim(), bccInput.value.trim(), attachments.length]);
|
|
687
|
+
}
|
|
688
|
+
let savedSnapshot = null; // set once the window is built (below)
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Whether closing now would lose something.
|
|
692
|
+
*
|
|
693
|
+
* Compared with the last saved state, or what the window opened with - so
|
|
694
|
+
* a reopened draft closed unchanged does not ask, and a reply or forward,
|
|
695
|
+
* which opens with `Re:` / `Fwd:` and a quote already in it, does not
|
|
696
|
+
* count as unsaved work until the reader actually changes something.
|
|
697
|
+
*/
|
|
698
|
+
function unsaved() {
|
|
699
|
+
return savedSnapshot !== null && snapshot() !== savedSnapshot;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Discard, the X, Escape and the backdrop all go through slideover.close,
|
|
703
|
+
// which asks (see the guard at the end) - so this button just closes.
|
|
704
|
+
cancel.addEventListener('click', () => slideover.close());
|
|
705
|
+
|
|
706
|
+
/** The question the close guard asks, for what would be lost. */
|
|
707
|
+
function discardQuestion() {
|
|
708
|
+
return savedUid
|
|
709
|
+
? 'Close without saving? The changes since you last saved this draft will be lost.'
|
|
710
|
+
: 'Discard this message? It has not been saved as a draft.';
|
|
711
|
+
}
|
|
712
|
+
|
|
707
713
|
|
|
708
714
|
/**
|
|
709
715
|
* The message as it currently stands.
|
|
@@ -855,7 +861,7 @@ export function openCompose({
|
|
|
855
861
|
replaces: savedUid
|
|
856
862
|
});
|
|
857
863
|
|
|
858
|
-
slideover.close();
|
|
864
|
+
slideover.close(true);
|
|
859
865
|
|
|
860
866
|
// Three outcomes, three different things to say. A message held
|
|
861
867
|
// for ten seconds has NOT been sent, and saying it has is how
|
|
@@ -905,10 +911,61 @@ export function openCompose({
|
|
|
905
911
|
}
|
|
906
912
|
}, 60);
|
|
907
913
|
|
|
914
|
+
// The baseline for "changed": what the window opened showing, read back out
|
|
915
|
+
// of the thing showing it (taken once the editor has been seeded).
|
|
916
|
+
//
|
|
917
|
+
// NOT `draft.body`: the editor is seeded from `draft.html` and derives its
|
|
918
|
+
// own text from that, so the two already differed on every HTML reply -
|
|
919
|
+
// which is why discarding an untouched reply asked whether to lose
|
|
920
|
+
// changes nobody had made. A signature makes that worse, since it means
|
|
921
|
+
// even a new message opens "changed". Comparing against what was actually
|
|
922
|
+
// put in front of the person is both the fix and the only baseline that
|
|
923
|
+
// stays true once anything else seeds the body.
|
|
924
|
+
setTimeout(() => { if (savedSnapshot === null) savedSnapshot = snapshot(); }, 0);
|
|
925
|
+
|
|
926
|
+
// Every way out asks when something would be lost - Discard, the X, Escape
|
|
927
|
+
// and the backdrop all call close(). Once closed it goes straight through:
|
|
928
|
+
// a later close() (the cleanup below, or the screen being left) must not ask again.
|
|
929
|
+
const rawClose = slideover.close.bind(slideover);
|
|
930
|
+
let closed = false;
|
|
931
|
+
let asking = false;
|
|
932
|
+
const closedListeners = [];
|
|
933
|
+
slideover.close = async (force) => {
|
|
934
|
+
if (!closed && force !== true && unsaved()) {
|
|
935
|
+
if (asking) return undefined;
|
|
936
|
+
asking = true;
|
|
937
|
+
// Two different losses, and saying the wrong one is worse than
|
|
938
|
+
// saying nothing: one has never been saved, the other has changes
|
|
939
|
+
// since it was.
|
|
940
|
+
const ok = await E.confirm(discardQuestion());
|
|
941
|
+
asking = false;
|
|
942
|
+
if (!ok) return undefined;
|
|
943
|
+
}
|
|
944
|
+
if (closed) return undefined;
|
|
945
|
+
closed = true;
|
|
946
|
+
return rawClose();
|
|
947
|
+
};
|
|
948
|
+
|
|
908
949
|
// The editor is a live component with its own listeners and autosave.
|
|
909
|
-
slideover
|
|
950
|
+
whenClosed(slideover, () => {
|
|
951
|
+
closed = true;
|
|
910
952
|
editor?.destroy?.();
|
|
911
953
|
stopResizing();
|
|
912
954
|
for (const suggester of suggesters) suggester.destroy?.();
|
|
955
|
+
closedListeners.splice(0).forEach(fn => { try { fn(); } catch { /* ignore */ } });
|
|
956
|
+
// Domma hides a closed slideover rather than removing it: every message
|
|
957
|
+
// written would otherwise leave its window behind in the page.
|
|
958
|
+
setTimeout(() => { try { slideover.element?.remove(); } catch { /* gone */ } }, 400);
|
|
913
959
|
});
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
/** Whether closing would lose something the reader wrote. */
|
|
963
|
+
dirty: () => !closed && unsaved(),
|
|
964
|
+
/** Save to Drafts now (what leaving the screen does with a changed message). */
|
|
965
|
+
saveDraft,
|
|
966
|
+
/** Close; `true` skips the question. */
|
|
967
|
+
close: (force) => slideover.close(force),
|
|
968
|
+
/** Called once, when the window has closed however it closed. */
|
|
969
|
+
onClosed: (fn) => { if (closed) fn(); else closedListeners.push(fn); }
|
|
970
|
+
};
|
|
914
971
|
}
|
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
* not hold. A 404 here is indistinguishable from "not installed", which is
|
|
29
29
|
* exactly the behaviour wanted.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
// Core since 0.67.0 (it was the Contacts plugin, at /api/plugins/contacts/contacts:
|
|
32
|
+
// that address has 404'd ever since, so "Add sender to contacts" and group
|
|
33
|
+
// expansion in compose silently vanished).
|
|
34
|
+
const CONTACTS_ENDPOINT = '/api/contacts/contacts';
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Fetch the address book once.
|
|
@@ -129,5 +129,8 @@ export function createDiagnosticsSection(host) {
|
|
|
129
129
|
container.appendChild(actions);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
id: 'diagnostics', label: 'Diagnostics', render,
|
|
134
|
+
help: 'What this screen can see about itself - whether it runs the build the server is serving, the mailbox and folder, where the list came from, and recent errors. Copy diagnostics puts it on the clipboard for a report.'
|
|
135
|
+
};
|
|
133
136
|
}
|
|
@@ -245,7 +245,7 @@ export function folderMenuItems(roots, {current = null, onPick, label = null}) {
|
|
|
245
245
|
return {
|
|
246
246
|
label: node.name,
|
|
247
247
|
icon: iconForFolder(node),
|
|
248
|
-
submenu: [target(node), {type: '
|
|
248
|
+
submenu: [target(node), {type: 'divider'}, ...walk(node.children)]
|
|
249
249
|
};
|
|
250
250
|
});
|
|
251
251
|
}
|