domma-cms 0.92.1 → 0.94.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 +5 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- package/admin/js/lib/api-tokens-arrange.js +2 -2
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/collection-entries-arrange.js +1 -1
- package/admin/js/lib/components-arrange.js +1 -1
- package/admin/js/lib/dashboard-arrange.js +1 -1
- package/admin/js/lib/dates.js +1 -0
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/media-arrange.js +1 -1
- package/admin/js/lib/notifications-arrange.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/related.js +1 -1
- package/admin/js/lib/timeline-builder.js +2 -2
- package/admin/js/templates/action-editor.html +6 -5
- package/admin/js/templates/actions-list.html +1 -1
- package/admin/js/templates/contacts.html +1 -1
- package/admin/js/templates/docs/api-actions.html +86 -60
- package/admin/js/templates/docs/api-authentication.html +159 -123
- package/admin/js/templates/docs/api-builder.html +197 -0
- package/admin/js/templates/docs/api-collections.html +199 -259
- package/admin/js/templates/docs/api-external.html +225 -0
- package/admin/js/templates/docs/api-forms.html +268 -0
- package/admin/js/templates/docs/api-layouts.html +70 -45
- package/admin/js/templates/docs/api-media.html +57 -80
- package/admin/js/templates/docs/api-navigation.html +66 -22
- package/admin/js/templates/docs/api-pages.html +109 -129
- package/admin/js/templates/docs/api-plugins.html +123 -61
- package/admin/js/templates/docs/api-scaffold.html +185 -0
- package/admin/js/templates/docs/api-settings.html +72 -64
- package/admin/js/templates/docs/api-users.html +74 -107
- package/admin/js/templates/docs/api-views.html +68 -54
- package/admin/js/templates/docs/components-howto.html +20 -17
- package/admin/js/templates/docs/components-reference.html +13 -16
- package/admin/js/templates/docs/components-rules.html +7 -6
- package/admin/js/templates/docs/components-walkthrough.html +19 -19
- package/admin/js/templates/docs/tutorial-crud.html +71 -40
- package/admin/js/templates/docs/tutorial-forms.html +51 -35
- package/admin/js/templates/docs/tutorial-plugin.html +132 -56
- package/admin/js/templates/docs/usage-actions.html +61 -15
- package/admin/js/templates/docs/usage-collections.html +108 -0
- package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
- package/admin/js/templates/docs/usage-dconfig.html +0 -3
- package/admin/js/templates/docs/usage-editions.html +213 -0
- package/admin/js/templates/docs/usage-media.html +22 -6
- package/admin/js/templates/docs/usage-navigation.html +74 -18
- package/admin/js/templates/docs/usage-pages.html +60 -20
- package/admin/js/templates/docs/usage-plugins.html +89 -17
- package/admin/js/templates/docs/usage-shortcodes.html +123 -70
- package/admin/js/templates/docs/usage-site-settings.html +50 -18
- package/admin/js/templates/docs/usage-tools.html +73 -0
- package/admin/js/templates/docs/usage-users-roles.html +99 -20
- package/admin/js/templates/docs/usage-views.html +36 -19
- package/admin/js/templates/documentation.html +153 -32
- package/admin/js/templates/page-editor.html +0 -5
- package/admin/js/templates/plugin-guide.html +15 -0
- package/admin/js/templates/plugin-guides.html +21 -0
- package/admin/js/templates/pro-docs.html +53 -234
- package/admin/js/templates/tutorials.html +5 -4
- package/admin/js/views/actions-list.js +3 -3
- package/admin/js/views/analytics.js +5 -5
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/block-editor.js +4 -4
- package/admin/js/views/blocks.js +4 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collection-entries.js +7 -7
- package/admin/js/views/component-editor.js +2 -2
- package/admin/js/views/contacts.js +22 -20
- package/admin/js/views/context-menu-editor.js +5 -5
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/form-editor.js +4 -4
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/media.js +3 -3
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/menu-locations.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/page-editor.js +8 -8
- package/admin/js/views/plugin-guides.js +5 -0
- package/admin/js/views/project-detail.js +2 -2
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +4 -4
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +17 -17
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +3 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +2 -2
- package/admin/js/views/view-editor.js +1 -1
- package/bin/cli.js +13 -13
- package/bin/lib/node-version.js +29 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +3 -2
- package/plugins/_lib/admin/mail/reader-view.js +7 -6
- package/plugins/_lib/admin/mail/scheduling.js +4 -2
- package/plugins/_lib/admin/mail/templates.js +4 -4
- package/plugins/_lib/admin/ui/dates.js +85 -0
- package/plugins/blog/CLAUDE.md +31 -22
- package/plugins/blog/admin/views/blog.js +3 -2
- package/plugins/blog/admin/views/comments.js +2 -1
- package/plugins/blog/admin/views/post-editor.js +4 -4
- package/plugins/blog/blocks/blog-card-row.html +1 -1
- package/plugins/blog/blocks/blog-card.html +2 -2
- package/plugins/blog/blocks/blog-post-classic.html +2 -2
- package/plugins/blog/blocks/blog-post-essay.html +2 -2
- package/plugins/blog/blocks/blog-post-feature.html +2 -2
- package/plugins/blog/blocks/blog-post-minimal.html +2 -2
- package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
- package/plugins/blog/blocks/blog-post-split.html +2 -2
- package/plugins/blog/docs/guide.md +205 -0
- package/plugins/blog/lib/layouts.js +3 -3
- package/plugins/blog/lib/page.js +2 -1
- package/plugins/blog/plugin.js +3 -3
- package/plugins/blog/plugin.json +4 -4
- package/plugins/blog/tests/layouts.test.js +6 -0
- package/plugins/feedback/CLAUDE.md +22 -3
- package/plugins/feedback/admin/lib/kit.js +6 -7
- package/plugins/feedback/admin/views/feedback.js +79 -10
- package/plugins/feedback/admin/views/send.js +28 -6
- package/plugins/feedback/docs/guide.md +95 -0
- package/plugins/feedback/lib/receiver.js +9 -2
- package/plugins/feedback/lib/sender.js +3 -2
- package/plugins/feedback/plugin.js +54 -6
- package/plugins/feedback/plugin.json +4 -4
- package/plugins/feedback/tests/api.test.js +74 -2
- package/plugins/free-tier.lock.json +49 -44
- package/plugins/mail-reader/CLAUDE.md +33 -18
- package/plugins/mail-reader/docs/guide.md +147 -0
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/security/CLAUDE.md +4 -1
- package/plugins/security/admin/views/security.js +5 -5
- package/plugins/security/docs/guide.md +170 -0
- package/plugins/security/plugin.js +2 -1
- package/plugins/security/plugin.json +2 -1
- package/plugins/shopping-cart/CLAUDE.md +7 -1
- package/plugins/shopping-cart/admin/lib/kit.js +5 -2
- package/plugins/shopping-cart/admin/views/orders.js +4 -4
- package/plugins/shopping-cart/admin/views/overview.js +2 -2
- package/plugins/shopping-cart/docs/guide.md +191 -0
- package/plugins/shopping-cart/lib/render.js +2 -1
- package/plugins/shopping-cart/plugin.json +3 -3
- package/public/js/collection-browser.js +2 -2
- package/public/js/site.js +1 -1
- package/scripts/gen-instance-secret.js +3 -1
- package/scripts/setup.js +3 -1
- package/server/middleware/auth.js +2 -1
- package/server/routes/api/actions.js +47 -27
- package/server/routes/api/blocks.js +2 -1
- package/server/routes/api/collections.js +16 -52
- package/server/routes/api/contacts.js +66 -3
- package/server/routes/api/documentation.js +42 -0
- package/server/routes/api/notifications.js +3 -2
- package/server/routes/api/users.js +10 -6
- package/server/server.js +16 -1
- package/server/services/actions.js +110 -34
- package/server/services/adapterRegistry.js +169 -16
- package/server/services/adapters/FileAdapter.js +25 -0
- package/server/services/adapters/MongoAdapter.js +23 -0
- package/server/services/collections.js +104 -1
- package/server/services/connectionManager.js +12 -0
- package/server/services/dates.js +81 -0
- package/server/services/docs.js +13 -2
- package/server/services/markdown.js +75 -26
- package/server/services/notification-sources.js +6 -5
- package/server/services/passwordReset.js +2 -1
- package/server/services/permissionRegistry.js +3 -2
- package/server/services/pluginGuides.js +255 -0
- package/server/services/pluginInstaller.js +54 -13
- package/server/services/plugins.js +29 -1
- package/server/services/presetCollections.js +31 -5
- package/server/services/renderer.js +2 -2
- package/server/services/sidebarBadges.js +3 -1
- package/server/services/tools.js +4 -2
- package/server/templates/page.html +2 -2
package/admin/js/views/seo.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as Gt}from"../api.js";import{addBannerAction as Kt}from"../lib/plugin-chrome.js";import{helpPopovers as zt}from"../lib/help-popover.js";import{onceClosed as Ft,openMenuAt as Yt,viewLifetime as Qt}from"../lib/tool-kit.js";import{snippet as Xt}from"../lib/seo-analyse.js";import{checkRule as Qe,matchRedirect as Vt}from"../lib/seo-redirects.js";import{checkSettings as Zt,mergeDefaults as Xe,ORG_TYPES as es}from"../lib/seo-settings.js";import{pickMedia as Ve}from"../lib/media-picker.js";import{fmtDate as ts}from"../lib/dates.js";const ss="/seo",Pe=/^#\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/,Ze=["overview","redirects","sitemap"],as="#/seo",os={pass:"\u2713",warn:"!",fail:"\u2715",info:"i"},ns={301:"301 moved",302:"302 for now",307:"307 for now",308:"308 moved",410:"410 gone"},is={apiRequest:Gt,addBannerAction:Kt,helpPopovers:zt,onceClosed:Ft,openMenuAt:Yt,viewLifetime:Qt},ls=async()=>is,y=r=>String(r??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Ae(r){const i=document.createElement("template");return i.innerHTML=r.trim(),i.content.firstElementChild}async function ne(r,i){try{await navigator.clipboard.writeText(r),E.toast(`Copied ${i}.`,{type:"success"})}catch{E.toast(`Could not copy - ${r}`,{type:"warning",duration:6e3})}}function et(){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:as}}))}catch{}}const tt=r=>typeof r!="number"?"":r>=85?"good":r>=60?"poor":"bad",st=r=>r?ts(r):"",at=r=>{if(!r)return"";const i=Math.round((Date.now()-Date.parse(r))/6e4);if(i<1)return"just now";if(i<60)return`${i} min ago`;const $=Math.round(i/60);return $<48?`${$} hour${$===1?"":"s"} ago`:st(r)};function rs(r){const i=d=>{let c=String(d??"");return/^[=+\-@\t\r]/.test(c)&&(c=`'${c}`),/[",\n\r]/.test(c)?`"${c.replace(/"/g,'""')}"`:c},$=Object.keys(r[0]||{});return`\uFEFF${[$.join(","),...r.map(d=>$.map(c=>i(d[c])).join(","))].join(`\r
|
|
2
2
|
`)}\r
|
|
3
|
-
`}function
|
|
3
|
+
`}function cs(r,i,$="text/csv;charset=utf-8"){const d=URL.createObjectURL(new Blob([i],{type:$})),c=Object.assign(document.createElement("a"),{href:d,download:r});document.body.appendChild(c),c.click(),c.remove(),setTimeout(()=>URL.revokeObjectURL(d),1e4)}const ds=`
|
|
4
4
|
<div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="path">
|
|
5
5
|
<span class="sox-pill" data-bind-data-tone="tone" data-bind-text="scoreText"></span>
|
|
6
6
|
<span class="sox-cell"><span data-bind-text="path"></span><small data-bind-text="sourceText"></small></span>
|
|
7
7
|
<span class="sox-cell sox-hide-sm" data-bind-text="title" data-bind-title="title"></span>
|
|
8
8
|
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="problems" data-bind-title="problems"></span>
|
|
9
9
|
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
|
|
10
|
-
</div>`,
|
|
10
|
+
</div>`,us=`
|
|
11
11
|
<div class="sox-row" data-bind-data-row="id" tabindex="0">
|
|
12
12
|
<span class="sox-cell"><span data-bind-text="text" data-bind-title="text"></span><small data-bind-text="detail" data-bind-title="detail"></small></span>
|
|
13
13
|
<span class="sox-pill" data-bind-data-tone="tone" data-bind-text="badge"></span>
|
|
14
14
|
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
|
|
15
|
-
</div>`,
|
|
15
|
+
</div>`,ps=`
|
|
16
16
|
<div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="note">
|
|
17
17
|
<span class="sox-cell sox-mono" data-bind-text="from" data-bind-title="from"></span>
|
|
18
18
|
<span class="sox-arrow" aria-hidden="true">\u2192</span>
|
|
@@ -20,7 +20,7 @@ import{apiRequest as Jt}from"../api.js";import{addBannerAction as Kt}from"../lib
|
|
|
20
20
|
<span class="sox-pill sox-hide-sm" data-bind-data-tone="tone" data-bind-text="code"></span>
|
|
21
21
|
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="hits" data-bind-title="lastHit"></span>
|
|
22
22
|
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
|
|
23
|
-
</div>`,
|
|
23
|
+
</div>`,ms=`
|
|
24
24
|
<div class="sox-row" data-bind-data-row="id" tabindex="0">
|
|
25
25
|
<span class="sox-cell"><span data-bind-text="path" data-bind-title="path"></span><small data-bind-text="title"></small></span>
|
|
26
26
|
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="source"></span>
|
|
@@ -31,27 +31,27 @@ import{apiRequest as Jt}from"../api.js";import{addBannerAction as Kt}from"../lib
|
|
|
31
31
|
<span class="sox-cell"><span data-bind-text="label"></span><small data-bind-text="detail"></small></span>
|
|
32
32
|
<span class="sox-pill" data-bind-data-tone="tone" data-bind-text="state"></span>
|
|
33
33
|
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
|
|
34
|
-
</div>`,
|
|
34
|
+
</div>`,bs=`
|
|
35
35
|
<div>
|
|
36
36
|
<div class="sox-group" data-bind-hidden="!head" data-bind-text="head"></div>
|
|
37
37
|
<div class="sox-check" data-bind-hidden="!title">
|
|
38
38
|
<span class="sox-status" data-bind-data-status="status" data-bind-text="glyph"></span>
|
|
39
39
|
<span class="sox-check-main"><strong data-bind-text="title"></strong><small data-bind-text="detail"></small><small data-bind-text="fix" data-bind-hidden="!fix"></small></span>
|
|
40
40
|
</div>
|
|
41
|
-
</div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(r){const i=await
|
|
41
|
+
</div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(r){const i=await ls();if(!Pe.test(location.hash))return;const $=seoView._lifetime??(seoView._lifetime=i.viewLifetime(Pe));$.reset();const d=r.get(0).querySelector(".sox")||r.get(0),c=()=>Pe.test(location.hash)&&d.isConnected,v=t=>($.add(t),t),p=(t,e)=>i.apiRequest(`${ss}${t}`,e),ot=location.hash.match(/^#\/seo\/([a-z0-9-]+)/)?.[1]||"overview",q=M.observable(ot),me=M.observable(!0),he=M.observable(""),S=M.observable(!0),m=M.observable(null),be=M.observable({results:[]}),G=M.observable("all"),ve=M.observable(""),fe=M.observable(""),x=M.observable({rules:[],chains:[],onRename:!0}),ge=M.observable(""),ie=M.observable(""),le=M.observable(""),Ie=M.observable("301"),D=M.observable({entries:[],left:[],sources:[],labels:{},robots:""}),V=M.observable("in"),ye=M.observable(""),Ne=M.observable([]),nt=M.computed(()=>!Ze.includes(q.value));let B=null;const K=t=>D.value.labels?.[t]||(t==="pages"?"Pages":t),it=M.computed(()=>(m.value?.score??null)===null?"\u2014":`${m.value.score}%`),lt=M.computed(()=>`${m.value?.score??0}%`),rt=M.computed(()=>tt(m.value?.score)),ct=M.computed(()=>{const t=m.value;return t?{pages:String(t.pages),failing:String(t.failing),duplicates:String(t.duplicates),redirects:String(t.redirects)}:{pages:"\u2014",failing:"\u2014",duplicates:"\u2014",redirects:"\u2014"}}),dt=M.computed(()=>m.value?.failing?String(m.value.failing):""),ut=M.computed(()=>{const t=m.value;return t?.running?`Checking\u2026 ${t.progress?.done||0} of ${t.progress?.total||"?"}`:t?.at?`Checked ${at(t.at)}`:"Not checked yet"}),je=M.computed(()=>[...new Set((be.value.results||[]).map(t=>t.source))]),qe=M.observable([]);let Z=[];v(M.effect(()=>{const t=fe.value.trim().toLowerCase(),e=G.value,s=ve.value;Z=(be.value.results||[]).filter(a=>(e==="all"||(e==="hidden"?a.noindex||a.unlisted:!a.noindex&&(a.error||a.failing>0)))&&(!s||a.source===s)&&(!t||[a.urlPath,a.title,a.label].some(n=>String(n||"").toLowerCase().includes(t)))),Z.sort((a,n)=>(a.error?-1:0)-(n.error?-1:0)||(a.score??101)-(n.score??101)),qe.value=Z.slice(0,1e3).map((a,n)=>({id:`p${n}`,path:a.urlPath,sourceText:[K(a.source),a.noindex?"hidden from search engines":a.unlisted?"not in the sitemap":""].filter(Boolean).join(" \xB7 "),title:a.title||a.label||"",scoreText:a.error?"!":typeof a.score=="number"?String(a.score):"\u2014",tone:a.error?"bad":a.noindex?"info":tt(a.score),problems:a.error||(a.problems?.length?a.problems.join(" \xB7 "):"Nothing to fix")}))}));const pt=M.computed(()=>m.value?.at?fe.value.trim()?"No page matches.":G.value==="problems"?"Nothing needs attention.":"No pages.":m.value?.running?"Checking every page - this takes a moment\u2026":"Not checked yet. Check now with the button top right."),De=M.observable([]);let T=[];v(M.effect(()=>{const t=m.value;if(T=[],t){const e=t.siteSeo||{};for(const s of t.issues?.duplicateTitles||[])T.push({kind:"dupe",text:`Title on ${s.paths.length} pages: "${s.text}"`,detail:s.paths.join(", "),paths:s.paths,tone:"poor",badge:"duplicate"});for(const s of t.issues?.duplicateDescriptions||[]){const a=e.defaultDescription&&s.text===e.defaultDescription;T.push({kind:"dupe",text:a?`${s.paths.length} pages use the site's default description`:`Description on ${s.paths.length} pages: "${s.text}"`,detail:s.paths.join(", "),paths:s.paths,tone:"poor",badge:a?"default":"duplicate"})}e.defaultImage||T.push({kind:"setting",text:"No default share image",detail:"Pages without their own image share with none. Set one in Settings \u203A SEO.",link:"#/settings",tone:"poor",badge:"setting"}),(!t.origin||/localhost|127\.0\.0\.1/.test(t.origin))&&T.push({kind:"setting",text:"The site address is not set",detail:"Canonical links and the sitemap use whatever address a visitor came in on. Set the site address in Settings.",link:"#/settings",tone:"poor",badge:"setting"}),t.chains&&T.push({kind:"chains",text:`${t.chains} redirect${t.chains===1?" leads":"s lead"} to another redirect`,detail:"Each hop is slower and loses a little standing. The Redirects tab shows which.",tone:"poor",badge:"redirects"})}De.value=T.map((e,s)=>({id:`s${s}`,text:e.text,detail:e.detail||"",tone:e.tone,badge:e.badge}))}));const xe=M.observable([]);let ee=[];v(M.effect(()=>{const t=ge.value.trim().toLowerCase(),e=new Set(x.value.chains.map(s=>s.id));ee=x.value.rules.filter(s=>!t||`${s.from} ${s.to} ${s.note}`.toLowerCase().includes(t)),xe.value=ee.map((s,a)=>({id:`r${a}`,from:s.from,to:s.code===410?"(gone)":s.to,note:[s.auto?"Added when the page moved":s.note,e.has(s.id)?"leads to another redirect":""].filter(Boolean).join(" \xB7 "),code:String(s.code),tone:s.code===410?"bad":e.has(s.id)?"poor":s.code===301||s.code===308?"good":"info",hits:s.hits?`${s.hits.toLocaleString("en-GB")} use${s.hits===1?"":"s"}`:"unused",lastHit:s.lastHit?`Last used ${at(s.lastHit)}`:"Not used yet"}))}));const mt=M.computed(()=>x.value.rules.length?String(x.value.rules.length):""),ht=M.computed(()=>`${ee.length+0*xe.value.length} of ${x.value.rules.length}`),bt=M.computed(()=>ge.value.trim()?"No redirect matches.":"No redirects yet."),vt=M.computed(()=>{const t=x.value.chains.length;return t?`${t} redirect${t===1?" leads":"s lead"} to another. Right-click one and choose "Go straight there" to make it one hop.`:""}),ft=M.computed(()=>x.value.onRename?"When a page is renamed, a redirect from its old address is added for you.":"Redirects are not added when a page is renamed (see Settings)."),Be=M.observable([]);v(M.effect(()=>{Be.value=D.value.sources.map((t,e)=>({id:`x${e}`,label:K(t.id),detail:`${t.listed} of ${t.count} listed`,state:t.on?"on":"off",tone:t.on?"good":""}))}));const we=M.observable([]);let z=[];v(M.effect(()=>{const t=ye.value.trim().toLowerCase();z=(V.value==="in"?D.value.entries:D.value.left).filter(s=>!t||`${s.urlPath} ${s.title}`.toLowerCase().includes(t)),we.value=z.slice(0,2e3).map((s,a)=>({id:`e${a}`,path:s.urlPath,title:s.title||"",source:K(s.source),note:s.why||[s.lastmod?`Changed ${st(s.lastmod)}`:"",s.changefreq||"",s.priority!==void 0?`priority ${s.priority}`:""].filter(Boolean).join(" \xB7 ")}))}));const gt=M.computed(()=>D.value.robots||""),yt=M.computed(()=>`${z.length+0*we.value.length} address${z.length===1?"":"es"}`),xt=M.computed(()=>D.value.hooks?ye.value.trim()?"No address matches.":V.value==="in"?"The sitemap is empty.":"Nothing is left out.":"This version of Domma CMS lists only its own pages in the sitemap."),re=t=>/403|forbidden/i.test(String(t?.message));async function te(){try{const t=await p("/overview");if(!c()||!t)return;m.value=t,he.value="",t.running&&Ue()}catch(t){c()&&(he.value=re(t)?"You do not have access to SEO.":`Could not load SEO: ${t.message||t}`)}}async function ke(){try{const t=await p("/audit");c()&&t&&(be.value=t)}catch{}}async function se(){try{const t=await p("/redirects");c()&&t&&(x.value=t)}catch{}}async function Me(){try{const t=await p("/sitemap");c()&&t&&(D.value=t)}catch(t){c()&&!re(t)&&E.toast(`Could not load the sitemap: ${t.message||t}`,{type:"error"})}}async function $e(){me.value=!0,await Promise.all([te(),ke(),se(),Me()]),c()&&(me.value=!1)}function Ue(){B||(B=setInterval(async()=>{if(!c()){clearInterval(B),B=null;return}await te(),m.peek()?.running||(clearInterval(B),B=null,await ke(),et())},1500))}v(()=>{B&&clearInterval(B)});async function _e(){try{const t=await p("/audit",{method:"POST",body:JSON.stringify({})});c()&&(m.value={...m.peek(),...t,running:!0}),Ue()}catch(t){E.toast(`Could not start the check: ${t.message||t}`,{type:"error"})}}async function Se(t,e,s=se){try{const a=await t();return e&&E.toast(e,{type:"success"}),await Promise.all([s(),te()]),a}catch(a){return re(a)?(S.value=!1,E.toast("Only people who may manage SEO can change this.",{type:"warning"})):E.toast(`Not done: ${a.message||a}`,{type:"error"}),null}}const wt=(t,e=`Redirect from ${t.from} added.`)=>Se(()=>p("/redirects",{method:"POST",body:JSON.stringify(t)}),e),kt=(t,e)=>Se(()=>p(`/redirects/${encodeURIComponent(t.join(","))}`,{method:"DELETE"}),`${e} removed.`);async function Mt(t){try{const s=(await p(`/redirects/test?path=${encodeURIComponent(t.replace(/\/\*$/,"/example"))}`)).hops||[];E.toast(s.length?s.map(a=>`${a.from} \u2192 ${a.code===410?"gone":a.to} (${a.code})`).join(" \xB7 "):"No redirect applies.",{type:s.length>1?"warning":"info",duration:7e3})}catch(e){E.toast(`Could not test: ${e.message||e}`,{type:"error"})}}function He(t){const e={from:M.observable(t.from),to:M.observable(t.code===410?"":t.to),code:M.observable(String(t.code)),note:M.observable(t.note||"")};We(`Redirect from ${t.from}`,e,`
|
|
42
42
|
<div class="sox-form-grid">
|
|
43
43
|
<div class="sox-wide"><label class="form-label" for="sox-r-from">From</label><input id="sox-r-from" class="form-input sox-mono" data-model="from.value"></div>
|
|
44
44
|
<div class="sox-wide"><label class="form-label" for="sox-r-to">To</label><input id="sox-r-to" class="form-input sox-mono" data-model="to.value" data-bind-disabled="code.value === '410'"></div>
|
|
45
45
|
<div><label class="form-label" for="sox-r-code">Kind</label><select id="sox-r-code" class="form-input" data-model="code.value">
|
|
46
|
-
${Object.entries(
|
|
46
|
+
${Object.entries(ns).map(([s,a])=>`<option value="${s}">${a}</option>`).join("")}</select></div>
|
|
47
47
|
<div><label class="form-label" for="sox-r-note">Note</label><input id="sox-r-note" class="form-input" data-model="note.value"></div>
|
|
48
48
|
</div>`,()=>{const s={from:e.from.value,to:e.to.value,code:Number(e.code.value),note:e.note.value},a=x.peek().rules.filter(n=>n.id!==t.id);return{body:s,errors:Qe(s,a).errors}},async s=>{await p(`/redirects/${encodeURIComponent(t.id)}`,{method:"PUT",body:JSON.stringify(s)}),E.toast("Redirect saved.",{type:"success"}),await se()})}function $t(){const t={csv:M.observable("")};We("Import redirects",t,`
|
|
49
49
|
<p class="sox-note">One redirect per line: <span class="sox-mono">from, to</span>, and optionally the kind (301, 302, 307, 308, 410) and a note. A header line is skipped. Paste from a spreadsheet, or another CMS's export.</p>
|
|
50
|
-
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,()=>({body:{csv:t.csv.value},errors:t.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async e=>{const s=await p("/redirects/import",{method:"POST",body:JSON.stringify(e)}),a=s.skipped||[];E.toast(`${s.added} added${a.length?`, ${a.length} skipped - line ${a[0].line}: ${a[0].why}`:"."}`,{type:a.length?"warning":"success",duration:a.length?9e3:4e3}),await Promise.all([se(),te()])},"Import")}function We(t,e,s,a,n,
|
|
50
|
+
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,()=>({body:{csv:t.csv.value},errors:t.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async e=>{const s=await p("/redirects/import",{method:"POST",body:JSON.stringify(e)}),a=s.skipped||[];E.toast(`${s.added} added${a.length?`, ${a.length} skipped - line ${a[0].line}: ${a[0].why}`:"."}`,{type:a.length?"warning":"success",duration:a.length?9e3:4e3}),await Promise.all([se(),te()])},"Import")}function We(t,e,s,a,n,h="Save"){const o=M.observable(!1),b=M.computed(()=>a()),O=M.computed(()=>Object.values(b.value.errors).join(" ")),L={...e,saving:o,errText:O,cannot:M.computed(()=>o.value||!!O.value)},f=E.slideover({title:t,size:"md",position:"right"}),g=Ae(`<div class="sox-form">${s}
|
|
51
51
|
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
52
|
-
<div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${
|
|
52
|
+
<div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${h.toLowerCase()}s.</small><span class="sox-spacer"></span>
|
|
53
53
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
54
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${y(
|
|
54
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${y(h)}</button></div></div>`);async function R(){if(!L.cannot.peek()){o.value=!0;try{await n(b.peek().body),f.close()}catch(k){E.toast(`Not saved: ${k.message||k}`,{type:"error"})}finally{o.value=!1}}}f.element.appendChild(g);const w=i.helpPopovers(g),P=M.applyBindings(L,g,{methods:{save:R,cancel:()=>f.close()}});g.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&k.key==="Enter"&&(k.preventDefault(),R())}),i.onceClosed(f,()=>{P.dispose(),w()}),v(()=>{try{f.close()}catch{}}),I.scan(g),f.open(),g.querySelector("input, textarea")?.focus()}let ce=!1;async function J(t){if(ce)return;ce=!0;let e;try{e=await p(`/item?path=${encodeURIComponent(t)}`)}catch(u){ce=!1,E.toast(`Could not open ${t}: ${u.message||u}`,{type:"error"});return}const s={title:M.observable(e.fields.title),description:M.observable(e.fields.description),focus:M.observable(e.fields.focus),image:M.observable(e.fields.image),noindex:M.observable(e.fields.noindex)},a=e.limits||{},n=e.page?.title||e.live?.h1||"",h=e.kind==="page"?`${n}${e.defaults.separator}${e.defaults.siteTitle}`:e.live?.title||"",o=e.kind==="page"?e.defaults.description||"":e.live?.description||"",b=e.kind==="page"?e.defaults.image:e.live?.image||"",O=M.computed(()=>s.title.value.trim()||h),L=M.computed(()=>s.description.value.trim()||o),f=M.computed(()=>s.image.value.trim()||b),g=e.origin||location.origin,R=M.computed(()=>Xt({title:O.value,description:L.value,url:`${g}${t}`,limits:a})),w=(u,oe,X)=>{const W=u.length;return{text:`${W} / ${X}`,width:`${Math.min(100,W/X*100)}%`,tone:W?W<oe||W>X?"poor":"good":"bad"}},P=M.computed(()=>w(O.value,a.titleMin||30,a.titleMax||60)),k=M.computed(()=>w(L.value,a.descMin||70,a.descMax||160)),Oe=(()=>{try{return new URL(g).host}catch{return location.host}})(),pe=M.computed(()=>f.value?`url("${f.value.replace(/["\\\n\r]/g,"")}")`:"none"),l=M.observable([]),_=u=>{const oe=[],X=u?.checks||[],W={fail:0,warn:1,pass:2,info:3};for(const Le of[...new Set(X.map(j=>j.group))]){oe.push({id:`g-${Le}`,head:Le,title:"",detail:"",fix:"",status:"",glyph:""});for(const j of X.filter(Re=>Re.group===Le).sort((Re,Jt)=>W[Re.status]-W[Jt.status]))oe.push({id:`c-${j.id}`,head:"",title:j.title,detail:j.detail,fix:j.status==="pass"?"":j.fix,status:j.status,glyph:os[j.status]||"?"})}l.value=oe};_(e.analysis);const A=M.observable(e.analysis?`Score ${e.analysis.score}% \xB7 ${e.analysis.failing} failing, ${e.analysis.warnings} could be better`:e.status===200?"":`Not live (answers ${e.status||"nothing"}) - the checks run once it is published.`),N=()=>({path:t,title:s.title.value,description:s.description.value,focus:s.focus.value,image:s.image.value,noindex:s.noindex.value});let F=JSON.stringify(N());const Y=M.observable(!1),ze=M.computed(()=>s.image.value.trim()&&!/^(https?:\/\/|\/)/i.test(s.image.value.trim())?"The image: a path like /media/photo.jpg, or a full web address.":""),Fe={...s,saving:Y,errText:ze,checks:l,scoreLine:A,serp:R,titleMeter:P,descMeter:k,shareBg:pe,effTitle:O,effDesc:L,host:Oe,hasImage:M.computed(()=>!!f.value),defTitle:h,defDesc:o,canManage:S,cannot:M.computed(()=>Y.value||!!ze.value||!S.value)},Q=E.slideover({title:`SEO \xB7 ${t}`,size:"lg",position:"right"}),Dt=e.kind==="url"?'<p class="sox-note is-warn">A plugin serves this address (a blog post, a listing). What you set here takes the place of its own SEO fields.</p>':e.page?.status!=="published"?`<p class="sox-note is-warn">This page is a ${y(e.page?.status||"draft")} - not live yet.</p>`:"",Bt=e.redirect?`<p class="sox-note is-bad">A redirect sends visitors from here to ${y(e.redirect.to||"(gone)")} - they never see this page.</p>`:"",H=Ae(`
|
|
55
55
|
<div class="sox-form">
|
|
56
56
|
${Dt}${Bt}
|
|
57
57
|
<section><h4>As it will look <span data-help="The search result is drawn the way Google draws it, cut where it cuts (roughly - it measures pixels). The share card is what Facebook, LinkedIn, WhatsApp and others show when someone shares the link."></span>
|
|
@@ -70,7 +70,7 @@ import{apiRequest as Jt}from"../api.js";import{addBannerAction as Kt}from"../lib
|
|
|
70
70
|
</section>
|
|
71
71
|
<section><h4>Search and sharing</h4><div class="sox-form-grid">
|
|
72
72
|
<div class="sox-wide"><label class="form-label" for="sox-i-title">Title <span data-help="The blue link in search results and the headline of a share. Say what the page is about, words that matter first. Empty uses the page title and the site name."></span></label>
|
|
73
|
-
<input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${y(
|
|
73
|
+
<input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${y(h)}" maxlength="200">
|
|
74
74
|
<div class="sox-meter" data-bind-data-tone="titleMeter.value.tone"><span class="sox-bar"><span data-bind-style-width="titleMeter.value.width"></span></span><span data-bind-text="titleMeter.value.text"></span></div></div>
|
|
75
75
|
<div class="sox-wide"><label class="form-label" for="sox-i-desc">Description <span data-help="The grey text under the link. One or two sentences that make someone want to click. Empty uses the site's default - which every other such page shares."></span></label>
|
|
76
76
|
<textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${y(o)}" maxlength="400"></textarea>
|
|
@@ -92,9 +92,9 @@ import{apiRequest as Jt}from"../api.js";import{addBannerAction as Kt}from"../lib
|
|
|
92
92
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
|
|
93
93
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save and check</button>
|
|
94
94
|
</div>
|
|
95
|
-
</div>`);H.querySelector(".sox-checks").innerHTML=
|
|
95
|
+
</div>`);H.querySelector(".sox-checks").innerHTML=bs,St(H.querySelector("#sox-media"));async function Ye(){if(!Fe.cannot.peek()){Y.value=!0;try{await p("/item",{method:"PUT",body:JSON.stringify(N())}),F=JSON.stringify(N()),E.toast("Saved.",{type:"success"});const u=await p(`/item?path=${encodeURIComponent(t)}`).catch(()=>null);u?.analysis&&(_(u.analysis),A.value=`Score ${u.analysis.score}% \xB7 ${u.analysis.failing} failing, ${u.analysis.warnings} could be better`),ke(),te(),et()}catch(u){re(u)&&(S.value=!1),E.toast(`Not saved: ${u.message||u}`,{type:"error"})}finally{Y.value=!1}}}Q.element.appendChild(H);const Ut=i.helpPopovers(H),_t=M.applyBindings(Fe,H,{methods:{save:Ye,cancel:()=>Q.close(),pickImage:async()=>{const u=await Ve({title:"Choose a share image",current:s.image.peek()});u&&(s.image.value=u)}}}),Ht=H.querySelector(".sox-share-img"),Wt=M.effect(()=>{Ht.style.backgroundImage=pe.value});H.addEventListener("keydown",u=>{(u.ctrlKey||u.metaKey)&&u.key==="Enter"&&(u.preventDefault(),Ye())}),i.onceClosed(Q,()=>{_t.dispose(),Ut(),Wt?.(),ce=!1}),Je(Q,()=>JSON.stringify(N())!==F),v(()=>{try{Q.close(!0)}catch{}}),I.scan(H),Q.open()}let Ee=null;async function St(t){try{Ee??(Ee=await i.apiRequest("/media").then(e=>Array.isArray(e)?e:e?.files||e?.items||[]));for(const e of Ee){const s=e.url||e.path||(e.filename?`/media/${e.filename}`:"");s&&/\.(jpe?g|png|webp|gif|avif|svg)$/i.test(s)&&t.appendChild(Object.assign(document.createElement("option"),{value:s}))}}catch{Ee=[]}}function Je(t,e){const s=t.close.bind(t);let a=!1,n=!1;t.close=async h=>{if(!n&&h!==!0&&e()){if(a)return;a=!0;const o=await E.confirm("Discard the changes you have not saved?");if(a=!1,!o)return}return n=!0,s()}}let de=!1;async function Et(){if(de)return;de=!0;let t;try{const l=await p("/settings");t=Xe(l.settings)}catch(l){de=!1,E.toast(`Could not load the settings: ${l.message||l}`,{type:"error"});return}const e=t.organisation,s=m.peek()?.siteSeo?.defaultTitle||"The site's name",a={orgOn:M.observable(e.enabled),orgType:M.observable(e.type),orgName:M.observable(e.name),orgUrl:M.observable(e.url),orgLogo:M.observable(e.logo),orgEmail:M.observable(e.email),orgPhone:M.observable(e.phone),orgSame:M.observable(e.sameAs.join(`
|
|
96
96
|
`)),street:M.observable(e.address.street),locality:M.observable(e.address.locality),region:M.observable(e.address.region),postcode:M.observable(e.address.postcode),country:M.observable(e.address.country),articles:M.observable(t.articles),exclude:M.observable(t.sitemap.exclude.join(`
|
|
97
|
-
`)),blockAll:M.observable(t.robots.blockAll),robotsExtra:M.observable(t.robots.extra),onRename:M.observable(t.redirects.onRename),every:M.observable(String(t.audit.everyHours)),tMin:M.observable(String(t.audit.titleMin)),tMax:M.observable(String(t.audit.titleMax)),dMin:M.observable(String(t.audit.descMin)),dMax:M.observable(String(t.audit.descMax)),words:M.observable(String(t.audit.minWords))},n=()=>({organisation:{enabled:a.orgOn.value,type:a.orgType.value,name:a.orgName.value,url:a.orgUrl.value,logo:a.orgLogo.value,email:a.orgEmail.value,phone:a.orgPhone.value,sameAs:a.orgSame.value,address:{street:a.street.value,locality:a.locality.value,region:a.region.value,postcode:a.postcode.value,country:a.country.value}},articles:a.articles.value,sitemap:{exclude:a.exclude.value,sources:t.sitemap.sources},robots:{blockAll:a.blockAll.value,extra:a.robotsExtra.value},redirects:{onRename:a.onRename.value},audit:{everyHours:a.every.value,titleMin:a.tMin.value,titleMax:a.tMax.value,descMin:a.dMin.value,descMax:a.dMax.value,minWords:a.words.value}}),
|
|
97
|
+
`)),blockAll:M.observable(t.robots.blockAll),robotsExtra:M.observable(t.robots.extra),onRename:M.observable(t.redirects.onRename),every:M.observable(String(t.audit.everyHours)),tMin:M.observable(String(t.audit.titleMin)),tMax:M.observable(String(t.audit.titleMax)),dMin:M.observable(String(t.audit.descMin)),dMax:M.observable(String(t.audit.descMax)),words:M.observable(String(t.audit.minWords))},n=()=>({organisation:{enabled:a.orgOn.value,type:a.orgType.value,name:a.orgName.value,url:a.orgUrl.value,logo:a.orgLogo.value,email:a.orgEmail.value,phone:a.orgPhone.value,sameAs:a.orgSame.value,address:{street:a.street.value,locality:a.locality.value,region:a.region.value,postcode:a.postcode.value,country:a.country.value}},articles:a.articles.value,sitemap:{exclude:a.exclude.value,sources:t.sitemap.sources},robots:{blockAll:a.blockAll.value,extra:a.robotsExtra.value},redirects:{onRename:a.onRename.value},audit:{everyHours:a.every.value,titleMin:a.tMin.value,titleMax:a.tMax.value,descMin:a.dMin.value,descMax:a.dMax.value,minWords:a.words.value}}),h=JSON.stringify(n()),o=M.observable(!1),b=M.computed(()=>Zt(n(),t)),O=M.computed(()=>Object.values(b.value.errors).join(" ")),L={...a,saving:o,errText:O,cannot:M.computed(()=>o.value||!!O.value),isPerson:M.computed(()=>a.orgType.value==="Person")},f=(l,_,A,N="",F="")=>`<div><label class="form-label" for="sox-s-${l}">${A}${N?` <span data-help="${y(N)}"></span>`:""}</label>
|
|
98
98
|
<input id="sox-s-${l}" class="form-input" data-model="${_}.value" ${F}></div>`,g=(l,_,A,N,F,Y="")=>f(l,_,A,Y,`type="number" min="${N}" max="${F}"`),R=(l,_,A="")=>`<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${l}.value"> ${_}${A?` <span data-help="${y(A)}"></span>`:""}</label>`,w=E.slideover({title:"SEO settings",size:"md",position:"right"}),P=Ae(`
|
|
99
99
|
<div class="sox-form">
|
|
100
100
|
<p class="sox-note">The site's default title, description and share image are in <a href="#/settings">Settings \u203A SEO</a>.</p>
|
|
@@ -141,7 +141,7 @@ import{apiRequest as Jt}from"../api.js";import{addBannerAction as Kt}from"../lib
|
|
|
141
141
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
142
142
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save</button>
|
|
143
143
|
</div>
|
|
144
|
-
</div>`);async function k(){if(!L.cannot.peek()){o.value=!0;try{await p("/settings",{method:"PUT",body:JSON.stringify(b.peek().settings)}),E.toast("Settings saved.",{type:"success"}),w.close(!0),$e()}catch(l){E.toast(`Not saved: ${l.message||l}`,{type:"error"})}finally{o.value=!1}}}w.element.appendChild(P);const Oe=i.helpPopovers(P),pe=M.applyBindings(L,P,{methods:{save:k,cancel:()=>w.close(),pickLogo:async()=>{const l=await Ve({title:"Choose a logo",current:a.orgLogo.peek()});l&&(a.orgLogo.value=l)}}});P.addEventListener("keydown",l=>{(l.ctrlKey||l.metaKey)&&l.key==="Enter"&&(l.preventDefault(),k())}),i.onceClosed(w,()=>{pe.dispose(),Oe(),de=!1}),
|
|
144
|
+
</div>`);async function k(){if(!L.cannot.peek()){o.value=!0;try{await p("/settings",{method:"PUT",body:JSON.stringify(b.peek().settings)}),E.toast("Settings saved.",{type:"success"}),w.close(!0),$e()}catch(l){E.toast(`Not saved: ${l.message||l}`,{type:"error"})}finally{o.value=!1}}}w.element.appendChild(P);const Oe=i.helpPopovers(P),pe=M.applyBindings(L,P,{methods:{save:k,cancel:()=>w.close(),pickLogo:async()=>{const l=await Ve({title:"Choose a logo",current:a.orgLogo.peek()});l&&(a.orgLogo.value=l)}}});P.addEventListener("keydown",l=>{(l.ctrlKey||l.metaKey)&&l.key==="Enter"&&(l.preventDefault(),k())}),i.onceClosed(w,()=>{pe.dispose(),Oe(),de=!1}),Je(w,()=>JSON.stringify(n())!==h),v(()=>{try{w.close(!0)}catch{}}),I.scan(P),w.open()}async function Tt(t,e){try{const s=await p("/settings"),a=Xe(s.settings);a.sitemap.sources[t]=e,await p("/settings",{method:"PUT",body:JSON.stringify(a)}),E.toast(`${K(t)} ${e?"back in":"left out of"} the sitemap.`,{type:"success"}),Me()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}const C=t=>Number(String(t?.dataset?.row||"").slice(1));function Ct(t){const e=Z[C(t)];return e?[{header:e.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>J(e.urlPath)},{label:"Open the page",icon:"external-link",action:()=>window.open(e.urlPath,"_blank","noopener")},{label:"Redirect it elsewhere\u2026",icon:"corner-up-right",disabled:!S.peek(),action:()=>{U("redirects"),ie.value=e.urlPath,le.value=""}},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>ne(e.urlPath,"the address")},{label:"Copy the problems",icon:"clipboard",disabled:!e.problems?.length&&!e.error,action:()=>ne(`${e.urlPath}
|
|
145
145
|
${e.error||e.problems.join(`
|
|
146
|
-
`)}`,"the problems")}]:[]}function Ot(t){const e=T[C(t)];return e?e.kind==="dupe"?[{header:"Open one of them"},...e.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>
|
|
147
|
-
`),"the addresses")}]:e.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>U("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=e.link}}]:[]}function Lt(t){const e=T[C(t)];e?.kind==="dupe"?
|
|
146
|
+
`)}`,"the problems")}]:[]}function Ot(t){const e=T[C(t)];return e?e.kind==="dupe"?[{header:"Open one of them"},...e.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>J(s)})),{type:"divider"},{label:"Copy the addresses",icon:"copy",action:()=>ne(e.paths.join(`
|
|
147
|
+
`),"the addresses")}]:e.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>U("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=e.link}}]:[]}function Lt(t){const e=T[C(t)];e?.kind==="dupe"?J(e.paths[0]):e?.kind==="chains"?U("redirects"):e?.link&&(location.hash=e.link)}function Rt(t){const e=ee[C(t)];if(!e)return[];const s=x.peek().rules,a=x.peek().chains.some(h=>h.id===e.id);let n=null;if(a){n=e.to;for(let h=0;h<10;h++){const o=Vt(s,n);if(!o)break;if(o.code===410){n=null;break}if(n=o.location,/^https?:/i.test(n))break}}return[{header:`${e.from} \u2192 ${e.code===410?"gone":e.to}`},{label:"Edit\u2026",icon:"edit-3",disabled:!S.peek(),action:()=>He(e)},{label:"Test it",icon:"play",action:()=>Mt(e.from)},{label:"Go straight there",icon:"fast-forward",disabled:!n||!S.peek(),action:()=>Se(()=>p(`/redirects/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({to:n})}),`${e.from} now goes straight to ${n}.`)},{label:"Open the old address",icon:"external-link",disabled:e.from.endsWith("/*"),action:()=>window.open(e.from,"_blank","noopener")},{type:"divider"},{label:"Copy",icon:"copy",action:()=>ne(`${e.from} ${e.to} ${e.code}`,"the redirect")},{label:"Remove",icon:"trash-2",danger:!0,disabled:!S.peek(),action:async()=>{await E.confirm(`Remove the redirect from ${y(e.from)}? Visitors to it will get "not found".`)&&kt([e.id],"Redirect")}}]}function Pt(t){const e=z[C(t)];return e?[{header:e.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>J(e.urlPath)},{label:"Open",icon:"external-link",action:()=>window.open(e.urlPath,"_blank","noopener")},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>ne(e.urlPath,"the address")}]:[]}function At(t){const e=D.peek().sources[C(t)];return e?[{header:K(e.id)},{label:e.on?"Leave all of it out":"List it again",icon:e.on?"eye-off":"eye",disabled:!S.peek(),action:()=>Tt(e.id,!e.on)}]:[]}function ae(t,e,s){const a=E.contextMenu(t,{cursor:"glyph",match:"[data-row]",inherit:!1,density:"compact",items:o=>e(o)});v(()=>a?.destroy?.());const n=o=>{const b=o.target.closest("[data-row]");if(!(!b||o.target.closest("a"))){if(o.target.closest(".sox-kebab")){i.openMenuAt(b);return}s?.(b)}},h=o=>{const b=o.target.closest("[data-row]");b&&(o.key==="F10"&&o.shiftKey?(o.preventDefault(),i.openMenuAt(b)):o.key==="Enter"&&o.target===b&&s?.(b))};t.addEventListener("click",n),t.addEventListener("keydown",h),v(()=>{t.removeEventListener("click",n),t.removeEventListener("keydown",h)})}const Te=d.querySelector(".sox-ext-body"),Ce=d.querySelector(".sox-ext-tabs");let ue=null;async function It(){let t=[];try{t=(await p("/extensions"))?.tabs||[]}catch{}c()&&(Ne.value=t,Ce.replaceChildren(...t.map(e=>{const s=document.createElement("button");return s.type="button",s.setAttribute("role","tab"),s.dataset.ext=e.key,s.innerHTML=`<span data-icon="${y(e.icon||"box")}" data-icon-size="14"></span> ${y(e.label)} <span class="sox-pro">PRO</span>`,e.help&&(s.title=e.help),s.addEventListener("click",()=>U(e.key)),s})),I.scan(Ce),Ge())}async function Ge(){for(const e of Ce.querySelectorAll("button"))e.classList.toggle("is-on",e.dataset.ext===q.peek());const t=Ne.peek().find(e=>e.key===q.peek());if(!t){Ze.includes(q.peek())||U("overview");return}if(ue?.key!==t.key){try{ue?.dispose?.()}catch{}Te.replaceChildren();try{const e=await import(t.module);if(!c()||q.peek()!==t.key)return;const s=await e.mount(Te,{kit:i,api:i.apiRequest,scope:v,here:c,openItem:J,reload:$e,E,M,I});ue={key:t.key,dispose:s}}catch(e){Te.innerHTML=`<p class="sox-note is-bad">${y(t.label)} could not be shown: ${y(e.message||e)}</p>`}}}v(()=>{try{ue?.dispose?.()}catch{}}),d.querySelector(".sox-pages").innerHTML=ds,d.querySelector(".sox-site").innerHTML=us,d.querySelector(".sox-rules").innerHTML=ps,d.querySelector(".sox-entries").innerHTML=ms,d.querySelector(".sox-sources").innerHTML=hs;const Ke=d.querySelector(".sox-select");v(M.effect(()=>{const t=je.value;Ke.replaceChildren(new Option("Everything",""),...t.map(e=>new Option(K(e),e))),Ke.value=ve.peek()}));function U(t){q.peek()!==t&&(q.value=t,history.replaceState(null,"",`#/seo${t==="overview"?"":`/${t}`}`),t==="redirects"&&se(),t==="sitemap"&&Me()),Ge()}const Nt={tab:q,busy:me,error:he,canManage:S,scoreText:it,scoreWidth:lt,scoreTone:rt,ov:ct,attention:dt,asOf:ut,sourceOptions:je,source:ve,show:G,query:fe,pageRows:qe,pagesEmpty:pt,siteRows:De,ruleRows:xe,ruleCount:mt,ruleText:ht,rulesEmpty:bt,chainText:vt,renameText:ft,ruleQuery:ge,newFrom:ie,newTo:le,newCode:Ie,sourceRows:Be,smRows:we,smShow:V,smQuery:ye,smText:yt,smEmpty:xt,robots:gt,extOn:nt},jt={tab_overview:()=>U("overview"),tab_redirects:()=>U("redirects"),tab_sitemap:()=>U("sitemap"),show_all:()=>{G.value="all"},show_problems:()=>{G.value="problems"},show_hidden:()=>{G.value="hidden"},sm_in:()=>{V.value="in"},sm_out:()=>{V.value="out"},importCsv:$t,exportRules:()=>{const t=x.peek().rules;if(!t.length){E.toast("Nothing to export.",{type:"info"});return}cs(`redirects-${location.hostname}-${new Date().toISOString().slice(0,10)}.csv`,rs(t.map(e=>({from:e.from,to:e.to,code:e.code,note:e.note||"",hits:e.hits||0,last_used:e.lastHit||""}))))},addRule:()=>{const t={from:ie.peek(),to:le.peek(),code:Number(Ie.peek())},{errors:e}=Qe(t,x.peek().rules);return Object.keys(e).length?(E.toast(Object.values(e).join(" "),{type:"warning"}),!1):(wt(t).then(s=>{s&&(ie.value="",le.value="")}),!1)}},qt=M.applyBindings(Nt,d,{methods:jt});v(()=>qt.dispose()),v(i.helpPopovers(d,{position:"bottom"})),ae(d.querySelector(".sox-pages"),Ct,t=>{const e=Z[C(t)];e&&J(e.urlPath)}),ae(d.querySelector(".sox-site"),Ot,Lt),ae(d.querySelector(".sox-rules"),Rt,t=>{const e=ee[C(t)];e&&S.peek()&&He(e)}),ae(d.querySelector(".sox-entries"),Pt,t=>{const e=z[C(t)];e&&J(e.urlPath)}),ae(d.querySelector(".sox-sources"),At),i.addBannerAction(r,{id:"seo-check",icon:"refresh-cw",title:"Check every page again",onClick:_e}),i.addBannerAction(r,{id:"seo-settings",icon:"settings",title:"SEO settings",onClick:Et}),I.scan(d),await Promise.all([$e(),It()]),c()&&I.scan(d),c()&&!m.peek()?.at&&!m.peek()?.running&&_e()}};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
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 Pe}from"../lib/help-popover.js";import{openMenuAt as Q,sidebarCount as he}from"../lib/tool-kit.js";import{changedTabs as $e,describeCacheKey as X,draftOf as Ae,expiresIn as De,formFromSettings as He,homeValue as je,payloadFromForm as Ke,siteUrlError as ve,settingsProblems as _e,snapshot as be,TAB_FIELDS as Ie}from"../lib/site-settings-arrange.js";import{fmtDateTime as Be}from"../lib/dates.js";const Z=/^\/settings(\?.*)?$/,ye="#/settings",T="settings.draft",Ue=`
|
|
2
2
|
<div class="stx-problem">
|
|
3
3
|
<span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
|
|
4
4
|
<span>{{text}}</span>
|
|
@@ -8,6 +8,6 @@ import{api as Q,apiRequest as w}from"../api.js";import{placeTools as Pe}from"../
|
|
|
8
8
|
<span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
|
|
9
9
|
<span class="stx-chip">{{expires}}</span>
|
|
10
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(c,p){const[i,O]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),l=(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}`),l(o.items,o.url?m:o.text||m)}};l(
|
|
11
|
+
</div>`,Fe=["general","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function ze(c,p){const[i,O]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),l=(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}`),l(o.items,o.url?m:o.text||m)}};l(Re(i?.items||[],O||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),c.replaceChildren();for(const[C,m]of a){const o=document.createElement("option");o.value=C,o.textContent=m,c.appendChild(o)}}let y=null,ge=!1;function Ne(){ge||!window.R?.use||(ge=!0,R.use((c,p,i)=>!(Z.test(p?.path||"")&&!Z.test(c?.path||""))||!y?.dirty()?i():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?i():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:c}={})=>{if(y&&!Z.test(c?.path||"")){const p=y;y=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(c){if(Ne(),y){const e=y;y=null,e.dispose()}const p=[],i=e=>(p.push(e),e);let O=!1;const a=c.get(0).querySelector(".stx")||c.get(0),l=()=>!O&&a.isConnected,C=E.loader(c.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=He(m||{},o||""),n={};for(const[e,t]of Object.entries(te))n[e]=M.observable(t);const _=()=>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),B=M.computed(()=>be(g.value)),L=M.computed(()=>be(_())!==B.value),P=M.observable(!1),$=M.observable(""),U=M.observable(!1),ne=M.computed(()=>{const e=new Set($e(_(),g.value));return Object.fromEntries(Object.keys(Ie).map(t=>[t,e.has(t)]))}),q=M.observable([]);i(M.effect(()=>{q.value=_e(_()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ae=M.observable(""),A=M.observable(!1),D=M.observable(""),j=M.observable(""),v=M.observable(null),F=M.observable([]),z=M.observable(""),N=M.observable(!0),V=M.observable(""),oe=M.observable([]);i(M.effect(()=>{const e=z.value.trim().toLowerCase(),t=Date.now();oe.value=[...F.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(r=>String(r).toLowerCase().includes(e))).map(s=>{const r=X(s.key);return{id:s.key,path:r.path,meta:[r.page?`as ${r.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:De(s.expiresAt,t)}})}));const ke={...n,saving:P,dirty:L,tabs:ne,problems:q,testTo:ae,testing:A,testResult:D,testTone:j,cacheQuery:z,cacheLoading:N,cacheError:V,cacheRows:oe,errorText:$,problemsText:M.computed(()=>q.value.length?"yes":""),siteUrlError:M.computed(()=>ve(n.siteUrl.value)),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(()=>v.value?.enabled===!0),cacheState:M.computed(()=>{const e=v.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=v.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=v.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>v.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>z.value.trim()?"Nothing matches.":v.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function W(){if(P.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}const r=ve(e.siteUrl);if(r){E.toast(`Site URL: ${r}`,{type:"error"});return}P.value=!0,$.value="";const h=String(e.title).trim()!==String(t.title).trim();try{await G.settings.save(Ke(e,ee.peek(),{adminHomeReady:U.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const d=await G.settings.get().catch(()=>null);if(!l())return;d&&(ee.value=d);const J={...e,smtpPass:""};n.smtpPass.value="",g.value=J;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),h&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),he(ye),E.toast("Settings saved.",{type:"success"})}catch(d){$.value=d.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{P.value=!1}}async function we(){await E.confirm("Throw away your unsaved settings?")&&(re(g.peek()),$.value="")}function re(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);u&&u.getValue()!==String(n.css.peek())?u.setValue(String(n.css.peek())):!u&&b&&(b.value=String(n.css.peek()))}async function Ee(){if(!A.peek()){A.value=!0,j.value="",D.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:ae.peek().trim()||void 0})});if(!l())return;D.value=e?.message||"Test email sent.",j.value="is-ok"}catch(e){if(!l())return;D.value=e.message||"Could not send the test email.",j.value="is-bad"}finally{A.value=!1}}}async function K(){N.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!l())return;v.value=e,F.value=Array.isArray(t?.entries)?t.entries:[],V.value=""}catch(e){if(!l())return;V.value=`Could not read the cache: ${e.message||e}`}finally{N.value=!1}}async function Se(){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"})}l()&&await K()}}async function Ce(){const e=v.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{v.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),he(ye)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}l()&&await K()}}const ie=e=>F.peek().find(t=>t.key===e);function xe(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=Ue;const x=a.querySelector(".stx-list");x.innerHTML=qe;const Me=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Te=M.applyBindings(ke,a,{methods:{save:W,discard:we,sendTest:Ee,refreshCache:K,clearCache:Se,toggleCache:Ce,corner:e=>{n.bcPosition.value=e}}});i(()=>Te.dispose()),i(Pe(a,{position:"bottom"}));const Oe=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Fe.indexOf(t.dataset.go);s>=0&&Me.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Oe);const Y=a.querySelector("#stx-admin-home");ze(Y,je(n.adminHome.peek())).then(()=>{l()&&(Y.value=n.adminHome.peek(),U.value=!0)}).catch(()=>{});const b=a.querySelector(".stx-css");b.value=String(n.css.peek());let u=null;if(!f)b.disabled=!0;else if(E.editor)try{u=E.editor(b,{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{u=null}if(u)u._editorEl&&u._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=u._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,r=t.selectionStart,h=s.lastIndexOf(`
|
|
12
12
|
`,r-1)+1,d=s.indexOf(`
|
|
13
|
-
`,r),
|
|
13
|
+
`,r),J=d===-1?s.slice(h):s.slice(h,d+1);navigator.clipboard?.writeText(J).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,h)+(d===-1?"":s.slice(d+1)),t.selectionStart=t.selectionEnd=h,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=b.value};b.addEventListener("input",e),i(()=>b.removeEventListener("input",e))}i(()=>{try{u?.destroy?.()}catch{}});const Le=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>xe(e.dataset.id)});i(()=>Le?.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),i(()=>{x.removeEventListener("click",le),x.removeEventListener("keydown",ue)}),fe(c,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),fe(c,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>W()});const de=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),W())};document.addEventListener("keydown",de,!0),i(()=>document.removeEventListener("keydown",de,!0));const pe=()=>{if(L.peek())try{S.set(T,{form:Ae(se()),base:B.peek(),at:new Date().toISOString()})}catch{}},me=e=>{L.peek()&&(pe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",me),i(()=>window.removeEventListener("beforeunload",me)),y={dirty:()=>L.peek(),dispose(){O=!0,pe(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),K();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!==B.peek(),t=k.at?` (${Be(k.at)})`:"",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)&&l()){const r={...g.peek()};for(const[h,d]of Object.entries(k.form))h in r&&h!=="smtpPass"&&(r[h]=d);f||(r.css=g.peek().css),re(r),U.peek()&&(Y.value=n.adminHome.peek())}}}};
|
package/admin/js/views/theme.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import{api as N}from"../api.js";import{addBannerAction as
|
|
1
|
+
import{api as N}from"../api.js";import{addBannerAction as je}from"../lib/plugin-chrome.js";import{helpPopovers as Ie}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Pe,sidebarCount as lt}from"../lib/tool-kit.js";import{populateThemeSelect as Ke}from"../lib/themes.js";import{cloneOverride as G,dayOrNight as it,formFromConfig as Q,knownThemeIds as ct,moveItem as Fe,overrideRow as dt,payloadFromForm as X,snapshot as ue,swatchColour as ze,themeDiff as mt,themeLabel as ht,themeProblems as ut}from"../lib/theme-settings-arrange.js";import{fmtDateTime as pt}from"../lib/dates.js";const pe=/^\/theme(\?.*)?$/,ft="#/theme",D="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
2
|
<div class="thx-row" data-id="{{id}}" tabindex="0" data-bind-class="off && 'is-off'">
|
|
3
3
|
<span class="thx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
|
|
4
4
|
<span class="thx-chip is-danger" data-bind-hidden="!missing" title="Its theme no longer exists">Theme gone</span>
|
|
5
5
|
<span class="thx-chip is-warn" data-bind-hidden="!empty" title="No theme, token or CSS - it changes nothing">Empty</span>
|
|
6
6
|
<span class="thx-chip" data-bind-hidden="!off">Off</span>
|
|
7
7
|
<button type="button" class="thx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
8
|
-
</div>`,
|
|
8
|
+
</div>`,fe=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)=>!(pe.test(y?.path||"")&&!pe.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&&!pe.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 Z=!1;const p=m.get(0).querySelector(".thx")||m.get(0),q=()=>!Z&&p.isConnected,ve=E.loader(m.get(0),{type:"dots"});let j,L;try{[j,L]=await Promise.all([N.theme.get(),N.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){ve.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(ve.destroy(),!p.isConnected)return;const ee=L.tokens||[],P=Array.isArray(L.custom)?L.custom:[],be=ct(L),f=M.observable(j),$=Q(j),s={};for(const[e,t]of Object.entries($))s[e]=M.observable(t);const ye=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),te=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),K=M.observable(ue($,j)),F=M.observable(!1),z=M.observable([]),oe=M.observable("(nothing yet)"),ne=M.observable(!1),_e=M.computed(()=>ue(ye(),f.value)),B=M.computed(()=>_e.value!==K.value),ke=M.computed(()=>ut(ye(),be)),ge=M.observable([]);u(M.effect(()=>{ge.value=s.overrides.value.map((e,t)=>dt(e,t,{custom:P,known:be}))}));const _=e=>ht(e,P),He={...s,saving:F,rows:ge,cssText:oe,cssError:ne,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ke.value.length?`${ke.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=it(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 we(e){if(!P.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of P){const a=document.createElement("option");a.value=o.id,a.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(a)}e.appendChild(t)}function xe(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 H=(e,t,o)=>{const a=p.querySelector(e);return Ke(a,o),we(a),xe(a,t),a};H("#thx-site",$.theme),H("#thx-admin",$.adminTheme,{includeAdmin:!0}),H("#thx-day",$.dayTheme),H("#thx-night",$.nightTheme);let ae=null,se=0;async function V(){const e=++se;try{const t=await N.theme.compile(X(te(),f.peek()));if(e!==se||!q())return;oe.value=t.css?.trim()||"(no overrides - nothing is injected)",ne.value=!1}catch(t){if(e!==se||!q())return;oe.value=t.message||String(t),ne.value=!0}}u(M.effect(()=>{s.overrides.value,clearTimeout(ae),ae=setTimeout(V,400)})),u(()=>clearTimeout(ae));async function re(){if(!F.peek()){F.value=!0,z.value=[];try{const e=await N.theme.save(X(te(),f.peek()));if(!q())return;f.value=e;const t=Q(e);for(const[a,l]of Object.entries(t))s[a].value=l;K.value=ue(t,e);try{S.remove?S.remove(D):S.set(D,null)}catch{}const{refreshAdminTheme:o}=await import("../lib/admin-theme.js");await o(N),lt(ft),E.toast("Theme saved.",{type:"success"}),V()}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{F.value=!1}}}async function Ve(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=Q(f.peek());for(const[t,o]of Object.entries(e))s[t].value=o;z.value=[]}const Ee=e=>s.overrides.peek().find(t=>t.id===e),C=e=>{s.overrides.value=e},le=(e,t)=>C(s.overrides.peek().map(o=>o.id===e?t:o)),Ye=e=>s.overrides.peek().findIndex(t=>t.id===e),ie=new Map;async function Se(e){if(!e)return null;if(ie.has(e))return ie.get(e);const o=(await N.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ie.set(e,o),o}const Ue=()=>(s.autoEnabled.peek()?s.dayTheme.peek():s.theme.peek())||"charcoal-dark",Y=new Set;function U(e){const t=e?Ee(e):G({});if(!t)return;const o=!e,a=G(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: ${fe(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=yt(`
|
|
9
9
|
<div class="thx-form">
|
|
10
10
|
<div class="thx-grid">
|
|
11
11
|
<div>
|
|
@@ -52,4 +52,4 @@ import{api as N}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-
|
|
|
52
52
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="done">
|
|
53
53
|
<span data-icon="check" data-icon-size="13"></span> ${o?"Add":"Done"}</button>
|
|
54
54
|
</div>
|
|
55
|
-
</div>`),
|
|
55
|
+
</div>`),ce=v.querySelector("#thx-o-theme");Ke(ce,{includeDefault:!0,defaultLabel:"Keep the site theme"}),we(ce),xe(ce,a.theme);const Qe=v.querySelector("#thx-token-names");for(const n of ee){const i=document.createElement("option");i.value=n,Qe.appendChild(i)}const de=v.querySelector(".thx-toks");function Ae(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))de.appendChild(Ae(n,i));function Ne(){const n={},i=[];return de.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),h=b.querySelector(".thx-tok-value").value.trim();!r||!h||(ee.length&&!ee.includes(r)&&i.push(r),n[r]=h)}),{tokens:n,bad:i}}const De=()=>({...a,label:l.label.peek(),scope:l.scope.peek(),enabled:l.enabled.peek(),theme:l.theme.peek(),css:l.css.peek(),tokens:Ne().tokens}),Xe=JSON.stringify(X({overrides:[a]}).overrides),Ze=()=>JSON.stringify(X({overrides:[De()]}).overrides)!==Xe,me=v.querySelector(".thx-diff-host");let Le=0;async function et(){const n=++Le;me.replaceChildren();const i=l.theme.peek();if(!i)return;const b=Ue(),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),me.appendChild(r);return}const[h,k]=await Promise.all([Se(b),Se(i)]);if(n!==Le||!h||!k)return;const g=mt(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 W=document.createElement("div");W.className="thx-diff-list";const qe=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(qe(d.from),document.createTextNode(`${d.name} `),qe(d.to)),W.appendChild(A)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,W.appendChild(d)}r.appendChild(W),me.appendChild(r)}const tt=M.effect(()=>{l.theme.value,et()});let J=!1,he=!1;const Re={addToken:()=>{const n=Ae();de.appendChild(n),n.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${fe(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(s.overrides.peek().filter(n=>n.id!==t.id)),J=!0,c.close())},done(){const{bad:n}=Ne();if(n.length){l.tokenErr.value=`Not a token this Domma defines: ${n.join(", ")}. Pick one from the list.`;return}const i=De();o?C([...s.overrides.peek(),i]):le(t.id,i),J=!0,c.close()}};c.element.appendChild(v);const ot=Ie(v),nt=M.applyBindings(l,v,{methods:Re}),at=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),Re.done())};v.addEventListener("keydown",at),Y.add(c),rt(c,()=>{tt(),nt.dispose(),ot(),Y.delete(c)});const st=c.close.bind(c);c.close=async n=>{if(!J&&n!==!0&&Ze()){if(he)return;he=!0;const i=await E.confirm("Discard your changes to this override?");if(he=!1,!i)return}return J=!0,st()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function Je(e){const t=Ee(e);if(!t)return[];const o=Ye(e),a=s.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>U(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>le(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>le(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const l=G({...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(Fe(s.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=a-1,action:()=>C(Fe(s.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${fe(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 We=M.applyBindings(He,p,{methods:{save:re,discard:Ve,refreshCss:V,addOverride:()=>U(null)}});u(()=>We.dispose()),u(Ie(p,{position:"bottom"}));const Ge=E.contextMenu(O,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>Je(e.dataset.id)});u(()=>Ge?.destroy?.());const Ce=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){Pe(e.target.closest(".thx-kebab"));return}U(t.dataset.id)}},Te=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Pe(t)):e.key==="Enter"&&e.target===t&&U(t.dataset.id))};O.addEventListener("click",Ce),O.addEventListener("keydown",Te),u(()=>{O.removeEventListener("click",Ce),O.removeEventListener("keydown",Te)}),je(m,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),je(m,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>re()});const Me=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||Y.size||(e.preventDefault(),e.stopImmediatePropagation(),re())};document.addEventListener("keydown",Me,!0),u(()=>document.removeEventListener("keydown",Me,!0));const $e=()=>{if(B.peek())try{S.set(D,{form:te(),base:K.peek(),at:new Date().toISOString()})}catch{}},Oe=e=>{B.peek()&&($e(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Oe),u(()=>window.removeEventListener("beforeunload",Oe)),x={dirty:()=>B.peek(),dispose(){Z=!0,$e(),[...Y].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p),V();let T=null;try{T=S.get(D)}catch{}if(T?.form){try{S.remove?S.remove(D):S.set(D,null)}catch{}const e=T.base&&T.base!==K.peek(),t=T.at?` (${pt(T.at)})`:"",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)&&q()){const a={...Q(f.peek()),...T.form};a.overrides=(a.overrides||[]).map(l=>G(l));for(const[l,c]of Object.entries(a))s[l]&&(s[l].value=c)}}}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{api as _,getUser as Ge}from"../api.js";import{addBannerAction as Te}from"../lib/plugin-chrome.js";import{resetPassword as Je}from"../lib/password-reset-actions.js";import{helpPopovers as We}from"../lib/help-popover.js";import{sidebarCount as Ne}from"../lib/tool-kit.js";import{roleIndex as Xe,initials as ze}from"../lib/users-arrange.js";import{fieldKind as de,fieldOptions as pe}from"../lib/my-profile-arrange.js";import{buildUserPayload as Qe,checkUserForm as Oe,draftOf as Ze,editRights as et,emailConflict as tt,formFromUser as ve,roleChoices as at,snapshot as oe}from"../lib/user-editor-arrange.js";const st=/^\/users\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=a=>String(a||"").split("?")[0].replace(/\/$/,""),p=a=>String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),re=a=>{if(!a)return"";try{return D(a).format("D MMM YYYY")}catch{return String(a).slice(0,10)}},ot=`<button type="button" class="uex-pick-chip" data-role="{{name}}" data-bind-class="on && 'is-on'" data-bind-disabled="disabled" title="{{title}}" data-bind-aria-pressed="on ? 'true' : 'false'">{{label}}</button>`,nt=`<button type="button" class="uex-pick-chip" data-project="{{slug}}" data-bind-class="on && 'is-on'" data-bind-aria-pressed="on ? 'true' : 'false'" title="{{title}}">{{name}}</button>`;let y=null,_e=!1;function rt(){_e||!window.R?.use||(_e=!0,R.use((a,r,v)=>{const c=ne(r?.path);return!(st.test(r?.path||"")&&ne(a?.path)!==c)||!y?.dirty()||y.path!==c?v():E.confirm("You have unsaved changes to this user. Leave anyway? They are kept for your next visit - except a password, which is typed again.").then(A=>{A?v():history.replaceState(null,"",`#${c}`)})}),M.subscribe("router:afterChange",({to:a}={})=>{if(y&&ne(a?.path)!==y.path){const r=y;y=null,r.dispose()}}))}function lt(a,r){const v=de(a.type),c=`uex-pf-${r}`,s=a.required?' <span class="text-danger">*</span>':"",A=a.help||a.description?` <span data-help="${p(a.help||a.description)}"></span>`:"",T=`<label class="form-label" for="${c}">${p(a.label||a.name)}${s}${A}</label>`,i=`<small class="uex-err" data-bind-hidden="!pe_${r}.value" data-bind-text="pe_${r}.value"></small>`,K=a.placeholder?` placeholder="${p(a.placeholder)}"`:"";switch(v){case"textarea":return`<div class="uex-wide">${T}<textarea id="${c}" class="form-input" rows="3"${K} data-model="pf_${r}.value"></textarea>${i}</div>`;case"select":return`<div>${T}<select id="${c}" class="form-input" data-model="pf_${r}.value"><option value="">-</option>${pe(a).map(m=>`<option value="${p(m.value)}">${p(m.label)}</option>`).join("")}</select>${i}</div>`;case"checkbox":return`<div><label class="uex-check"><input id="${c}" type="checkbox" data-model="pf_${r}.value"> ${p(a.label||a.name)}${s}</label>${A}${i}</div>`;case"multi":return`<div class="uex-wide">${T}<div data-multi="${r}">${pe(a).map((m,u)=>`<label class="uex-check"><input type="checkbox" data-opt="${p(m.value)}" data-bind-checked="po_${r}_${u}.value"> ${p(m.label)}</label>`).join("")}</div>${i}</div>`;default:return`<div>${T}<input id="${c}" type="${v}" class="form-input"${K} data-model="pf_${r}.value">${i}</div>`}}function me(a,r){const v=a.querySelector(".dm-plugin-banner-name");if(!v)return;const c=v.querySelector(".dm-plugin-banner-parent")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-parent",textContent:"Users"}),s=v.querySelector(".dm-plugin-banner-sep")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-sep",textContent:"\u203A"});s.setAttribute("aria-hidden","true"),v.replaceChildren(c,s,document.createTextNode(r))}export const userEditorView={templateUrl:"/admin/js/templates/user-editor.html",async onMount(a){if(rt(),y){const e=y;y=null,e.dispose()}const r=ne(location.hash.slice(1)),v=r.match(/^\/users\/edit\/([^/?#]+)/),c=v?decodeURIComponent(v[1]):null,s=!c,A=s&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",T=[],i=e=>(T.push(e),e);let K=!1;const m=a.get(0),u=m.querySelector(".uex")||m,N=()=>!K&&u.isConnected,B=M.observable(!0),fe=M.observable(""),V=M.observable(!1),f=M.observable(null),q=M.observable(""),w=M.observable(""),W=M.observable(""),Be=M.observable(!1),j=M.observable("user"),b=M.observable([]),X=M.observable(""),C=M.observable([]),z=M.observable(!0),U=M.observable(""),G=M.observable(""),he=M.observable(!1),be=M.observable(!1),ge=M.observable([]),ke=M.observable([]),$=M.observable({self:!1,canEdit:!0,reason:""}),H=M.observable([]);let x={};const ye=M.observable([]);let g=[];const O=new Map,we=M.observable(0),k=M.observable(ve(null,[])),qe=()=>(we.value,Object.fromEntries([...O].map(([e,t])=>[e,t.value]))),De=()=>Object.fromEntries([...O].map(([e,t])=>[e,t.peek()])),$e=()=>({name:q.value,email:w.value,password:W.value,role:j.value,additionalRoles:b.value,ownedByProject:X.value,projects:C.value,isActive:z.value,profile:qe()}),le=()=>({name:q.peek(),email:w.peek(),password:W.peek(),role:j.peek(),additionalRoles:b.peek(),ownedByProject:X.peek(),projects:C.peek(),isActive:z.peek(),profile:De()}),Q=M.computed(()=>Oe($e(),{isNew:s,fields:g,saved:s?void 0:k.value,choices:H.value})),Y=M.computed(()=>!B.value&&$.value.canEdit&&oe($e())!==oe(k.value)),F=M.computed(()=>!$.value.canEdit);i(M.effect(()=>{const e=new Set(b.value);ge.value=H.value.filter(t=>t.name!==j.value).map(t=>({name:t.name,label:t.label,on:e.has(t.name),disabled:!t.assignable&&!e.has(t.name),title:t.assignable||e.has(t.name)?`Level ${t.level}`:t.reason}))})),i(M.effect(()=>{const e=new Set(C.value);ke.value=ye.value.map(t=>({slug:t.slug,name:t.name||t.slug,on:e.has(t.slug),title:t.slug}))}));const ie={loading:B,loadError:fe,saving:V,name:q,email:w,password:W,showPw:Be,role:j,ownedByProject:X,isActive:z,hasProfile:he,hasProjects:be,extraChips:ge,projectChips:ke,err:Q,dirty:Y,locked:F,isSelf:M.computed(()=>$.value.self),lockedReason:M.computed(()=>$.value.reason),emailErr:M.computed(()=>Q.value.email||(U.value&&w.value.trim().toLowerCase()===G.value?U.value:"")),cannotSave:M.computed(()=>V.value||F.value||!Y.value||Object.keys(Q.value).length>0||!!(U.value&&w.value.trim().toLowerCase()===G.value)),saveText:s?"Create user":"Save",dirtyText:s?"This user has not been created yet.":"You have unsaved changes.",pwLabel:M.computed(()=>s?"Password":"New password"),pwPlaceholder:M.computed(()=>s?"At least 8 characters":"Leave empty to keep it"),avatar:M.computed(()=>ze({name:q.value,email:w.value})),shownName:M.computed(()=>q.value.trim()||(s?"New user":f.value?.name||"")),shownEmail:M.computed(()=>w.value.trim()||""),roleText:M.computed(()=>[j.value,...b.value.filter(t=>t!==j.value)].filter(Boolean).map(t=>x[t]?.label||t).join(" + ")||"-"),accessHint:M.computed(()=>C.value.length?`Sees only ${C.value.length===1?"that project":"those projects"}, plus Core.`:"Nothing ticked: no restriction - sees every project."),factsText:M.computed(()=>{const e=f.value;return s||!e?"Not created yet":[e.createdAt&&`Added ${re(e.createdAt)}`,e.lastLogin?`last signed in ${re(e.lastLogin)}`:"never signed in",e.updatedAt&&`changed ${re(e.updatedAt)}`].filter(Boolean).join(" \xB7 ")})},Z=`user-editor.draft.${c||"new"}`;function ee(e){q.value=e.name,w.value=e.email,W.value="",j.value=e.role,b.value=[...e.additionalRoles||[]],X.value=e.ownedByProject||"",C.value=[...e.projects||[]],z.value=e.isActive!==!1;for(const[t,o]of O)t in(e.profile||{})&&(o.value=e.profile[t])}function Ie(e,t){const o=tt(e);return o?(U.value=o,G.value=String(t||"").trim().toLowerCase(),u.querySelector("#uex-email")?.focus(),!0):!1}async function ce(){if(V.peek()||B.peek()||F.peek())return;const e=le(),t=Oe(e,{isNew:s,fields:g,saved:s?void 0:k.peek(),choices:H.peek()});if(Object.keys(t).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const o=Qe(k.peek(),e,{isNew:s,fields:g,meta:f.peek()?.meta||{}});if(!o){E.toast("Nothing has changed.",{type:"info"});return}if(!(!s&&$.peek().self&&o.update.role&&(x[o.update.role]?.level??99)>(x[k.peek().role]?.level??99)&&!await E.confirm(`Give yourself the less senior role "${p(x[o.update.role]?.label||o.update.role)}"? You will not be able to raise it again yourself.`))){V.value=!0;try{if(s){const d=await _.users.create(o.create);if(!N())return;te(),k.value=le(),E.toast(`Created ${d?.name||e.name.trim()}.`,{type:"success"}),Ne("#/users"),location.hash=d?.id?`#/users/edit/${encodeURIComponent(d.id)}`:"#/users";return}const n=await _.users.update(c,o.update);if(!N())return;f.value=n;const l=ve(n,g);if(k.value=l,ee(l),te(),me(m,n.name||n.email||"Edit"),$.peek().self){try{S.set("auth_user",{...S.get("auth_user")||{},name:n.name,email:n.email})}catch{}const d=document.getElementById("topbar-user-name");d&&n.name&&(d.textContent=n.name)}Ne("#/users"),E.toast("Saved.",{type:"success"})}catch(n){Ie(n,e.email)||E.toast(`Not saved: ${n.message||n}`,{type:"error"})}finally{V.value=!1}}}async function Ue(){Y.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!N()||(ee(k.peek()),te()))}function Me(){if(!Y.peek())return;const e=Ze(le());if(oe({...e,password:""})!==oe(k.peek()))try{S.set(Z,{form:e,at:new Date().toISOString(),basedOn:f.peek()?.updatedAt||null})}catch{}}function te(){try{S.remove?S.remove(Z):S.set(Z,null)}catch{}}u.querySelector(".uex-roles").innerHTML=ot,u.querySelector(".uex-projects").innerHTML=nt;const Ee=e=>{if(F.peek())return;const t=e.target.closest(".uex-pick-chip");if(!(!t||t.disabled)){if(t.dataset.role){const o=t.dataset.role,n=b.peek();b.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}else if(t.dataset.project){const o=t.dataset.project,n=C.peek();C.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}}};u.addEventListener("click",Ee),i(()=>u.removeEventListener("click",Ee)),i(M.effect(()=>{U.peek()&&w.value.trim().toLowerCase()!==G.peek()&&(U.value="",G.value="")})),i(M.effect(()=>{const e=j.value;b.peek().includes(e)&&(b.value=b.peek().filter(t=>t!==e))})),Te(a,{id:"user-editor-back",icon:"arrow-left",title:"Back to users",onClick:()=>{location.hash="#/users"}}),Te(a,{id:"user-editor-save",icon:"save",title:s?"Create user (Ctrl+S)":"Save (Ctrl+S)",onClick:()=>ce()}),me(m,s?"New user":"Edit");const xe=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!N()||(e.preventDefault(),e.stopImmediatePropagation(),ce())};document.addEventListener("keydown",xe,!0),i(()=>document.removeEventListener("keydown",xe,!0));const Se=e=>{Y.peek()&&(Me(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Se),i(()=>window.removeEventListener("beforeunload",Se)),y={path:r,dirty:()=>Y.peek(),dispose(){K=!0,Me(),T.splice(0).reverse().forEach(e=>{try{e()}catch{}})}};let h=null,je=null,Ce=null,L=[];try{[h,Ce,je,L]=await Promise.all([s?Promise.resolve(null):_.users.get(c),_.collections.listEntries("roles",{limit:1e3}).catch(()=>null),_.collections.get("user-profiles").catch(()=>null),_.projects.list().catch(()=>[])])}catch(e){if(!N())return;fe.value=e?.status===404?"There is no such user - it may have been deleted.":`Could not load this user: ${e.message||e}`,B.value=!1,I.scan(u);return}if(!N())return;L=Array.isArray(L)?L:[],ye.value=L,x=Xe(Ce?.entries||[]),Object.keys(x).length||(x={admin:{label:"Admin",level:1},user:{label:"User",level:2}});const Le=Ge()||{};$.value=et(Le,h,x),H.value=at(x,Le,h),f.value=h;const He=u.querySelector(".uex-role-select");He.innerHTML=H.peek().map(e=>`<option value="${p(e.name)}"${e.assignable?"":" disabled"}>${p(e.label)}${e.assignable?"":" - only a more senior role can give it"}</option>`).join("");const Ye=u.querySelector(".uex-owner-select");Ye.innerHTML=`<option value="">(none)</option>${L.map(e=>`<option value="${p(e.slug)}">${p(e.name||e.slug)}</option>`).join("")}`,be.value=L.length>0;const Pe=H.peek().filter(e=>e.assignable).map(e=>e.name),Fe=Pe.includes("user")?"user":Pe.at(-1)||"user";g=(je?.fields||[]).filter(e=>e&&e.name);const J=ve(h,g,{project:L.some(e=>e.slug===A)?A:"",defaultRole:Fe});g.forEach(e=>O.set(e.name,M.observable(J.profile[e.name])));const Ae=async e=>{const t=f.peek();if(t&&await Je(t,e))try{f.value={...t,...await _.users.get(t.id)}}catch{}};ie.canReset=M.computed(()=>!s&&!B.value&&$.value.canEdit&&!$.value.self&&f.value?.isActive!==!1),ie.resetOpen=M.computed(()=>!!f.value?.resetLinkExpires&&Date.parse(f.value.resetLinkExpires)>Date.now());const Ke=M.applyBindings(ie,u,{methods:{save:ce,discard:Ue,resetEmail:()=>Ae("email"),resetLink:()=>Ae("link")}});i(()=>Ke.dispose());const ue=g.map((e,t)=>({f:e,i:t})).filter(({f:e})=>de(e.type)!=="hidden");if(ue.length){const e=u.querySelector(".uex-profile");e.innerHTML=ue.map(({f:l,i:d})=>lt(l,d)).join("");const t={};g.forEach((l,d)=>{t[`pf_${d}`]=O.get(l.name),t[`pe_${d}`]=M.computed(()=>Q.value[`profile.${l.name}`]||""),de(l.type)==="multi"&&pe(l).forEach((ae,se)=>{t[`po_${d}_${se}`]=M.computed(()=>(O.get(l.name).value||[]).includes(ae.value))})});const o=M.applyBindings(t,e);i(()=>o.dispose());const n=l=>{const d=l.target.closest("[data-multi]");if(!d||!l.target.matches("input[data-opt]"))return;const ae=O.get(g[Number(d.dataset.multi)].name),se=l.target.dataset.opt,Re=ae.peek()||[];ae.value=l.target.checked?[...new Set([...Re,se])]:Re.filter(Ve=>Ve!==se)};e.addEventListener("change",n),i(()=>e.removeEventListener("change",n))}he.value=ue.length>0,i(We(u,{position:"bottom"})),k.value=J,ee(J),we.value++,B.value=!1,me(m,s?"New user":h.name||h.email||"Edit"),I.scan(u),F.peek()||u.querySelector("#uex-name")?.focus();let P=null;try{P=S.get(Z)}catch{}if(P?.form&&!F.peek()){te();const e=P.at?` (${re(P.at)})`:"",t=!s&&P.basedOn&&h?.updatedAt&&P.basedOn!==h.updatedAt?" This user has been changed since - restoring puts your version over it.":"",o=s?"the new user you started":`"${p(h.name||h.email)}"`;await E.confirm(`You left unsaved changes to ${o}${e}.${t} Restore them?`)&&N()&&ee({...J,...P.form,profile:{...J.profile,...P.form.profile||{}}})}}};
|
|
1
|
+
import{api as _,getUser as Ve}from"../api.js";import{addBannerAction as Te}from"../lib/plugin-chrome.js";import{resetPassword as Ge}from"../lib/password-reset-actions.js";import{helpPopovers as Je}from"../lib/help-popover.js";import{sidebarCount as Re}from"../lib/tool-kit.js";import{roleIndex as We,initials as Xe}from"../lib/users-arrange.js";import{fieldKind as ce,fieldOptions as ue}from"../lib/my-profile-arrange.js";import{buildUserPayload as ze,checkUserForm as Ne,draftOf as Qe,editRights as Ze,emailConflict as et,formFromUser as de,roleChoices as tt,snapshot as se}from"../lib/user-editor-arrange.js";import{fmtDate as pe,fmtDateTime as at}from"../lib/dates.js";const st=/^\/users\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,oe=a=>String(a||"").split("?")[0].replace(/\/$/,""),p=a=>String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),ot=`<button type="button" class="uex-pick-chip" data-role="{{name}}" data-bind-class="on && 'is-on'" data-bind-disabled="disabled" title="{{title}}" data-bind-aria-pressed="on ? 'true' : 'false'">{{label}}</button>`,nt=`<button type="button" class="uex-pick-chip" data-project="{{slug}}" data-bind-class="on && 'is-on'" data-bind-aria-pressed="on ? 'true' : 'false'" title="{{title}}">{{name}}</button>`;let y=null,Oe=!1;function lt(){Oe||!window.R?.use||(Oe=!0,R.use((a,l,v)=>{const c=oe(l?.path);return!(st.test(l?.path||"")&&oe(a?.path)!==c)||!y?.dirty()||y.path!==c?v():E.confirm("You have unsaved changes to this user. Leave anyway? They are kept for your next visit - except a password, which is typed again.").then(A=>{A?v():history.replaceState(null,"",`#${c}`)})}),M.subscribe("router:afterChange",({to:a}={})=>{if(y&&oe(a?.path)!==y.path){const l=y;y=null,l.dispose()}}))}function rt(a,l){const v=ce(a.type),c=`uex-pf-${l}`,s=a.required?' <span class="text-danger">*</span>':"",A=a.help||a.description?` <span data-help="${p(a.help||a.description)}"></span>`:"",T=`<label class="form-label" for="${c}">${p(a.label||a.name)}${s}${A}</label>`,i=`<small class="uex-err" data-bind-hidden="!pe_${l}.value" data-bind-text="pe_${l}.value"></small>`,K=a.placeholder?` placeholder="${p(a.placeholder)}"`:"";switch(v){case"textarea":return`<div class="uex-wide">${T}<textarea id="${c}" class="form-input" rows="3"${K} data-model="pf_${l}.value"></textarea>${i}</div>`;case"select":return`<div>${T}<select id="${c}" class="form-input" data-model="pf_${l}.value"><option value="">-</option>${ue(a).map(m=>`<option value="${p(m.value)}">${p(m.label)}</option>`).join("")}</select>${i}</div>`;case"checkbox":return`<div><label class="uex-check"><input id="${c}" type="checkbox" data-model="pf_${l}.value"> ${p(a.label||a.name)}${s}</label>${A}${i}</div>`;case"multi":return`<div class="uex-wide">${T}<div data-multi="${l}">${ue(a).map((m,u)=>`<label class="uex-check"><input type="checkbox" data-opt="${p(m.value)}" data-bind-checked="po_${l}_${u}.value"> ${p(m.label)}</label>`).join("")}</div>${i}</div>`;default:return`<div>${T}<input id="${c}" type="${v}" class="form-input"${K} data-model="pf_${l}.value">${i}</div>`}}function ve(a,l){const v=a.querySelector(".dm-plugin-banner-name");if(!v)return;const c=v.querySelector(".dm-plugin-banner-parent")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-parent",textContent:"Users"}),s=v.querySelector(".dm-plugin-banner-sep")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-sep",textContent:"\u203A"});s.setAttribute("aria-hidden","true"),v.replaceChildren(c,s,document.createTextNode(l))}export const userEditorView={templateUrl:"/admin/js/templates/user-editor.html",async onMount(a){if(lt(),y){const e=y;y=null,e.dispose()}const l=oe(location.hash.slice(1)),v=l.match(/^\/users\/edit\/([^/?#]+)/),c=v?decodeURIComponent(v[1]):null,s=!c,A=s&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",T=[],i=e=>(T.push(e),e);let K=!1;const m=a.get(0),u=m.querySelector(".uex")||m,N=()=>!K&&u.isConnected,B=M.observable(!0),me=M.observable(""),Y=M.observable(!1),f=M.observable(null),q=M.observable(""),w=M.observable(""),J=M.observable(""),_e=M.observable(!1),j=M.observable("user"),b=M.observable([]),W=M.observable(""),C=M.observable([]),X=M.observable(!0),U=M.observable(""),V=M.observable(""),fe=M.observable(!1),he=M.observable(!1),be=M.observable([]),ge=M.observable([]),$=M.observable({self:!1,canEdit:!0,reason:""}),D=M.observable([]);let x={};const ke=M.observable([]);let g=[];const O=new Map,ye=M.observable(0),k=M.observable(de(null,[])),Be=()=>(ye.value,Object.fromEntries([...O].map(([e,t])=>[e,t.value]))),qe=()=>Object.fromEntries([...O].map(([e,t])=>[e,t.peek()])),we=()=>({name:q.value,email:w.value,password:J.value,role:j.value,additionalRoles:b.value,ownedByProject:W.value,projects:C.value,isActive:X.value,profile:Be()}),ne=()=>({name:q.peek(),email:w.peek(),password:J.peek(),role:j.peek(),additionalRoles:b.peek(),ownedByProject:W.peek(),projects:C.peek(),isActive:X.peek(),profile:qe()}),z=M.computed(()=>Ne(we(),{isNew:s,fields:g,saved:s?void 0:k.value,choices:D.value})),H=M.computed(()=>!B.value&&$.value.canEdit&&se(we())!==se(k.value)),F=M.computed(()=>!$.value.canEdit);i(M.effect(()=>{const e=new Set(b.value);be.value=D.value.filter(t=>t.name!==j.value).map(t=>({name:t.name,label:t.label,on:e.has(t.name),disabled:!t.assignable&&!e.has(t.name),title:t.assignable||e.has(t.name)?`Level ${t.level}`:t.reason}))})),i(M.effect(()=>{const e=new Set(C.value);ge.value=ke.value.map(t=>({slug:t.slug,name:t.name||t.slug,on:e.has(t.slug),title:t.slug}))}));const le={loading:B,loadError:me,saving:Y,name:q,email:w,password:J,showPw:_e,role:j,ownedByProject:W,isActive:X,hasProfile:fe,hasProjects:he,extraChips:be,projectChips:ge,err:z,dirty:H,locked:F,isSelf:M.computed(()=>$.value.self),lockedReason:M.computed(()=>$.value.reason),emailErr:M.computed(()=>z.value.email||(U.value&&w.value.trim().toLowerCase()===V.value?U.value:"")),cannotSave:M.computed(()=>Y.value||F.value||!H.value||Object.keys(z.value).length>0||!!(U.value&&w.value.trim().toLowerCase()===V.value)),saveText:s?"Create user":"Save",dirtyText:s?"This user has not been created yet.":"You have unsaved changes.",pwLabel:M.computed(()=>s?"Password":"New password"),pwPlaceholder:M.computed(()=>s?"At least 8 characters":"Leave empty to keep it"),avatar:M.computed(()=>Xe({name:q.value,email:w.value})),shownName:M.computed(()=>q.value.trim()||(s?"New user":f.value?.name||"")),shownEmail:M.computed(()=>w.value.trim()||""),roleText:M.computed(()=>[j.value,...b.value.filter(t=>t!==j.value)].filter(Boolean).map(t=>x[t]?.label||t).join(" + ")||"-"),accessHint:M.computed(()=>C.value.length?`Sees only ${C.value.length===1?"that project":"those projects"}, plus Core.`:"Nothing ticked: no restriction - sees every project."),factsText:M.computed(()=>{const e=f.value;return s||!e?"Not created yet":[e.createdAt&&`Added ${pe(e.createdAt)}`,e.lastLogin?`last signed in ${pe(e.lastLogin)}`:"never signed in",e.updatedAt&&`changed ${pe(e.updatedAt)}`].filter(Boolean).join(" \xB7 ")})},Q=`user-editor.draft.${c||"new"}`;function Z(e){q.value=e.name,w.value=e.email,J.value="",j.value=e.role,b.value=[...e.additionalRoles||[]],W.value=e.ownedByProject||"",C.value=[...e.projects||[]],X.value=e.isActive!==!1;for(const[t,o]of O)t in(e.profile||{})&&(o.value=e.profile[t])}function Ie(e,t){const o=et(e);return o?(U.value=o,V.value=String(t||"").trim().toLowerCase(),u.querySelector("#uex-email")?.focus(),!0):!1}async function re(){if(Y.peek()||B.peek()||F.peek())return;const e=ne(),t=Ne(e,{isNew:s,fields:g,saved:s?void 0:k.peek(),choices:D.peek()});if(Object.keys(t).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const o=ze(k.peek(),e,{isNew:s,fields:g,meta:f.peek()?.meta||{}});if(!o){E.toast("Nothing has changed.",{type:"info"});return}if(!(!s&&$.peek().self&&o.update.role&&(x[o.update.role]?.level??99)>(x[k.peek().role]?.level??99)&&!await E.confirm(`Give yourself the less senior role "${p(x[o.update.role]?.label||o.update.role)}"? You will not be able to raise it again yourself.`))){Y.value=!0;try{if(s){const d=await _.users.create(o.create);if(!N())return;ee(),k.value=ne(),E.toast(`Created ${d?.name||e.name.trim()}.`,{type:"success"}),Re("#/users"),location.hash=d?.id?`#/users/edit/${encodeURIComponent(d.id)}`:"#/users";return}const n=await _.users.update(c,o.update);if(!N())return;f.value=n;const r=de(n,g);if(k.value=r,Z(r),ee(),ve(m,n.name||n.email||"Edit"),$.peek().self){try{S.set("auth_user",{...S.get("auth_user")||{},name:n.name,email:n.email})}catch{}const d=document.getElementById("topbar-user-name");d&&n.name&&(d.textContent=n.name)}Re("#/users"),E.toast("Saved.",{type:"success"})}catch(n){Ie(n,e.email)||E.toast(`Not saved: ${n.message||n}`,{type:"error"})}finally{Y.value=!1}}}async function Ue(){H.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!N()||(Z(k.peek()),ee()))}function $e(){if(!H.peek())return;const e=Qe(ne());if(se({...e,password:""})!==se(k.peek()))try{S.set(Q,{form:e,at:new Date().toISOString(),basedOn:f.peek()?.updatedAt||null})}catch{}}function ee(){try{S.remove?S.remove(Q):S.set(Q,null)}catch{}}u.querySelector(".uex-roles").innerHTML=ot,u.querySelector(".uex-projects").innerHTML=nt;const Me=e=>{if(F.peek())return;const t=e.target.closest(".uex-pick-chip");if(!(!t||t.disabled)){if(t.dataset.role){const o=t.dataset.role,n=b.peek();b.value=n.includes(o)?n.filter(r=>r!==o):[...n,o]}else if(t.dataset.project){const o=t.dataset.project,n=C.peek();C.value=n.includes(o)?n.filter(r=>r!==o):[...n,o]}}};u.addEventListener("click",Me),i(()=>u.removeEventListener("click",Me)),i(M.effect(()=>{U.peek()&&w.value.trim().toLowerCase()!==V.peek()&&(U.value="",V.value="")})),i(M.effect(()=>{const e=j.value;b.peek().includes(e)&&(b.value=b.peek().filter(t=>t!==e))})),Te(a,{id:"user-editor-back",icon:"arrow-left",title:"Back to users",onClick:()=>{location.hash="#/users"}}),Te(a,{id:"user-editor-save",icon:"save",title:s?"Create user (Ctrl+S)":"Save (Ctrl+S)",onClick:()=>re()}),ve(m,s?"New user":"Edit");const Ee=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!N()||(e.preventDefault(),e.stopImmediatePropagation(),re())};document.addEventListener("keydown",Ee,!0),i(()=>document.removeEventListener("keydown",Ee,!0));const xe=e=>{H.peek()&&($e(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",xe),i(()=>window.removeEventListener("beforeunload",xe)),y={path:l,dirty:()=>H.peek(),dispose(){K=!0,$e(),T.splice(0).reverse().forEach(e=>{try{e()}catch{}})}};let h=null,Se=null,je=null,L=[];try{[h,je,Se,L]=await Promise.all([s?Promise.resolve(null):_.users.get(c),_.collections.listEntries("roles",{limit:1e3}).catch(()=>null),_.collections.get("user-profiles").catch(()=>null),_.projects.list().catch(()=>[])])}catch(e){if(!N())return;me.value=e?.status===404?"There is no such user - it may have been deleted.":`Could not load this user: ${e.message||e}`,B.value=!1,I.scan(u);return}if(!N())return;L=Array.isArray(L)?L:[],ke.value=L,x=We(je?.entries||[]),Object.keys(x).length||(x={admin:{label:"Admin",level:1},user:{label:"User",level:2}});const Ce=Ve()||{};$.value=Ze(Ce,h,x),D.value=tt(x,Ce,h),f.value=h;const De=u.querySelector(".uex-role-select");De.innerHTML=D.peek().map(e=>`<option value="${p(e.name)}"${e.assignable?"":" disabled"}>${p(e.label)}${e.assignable?"":" - only a more senior role can give it"}</option>`).join("");const He=u.querySelector(".uex-owner-select");He.innerHTML=`<option value="">(none)</option>${L.map(e=>`<option value="${p(e.slug)}">${p(e.name||e.slug)}</option>`).join("")}`,he.value=L.length>0;const Le=D.peek().filter(e=>e.assignable).map(e=>e.name),Fe=Le.includes("user")?"user":Le.at(-1)||"user";g=(Se?.fields||[]).filter(e=>e&&e.name);const G=de(h,g,{project:L.some(e=>e.slug===A)?A:"",defaultRole:Fe});g.forEach(e=>O.set(e.name,M.observable(G.profile[e.name])));const Pe=async e=>{const t=f.peek();if(t&&await Ge(t,e))try{f.value={...t,...await _.users.get(t.id)}}catch{}};le.canReset=M.computed(()=>!s&&!B.value&&$.value.canEdit&&!$.value.self&&f.value?.isActive!==!1),le.resetOpen=M.computed(()=>!!f.value?.resetLinkExpires&&Date.parse(f.value.resetLinkExpires)>Date.now());const Ke=M.applyBindings(le,u,{methods:{save:re,discard:Ue,resetEmail:()=>Pe("email"),resetLink:()=>Pe("link")}});i(()=>Ke.dispose());const ie=g.map((e,t)=>({f:e,i:t})).filter(({f:e})=>ce(e.type)!=="hidden");if(ie.length){const e=u.querySelector(".uex-profile");e.innerHTML=ie.map(({f:r,i:d})=>rt(r,d)).join("");const t={};g.forEach((r,d)=>{t[`pf_${d}`]=O.get(r.name),t[`pe_${d}`]=M.computed(()=>z.value[`profile.${r.name}`]||""),ce(r.type)==="multi"&&ue(r).forEach((te,ae)=>{t[`po_${d}_${ae}`]=M.computed(()=>(O.get(r.name).value||[]).includes(te.value))})});const o=M.applyBindings(t,e);i(()=>o.dispose());const n=r=>{const d=r.target.closest("[data-multi]");if(!d||!r.target.matches("input[data-opt]"))return;const te=O.get(g[Number(d.dataset.multi)].name),ae=r.target.dataset.opt,Ae=te.peek()||[];te.value=r.target.checked?[...new Set([...Ae,ae])]:Ae.filter(Ye=>Ye!==ae)};e.addEventListener("change",n),i(()=>e.removeEventListener("change",n))}fe.value=ie.length>0,i(Je(u,{position:"bottom"})),k.value=G,Z(G),ye.value++,B.value=!1,ve(m,s?"New user":h.name||h.email||"Edit"),I.scan(u),F.peek()||u.querySelector("#uex-name")?.focus();let P=null;try{P=S.get(Q)}catch{}if(P?.form&&!F.peek()){ee();const e=P.at?` (${at(P.at)})`:"",t=!s&&P.basedOn&&h?.updatedAt&&P.basedOn!==h.updatedAt?" This user has been changed since - restoring puts your version over it.":"",o=s?"the new user you started":`"${p(h.name||h.email)}"`;await E.confirm(`You left unsaved changes to ${o}${e}.${t} Restore them?`)&&N()&&Z({...G,...P.form,profile:{...G.profile,...P.form.profile||{}}})}}};
|
package/admin/js/views/users.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as B,apiRequest as O,getUser as ae}from"../api.js";import{addBannerAction as oe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{openMenuAt as
|
|
1
|
+
import{api as B,apiRequest as O,getUser as ae}from"../api.js";import{addBannerAction as oe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{openMenuAt as F,settingsStore as ie,sidebarCount as v,viewLifetime as re}from"../lib/tool-kit.js";import{resetPassword as le,withdrawReset as ce}from"../lib/password-reset-actions.js";import{filterByProject as de}from"../lib/project-context.js";import{FILTERS as ue,SORTS as pe,arrangeUsers as me,canManage as W,resetAllowed as he,roleIndex as fe,userRow as ve}from"../lib/users-arrange.js";import{fmtDateTime as be}from"../lib/dates.js";const K=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,b="#/users",ye=`
|
|
2
2
|
<div class="usx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="usx-avatar" aria-hidden="true">{{initials}}</span>
|
|
4
4
|
<span class="usx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
|
|
@@ -11,4 +11,4 @@ import{api as B,apiRequest as O,getUser as ae}from"../api.js";import{addBannerAc
|
|
|
11
11
|
<span class="usx-chip is-outline" data-bind-hidden="!resetOpen" title="A password reset link was sent and has not been used yet">Reset link open</span>
|
|
12
12
|
</span>
|
|
13
13
|
<button type="button" class="usx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
14
|
-
</div>`,
|
|
14
|
+
</div>`,Y=a=>String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),_=ie("users.list",a=>({filter:ue.includes(a?.filter)?a.filter:"all",sort:pe.includes(a?.sort)?a.sort:"name"})),i=re(K);function we(){const a=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!a)return null;try{return decodeURIComponent(a[1])}catch{return a[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(a){i.reset();const r=a.get(0).querySelector(".usx")||a.get(0),m=()=>K.test(location.hash)&&r.isConnected,f=ae(),l=we(),x=_.read(),c=M.observable([]),d=M.observable({}),R=M.observable(!0),y=M.observable(""),u=M.observable(x.filter),w=M.observable(x.sort),g=M.observable(""),$=M.observable(""),k=M.observable(0);i.add(M.effect(()=>{_.write({filter:u.value,sort:w.value})}));const A=M.observable([]);i.add(M.effect(()=>{const e=d.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():be(s)}catch{return s}};A.value=me(c.value,{filter:u.value,role:g.value,query:$.value,sort:w.value}).map(s=>ve(s,{roles:e,me:f,when:t}))}));const G=M.computed(()=>{const e=c.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),H=M.computed(()=>c.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),J=M.computed(()=>l?`Showing only users in the project "${l}"${k.value?` - ${k.value} more elsewhere`:""}.`:""),C=e=>c.peek().find(t=>t.id===e);async function h(){try{const[e,t]=await Promise.all([B.users.list(),O("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]);if(!m())return;let s=Array.isArray(e)?e:[];if(l){const o=de(s,l);k.value=s.length-o.length,s=o}d.value=fe(t?.entries||[]),V(),c.value=s,y.value=""}catch(e){y.value=`Could not load the users: ${e.message||e}`}finally{R.value=!1}}function V(){const e=r.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=g.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(d.peek()).sort((s,o)=>s[1].level-o[1].level).forEach(([s,o])=>{const n=document.createElement("option");n.value=s,n.textContent=o.label,e.appendChild(n)}),e.value=t}const S=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function T(e,t){if(!(!t&&!await E.confirm(`Make ${Y(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 O(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),v(b),m()&&await h()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function z(e){if(await E.confirm(`Delete ${Y(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await B.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),v(b),m()&&await h()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function j(e,t){await le(e,t)&&(v(b),m()&&await h())}async function Q(e){await ce(e)&&(v(b),m()&&await h())}async function X(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 Z(e){const t=C(e);if(!t)return[];const s=W(f,t,d.peek()),o=s.self?"not on your own account":"your role is not senior to theirs",n=he(f,t,d.peek()),se=!!t.resetLinkExpires&&Date.parse(t.resetLinkExpires)>Date.now(),P=q=>n.ok?q:`${q.replace(/…$/,"")} - ${n.why}`;return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${o}`,icon:"edit",disabled:!s.edit,action:()=>S(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>X(t)},{type:"divider"},{label:P("Send password reset email\u2026"),icon:"mail",disabled:!n.ok,action:()=>j(t,"email")},{label:P("Copy a reset link"),icon:"link",disabled:!n.ok,action:()=>j(t,"link")},...se?[{label:s.other?"Withdraw the reset link":`Withdraw the reset link - ${o}`,icon:"x-circle",disabled:!s.other,action:()=>Q(t)}]:[],{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${o}`,icon:"play",disabled:!s.other,action:()=>T(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${o}`,icon:"pause",disabled:!s.other,action:()=>T(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${o}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>z(t)}]}const p=r.querySelector(".usx-list");p.innerHTML=ye;const ee=M.applyBindings({users:c,loading:R,loadError:y,filter:u,sort:w,role:g,query:$,rows:A,counts:G,emptyText:H,projectNote:J},r,{methods:{showAll:()=>{u.value="all"},showActive:()=>{u.value="active"},showInactive:()=>{u.value="inactive"}}});i.add(()=>ee.dispose()),i.add(ne(r,{position:"bottom"}));const te=E.contextMenu(p,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>Z(e.dataset.id)});i.add(()=>te?.destroy?.());const L=e=>{const t=C(e);t&&(W(f,t,d.peek()).edit?S(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},N=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){F(s);return}L(t.dataset.id)},U=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),F(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};p.addEventListener("click",N),p.addEventListener("keydown",U),i.add(()=>{p.removeEventListener("click",N),p.removeEventListener("keydown",U)}),oe(a,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=l?`#/users/new?project=${encodeURIComponent(l)}`:"#/users/new"}}),I.scan(r),await h()}};
|