domma-cms 1.6.55 → 1.6.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
- package/server/templates/page.html +1 -1
package/admin/js/views/seo.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib/plugin-chrome.js";import{helpPopovers as ws}from"../lib/help-popover.js";import{onceClosed as ks,openMenuAt as Ms,viewLifetime as $s}from"../lib/tool-kit.js";import{snippet as Ss}from"../lib/seo-analyse.js";import{checkRule as bt,matchRedirect as Es}from"../lib/seo-redirects.js";import{checkSettings as Ts,mergeDefaults as vt,ORG_TYPES as Cs}from"../lib/seo-settings.js";import{pickMedia as gt}from"../lib/media-picker.js";import{fmtDate as Os,fmtDateTime as Ke}from"../lib/dates.js";import{SHAPES as Ls,whileLoading as Rs}from"../lib/loading.js";import{countText as fe,draftAction as As,draftKey as Ge,filterPages as Ps,fingerprint as z,pageCounts as Is,restoreQuestion as ze,usableDraft as yt}from"../lib/seo-arrange.js";const js="/seo",Fe=/^#\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/,xt=["overview","redirects","sitemap"],Ns="#/seo",Ds={pass:"\u2713",warn:"!",fail:"\u2715",info:"i"},qs={301:"301 moved",302:"302 for now",307:"307 for now",308:"308 moved",410:"410 gone"},Us={apiRequest:ys,addBannerAction:xs,helpPopovers:ws,onceClosed:ks,openMenuAt:Ms,viewLifetime:$s},Bs=async()=>Us,R=i=>String(i??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Je(i){const l=document.createElement("template");return l.innerHTML=i.trim(),l.content.firstElementChild}async function be(i,l){try{await navigator.clipboard.writeText(i),E.toast(`Copied ${l}.`,{type:"success"})}catch{E.toast(`Could not copy - ${i}`,{type:"warning",duration:6e3})}}const A={user(){try{return S.get("auth_user")?.id||""}catch{return""}},get(i){try{return i?S.get(i)??null:null}catch{return null}},set(i,l){try{i&&S.set(i,l)}catch{}},drop(i){try{i&&(S.remove?S.remove(i):S.set(i,null))}catch{}},keys(i){try{return(S.keys?.()||[]).filter(l=>l.startsWith(i))}catch{return[]}}},_s=()=>new Promise(i=>setTimeout(i,0)),Ye=async(i,l)=>(await _s(),E.confirm(R(i),l)),Qe=i=>Ye(i.text,{title:i.title,confirmText:i.confirmText,cancelText:i.cancelText});function wt(){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:Ns}}))}catch{}}const kt=i=>typeof i!="number"?"":i>=85?"good":i>=60?"poor":"bad",Mt=i=>i?Os(i):"",$t=i=>{if(!i)return"";const l=Math.round((Date.now()-Date.parse(i))/6e4);if(l<1)return"just now";if(l<60)return`${l} min ago`;const q=Math.round(l/60);return q<48?`${q} hour${q===1?"":"s"} ago`:Mt(i)};function Hs(i){const l=m=>{let n=String(m??"");return/^[=+\-@\t\r]/.test(n)&&(n=`'${n}`),/[",\n\r]/.test(n)?`"${n.replace(/"/g,'""')}"`:n},q=Object.keys(i[0]||{});return`\uFEFF${[q.join(","),...i.map(m=>q.map(n=>l(m[n])).join(","))].join(`\r
|
|
2
2
|
`)}\r
|
|
3
|
-
`}function
|
|
3
|
+
`}function Ws(i,l,q="text/csv;charset=utf-8"){const m=URL.createObjectURL(new Blob([l],{type:q})),n=Object.assign(document.createElement("a"),{href:m,download:i});document.body.appendChild(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(m),1e4)}const Ks=`
|
|
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>`,Gs=`
|
|
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>`,zs=`
|
|
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,42 +20,42 @@ import{apiRequest as Gt}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>`,Fs=`
|
|
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>
|
|
27
27
|
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="note" data-bind-title="note"></span>
|
|
28
28
|
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
|
|
29
|
-
</div>`,
|
|
29
|
+
</div>`,Js=`
|
|
30
30
|
<div class="sox-row" data-bind-data-row="id" tabindex="0">
|
|
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>`,Ys=`
|
|
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(
|
|
41
|
+
</div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(i){const l=await Bs();if(!Fe.test(location.hash))return;const q=seoView._lifetime??(seoView._lifetime=l.viewLifetime(Fe));q.reset();const m=i.get(0).querySelector(".sox")||i.get(0),n=()=>Fe.test(location.hash)&&m.isConnected,P=e=>(q.add(e),e),v=(e,t)=>l.apiRequest(`${js}${e}`,t),St=location.hash.match(/^#\/seo\/([a-z0-9-]+)/)?.[1]||"overview",F=M.observable(St),Ee=M.observable(!0),Te=M.observable(""),U=M.observable(!0),x=M.observable(null),ve=M.observable({results:[]}),te=M.observable("all"),ge=M.observable(""),Ce=M.observable(""),N=M.observable({rules:[],chains:[],onRename:!0}),Oe=M.observable(""),ye=M.observable(""),xe=M.observable(""),Ve=M.observable("301"),_=M.observable({entries:[],left:[],sources:[],labels:{},robots:""}),de=M.observable("in"),Le=M.observable(""),Xe=M.observable([]),Et=M.computed(()=>!xt.includes(F.value)),Ze=M.observable(!0),et=M.observable(!0),tt=M.observable(!0);let J=null;const se=e=>_.value.labels?.[e]||(e==="pages"?"Pages":e),Tt=M.computed(()=>(x.value?.score??null)===null?"\u2014":`${x.value.score}%`),Ct=M.computed(()=>`${x.value?.score??0}%`),Ot=M.computed(()=>kt(x.value?.score)),Lt=M.computed(()=>{const e=x.value;return e?{pages:String(e.pages),failing:String(e.failing),duplicates:String(e.duplicates),redirects:String(e.redirects)}:{pages:"\u2014",failing:"\u2014",duplicates:"\u2014",redirects:"\u2014"}}),Rt=M.computed(()=>x.value?.failing?String(x.value.failing):""),At=M.computed(()=>{const e=x.value;return e?.running?`Checking\u2026 ${e.progress?.done||0} of ${e.progress?.total||"?"}`:e?.at?`Checked ${$t(e.at)}`:"Not checked yet"}),st=M.computed(()=>[...new Set((ve.value.results||[]).map(e=>e.source))]),at=M.observable([]);let we=[];const Pt=M.computed(()=>{const e=Is(ve.value.results,{source:ge.value});return{all:fe(e.all),problems:fe(e.problems),hidden:fe(e.hidden)}});P(M.effect(()=>{we=Ps(ve.value.results,{show:te.value,source:ge.value,query:Ce.value}),at.value=we.slice(0,1e3).map((e,t)=>({id:`p${t}`,path:e.urlPath,sourceText:[se(e.source),e.noindex?"hidden from search engines":e.unlisted?"not in the sitemap":""].filter(Boolean).join(" \xB7 "),title:e.title||e.label||"",scoreText:e.error?"!":typeof e.score=="number"?String(e.score):"\u2014",tone:e.error?"bad":e.noindex?"info":kt(e.score),problems:e.error||(e.problems?.length?e.problems.join(" \xB7 "):"Nothing to fix")}))}));const It=M.computed(()=>x.value?.at?Ce.value.trim()?"No page matches.":te.value==="problems"?"Nothing needs attention.":"No pages.":x.value?.running?"Checking every page - this takes a moment\u2026":"Not checked yet. Check now with the button top right."),ot=M.observable([]);let W=[];P(M.effect(()=>{const e=x.value;if(W=[],e){const t=e.siteSeo||{};for(const s of e.issues?.duplicateTitles||[])W.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 e.issues?.duplicateDescriptions||[]){const o=t.defaultDescription&&s.text===t.defaultDescription;W.push({kind:"dupe",text:o?`${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:o?"default":"duplicate"})}t.defaultImage||W.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"}),(!e.origin||/localhost|127\.0\.0\.1/.test(e.origin))&&W.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"}),e.chains&&W.push({kind:"chains",text:`${e.chains} redirect${e.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"})}ot.value=W.map((t,s)=>({id:`s${s}`,text:t.text,detail:t.detail||"",tone:t.tone,badge:t.badge}))}));const Re=M.observable([]);let ue=[];P(M.effect(()=>{const e=Oe.value.trim().toLowerCase(),t=new Set(N.value.chains.map(s=>s.id));ue=N.value.rules.filter(s=>!e||`${s.from} ${s.to} ${s.note}`.toLowerCase().includes(e)),Re.value=ue.map((s,o)=>({id:`r${o}`,from:s.from,to:s.code===410?"(gone)":s.to,note:[s.auto?"Added when the page moved":s.note,t.has(s.id)?"leads to another redirect":""].filter(Boolean).join(" \xB7 "),code:String(s.code),tone:s.code===410?"bad":t.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 ${$t(s.lastHit)}`:"Not used yet"}))}));const jt=M.computed(()=>N.value.rules.length?String(N.value.rules.length):""),Nt=M.computed(()=>`${ue.length+0*Re.value.length} of ${N.value.rules.length}`),Dt=M.computed(()=>Oe.value.trim()?"No redirect matches.":"No redirects yet."),qt=M.computed(()=>{const e=N.value.chains.length;return e?`${e} redirect${e===1?" leads":"s lead"} to another. Right-click one and choose "Go straight there" to make it one hop.`:""}),Ut=M.computed(()=>N.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)."),nt=M.observable([]);P(M.effect(()=>{nt.value=_.value.sources.map((e,t)=>({id:`x${t}`,label:se(e.id),detail:`${e.listed} of ${e.count} listed`,state:e.on?"on":"off",tone:e.on?"good":""}))}));const Ae=M.observable([]);let ae=[];P(M.effect(()=>{const e=Le.value.trim().toLowerCase();ae=(de.value==="in"?_.value.entries:_.value.left).filter(s=>!e||`${s.urlPath} ${s.title}`.toLowerCase().includes(e)),Ae.value=ae.slice(0,2e3).map((s,o)=>({id:`e${o}`,path:s.urlPath,title:s.title||"",source:se(s.source),note:s.why||[s.lastmod?`Changed ${Mt(s.lastmod)}`:"",s.changefreq||"",s.priority!==void 0?`priority ${s.priority}`:""].filter(Boolean).join(" \xB7 ")}))}));const Bt=M.computed(()=>_.value.robots||""),_t=M.computed(()=>({in:fe((_.value.entries||[]).length),out:fe((_.value.left||[]).length)})),Ht=M.computed(()=>`${ae.length+0*Ae.value.length} address${ae.length===1?"":"es"}`),Wt=M.computed(()=>_.value.hooks?Le.value.trim()?"No address matches.":de.value==="in"?"The sitemap is empty.":"Nothing is left out.":"This version of Domma CMS lists only its own pages in the sitemap."),ke=e=>/403|forbidden/i.test(String(e?.message));async function oe(){try{const e=await v("/overview");if(!n()||!e)return;x.value=e,Te.value="",e.running&&it()}catch(e){n()&&(Te.value=ke(e)?"You do not have access to SEO.":`Could not load SEO: ${e.message||e}`)}}const Pe=(e,t,s,o=Ls.rows)=>e.peek()?Rs(m.querySelector(t),s,o).finally(()=>{n()&&(e.value=!1)}):s;async function Ie(){try{const e=await Pe(Ze,".sox-pages-loading",v("/audit"));n()&&e&&(ve.value=e)}catch{}}async function pe(){try{const e=await Pe(et,".sox-rules-loading",v("/redirects"));if(!n()||!e)return;N.value=e,Jt(e.rules||[])}catch{}}async function je(){try{const e=await Pe(tt,".sox-entries-loading",v("/sitemap"));n()&&e&&(_.value=e)}catch(e){n()&&!ke(e)&&E.toast(`Could not load the sitemap: ${e.message||e}`,{type:"error"})}}async function Ne(){Ee.value=!0,await Promise.all([oe(),Ie(),pe(),je()]),n()&&(Ee.value=!1)}function it(){J||(J=setInterval(async()=>{if(!n()){clearInterval(J),J=null;return}await oe(),x.peek()?.running||(clearInterval(J),J=null,await Ie(),wt())},1500))}P(()=>{J&&clearInterval(J)});async function rt(){try{const e=await v("/audit",{method:"POST",body:JSON.stringify({})});n()&&(x.value={...x.peek(),...e,running:!0}),it()}catch(e){n()&&E.toast(`Could not start the check: ${e.message||e}`,{type:"error"})}}async function De(e,t,s=pe){try{const o=await e();return n()&&(t&&E.toast(t,{type:"success"}),await Promise.all([s(),oe()])),o}catch(o){return n()&&(ke(o)?(U.value=!1,E.toast("Only people who may manage SEO can change this.",{type:"warning"})):E.toast(`Not done: ${o.message||o}`,{type:"error"})),null}}const Kt=(e,t=`Redirect from ${e.from} added.`)=>De(()=>v("/redirects",{method:"POST",body:JSON.stringify(e)}),t),Gt=(e,t)=>De(()=>v(`/redirects/${encodeURIComponent(e.join(","))}`,{method:"DELETE"}),`${t} removed.`);async function zt(e){try{const t=await v(`/redirects/test?path=${encodeURIComponent(e.replace(/\/\*$/,"/example"))}`);if(!n())return;const s=t.hops||[];E.toast(s.length?s.map(o=>`${o.from} \u2192 ${o.code===410?"gone":o.to} (${o.code})`).join(" \xB7 "):"No redirect applies.",{type:s.length>1?"warning":"info",duration:7e3})}catch(t){n()&&E.toast(`Could not test: ${t.message||t}`,{type:"error"})}}const lt=e=>({from:e.from||"",to:e.code===410?"":e.to||"",code:String(e.code||301),note:e.note||""});function qe(e,{asNew:t=!1,adopt:s=null}={}){const o=t?{from:"",to:"",code:"301",note:""}:lt(e),a={from:M.observable(o.from),to:M.observable(o.to),code:M.observable(o.code),note:M.observable(o.note)},p=t?"":z(lt(e));dt(t?"New redirect":`Redirect from ${e.from}`,a,`
|
|
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(qs).map(([r,d])=>`<option value="${r}">${d}</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
|
-
</div>`,()=>{const
|
|
48
|
+
</div>`,()=>{const r={from:a.from.value,to:a.to.value,code:Number(a.code.value),note:a.note.value},d=N.peek().rules.filter(h=>t||h.id!==e.id);return{body:r,errors:bt(r,d).errors}},async r=>{t?await v("/redirects",{method:"POST",body:JSON.stringify(r)}):await v(`/redirects/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify(r)}),n()&&(E.toast(t?`Redirect from ${r.from} added.`:"Redirect saved.",{type:"success"}),await Promise.all([pe(),oe()]))},t?"Add":"Save",{kind:"redirect",id:e.id,what:t?`the redirect from ${s?.from||e.from||"?"}`:`the redirect from ${e.from}`,was:p,adopt:s})}function Ft(){const e={csv:M.observable("")};dt("Import redirects",e,`
|
|
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:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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:e.csv.value},errors:e.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async t=>{const s=await v("/redirects/import",{method:"POST",body:JSON.stringify(t)});if(!n())return;const o=s.skipped||[];E.toast(`${s.added} added${o.length?`, ${o.length} skipped - line ${o[0].line}: ${o[0].why}`:"."}`,{type:o.length?"warning":"success",duration:o.length?9e3:4e3}),await Promise.all([pe(),oe()])},"Import",{kind:"import",what:"the redirects you were importing"})}let ct=!1;async function Jt(e){if(ct||!U.peek())return;ct=!0;const t=Ge(A.user(),"redirect","");if(!t)return;const s=new Set(e.map(o=>String(o.id)));for(const o of A.keys(`${t}.`)){let a;try{a=decodeURIComponent(o.slice(t.length+1))}catch{continue}if(s.has(a))continue;const p=yt(A.get(o),{from:"",to:"",code:"301",note:""});if(!p){A.drop(o);continue}if(await Qe(ze({what:`the redirect from ${p.values.from||"?"}`,when:p.at?Ke(p.at):"",gone:"Keep them to add it again as a new redirect."}))){A.drop(o);continue}n()&&qe({id:a},{asNew:!0,adopt:p.values});return}}function Ue(e,{kind:t,id:s="",what:o,read:a,write:p,was:r=""}){const d=Ge(A.user(),t,s);let h=z(a()),C=r,w=!0,O=!1,j=!1;const L=()=>z(a())!==h,g=()=>{const u=As({dirty:L(),owns:w});u==="write"?A.set(d,{values:a(),was:C,at:new Date().toISOString()}):u==="remove"&&A.drop(d)},$=u=>{O||(g(),L()&&(u.preventDefault(),u.returnValue=""))};window.addEventListener("beforeunload",$);const T=e.close.bind(e);let B=()=>{};const H=()=>{O=!0,window.removeEventListener("beforeunload",$),B()};return e.close=async u=>{if(O)return T(u);if(!(u!==!0&&!w)){if(u!==!0&&L()){if(j)return;j=!0;const f=await Ye("Throw away the changes you have not saved?",{title:"Unsaved changes",confirmText:"Throw away",cancelText:"Keep editing"});if(j=!1,!f||O)return;w&&A.drop(d)}else u!==!0&&g();return H(),T(u)}},B=q.add(()=>{if(!O){g(),H();try{T(!0)}catch{}}}),{saved(u,f=C){h=z(u),C=f,w=!0,g()},adopt(u,f=""){p(u),C=f,w=!0},async offer(u=r){const f=A.get(d),c=yt(f,a());return c?(w=!1,await Qe(ze({what:o,when:c.at?Ke(c.at):"",stale:c.was!==u}))?(A.drop(d),w=!0,!1):O?!1:(w=!0,p(c.values),C=c.was,!0)):(f&&A.drop(d),!1)}}}let Me=!1;function dt(e,t,s,o,a,p="Save",r=null){if(Me)return;Me=!0;let d=null;try{const h=M.observable(!1),C=M.computed(()=>o()),w=M.computed(()=>Object.values(C.value.errors).join(" ")),O={...t,saving:h,errText:w,cannot:M.computed(()=>h.value||!!w.value)},j=()=>Object.fromEntries(Object.entries(t).map(([u,f])=>[u,f.peek()])),L=u=>{for(const[f,c]of Object.entries(t))f in u&&(c.value=u[f])};d=E.slideover({title:e,size:"md",position:"right"});const g=Je(`<div class="sox-form">${s}
|
|
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 ${p.toLowerCase()}s.</small><span class="sox-spacer"></span>
|
|
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">${R(p)}</button></div></div>`);let $=null;async function T(){if(O.cannot.peek())return;h.value=!0;const u=j();g.inert=!0;try{await a(C.peek().body),$?.saved(u,""),d.close(!0)}catch(f){g.inert=!1,n()&&E.toast(`Not saved: ${f.message||f}`,{type:"error"})}finally{h.value=!1}}d.element.appendChild(g);const B=l.helpPopovers(g),H=M.applyBindings(O,g,{methods:{save:T,cancel:()=>d.close()}});g.addEventListener("keydown",u=>{(u.ctrlKey||u.metaKey)&&u.key==="Enter"&&(u.preventDefault(),T())}),l.onceClosed(d,()=>{H.dispose(),B(),Me=!1}),r?($=Ue(d,{...r,read:j,write:L}),r.adopt?$.adopt(r.adopt,""):$.offer(r.was)):P(()=>{try{d.close(!0)}catch{}}),I.scan(g),d.open(),g.querySelector("input, textarea")?.focus()}catch(h){Me=!1;try{d?.close(!0)}catch{}E.toast(`Could not open ${e}: ${h.message||h}`,{type:"error"})}}let ne=!1;const Yt=e=>({title:String(e.title??"").trim().slice(0,200),description:String(e.description??"").trim().slice(0,400),image:String(e.image??"").trim().slice(0,500),noindex:e.noindex===!0,focus:String(e.focus??"").trim().slice(0,100)});async function X(e){if(ne)return;ne=!0;let t;try{t=await v(`/item?path=${encodeURIComponent(e)}`)}catch(s){if(ne=!1,!n())return;const o=s?.status===404?Ge(A.user(),"item",e):null,a=o?A.get(o):null;if(a?.values){await Qe(ze({what:e,when:a.at?Ke(a.at):"",gone:"Keep them and they are offered again if the address is served again."}))&&A.drop(o);return}E.toast(`Could not open ${e}: ${s.message||s}`,{type:"error"});return}if(!n()){ne=!1;return}try{Qt(e,t)}catch(s){ne=!1,E.toast(`Could not open ${e}: ${s.message||s}`,{type:"error"})}}function Qt(e,t){const s={title:M.observable(t.fields.title),description:M.observable(t.fields.description),focus:M.observable(t.fields.focus),image:M.observable(t.fields.image),noindex:M.observable(t.fields.noindex)},o=t.limits||{},a=t.page?.title||t.live?.h1||"",p=t.kind==="page"?`${a}${t.defaults.separator}${t.defaults.siteTitle}`:t.live?.title||"",r=t.kind==="page"?t.defaults.description||"":t.live?.description||"",d=t.kind==="page"?t.defaults.image:t.live?.image||"",h=M.computed(()=>s.title.value.trim()||p),C=M.computed(()=>s.description.value.trim()||r),w=M.computed(()=>s.image.value.trim()||d),O=t.origin||location.origin,j=M.computed(()=>Ss({title:h.value,description:C.value,url:`${O}${e}`,limits:o})),L=(y,b,ce)=>{const V=y.length;return{text:`${V} / ${ce}`,width:`${Math.min(100,V/ce*100)}%`,tone:V?V<b||V>ce?"poor":"good":"bad"}},g=M.computed(()=>L(h.value,o.titleMin||30,o.titleMax||60)),$=M.computed(()=>L(C.value,o.descMin||70,o.descMax||160)),T=(()=>{try{return new URL(O).host}catch{return location.host}})(),B=M.computed(()=>w.value?`url("${w.value.replace(/["\\\n\r]/g,"")}")`:"none"),H=M.observable([]),u=y=>{const b=[],ce=y?.checks||[],V={fail:0,warn:1,pass:2,info:3};for(const He of[...new Set(ce.map(G=>G.group))]){b.push({id:`g-${He}`,head:He,title:"",detail:"",fix:"",status:"",glyph:""});for(const G of ce.filter(We=>We.group===He).sort((We,gs)=>V[We.status]-V[gs.status]))b.push({id:`c-${G.id}`,head:"",title:G.title,detail:G.detail,fix:G.status==="pass"?"":G.fix,status:G.status,glyph:Ds[G.status]||"?"})}H.value=b};u(t.analysis);const f=M.observable(t.analysis?`Score ${t.analysis.score}% \xB7 ${t.analysis.failing} failing, ${t.analysis.warnings} could be better`:t.status===200?"":`Not live (answers ${t.status||"nothing"}) - the checks run once it is published.`),c=()=>({title:s.title.peek(),description:s.description.peek(),focus:s.focus.peek(),image:s.image.peek(),noindex:s.noindex.peek()}),k=y=>{for(const b of Object.keys(s))b in y&&(s[b].value=y[b])},D=M.observable(!1),Z=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.":""),le={...s,saving:D,errText:Z,checks:H,scoreLine:f,serp:j,titleMeter:g,descMeter:$,shareBg:B,effTitle:h,effDesc:C,host:T,hasImage:M.computed(()=>!!w.value),defTitle:p,defDesc:r,canManage:U,cannot:M.computed(()=>D.value||!!Z.value||!U.value)},ee=E.slideover({title:`SEO \xB7 ${e}`,size:"lg",position:"right"}),ps=t.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>':t.page?.status!=="published"?`<p class="sox-note is-warn">This page is a ${R(t.page?.status||"draft")} - not live yet.</p>`:"",ms=t.redirect?`<p class="sox-note is-bad">A redirect sends visitors from here to ${R(t.redirect.to||"(gone)")} - they never see this page.</p>`:"",Q=Je(`
|
|
55
55
|
<div class="sox-form">
|
|
56
|
-
${
|
|
56
|
+
${ps}${ms}
|
|
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>
|
|
58
|
-
<span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${
|
|
58
|
+
<span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${R(e)}" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="12"></span> Open</a></h4>
|
|
59
59
|
<div class="sox-previews">
|
|
60
60
|
<div class="sox-serp" aria-label="Search result preview">
|
|
61
61
|
<div class="sox-serp-crumbs" data-bind-text="serp.value.crumbs"></div>
|
|
@@ -70,15 +70,15 @@ import{apiRequest as Gt}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="${
|
|
73
|
+
<input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${R(p)}" 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
|
-
<textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${
|
|
76
|
+
<textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${R(r)}" maxlength="400"></textarea>
|
|
77
77
|
<div class="sox-meter" data-bind-data-tone="descMeter.value.tone"><span class="sox-bar"><span data-bind-style-width="descMeter.value.width"></span></span><span data-bind-text="descMeter.value.text"></span></div></div>
|
|
78
78
|
<div><label class="form-label" for="sox-i-focus">Focus keyphrase <span data-help="The search this page should be found for - 'garden tools', 'plumber in Leeds'. The checks then look for it in the title, description, heading, address and opening. It is not shown anywhere."></span></label>
|
|
79
79
|
<input id="sox-i-focus" class="form-input" data-model="focus.value" maxlength="100"></div>
|
|
80
80
|
<div><label class="form-label" for="sox-i-image">Share image <span data-help="Shown when the page is shared. 1200 \xD7 630 pixels is ideal. Choose it from Media (or upload one), or type a full address. Empty uses the site's default image."></span></label>
|
|
81
|
-
<div class="sox-pick"><input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${
|
|
81
|
+
<div class="sox-pick"><input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${R(d||"/media/\u2026")}">
|
|
82
82
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="pickImage"><span data-icon="image"></span> Choose\u2026</button></div></div>
|
|
83
83
|
<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="noindex.value"> Hide from search engines
|
|
84
84
|
<span data-help="Asks search engines not to list this page, and leaves it out of the sitemap. For thank-you pages, private landing pages and the like. It stays reachable by its address."></span></label>
|
|
@@ -92,48 +92,48 @@ import{apiRequest as Gt}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>`);
|
|
96
|
-
`)),street:M.observable(
|
|
97
|
-
`)),blockAll:M.observable(
|
|
98
|
-
<input id="sox-s-${
|
|
95
|
+
</div>`);Q.querySelector(".sox-checks").innerHTML=Ys,Vt(Q.querySelector("#sox-media"));let Se=null;async function ht(){if(le.cannot.peek())return;D.value=!0;const y=c();try{if(await v("/item",{method:"PUT",body:JSON.stringify({path:e,...y})}),Se?.saved(y,z(Yt(y))),!n())return;E.toast("Saved.",{type:"success"});const b=await v(`/item?path=${encodeURIComponent(e)}`).catch(()=>null);if(!n())return;b?.fields&&Se?.saved(y,z(b.fields)),b?.analysis&&(u(b.analysis),f.value=`Score ${b.analysis.score}% \xB7 ${b.analysis.failing} failing, ${b.analysis.warnings} could be better`),Ie(),oe(),wt()}catch(b){if(!n())return;ke(b)&&(U.value=!1),E.toast(`Not saved: ${b.message||b}`,{type:"error"})}finally{D.value=!1}}ee.element.appendChild(Q);const hs=l.helpPopovers(Q),fs=M.applyBindings(le,Q,{methods:{save:ht,cancel:()=>ee.close(),pickImage:async()=>{const y=await gt({title:"Choose a share image",current:s.image.peek()});y&&(s.image.value=y)}}}),bs=Q.querySelector(".sox-share-img"),vs=M.effect(()=>{bs.style.backgroundImage=B.value});Q.addEventListener("keydown",y=>{(y.ctrlKey||y.metaKey)&&y.key==="Enter"&&(y.preventDefault(),ht())}),l.onceClosed(ee,()=>{fs.dispose(),hs(),vs?.(),ne=!1});const ft=z(t.fields);Se=Ue(ee,{kind:"item",id:e,what:e,read:c,write:k,was:ft}),I.scan(Q),ee.open(),Se.offer(ft)}let Be=null;async function Vt(e){try{Be??(Be=await l.apiRequest("/media").then(t=>Array.isArray(t)?t:t?.files||t?.items||[]));for(const t of Be){const s=t.url||t.path||(t.filename?`/media/${t.filename}`:"");s&&/\.(jpe?g|png|webp|gif|avif|svg)$/i.test(s)&&e.appendChild(Object.assign(document.createElement("option"),{value:s}))}}catch{Be=[]}}let ie=!1;async function Xt(){if(ie)return;ie=!0;let e,t;try{const s=await v("/settings");e=vt(s.settings),t=z(s.settings)}catch(s){ie=!1,n()&&E.toast(`Could not load the settings: ${s.message||s}`,{type:"error"});return}if(!n()){ie=!1;return}try{Zt(e,t)}catch(s){ie=!1,E.toast(`Could not open the settings: ${s.message||s}`,{type:"error"})}}function Zt(e,t){const s=e.organisation,o=x.peek()?.siteSeo?.defaultTitle||"The site's name",a={orgOn:M.observable(s.enabled),orgType:M.observable(s.type),orgName:M.observable(s.name),orgUrl:M.observable(s.url),orgLogo:M.observable(s.logo),orgEmail:M.observable(s.email),orgPhone:M.observable(s.phone),orgSame:M.observable(s.sameAs.join(`
|
|
96
|
+
`)),street:M.observable(s.address.street),locality:M.observable(s.address.locality),region:M.observable(s.address.region),postcode:M.observable(s.address.postcode),country:M.observable(s.address.country),articles:M.observable(e.articles),exclude:M.observable(e.sitemap.exclude.join(`
|
|
97
|
+
`)),blockAll:M.observable(e.robots.blockAll),robotsExtra:M.observable(e.robots.extra),onRename:M.observable(e.redirects.onRename),every:M.observable(String(e.audit.everyHours)),tMin:M.observable(String(e.audit.titleMin)),tMax:M.observable(String(e.audit.titleMax)),dMin:M.observable(String(e.audit.descMin)),dMax:M.observable(String(e.audit.descMax)),words:M.observable(String(e.audit.minWords))},p=()=>({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:e.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}}),r=()=>Object.fromEntries(Object.entries(a).map(([c,k])=>[c,k.peek()])),d=c=>{for(const[k,D]of Object.entries(a))k in c&&(D.value=c[k])},h=M.observable(!1),C=M.computed(()=>Ts(p(),e)),w=M.computed(()=>Object.values(C.value.errors).join(" ")),O={...a,saving:h,errText:w,cannot:M.computed(()=>h.value||!!w.value),isPerson:M.computed(()=>a.orgType.value==="Person")},j=(c,k,D,Z="",le="")=>`<div><label class="form-label" for="sox-s-${c}">${D}${Z?` <span data-help="${R(Z)}"></span>`:""}</label>
|
|
98
|
+
<input id="sox-s-${c}" class="form-input" data-model="${k}.value" ${le}></div>`,L=(c,k,D,Z,le,ee="")=>j(c,k,D,ee,`type="number" min="${Z}" max="${le}"`),g=(c,k,D="")=>`<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${c}.value"> ${k}${D?` <span data-help="${R(D)}"></span>`:""}</label>`,$=E.slideover({title:"SEO settings",size:"md",position:"right"}),T=Je(`
|
|
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>
|
|
101
101
|
<section><h4>Who is behind the site <span data-help="Structured data on the home page, so search engines can show the name, logo and contact details - a knowledge panel, or a local business in maps."></span></h4><div class="sox-form-grid">
|
|
102
|
-
${
|
|
102
|
+
${g("orgOn","Tell search engines who runs the site")}
|
|
103
103
|
<div><label class="form-label" for="sox-s-type">It is</label><select id="sox-s-type" class="form-input" data-model="orgType.value">
|
|
104
|
-
${
|
|
105
|
-
${
|
|
106
|
-
${
|
|
104
|
+
${Cs.map(c=>`<option value="${c}">${{Organization:"An organisation",LocalBusiness:"A local business",Person:"A person"}[c]}</option>`).join("")}</select></div>
|
|
105
|
+
${j("name","orgName","Name","Empty uses the site's name.",`placeholder="${R(o)}"`)}
|
|
106
|
+
${j("url","orgUrl","Web address","Empty uses the site address.",'placeholder="https://\u2026"')}
|
|
107
107
|
<div><label class="form-label" for="sox-s-logo">Logo (or photo) <span data-help="A square image works best. Choose it from Media (or upload one), or type a full address."></span></label>
|
|
108
108
|
<div class="sox-pick"><input id="sox-s-logo" class="form-input sox-mono" data-model="orgLogo.value" placeholder="/media/logo.png">
|
|
109
109
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="pickLogo"><span data-icon="image"></span> Choose\u2026</button></div></div>
|
|
110
|
-
${
|
|
111
|
-
${
|
|
110
|
+
${j("email","orgEmail","Email","",'type="email"')}
|
|
111
|
+
${j("phone","orgPhone","Phone")}
|
|
112
112
|
<div class="sox-wide" data-bind-hidden="isPerson.value"><label class="form-label">Address <span data-help="For a local business, the address customers visit. Leave empty for an organisation without one."></span></label>
|
|
113
113
|
<div class="sox-form-grid"><input class="form-input sox-wide" placeholder="Street" aria-label="Street" data-model="street.value">
|
|
114
114
|
<input class="form-input" placeholder="Town or city" aria-label="Town or city" data-model="locality.value"><input class="form-input" placeholder="County or region" aria-label="County or region" data-model="region.value">
|
|
115
115
|
<input class="form-input" placeholder="Postcode" aria-label="Postcode" data-model="postcode.value"><input class="form-input" placeholder="Country (GB)" aria-label="Country" data-model="country.value"></div></div>
|
|
116
116
|
<div class="sox-wide"><label class="form-label" for="sox-s-same">Profiles elsewhere <span data-help="Your pages on Facebook, LinkedIn, Instagram, X and so on - one address per line. Search engines use them to know they are you."></span></label>
|
|
117
117
|
<textarea id="sox-s-same" rows="3" class="form-input sox-mono" data-model="orgSame.value" placeholder="https://www.linkedin.com/company/\u2026"></textarea></div>
|
|
118
|
-
${
|
|
118
|
+
${g("articles","Mark blog posts as articles","Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.")}
|
|
119
119
|
</div></section>
|
|
120
120
|
<section><h4>Sitemap and robots.txt</h4><div class="sox-form-grid">
|
|
121
121
|
<div class="sox-wide"><label class="form-label" for="sox-s-ex">Leave out of the sitemap <span data-help="One address per line. Ending with / leaves out everything under it: /members/ covers /members/area. To hide one page from search engines altogether, open it on the Pages tab instead."></span></label>
|
|
122
122
|
<textarea id="sox-s-ex" rows="3" class="form-input sox-mono" data-model="exclude.value" placeholder="/thank-you /members/"></textarea></div>
|
|
123
|
-
${
|
|
123
|
+
${g("blockAll","Ask every search engine to stay away","robots.txt then says Disallow: / to everyone. For a site that is not ready yet - remember to switch it off at launch. It is a request: well-behaved crawlers honour it.")}
|
|
124
124
|
<div class="sox-wide"><label class="form-label" for="sox-s-rob">More robots.txt rules <span data-help="Lines like Disallow: /private/ join the rules for every crawler. Start with User-agent: to add rules for one crawler, e.g. User-agent: GPTBot then Disallow: / to keep an AI crawler out."></span></label>
|
|
125
125
|
<textarea id="sox-s-rob" rows="3" class="form-input sox-mono" data-model="robotsExtra.value" placeholder="Disallow: /private/"></textarea></div>
|
|
126
126
|
</div></section>
|
|
127
127
|
<section><h4>Redirects</h4><div class="sox-form-grid">
|
|
128
|
-
${
|
|
128
|
+
${g("onRename","Add a redirect when a page is renamed","The old address keeps working, and any redirect that pointed at it is re-aimed at the new one - so there are never chains.")}
|
|
129
129
|
</div></section>
|
|
130
130
|
<section><h4>The checks</h4><div class="sox-form-grid">
|
|
131
|
-
${
|
|
132
|
-
${
|
|
133
|
-
${
|
|
134
|
-
${
|
|
135
|
-
${
|
|
136
|
-
${
|
|
131
|
+
${L("every","every","Check every page every (hours)",0,168,"0 checks only when you ask.")}
|
|
132
|
+
${L("words","words","Words a page should have",0,5e3)}
|
|
133
|
+
${L("tmin","tMin","Shortest title",10,70)}
|
|
134
|
+
${L("tmax","tMax","Longest title",30,100,"Google cuts titles at about 60 characters.")}
|
|
135
|
+
${L("dmin","dMin","Shortest description",30,160)}
|
|
136
|
+
${L("dmax","dMax","Longest description",80,320,"Google cuts descriptions at about 155-160 characters.")}
|
|
137
137
|
</div></section>
|
|
138
138
|
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
139
139
|
<div class="sox-actions">
|
|
@@ -141,7 +141,7 @@ import{apiRequest as Gt}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
|
|
145
|
-
${
|
|
146
|
-
`)}`,"the problems")}]:[]}function
|
|
147
|
-
`),"the addresses")}]:
|
|
144
|
+
</div>`);let B=null;async function H(){if(O.cannot.peek())return;h.value=!0;const c=r();T.inert=!0;try{const k=await v("/settings",{method:"PUT",body:JSON.stringify(C.peek().settings)});if(B?.saved(c,z(k?.settings??null)),$.close(!0),!n())return;E.toast("Settings saved.",{type:"success"}),Ne()}catch(k){T.inert=!1,n()&&E.toast(`Not saved: ${k.message||k}`,{type:"error"})}finally{h.value=!1}}$.element.appendChild(T);const u=l.helpPopovers(T),f=M.applyBindings(O,T,{methods:{save:H,cancel:()=>$.close(),pickLogo:async()=>{const c=await gt({title:"Choose a logo",current:a.orgLogo.peek()});c&&(a.orgLogo.value=c)}}});T.addEventListener("keydown",c=>{(c.ctrlKey||c.metaKey)&&c.key==="Enter"&&(c.preventDefault(),H())}),l.onceClosed($,()=>{f.dispose(),u(),ie=!1}),B=Ue($,{kind:"settings",what:"the SEO settings",read:r,write:d,was:t}),I.scan(T),$.open(),B.offer(t)}async function es(e,t){try{const s=await v("/settings"),o=vt(s.settings);if(o.sitemap.sources[e]=t,await v("/settings",{method:"PUT",body:JSON.stringify(o)}),!n())return;E.toast(`${se(e)} ${t?"back in":"left out of"} the sitemap.`,{type:"success"}),je()}catch(s){n()&&E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}const K=e=>Number(String(e?.dataset?.row||"").slice(1));function ts(e){const t=we[K(e)];return t?[{header:t.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>X(t.urlPath)},{label:"Open the page",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Redirect it elsewhere\u2026",icon:"corner-up-right",disabled:!U.peek(),action:()=>{Y("redirects"),ye.value=t.urlPath,xe.value=""}},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>be(t.urlPath,"the address")},{label:"Copy the problems",icon:"clipboard",disabled:!t.problems?.length&&!t.error,action:()=>be(`${t.urlPath}
|
|
145
|
+
${t.error||t.problems.join(`
|
|
146
|
+
`)}`,"the problems")}]:[]}function ss(e){const t=W[K(e)];return t?t.kind==="dupe"?[{header:"Open one of them"},...t.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>X(s)})),{type:"divider"},{label:"Copy the addresses",icon:"copy",action:()=>be(t.paths.join(`
|
|
147
|
+
`),"the addresses")}]:t.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>Y("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=t.link}}]:[]}function as(e){const t=W[K(e)];t?.kind==="dupe"?X(t.paths[0]):t?.kind==="chains"?Y("redirects"):t?.link&&(location.hash=t.link)}function os(e){const t=ue[K(e)];if(!t)return[];const s=N.peek().rules,o=N.peek().chains.some(p=>p.id===t.id);let a=null;if(o){a=t.to;for(let p=0;p<10;p++){const r=Es(s,a);if(!r)break;if(r.code===410){a=null;break}if(a=r.location,/^https?:/i.test(a))break}}return[{header:`${t.from} \u2192 ${t.code===410?"gone":t.to}`},{label:"Edit\u2026",icon:"edit-3",disabled:!U.peek(),action:()=>qe(t)},{label:"Test it",icon:"play",action:()=>zt(t.from)},{label:"Go straight there",icon:"fast-forward",disabled:!a||!U.peek(),action:()=>De(()=>v(`/redirects/${encodeURIComponent(t.id)}`,{method:"PUT",body:JSON.stringify({to:a})}),`${t.from} now goes straight to ${a}.`)},{label:"Open the old address",icon:"external-link",disabled:t.from.endsWith("/*"),action:()=>window.open(t.from,"_blank","noopener")},{type:"divider"},{label:"Copy",icon:"copy",action:()=>be(`${t.from} ${t.to} ${t.code}`,"the redirect")},{label:"Remove",icon:"trash-2",danger:!0,disabled:!U.peek(),action:async()=>{await Ye(`Remove the redirect from ${t.from}? Visitors to it will get "not found".`)&&Gt([t.id],"Redirect")}}]}function ns(e){const t=ae[K(e)];return t?[{header:t.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>X(t.urlPath)},{label:"Open",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>be(t.urlPath,"the address")}]:[]}function is(e){const t=_.peek().sources[K(e)];return t?[{header:se(t.id)},{label:t.on?"Leave all of it out":"List it again",icon:t.on?"eye-off":"eye",disabled:!U.peek(),action:()=>es(t.id,!t.on)}]:[]}function me(e,t,s){const o=E.contextMenu(e,{cursor:"glyph",match:"[data-row]",inherit:!1,density:"compact",items:r=>t(r)});P(()=>o?.destroy?.());const a=r=>{const d=r.target.closest("[data-row]");if(!(!d||r.target.closest("a"))){if(r.target.closest(".sox-kebab")){l.openMenuAt(d);return}s?.(d)}},p=r=>{const d=r.target.closest("[data-row]");d&&(r.key==="F10"&&r.shiftKey?(r.preventDefault(),l.openMenuAt(d)):r.key==="Enter"&&r.target===d&&(r.preventDefault(),r.stopPropagation(),setTimeout(()=>{n()&&d.isConnected&&s?.(d)},0)))};e.addEventListener("click",a),e.addEventListener("keydown",p),P(()=>{e.removeEventListener("click",a),e.removeEventListener("keydown",p)})}const rs=m.querySelector(".sox-ext"),_e=m.querySelector(".sox-ext-tabs");let re=null,$e="",he=0;const ut=()=>{const e=re;re=null;try{e?.dispose?.()}catch{}e?.box?.remove()};async function ls(){let e=[];try{e=(await v("/extensions"))?.tabs||[]}catch{}n()&&(Xe.value=e,_e.replaceChildren(...e.map(t=>{const s=document.createElement("button");return s.type="button",s.setAttribute("role","tab"),s.dataset.ext=t.key,s.innerHTML=`<span data-icon="${R(t.icon||"box")}" data-icon-size="14"></span> ${R(t.label)} <span class="sox-pro">PRO</span>`,t.help&&(s.title=t.help),s.addEventListener("click",()=>Y(t.key)),s})),I.scan(_e),pt())}async function pt(){if(!n())return;for(const a of _e.querySelectorAll("button"))a.classList.toggle("is-on",a.dataset.ext===F.peek());const e=Xe.peek().find(a=>a.key===F.peek());if(re&&re.key!==e?.key&&ut(),!e){$e="",he++,xt.includes(F.peek())||Y("overview");return}if(re?.key===e.key||$e===e.key)return;const t=++he;$e=e.key;const s=document.createElement("div");s.className="sox-panel sox-ext-body",rs.replaceChildren(s);const o=()=>t===he&&n()&&F.peek()===e.key;try{const a=await import(e.module);if(!o())return;const p=await a.mount(s,{kit:l,api:l.apiRequest,scope:P,here:n,openItem:X,reload:Ne,E,M,I});if(!o()){try{p?.()}catch{}s.remove();return}re={key:e.key,dispose:p,box:s}}catch(a){o()&&(s.innerHTML=`<p class="sox-note is-bad">${R(e.label)} could not be shown: ${R(a.message||a)}</p>`)}finally{t===he&&($e="")}}P(()=>{he++,ut()}),m.querySelector(".sox-pages").innerHTML=Ks,m.querySelector(".sox-site").innerHTML=Gs,m.querySelector(".sox-rules").innerHTML=zs,m.querySelector(".sox-entries").innerHTML=Fs,m.querySelector(".sox-sources").innerHTML=Js;const mt=m.querySelector(".sox-select");P(M.effect(()=>{const e=st.value;mt.replaceChildren(new Option("Everything",""),...e.map(t=>new Option(se(t),t))),mt.value=ge.peek()}));function Y(e){F.peek()!==e&&(F.value=e,history.replaceState(null,"",`#/seo${e==="overview"?"":`/${e}`}`),e==="redirects"&&pe(),e==="sitemap"&&je()),pt()}const cs={tab:F,busy:Ee,error:Te,canManage:U,scoreText:Tt,scoreWidth:Ct,scoreTone:Ot,ov:Lt,attention:Rt,asOf:At,sourceOptions:st,source:ge,show:te,query:Ce,pageRows:at,pagesEmpty:It,siteRows:ot,ruleRows:Re,ruleCount:jt,ruleText:Nt,rulesEmpty:Dt,chainText:qt,renameText:Ut,ruleQuery:Oe,newFrom:ye,newTo:xe,newCode:Ve,sourceRows:nt,smRows:Ae,smShow:de,smQuery:Le,smText:Ht,smEmpty:Wt,robots:Bt,extOn:Et,counts:Pt,smCounts:_t,pagesLoading:Ze,rulesLoading:et,smLoading:tt},ds={tab_overview:()=>Y("overview"),tab_redirects:()=>Y("redirects"),tab_sitemap:()=>Y("sitemap"),show_all:()=>{te.value="all"},show_problems:()=>{te.value="problems"},show_hidden:()=>{te.value="hidden"},sm_in:()=>{de.value="in"},sm_out:()=>{de.value="out"},importCsv:Ft,exportRules:()=>{const e=N.peek().rules;if(!e.length){E.toast("Nothing to export.",{type:"info"});return}Ws(`redirects-${location.hostname}-${new Date().toISOString().slice(0,10)}.csv`,Hs(e.map(t=>({from:t.from,to:t.to,code:t.code,note:t.note||"",hits:t.hits||0,last_used:t.lastHit||""}))))},addRule:()=>{const e={from:ye.peek(),to:xe.peek(),code:Number(Ve.peek())},{errors:t}=bt(e,N.peek().rules);return Object.keys(t).length?(E.toast(Object.values(t).join(" "),{type:"warning"}),!1):(Kt(e).then(s=>{s&&(ye.value="",xe.value="")}),!1)}},us=M.applyBindings(cs,m,{methods:ds});P(()=>us.dispose()),P(l.helpPopovers(m,{position:"bottom"})),me(m.querySelector(".sox-pages"),ts,e=>{const t=we[K(e)];t&&X(t.urlPath)}),me(m.querySelector(".sox-site"),ss,as),me(m.querySelector(".sox-rules"),os,e=>{const t=ue[K(e)];t&&U.peek()&&qe(t)}),me(m.querySelector(".sox-entries"),ns,e=>{const t=ae[K(e)];t&&X(t.urlPath)}),me(m.querySelector(".sox-sources"),is),l.addBannerAction(i,{id:"seo-check",icon:"refresh-cw",title:"Check every page again",onClick:rt}),l.addBannerAction(i,{id:"seo-settings",icon:"settings",title:"SEO settings",onClick:Xt}),I.scan(m),await Promise.all([Ne(),ls()]),n()&&I.scan(m),n()&&!x.peek()?.at&&!x.peek()?.running&&rt()}};
|
package/admin/js/views/todo.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import{protectToolEditor as
|
|
2
|
-
<div class="todo-row{{doneClass}}" data-id="{{id}}">
|
|
1
|
+
import{protectToolEditor as At,mountToolTrash as Ct,toolDraftKey as Lt}from"../lib/tool-recovery.js";import{apiRequest as Ot}from"../api.js";import{addBannerAction as $t}from"../lib/plugin-chrome.js";import{helpPopovers as et}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Pt,settingsStore as qt,sidebarCount as It,viewLifetime as Ft}from"../lib/tool-kit.js";import{SHAPES as Nt,whileLoading as Yt}from"../lib/loading.js";import{arrangeTodos as jt,countTodos as Kt,daysUntil as Ht,DEFAULT_SETTINGS as dt,dueDay as Rt,dueLabel as Bt,isoDay as H,isOverdue as Ut,changedFields as zt,normaliseSettings as ct,PRIORITIES as $,quickAddKept as Gt,STATUSES as ot}from"../lib/todo-arrange.js";const m={"not-started":"Not started","in-progress":"In progress",completed:"Completed",highest:"Highest",high:"High",medium:"Medium",low:"Low",lowest:"Lowest",open:"Open",all:"All",overdue:"Overdue"},ut=qt("todo.settings",ct),P=(r,d="GET",x)=>Ot(r,{method:d,...x!==void 0?{body:JSON.stringify(x)}:{}}),Wt=`
|
|
2
|
+
<div class="todo-row{{doneClass}}" data-id="{{id}}" tabindex="0" aria-label="{{text}}">
|
|
3
3
|
<input type="checkbox" class="todo-check" aria-label="Done" data-bind-checked="isDone">
|
|
4
4
|
<span class="todo-text" title="Click to edit">{{text}}</span>
|
|
5
5
|
<span class="todo-due{{dueClass}}" title="{{dueTitle}}">{{due}}</span>
|
|
6
6
|
<button type="button" class="todo-pill" data-kind="status" data-value="{{status}}" title="Change status">{{statusLabel}}</button>
|
|
7
7
|
<button type="button" class="todo-pill" data-kind="priority" data-value="{{priority}}" title="Change priority">{{priorityLabel}}</button>
|
|
8
8
|
<button type="button" class="todo-kebab" aria-label="Task actions" title="Actions">\u22EE</button>
|
|
9
|
-
</div>`;function
|
|
9
|
+
</div>`;function pt(r){if(r){try{r.close=Object.getPrototypeOf(r).close?.bind(r)??r.close,r.destroy?.()}catch{}try{r.element?.remove()}catch{}}}function ft(r){const d=document.createElement("template");return d.innerHTML=r.trim(),d.content.firstElementChild}const u=Ft(/^#\/todo\b/);export const todoView={templateUrl:"/admin/js/templates/todo.html",async onMount(r){u.reset();const d=r.get(0);let x=!1;u.add(()=>{x=!0});const l=()=>!x&&d.isConnected,p=M.observable(ut.read()),i=M.observable([]),A=M.observable(!0),R=M.observable(!1),q=M.observable(!1);let B=!1;const C=M.observable(""),U=M.observable(""),z=M.observable(p.peek().defaultFilter),y=M.observable(""),k=M.observable(p.peek().defaultPriority),w=M.observable(""),G=M.observable(!1),at=M.observable(!1),f=Lt("todo","quick-add");try{const t=f&&S.get(f);t?.text&&(y.value=t.text,k.value=$.includes(t.priority)?t.priority:p.peek().defaultPriority,w.value=t.due||"")}catch{}u.add(M.effect(()=>{const t={text:y.value,priority:k.value,due:w.value};try{f&&(t.text?S.set(f,t):S.remove(f))}catch{}}));const W=M.computed(()=>Kt(i.value)),vt=M.computed(()=>["open","all","not-started","in-progress","completed","overdue"].map(t=>({value:t,label:`${m[t]} (${W.value[t]})`}))),F=M.observable([]);u.add(M.effect(()=>{const t=p.value.relativeDue;F.value=jt(i.value,{filter:z.value,query:U.value,sort:p.value.sort}).map(e=>{const o=Ht(e.dueAt),a=Ut(e);return{id:e.id,text:e.text,status:e.status,priority:e.priority,statusLabel:m[e.status]??e.status,priorityLabel:m[e.priority]??e.priority,doneClass:e.status==="completed"?" is-done":"",isDone:e.status==="completed",due:Bt(e.dueAt,{relative:t,format:s=>D(s).format("D MMM")}),dueClass:a?" is-overdue":o===0&&e.status!=="completed"?" is-today":"",dueTitle:o===null?"":`Due ${D(Rt(e.dueAt)).format("dddd D MMMM YYYY")}`}})}));const bt={rows:F,loading:A,busy:R,loadError:C,query:U,filter:z,filterOptions:vt,counts:W,draft:y,draftPriority:k,draftDue:w,addFocus:G,searchFocus:at,cannotAdd:M.computed(()=>q.value||!y.value.trim()),isEmpty:M.computed(()=>!A.value&&!C.value&&F.value.length===0),emptyText:M.computed(()=>i.value.length?z.value==="open"&&!U.value.trim()?"Nothing open - everything is done.":"No tasks match. Change the filter or clear the search.":"No tasks yet. Add one above."),countText:M.computed(()=>{if(A.value)return"";const t=F.value.length,e=i.value.length;return t===e?`${e} task${e===1?"":"s"}`:`${t} of ${e}`})};u.add(M.effect(()=>ut.write(p.value))),u.add(M.effect(()=>{i.value,B&&It("#/todo")}));const mt=(t,e)=>{p.value=ct({...p.peek(),[t]:e})},N=t=>i.peek().find(e=>e.id===t),yt=t=>{i.value=i.peek().map(e=>e.id===t.id?t:e)};let L=null,_=!1;function J(){return L?(_=!0,L):(R.value=!0,L=(async()=>{do _=!1,await ht();while(_&&l())})().finally(()=>{L=null,l()&&(R.value=!1)}),L)}async function ht(){const t=!B;t&&(A.value=!0,C.value="");try{const e=P("/todo/items"),o=t?await Yt(d.querySelector(".todo-loading"),e,Nt.rows):await e;if(!l())return;i.value=Array.isArray(o)?o:o.data??[],B=!0,C.value=""}catch{if(!l())return;C.value="Could not load this list. Your items have not been removed.",E.toast("Failed to load tasks",{type:"error"})}finally{l()&&(A.value=!1)}}async function b(t,e,o){try{const a=await P(`/todo/items/${t}`,"PUT",e);return l()&&(yt(a),o&&E.toast(o,{type:"success"})),!0}catch{return l()&&E.toast("Failed to update task",{type:"error"}),!1}}async function gt(){const t=y.peek().trim();if(!t||q.peek())return;const e={text:t,priority:k.peek(),due:w.peek()};q.value=!0;try{const o=await P("/todo/items","POST",{text:t,priority:e.priority,status:"not-started",dueAt:e.due||null}),a=l()?{text:y.peek(),priority:k.peek(),due:w.peek()}:(()=>{try{return f&&S.get(f)}catch{return null}})(),s=Gt(e,a);if(!l()){if(!s)try{f&&S.remove(f)}catch{}return}i.value=[...i.peek(),o],s||(y.value="",w.value="",k.value=p.peek().defaultPriority),G.value=!0}catch{l()&&E.toast("Failed to add task",{type:"error"})}finally{l()&&(q.value=!1)}}const kt=()=>(gt(),!1);function st(t){c&&t.includes(c.id)&&(c.recovery?.discard(),c.so.close())}async function nt(t){if(p.peek().confirmDelete&&!await E.confirm("Move this task to Trash?")||!l())return!1;try{return await P(`/todo/items/${t}`,"DELETE"),l()&&(i.value=i.peek().filter(e=>e.id!==t),st([t]),E.toast("Task moved to Trash",{type:"success"})),!0}catch{return E.toast("Failed to delete task",{type:"error"}),!1}}async function wt(){const t=W.peek().completed;if(!(!t||!await E.confirm(`Move ${t} completed task${t===1?"":"s"} to Trash?`))&&l())try{if(await P("/todo/completed","DELETE"),!l())return;const e=i.peek().filter(o=>o.status==="completed").map(o=>o.id);i.value=i.peek().filter(o=>o.status!=="completed"),st(e),E.toast("Completed tasks moved to Trash",{type:"success"})}catch{E.toast("Failed to clear completed tasks",{type:"error"})}}const V=new Set;async function lt(t){const e=N(t);if(!e||V.has(t))return!1;V.add(t);const o=e.status==="completed"?"not-started":"completed";try{return await b(t,{status:o},o==="completed"?"Task completed":"Task reopened")}finally{V.delete(t)}}let c=null,Q=!1;async function X(t){if(!(Q||c?.id===t)){Q=!0;try{if(c&&!await c.so.close()||!l()||c)return;const e=N(t);e&&Mt(e)}catch(e){pt(c?.so),c=null,console.error("[todo] editor failed to open",e),E.toast("Could not open the task",{type:"error"})}finally{Q=!1}}}function Mt(t){const e=t.id,o=E.slideover({title:"Edit task",size:"md",position:"right"});c={so:o,id:e,recovery:null};const a=ft(`
|
|
10
10
|
<div class="todo-panel">
|
|
11
11
|
<div>
|
|
12
12
|
<label class="form-label" for="todo-edit-text">Task</label>
|
|
@@ -16,13 +16,13 @@ import{protectToolEditor as dt,mountToolTrash as rt,toolDraftKey as ct}from"../l
|
|
|
16
16
|
<div>
|
|
17
17
|
<label class="form-label" for="todo-edit-status">Status</label>
|
|
18
18
|
<select id="todo-edit-status" class="form-input" data-model="status.value">
|
|
19
|
-
${
|
|
19
|
+
${ot.map(n=>`<option value="${n}">${m[n]}</option>`).join("")}
|
|
20
20
|
</select>
|
|
21
21
|
</div>
|
|
22
22
|
<div>
|
|
23
23
|
<label class="form-label" for="todo-edit-priority">Priority</label>
|
|
24
24
|
<select id="todo-edit-priority" class="form-input" data-model="priority.value">
|
|
25
|
-
${
|
|
25
|
+
${$.map(n=>`<option value="${n}">${m[n]}</option>`).join("")}
|
|
26
26
|
</select>
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
@@ -48,7 +48,7 @@ import{protectToolEditor as dt,mountToolTrash as rt,toolDraftKey as ct}from"../l
|
|
|
48
48
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save"
|
|
49
49
|
data-bind-disabled="cannotSave.value">Save</button>
|
|
50
50
|
</div>
|
|
51
|
-
</div>`),
|
|
51
|
+
</div>`),s={text:M.observable(t.text||""),status:M.observable(t.status),priority:M.observable(t.priority),due:M.observable(t.dueAt?String(t.dueAt).slice(0,10):""),created:t.createdAt?D(t.createdAt).format("D MMM YYYY"):"earlier"};s.saving=M.observable(!1),s.cannotSave=M.computed(()=>s.saving.value||!s.text.value.trim());const Z={text:(t.text||"").trim(),status:t.status,priority:t.priority,dueAt:s.due.peek()||null},Y=async()=>{if(s.saving.peek()||!s.text.peek().trim())return;const n=zt(Z,{text:s.text.peek().trim(),status:s.status.peek(),priority:s.priority.peek(),dueAt:s.due.peek()||null});if(!Object.keys(n).length){j.saved(),o.close();return}s.saving.value=!0,a.inert=!0;const g=await b(t.id,n,"Task saved");s.saving.value=!1,g?(j.saved(),o.close()):a.inert=!1};for(const[n,g]of[["status",ot],["priority",$]]){const K=t[n];if(K&&!g.includes(K)){const tt=document.createElement("option");tt.value=K,tt.textContent=K,a.querySelector(`#todo-edit-${n}`).prepend(tt)}}o.element.appendChild(a);const v=et(a),T=M.applyBindings(s,a,{methods:{save:Y,cancel:()=>o.close(),del:async()=>{await nt(t.id)&&(j.discard(),o.close())},dueIn:n=>{s.due.value=H(Number(n.currentTarget.dataset.days))},clearDue:()=>{s.due.value=""}}});a.addEventListener("keydown",n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),Y())}),rt(o,()=>{T.dispose(),v(),c?.so===o&&(c=null)});const j=At({so:o,panel:a,tool:"todo",id:e,lifetime:u,read:()=>Object.fromEntries(["text","status","priority","due"].map(n=>[n,s[n].peek()])),write:n=>{for(const g of["text","status","priority","due"])g in n&&(s[g].value=n[g])}});c.recovery=j,o.open(),setTimeout(()=>a.querySelector("#todo-edit-text")?.focus(),60)}let h=null;function Et(){if(!h)try{Tt()}catch(t){pt(h),h=null,console.error("[todo] settings failed to open",t)}}u.add(()=>h?.close());function Tt(){const t=E.slideover({title:"Todo settings",size:"sm",position:"right"});h=t;const e=v=>v.map(T=>`<option value="${T}">${m[T]}</option>`).join(""),o=ft(`
|
|
52
52
|
<div class="todo-panel">
|
|
53
53
|
<div>
|
|
54
54
|
<label class="form-label" for="todo-set-filter">Show on opening
|
|
@@ -72,7 +72,7 @@ import{protectToolEditor as dt,mountToolTrash as rt,toolDraftKey as ct}from"../l
|
|
|
72
72
|
<div>
|
|
73
73
|
<label class="form-label" for="todo-set-priority">Priority for new tasks</label>
|
|
74
74
|
<select id="todo-set-priority" class="form-input" data-model="defaultPriority.value">
|
|
75
|
-
${e(
|
|
75
|
+
${e($)}
|
|
76
76
|
</select>
|
|
77
77
|
</div>
|
|
78
78
|
<div class="todo-setting-row">
|
|
@@ -93,4 +93,4 @@ import{protectToolEditor as dt,mountToolTrash as rt,toolDraftKey as ct}from"../l
|
|
|
93
93
|
<span class="spacer"></span>
|
|
94
94
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
|
|
95
95
|
</div>
|
|
96
|
-
</div>`),
|
|
96
|
+
</div>`),a=v=>M.computed({read:()=>p.value[v],write:T=>mt(v,T)}),s=Object.fromEntries(Object.keys(dt).map(v=>[v,a(v)]));t.element.appendChild(o);const Z=et(o),Y=M.applyBindings(s,o,{methods:{done:()=>t.close(),reset:()=>{p.value={...dt}}}});rt(t,()=>{Y.dispose(),Z(),h===t&&(h=null)}),t.open()}$t(r,{id:"todo-settings",icon:"settings",title:"Todo settings",onClick:Et}),d.querySelector(".todo-rows").innerHTML=Wt,u.add(et(d,{position:"bottom"}));const xt=M.applyBindings(bt,d,{methods:{retry:J,add:kt,clearCompleted:wt}});u.add(()=>xt.dispose()),Ct({root:d,toolbar:d.querySelector(".todo-bar"),tool:"todo",endpoint:"/todo",itemPath:"/items",label:"Todo",titleOf:t=>t.text,reload:J,lifetime:u});const O=d.querySelector(".todo-list"),St=E.contextMenu(O,{cursor:"glyph",match:".todo-row",inherit:!1,density:"compact",items:t=>{const e=t.dataset.id,o=N(e);return o?[{label:"Edit\u2026",icon:"edit",action:()=>X(e)},{label:o.status==="completed"?"Reopen":"Complete",icon:"check",action:()=>lt(e)},{type:"divider"},{label:"Status",icon:"circle",submenu:ot.map(a=>({label:m[a],disabled:o.status===a,action:()=>b(e,{status:a})}))},{label:"Priority",icon:"flag",submenu:$.map(a=>({label:m[a],disabled:o.priority===a,action:()=>b(e,{priority:a})}))},{label:"Due",icon:"calendar",submenu:[{label:"Today",action:()=>b(e,{dueAt:H(0)})},{label:"Tomorrow",action:()=>b(e,{dueAt:H(1)})},{label:"Next week",action:()=>b(e,{dueAt:H(7)})},{type:"divider"},{label:"No due date",disabled:!o.dueAt,action:()=>b(e,{dueAt:null})}]},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>nt(e)}]:[]}});u.add(()=>St?.destroy?.()),O.addEventListener("click",t=>{const e=t.target.closest(".todo-row");if(e){if(t.target.closest(".todo-kebab, .todo-pill"))return Pt(t.target.closest(".todo-kebab, .todo-pill"));t.target.closest(".todo-text")&&X(e.dataset.id)}}),O.addEventListener("keydown",t=>{const e=t.target.closest?.(".todo-row");e&&t.key==="Enter"&&t.target===e&&(t.preventDefault(),X(e.dataset.id))});const Dt=t=>{t.target.closest?.("input, textarea, select")&&t.stopPropagation()};O.addEventListener("contextmenu",Dt),O.addEventListener("change",async t=>{if(!t.target.matches(".todo-check"))return;const e=t.target,o=e.closest(".todo-row").dataset.id;!await lt(o)&&l()&&e.isConnected&&(e.checked=N(o)?.status==="completed")});const it=t=>{t.ctrlKey||t.metaKey||t.altKey||t.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(t.key==="/"?(t.preventDefault(),at.value=!0):(t.key==="n"||t.key==="N")&&(t.preventDefault(),G.value=!0))};document.addEventListener("keydown",it),u.add(()=>document.removeEventListener("keydown",it)),I.scan(d),await J()}};
|