domma-cms 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/admin/js/app.js +4 -4
  2. package/admin/js/lib/api-tokens-arrange.js +3 -0
  3. package/admin/js/lib/effects-arrange.js +3 -0
  4. package/admin/js/lib/layouts-arrange.js +1 -0
  5. package/admin/js/lib/my-profile-arrange.js +1 -0
  6. package/admin/js/lib/notifications-arrange.js +1 -0
  7. package/admin/js/lib/plugins-arrange.js +2 -0
  8. package/admin/js/lib/roles-arrange.js +1 -0
  9. package/admin/js/lib/search-settings-arrange.js +1 -0
  10. package/admin/js/lib/sidebar-renderer.js +2 -2
  11. package/admin/js/lib/site-settings-arrange.js +1 -0
  12. package/admin/js/lib/users-arrange.js +1 -0
  13. package/admin/js/templates/api-tokens.html +83 -10
  14. package/admin/js/templates/effects.html +155 -72
  15. package/admin/js/templates/layouts.html +126 -43
  16. package/admin/js/templates/my-profile.html +100 -12
  17. package/admin/js/templates/notifications.html +70 -10
  18. package/admin/js/templates/plugins.html +100 -28
  19. package/admin/js/templates/roles.html +78 -7
  20. package/admin/js/templates/search.html +193 -216
  21. package/admin/js/templates/settings.html +404 -472
  22. package/admin/js/templates/users.html +69 -9
  23. package/admin/js/views/api-tokens.js +62 -8
  24. package/admin/js/views/effects.js +31 -1
  25. package/admin/js/views/layouts.js +81 -1
  26. package/admin/js/views/my-profile.js +1 -1
  27. package/admin/js/views/notifications.js +18 -1
  28. package/admin/js/views/plugins.js +97 -39
  29. package/admin/js/views/roles.js +49 -4
  30. package/admin/js/views/search.js +7 -1
  31. package/admin/js/views/settings.js +13 -3
  32. package/admin/js/views/users.js +13 -4
  33. package/package.json +1 -1
  34. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  35. package/plugins/_lib/admin/mail/contacts.js +4 -1
  36. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  37. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  38. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  39. package/plugins/_lib/admin/mail/mail.css +1 -1
  40. package/plugins/_lib/admin/mail/mail.html +1 -1
  41. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  42. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  43. package/plugins/_lib/mail/readRoutes.js +43 -1
  44. package/plugins/_lib/mail/unreadBadge.js +42 -0
  45. package/plugins/free-tier.lock.json +5 -5
  46. package/plugins/mail-reader/CLAUDE.md +1 -0
  47. package/plugins/mail-reader/plugin.js +3 -1
  48. package/plugins/mail-reader/plugin.json +5 -4
  49. package/server/routes/api/api-tokens.js +56 -0
  50. package/server/routes/api/auth.js +7 -1
  51. package/server/routes/api/collections.js +13 -1
  52. package/server/routes/api/effects.js +68 -13
  53. package/server/routes/api/layouts.js +93 -2
  54. package/server/routes/api/notifications.js +140 -14
  55. package/server/routes/api/plugins.js +135 -4
  56. package/server/routes/api/roles.js +111 -0
  57. package/server/routes/api/search.js +38 -4
  58. package/server/routes/api/settings.js +52 -0
  59. package/server/routes/api/users.js +53 -1
  60. package/server/server.js +12 -0
  61. package/server/services/plugins.js +7 -0
  62. package/server/services/roles.js +3 -0
  63. package/server/services/search.js +58 -0
  64. package/server/services/users.js +14 -0
  65. package/admin/js/templates/plugin-marketplace.html +0 -29
  66. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -1,12 +1,72 @@
1
- <div class="view-header">
2
- <h1><span data-icon="users"></span> Users</h1>
3
- <div id="users-header-actions">
4
- <a href="#/users/new" class="btn btn-primary"><span data-icon="plus"></span> New User</a>
5
- </div>
6
- </div>
1
+ <!-- Users. Bound with M.applyBindings - see admin/views/users.js, which also inserts the row template
2
+ (the router renders a templateUrl through _.render, eating any {{…}}). No page heading: the Tool
3
+ banner above is the heading, and New user is a banner action. -->
4
+ <div class="usx">
5
+ <p class="usx-note">Everyone who can sign in to this admin, with their role and when they last signed in.
6
+ <span data-help-title="Users" data-help="A role decides what someone can do (System › Roles). You can edit your own account, and anyone whose role is junior to yours - not a peer, not someone senior. Project access narrows what a user sees to those projects (and Core); none means everything. An inactive account cannot sign in, but keeps its work."></span></p>
7
+ <p class="usx-note" data-bind-hidden="!projectNote.value">
8
+ <span data-bind-text="projectNote.value"></span> <a href="#/users">Show all users</a></p>
7
9
 
8
- <div class="card">
9
- <div class="card-body">
10
- <div id="users-table"></div>
10
+ <div class="usx-card">
11
+ <div class="usx-bar">
12
+ <div class="usx-seg" role="group" aria-label="Show">
13
+ <button type="button" data-on-click="showAll" data-bind-class="filter.value === 'all' && 'is-on'">All <b data-bind-text="counts.value.all"></b></button>
14
+ <button type="button" data-on-click="showActive" data-bind-class="filter.value === 'active' && 'is-on'">Active <b data-bind-text="counts.value.active"></b></button>
15
+ <button type="button" data-on-click="showInactive" data-bind-class="filter.value === 'inactive' && 'is-on'">Inactive <b data-bind-text="counts.value.inactive"></b></button>
16
+ </div>
17
+ <select class="form-input usx-select" aria-label="Role" data-model="role.value">
18
+ <option value="">Every role</option>
19
+ </select>
20
+ <input type="search" class="form-input usx-search" placeholder="Search name, email, role…" aria-label="Search users" data-model="query.value">
21
+ <select class="form-input usx-select" aria-label="Sort" data-model="sort.value">
22
+ <option value="name">Name</option>
23
+ <option value="recent">Last signed in</option>
24
+ </select>
25
+ <span data-help-title="The list" data-help="Click someone to edit them. Right-click (or ⋮, or Shift+F10) to edit, copy their email, make the account active or inactive, or delete it. Greyed actions are ones your role does not allow - the menu says why."></span>
26
+ </div>
27
+ <p class="usx-note" data-bind-hidden="!loading.value">Loading…</p>
28
+ <p class="usx-note is-bad" data-bind-hidden="!loadError.value" data-bind-text="loadError.value"></p>
29
+ <p class="usx-note" data-bind-hidden="loading.value || loadError.value || rows.value.length" data-bind-text="emptyText.value"></p>
30
+ <div class="usx-list" data-each="rows.value key=id"></div>
11
31
  </div>
12
32
  </div>
33
+
34
+ <style>
35
+ .usx { display: flex; flex-direction: column; gap: .75rem; }
36
+ .usx [hidden] { display: none !important; }
37
+ .usx-note { margin: 0; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
38
+ .usx-note.is-bad { color: var(--dm-danger); }
39
+ .usx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .6rem; }
40
+ .usx-bar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
41
+ .usx-search { flex: 1; min-width: 10rem; max-width: 20rem; }
42
+ .usx-select { width: auto; flex: 0 0 auto; }
43
+ .usx-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
44
+ .usx-seg button { border: 0; background: none; color: var(--dm-text); padding: .3rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
45
+ .usx-seg button + button { border-left: 1px solid var(--dm-border); }
46
+ .usx-seg button b { font-weight: 500; opacity: .65; margin-left: .2rem; }
47
+ .usx-seg button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
48
+ .usx-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
49
+ .usx-list { display: flex; flex-direction: column; }
50
+ .usx-row { display: flex; align-items: center; gap: .75rem; padding: .55rem .35rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; }
51
+ .usx-row:last-child { border-bottom: 0; }
52
+ .usx-row:hover, .usx-row:focus-visible { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); outline: none; }
53
+ .usx-row.is-locked { cursor: default; }
54
+ .usx-avatar { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; flex-shrink: 0; border-radius: 50%;
55
+ font-size: var(--dm-text-xs); font-weight: 700; color: var(--dm-primary); background: color-mix(in srgb, var(--dm-primary) 14%, transparent); }
56
+ .usx-row.is-off .usx-avatar { color: var(--dm-text-muted); background: color-mix(in srgb, var(--dm-text-muted) 14%, transparent); }
57
+ .usx-row.is-off .usx-main strong { color: var(--dm-text-muted); }
58
+ .usx-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
59
+ .usx-main strong, .usx-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
+ .usx-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
61
+ .usx-chips { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: .3rem; flex-shrink: 0; max-width: 55%; }
62
+ .usx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .1rem .5rem; border-radius: 999px; white-space: nowrap;
63
+ background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
64
+ .usx-chip.is-ok { background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); }
65
+ .usx-chip.is-warn { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
66
+ .usx-chip.is-danger { background: color-mix(in srgb, var(--dm-danger) 16%, transparent); color: var(--dm-danger); }
67
+ .usx-chip.is-primary { background: color-mix(in srgb, var(--dm-primary) 14%, transparent); color: var(--dm-primary); }
68
+ .usx-chip.is-outline { background: none; border: 1px solid var(--dm-border); }
69
+ .usx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
70
+ .usx-kebab:hover { color: var(--dm-primary); }
71
+ @media (max-width: 640px) { .usx-chips { max-width: 40%; } .usx-main small { white-space: normal; } }
72
+ </style>
@@ -1,8 +1,62 @@
1
- import{api as m}from"../api.js";import{keepTableIconsAlive as g}from"../lib/table-icons.js";function d(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function f(a){return!Array.isArray(a)||a.length===0?'<span class="text-muted">All collections in project</span>':d(a.map(l=>{const r=Array.isArray(l.verbs)&&l.verbs.length?l.verbs.join(","):"all";return`${l.collection}: ${r}`}).join("; "))}function h(a){const l=[];for(const r of(a||"").split(`
2
- `)){const n=r.trim();if(!n)continue;const[o,e]=n.split(":").map(s=>s.trim());if(!o)return{error:`Invalid scope line: "${n}"`};const t={collection:o};if(e){const s=e.split(",").map(i=>i.trim()).filter(Boolean),c=s.find(i=>!["create","read","update","delete"].includes(i));if(c)return{error:`Unknown verb "${c}" in scope "${n}"`};t.verbs=s}l.push(t)}return{scopes:l}}function b(a,l,r){const n=document.createElement("div");n.style.marginBottom=".75rem";const o=document.createElement("label");if(o.className="form-label",o.textContent=a,n.appendChild(o),n.appendChild(l),r){const e=document.createElement("p");e.className="text-muted",e.style.cssText="font-size:.8rem;margin:.25rem 0 0;",e.textContent=r,n.appendChild(e)}return n}function v(a,l){const r=E.slideover({title:"Token created",size:"sm",position:"right"}),n=document.createElement("div");n.style.padding="1rem";const o=document.createElement("p");o.innerHTML="<strong>Copy this token now.</strong> It is shown once and cannot be recovered - only a hash is stored.",o.style.marginBottom=".75rem",n.appendChild(o);const e=document.createElement("code");e.textContent=a,e.style.cssText="display:block;padding:.6rem;word-break:break-all;user-select:all;margin-bottom:.75rem;",n.appendChild(e);const t=document.createElement("button");t.className="btn btn-primary",t.textContent="Copy to clipboard",t.style.marginRight=".5rem",t.addEventListener("click",async()=>{try{await navigator.clipboard.writeText(a),t.textContent="Copied!"}catch{E.toast("Copy failed - select the token text manually.",{type:"error"})}}),n.appendChild(t);const s=document.createElement("button");s.className="btn btn-secondary",s.textContent="Done",s.addEventListener("click",()=>{r.close(),l()}),n.appendChild(s),r.element.appendChild(n),r.open()}function x(a,l){const r=E.slideover({title:"New API Token",size:"md",position:"right"}),n=document.createElement("div");n.style.padding=".5rem 1rem 1rem";const o=document.createElement("input");o.type="text",o.className="form-input",o.placeholder="e.g. mobile-app, ci-pipeline";const e=document.createElement("select");e.className="form-input";for(const i of a){const p=document.createElement("option");p.value=i.slug,p.textContent=`${i.name} (${i.slug})`,e.appendChild(p)}const t=document.createElement("input");t.type="datetime-local",t.className="form-input";const s=document.createElement("textarea");s.className="form-input",s.rows=3,s.placeholder=`jobs: read
3
- enquiries: create, read`,n.appendChild(b("Name",o)),n.appendChild(b("Project",e,"The token only works on collections in this project.")),n.appendChild(b("Expires (optional)",t,"Leave empty for a non-expiring token.")),n.appendChild(b("Scopes (optional)",s,'One per line: "collection: verb, verb". Leave empty to allow every collection in the project.'));const c=document.createElement("button");c.className="btn btn-primary",c.textContent="Create token",c.addEventListener("click",async()=>{const i=o.value.trim();if(!i){E.toast("A token name is required.",{type:"error"});return}const{scopes:p,error:y}=h(s.value);if(y){E.toast(y,{type:"error"});return}const k={name:i,project:e.value,scopes:p,expiresAt:t.value?new Date(t.value).toISOString():null};try{c.disabled=!0;const{plaintext:u}=await m.apiTokens.create(k);r.close(),v(u,l)}catch(u){c.disabled=!1,E.toast(`Create failed: ${u.message||u}`,{type:"error"})}}),n.appendChild(c),r.element.appendChild(n),r.open(),setTimeout(()=>o.focus(),100)}export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(a){const l=E.loader(a.get(0),{type:"dots"}),[r,n]=await Promise.all([m.apiTokens.list().catch(()=>[]),m.projects.list().catch(()=>[])]);l.destroy(),T.create("#api-tokens-table",{pageSize:25,data:r,emptyMessage:'No API tokens yet. Click "New token" to create one.',columns:[{key:"name",title:"Name",sortable:!0},{key:"project",title:"Project",sortable:!0,render:e=>`<code>${d(e)}</code>`},{key:"tokenHint",title:"Token",render:e=>`<code>dcms_\u2026${d(e||"????")}</code>`},{key:"scopes",title:"Scopes",render:e=>f(e)},{key:"enabled",title:"Status",render:(e,t)=>t.expiresAt&&Date.parse(t.expiresAt)<Date.now()?'<span class="badge badge-warning">Expired</span>':e?'<span class="badge badge-success">Enabled</span>':'<span class="badge badge-secondary">Disabled</span>'},{key:"expiresAt",title:"Expires",render:e=>e?D(e).format("D MMM YYYY, HH:mm"):'<span class="text-muted">-</span>'},{key:"lastUsedAt",title:"Last used",render:e=>e?D(e).format("D MMM YYYY, HH:mm"):'<span class="text-muted">Never</span>'},{key:"_actions",title:"Actions",render:(e,t)=>`
4
- <span style="display:inline-flex;gap:0.25rem;">
5
- <button class="btn btn-sm btn-ghost btn-toggle-token" data-id="${d(t.id)}" data-enabled="${t.enabled?"1":""}" data-tooltip="${t.enabled?"Disable":"Enable"}"><span data-icon="${t.enabled?"pause":"play"}" data-icon-size="14"></span></button>
6
- <button class="btn btn-sm btn-ghost btn-revoke-token" data-id="${d(t.id)}" data-name="${d(t.name)}" data-tooltip="Revoke"><span data-icon="trash" data-icon-size="14"></span></button>
7
- </span>
8
- `}]}),Domma.icons.scan(a.get(0)),g(a.get(0)),document.querySelectorAll("#api-tokens-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})});const o=()=>location.reload();a.off("click","#btn-new-token").on("click","#btn-new-token",()=>{if(!n.length){E.toast("No projects available - create a project first.",{type:"error"});return}x(n,o)}),a.off("click",".btn-toggle-token").on("click",".btn-toggle-token",async function(){const e=$(this).data("id"),t=!!$(this).data("enabled");try{await m.apiTokens.update(e,{enabled:!t}),E.toast(`Token ${t?"disabled":"enabled"}.`,{type:"success"}),o()}catch(s){E.toast(`Update failed: ${s.message||s}`,{type:"error"})}}),a.off("click",".btn-revoke-token").on("click",".btn-revoke-token",async function(){const e=$(this).data("id"),t=$(this).data("name");if(await E.confirm(`Revoke token "${t}"? External callers using it will immediately lose access. This cannot be undone.`))try{await m.apiTokens.revoke(e),E.toast("Token revoked.",{type:"success"}),o()}catch(s){E.toast(`Revoke failed: ${s.message||s}`,{type:"error"})}})}};
1
+ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as le,openMenuAt as re,sidebarCount as Me,viewLifetime as Le}from"../lib/tool-kit.js";import{FILTERS as Ae,arrangeTokens as $e,checkToken as Ne,countStates as Oe,scopesToText as Ie,toLocalInput as qe,tokenRow as Pe,tokenState as ce}from"../lib/api-tokens-arrange.js";const de=/^#\/api-tokens\/?(\?.*)?$/,Re="#/api-tokens",R="api-tokens.draft",H="api-tokens.filter",De=`
2
+ <div class="atx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="atx-icon" aria-hidden="true"><span data-icon="key" data-icon-size="16"></span></span>
4
+ <span class="atx-main"><strong>{{name}}</strong><small><code>{{hint}}</code> \xB7 {{meta}}</small></span>
5
+ <span class="atx-chips">
6
+ <span class="atx-chip" title="The project it works in">{{project}}</span>
7
+ <span class="atx-chip" data-bind-class="stateCls" data-bind-text="stateLabel"></span>
8
+ </span>
9
+ <span class="atx-used" data-bind-text="used"></span>
10
+ <button type="button" class="atx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
+ </div>`,A=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(c){const d=document.createElement("template");return d.innerHTML=c.trim(),d.content.firstElementChild}async function ue(c){try{return await navigator.clipboard.writeText(c),!0}catch{return!1}}const l=Le(de);export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(c){l.reset();const d=c.get(0).querySelector(".atx")||c.get(0),$=()=>de.test(location.hash)&&d.isConnected,w=M.observable([]),j=M.observable([]),D=M.observable(!0),_=M.observable("");let U="all";try{U=Ae.includes(S.get(H))?S.get(H):"all"}catch{}const r=M.observable(U),b=M.observable(""),Y=M.observable("");l.add(M.effect(()=>{const e=r.value;try{S.set(H,e)}catch{}}));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=$e(w.value,{filter:r.value,project:b.value,query:Y.value},e).map(t=>Pe(t,e))}));const V=M.computed(()=>Oe(w.value)),fe=M.computed(()=>w.value.length?"No tokens match.":"No API tokens yet - New token in the banner makes one."),T=e=>w.peek().find(t=>t.id===e);async function J(){try{const[e,t]=await Promise.all([g.apiTokens.list(),g.projects.list().catch(()=>[])]);if(!$())return;w.value=Array.isArray(e)?e:[],j.value=Array.isArray(t)?t:[],_.value="",ke()}catch(e){_.value=`Could not load the tokens: ${e.message||e}`}finally{D.value=!1}}const N=async()=>{Me(Re),$()&&await J()};async function W(e,t){if(!(!t&&!await E.confirm(`Switch off "${A(e.name)}"? Callers using it are refused until it is switched back on.`)))try{await g.apiTokens.update(e.id,{enabled:t}),E.toast(`"${e.name}" ${t?"switched on":"switched off"}.`,{type:"success"}),await N()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function me(e){if(await E.confirm(`Revoke "${A(e.name)}"? Anything using it loses access at once. This cannot be undone - a new token would have a different value.`))try{await g.apiTokens.revoke(e.id),E.toast(`Revoked "${e.name}".`,{type:"success"}),await N()}catch(t){E.toast(`Not revoked: ${t.message||t}`,{type:"error"})}}let h=null,y=null;const C=new Set;let ve=!1;function G(e,t,a){const o=e.close.bind(e);let s=!1,u=!1;return e.close=async v=>{if(!s&&v!==!0&&t()){if(u)return;u=!0;const O=await E.confirm(a);if(u=!1,!O)return}return s=!0,o()},()=>{s=!0}}function L(e=null,t=null){const a=e?T(e):null;if(e&&!a){E.toast("That token is no longer there.",{type:"warning"});return}const o=!a,s=j.peek();if(o&&!s.length){E.toast("Make a project first - a token works inside one.",{type:"warning"});return}const u={name:a?.name??"",project:a?.project??(b.peek()||s[0]?.slug||""),expires:qe(a?.expiresAt),scopes:Ie(a?.scopes),enabled:a?a.enabled!==!1:!0,...t||{}},v=Object.fromEntries(Object.entries(u).map(([n,i])=>[n,M.observable(i)])),O=n=>JSON.stringify(Object.fromEntries(Object.entries(n).map(([i,z])=>[i,typeof z=="boolean"?z:String(z??"")]))),ee=()=>Object.fromEntries(Object.keys(v).map(n=>[n,v[n].peek()])),ge=O(u),te=()=>O(ee())!==ge||!!t,q=M.observable(!1),K=M.computed(()=>Ne(Object.fromEntries(Object.keys(v).map(n=>[n,v[n].value])),{isNew:o,projects:j.value.map(n=>n.slug)})),P=n=>M.computed(()=>K.value.errors[n]||""),Ee=a?ce(a):null,ae={...v,saving:q,isNew:o,notNew:!o,nameErr:P("name"),projectErr:P("project"),scopesErr:P("scopes"),expiresErr:P("expires"),cannot:M.computed(()=>q.value||Object.keys(K.value.errors).length>0),saveText:o?"Create token":"Save",fixedProject:a?`${a.project} - fixed for the token's life (revoke and make a new one to move it)`:"",hint:a?`dcms_\u2026${a.tokenHint||"????"}${Ee?.state==="expired"?" - expired: set a later date to bring it back":""}`:""},f=E.slideover({title:o?"New API token":`Edit ${A(a.name)}`,size:"md",position:"right"}),x=pe(`
12
+ <div class="atx-form">
13
+ <div>
14
+ <label class="form-label" for="atx-f-name">Name <span class="text-danger">*</span></label>
15
+ <input id="atx-f-name" type="text" class="form-input" maxlength="80" placeholder="mobile-app, ci-pipeline\u2026" data-model="name.value">
16
+ <small class="atx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
17
+ </div>
18
+ <div data-bind-hidden="notNew">
19
+ <label class="form-label" for="atx-f-project">Project
20
+ <span data-help="The token works only on collections in this project (and API Builder endpoints under it). It cannot be moved later."></span></label>
21
+ <select id="atx-f-project" class="form-input" data-model="project.value"></select>
22
+ <small class="atx-err" data-bind-hidden="!projectErr.value" data-bind-text="projectErr.value"></small>
23
+ </div>
24
+ <div data-bind-hidden="isNew">
25
+ <label class="form-label">Token</label>
26
+ <div class="atx-fixed"><code data-bind-text="hint"></code><br><span data-bind-text="fixedProject"></span></div>
27
+ </div>
28
+ <div>
29
+ <label class="form-label" for="atx-f-scopes">Scopes
30
+ <span data-help-title="Scopes" data-help="Leave empty and the token may use every collection in its project that allows token access. Otherwise one line per collection: the collection's slug, then a colon and the verbs it may use - create, read, update, delete. No verbs means all four. For example: jobs: read"></span></label>
31
+ <textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read&#10;enquiries: create, read" data-model="scopes.value"></textarea>
32
+ <small class="atx-err" data-bind-hidden="!scopesErr.value" data-bind-text="scopesErr.value"></small>
33
+ </div>
34
+ <div>
35
+ <label class="form-label" for="atx-f-expires">Expires
36
+ <span data-help="Leave empty for a token that never expires. From this moment on, calls with it are refused - the sidebar badge warns two weeks ahead."></span></label>
37
+ <input id="atx-f-expires" type="datetime-local" class="form-input" data-model="expires.value">
38
+ <small class="atx-err" data-bind-hidden="!expiresErr.value" data-bind-text="expiresErr.value"></small>
39
+ </div>
40
+ <div class="atx-setting" data-bind-hidden="isNew">
41
+ <label class="form-label" for="atx-f-enabled">Switched on
42
+ <span data-help="Off refuses every call with the token without losing it - switch it back on to carry on. Revoke (from the list) removes it for good."></span></label>
43
+ <input id="atx-f-enabled" type="checkbox" class="form-switch" data-model="enabled.value">
44
+ </div>
45
+ <div class="atx-actions">
46
+ <small class="atx-hint">Ctrl+Enter saves.</small>
47
+ <span class="atx-spacer"></span>
48
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
49
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
50
+ <span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
51
+ </div>
52
+ </div>`),ne=x.querySelector("#atx-f-project");for(const n of s){const i=document.createElement("option");i.value=n.slug,i.textContent=`${n.name||n.slug} (${n.slug})`,ne.appendChild(i)}let B=()=>{};async function oe(){if(ae.cannot.peek())return;q.value=!0;const{payload:n}=K.peek();try{if(o){const i=await g.apiTokens.create(n);B(),f.close(),await N(),be(i?.plaintext,n.name)}else await g.apiTokens.update(a.id,n),E.toast("Saved.",{type:"success"}),B(),f.close(),await N()}catch(i){E.toast(`Not saved: ${i.message||i}`,{type:"error"})}finally{q.value=!1}}f.element.appendChild(x);const je=ie(x),Se=M.applyBindings(ae,x,{methods:{save:oe,cancel:()=>f.close()}});ne.value=v.project.peek();const Te=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),oe())};x.addEventListener("keydown",Te);const se={id:a?.id??null,dirty:te,values:ee};h=se,C.add(f),le(f,()=>{Se.dispose(),je(),C.delete(f),h===se&&(h=null)}),B=G(f,te,"Discard the changes you have not saved?"),I.scan(x),f.open(),x.querySelector("#atx-f-name")?.focus()}function be(e,t){if(!e){E.toast("The token was made, but its value did not come back - revoke it and make another.",{type:"error"});return}let a=!1;const o=E.slideover({title:"Token created",size:"sm",position:"right"}),s=pe(`
53
+ <div class="atx-secret">
54
+ <p class="atx-warn"><strong>Copy it now.</strong> This is the only time it is shown - only a fingerprint is kept. If it is lost, revoke it and make another.</p>
55
+ <code class="atx-token"></code>
56
+ <div class="atx-actions">
57
+ <small class="atx-hint copied-note" hidden>Copied.</small>
58
+ <span class="atx-spacer"></span>
59
+ <button type="button" class="btn btn-sm btn-primary atx-copy"><span data-icon="copy" data-icon-size="13"></span> Copy</button>
60
+ <button type="button" class="btn btn-sm btn-ghost atx-done">Done</button>
61
+ </div>
62
+ </div>`);s.querySelector(".atx-token").textContent=e,s.querySelector(".atx-copy").addEventListener("click",async()=>{await ue(e)?(a=!0,s.querySelector(".copied-note").hidden=!1):E.toast("Copy failed - select the token and copy it by hand.",{type:"error"})}),s.querySelector(".atx-done").addEventListener("click",()=>o.close()),o.element.appendChild(s);const u={copied:()=>a};o.__dmSecret=!0,y=u,C.add(o),le(o,()=>{C.delete(o),y===u&&(y=null),e=null}),G(o,()=>!a,`You have not copied the token for "${A(t)}". Once this closes it can never be shown again. Close anyway?`),I.scan(s),o.open()}function he(e){const t=T(e);if(!t)return[];const{state:a}=ce(t);return[{header:t.name||"Token"},{label:"Edit\u2026",icon:"edit",action:()=>L(t.id)},t.enabled===!1?{label:"Switch on",icon:"play",action:()=>W(t,!0)}:{label:"Switch off\u2026",icon:"pause",disabled:a==="expired",action:()=>W(t,!1)},{type:"divider"},{label:"Copy its ending",icon:"copy",action:async()=>{E.toast(await ue(`dcms_\u2026${t.tokenHint||""}`)?"Copied the ending - the token itself is never shown again.":"Copy failed.",{type:"info"})}},{label:`Show only ${t.project}`,icon:"filter",disabled:b.peek()===t.project,action:()=>{b.value=t.project,ye()}},{type:"divider"},{label:"Revoke\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=d.querySelector(".atx-list");m.innerHTML=De;const k=d.querySelector(".atx-project"),ye=()=>{k&&(k.value=b.peek())};function ke(){if(!k)return;const e=b.peek();for(;k.options.length>1;)k.remove(1);for(const t of j.peek()){const a=document.createElement("option");a.value=t.slug,a.textContent=t.name||t.slug,k.appendChild(a)}k.value=e}const xe=M.applyBindings({tokens:w,projects:j,loading:D,loadError:_,filter:r,project:b,query:Y,rows:F,counts:V,emptyText:fe},d,{methods:{showAll:()=>{r.value="all"},showActive:()=>{r.value="active"},showExpiring:()=>{r.value="expiring"},showExpired:()=>{r.value="expired"},showDisabled:()=>{r.value="disabled"}}});l.add(()=>xe.dispose()),l.add(ie(d,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),l.add(M.effect(()=>{const e=V.value,t=r.peek();t!=="all"&&!D.value&&!e[t]&&(r.value="all")}));const we=E.contextMenu(m,{cursor:"glyph",match:".atx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.id)});l.add(()=>we?.destroy?.());const Q=e=>{const t=e.target.closest(".atx-row");if(!t)return;const a=e.target.closest(".atx-kebab");if(a){re(a);return}L(t.dataset.id)},X=e=>{const t=e.target.closest(".atx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};m.addEventListener("click",Q),m.addEventListener("keydown",X),l.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),Ce(c,{id:"api-tokens-new",icon:"plus",title:"New token",onClick:()=>L()});const Z=e=>{(h?.dirty()||y&&!y.copied())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),l.add(()=>window.removeEventListener("beforeunload",Z)),l.add(()=>{if(h?.dirty())try{S.set(R,{id:h.id,values:h.values(),at:new Date().toISOString()})}catch{}ve=!0,[...C].forEach(e=>{if(!(y&&e.__dmSecret&&!y.copied()))try{e.close(!0)}catch{}})}),I.scan(d),await J(),!$())return;let p=null;try{p=S.get(R)}catch{}if(p?.values){try{S.remove?S.remove(R):S.set(R,null)}catch{}const e=p.id&&T(p.id)?`your changes to "${A(T(p.id).name)}"`:"the new token you started";await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&L(p.id&&T(p.id)?p.id:null,p.values)}}};
@@ -1 +1,31 @@
1
- import{apiRequest as a}from"/admin/js/api.js";export const effectsView={templateUrl:"/admin/js/templates/effects.html",async onMount(t){let e={};const f=E.loader(t.get(0),{type:"dots"});try{e=await a("/effects/settings")}catch{E.toast("Could not load settings.",{type:"error"})}finally{f.destroy()}t.find("#field-respect-motion").prop("checked",e.respectMotion!==!1),t.find("#field-default-duration").val(e.defaultDuration??600),t.find("#field-default-animation").val(e.defaultAnimation||"fade"),t.find("#field-default-threshold").val(e.defaultThreshold??.1),t.find("#save-settings-btn").off("click").on("click",async()=>{const s={respectMotion:t.find("#field-respect-motion").prop("checked"),defaultDuration:parseInt(t.find("#field-default-duration").val(),10)||600,defaultAnimation:t.find("#field-default-animation").val(),defaultThreshold:parseFloat(t.find("#field-default-threshold").val())||.1};try{await a("/effects/settings",{method:"PUT",body:JSON.stringify(s)}),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}}),E.tabs(t.find("#effects-tabs").get(0)),t.find(".effects-tab-btn").on("click",function(){const s=this.getAttribute("data-fx-tab");t.find(".effects-tab-btn").removeClass("active"),$(this).addClass("active"),t.find(".effects-tab-panel").hide(),t.find(`#tab-${s}`).show()}),t.find(".effects-tab-panel").hide(),t.find("#tab-entrance").show(),Domma.icons.scan()}};
1
+ import{apiRequest as ft}from"../api.js";import{addBannerAction as At}from"../lib/plugin-chrome.js";import{helpPopovers as pt}from"../lib/help-popover.js";import{onceClosed as jt,openMenuAt as mt}from"../lib/tool-kit.js";import{EFFECTS as D,getEffect as bt}from"../lib/effect-defs.js";import{arrangeEffects as yt,attrRow as $t,categoriesOf as Ft,changedFromDefaults as Rt,checkSettings as A,DEFAULT_SETTINGS as j,effectRow as ht,exampleShortcode as w,formFromSettings as p,settingsSnapshot as $}from"../lib/effects-arrange.js";const V=/^\/effects(\?.*)?$/,m="effects.draft",vt="effects.tab",It=`
2
+ <div class="fxx-row" data-name="{{name}}" tabindex="0">
3
+ <code class="fxx-tag">{{tag}}</code>
4
+ <span class="fxx-main"><strong>{{label}}</strong><small>{{description}}</small><small>{{meta}}</small></span>
5
+ <span class="fxx-chip is-info">{{category}}</span>
6
+ <span class="fxx-chip is-warn" data-bind-hidden="!fullPage" title="Runs full-page on the published site - the editor cannot preview it">Full page</span>
7
+ <button type="button" class="fxx-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
8
+ </div>`,Kt=`<button type="button" data-cat="{{key}}" data-bind-class="on && 'is-on'">{{label}} <b>{{count}}</b></button>`,Nt=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Bt(s){const r=document.createElement("template");return r.innerHTML=s.trim(),r.content.firstElementChild}async function k(s,r){try{await navigator.clipboard.writeText(s),E.toast(`${r} copied.`,{type:"success"})}catch{E.toast(`Could not reach the clipboard - select the ${r.toLowerCase()} and copy it.`,{type:"warning"})}}let f=null,gt=!1;function Pt(){gt||!window.R?.use||(gt=!0,R.use((s,r,o)=>!(V.test(r?.path||"")&&!V.test(s?.path||""))||!f?.dirty()?o():E.confirm("You have unsaved effect settings. Leave anyway? They are kept as a draft for your next visit.").then(i=>{i?o():history.replaceState(null,"","#/effects")})),M.subscribe("router:afterChange",({to:s}={})=>{if(f&&!V.test(s?.path||"")){const r=f;f=null,r.dispose()}}))}export const effectsView={templateUrl:"/admin/js/templates/effects.html",async onMount(s){if(Pt(),f){const t=f;f=null,t.dispose()}const r=[],o=t=>(r.push(t),t);let F=!1;const i=s.get(0).querySelector(".fxx")||s.get(0),C=()=>!F&&i.isConnected,b=M.observable({...j}),y=M.observable($(p(j))),xt=p(j),d=Object.fromEntries(Object.entries(xt).map(([t,e])=>[t,M.observable(e)])),K=()=>Object.fromEntries(Object.keys(d).map(t=>[t,d[t].peek()])),T=t=>{for(const[e,a]of Object.entries(t))d[e]&&(d[e].value=a)},N=M.observable(!1),J=M.observable(!1),L=M.computed(()=>A(Object.fromEntries(Object.keys(d).map(t=>[t,d[t].value])))),h=M.computed(()=>J.value&&$(Object.fromEntries(Object.keys(d).map(t=>[t,d[t].value])))!==y.value),B=t=>M.computed(()=>L.value.errors[t]||""),P=M.observable("all"),_=M.observable(""),Q=M.observable("entrance"),X=M.observable([]);o(M.effect(()=>{X.value=yt(D,{category:P.value,query:_.value}).map(ht)}));const Z=M.observable([]);o(M.effect(()=>{const t=P.value,e=_.value,a=n=>yt(D,{category:n,query:e}).length;Z.value=[{key:"all",label:"All",count:a("all"),on:t==="all"},...Ft(D).map(n=>({key:n,label:n,count:a(n),on:t===n}))]}));const tt={...d,saved:b,dirty:h,rows:X,cats:Z,query:_,guide:Q,effectCount:D.length,durErr:B("defaultDuration"),thrErr:B("defaultThreshold"),animErr:B("defaultAnimation"),cannotSave:M.computed(()=>N.value||!h.value||Object.keys(L.value.errors).length>0),motionText:M.computed(()=>b.value.respectMotion?"Respects reduced motion":"Ignores reduced motion"),defaultsText:M.computed(()=>{const t=Rt(b.value).length;return t?`${t} changed from Domma's defaults`:"Domma's defaults"})};async function Et(){try{const t=await ft("/effects/settings");if(!C())return;const e=p(t);b.value=A(e).payload,y.value=$(e),T(e)}catch(t){E.toast(`Could not load the effect settings: ${t.message||t}`,{type:"error"})}finally{J.value=!0}}async function H(){if(tt.cannotSave.peek()){Object.keys(L.peek().errors).length&&E.toast("Fix the highlighted settings first.",{type:"warning"});return}N.value=!0;const{payload:t}=L.peek();try{const a=(await ft("/effects/settings",{method:"PUT",body:JSON.stringify(t)}))?.settings||t;b.value=A(p(a)).payload,y.value=$(K());try{S.remove?S.remove(m):S.set(m,null)}catch{}E.toast("Effect settings saved - the site uses them on its next page load.",{type:"success"})}catch(e){E.toast(`Not saved: ${e.message||e}`,{type:"error"})}finally{N.value=!1}}async function St(){(!h.peek()||await E.confirm("Throw away the changes you have not saved?"))&&T(p(b.peek()))}function wt(){T(p(j)),E.toast("Domma's defaults are filled in - Save to use them.",{type:"info"})}const U=new Set;function W(t){const e=bt(t);if(!e)return;const a=ht(e),n=E.slideover({title:Nt(`${a.label} - ${a.tag}`),size:"md",position:"right"}),ct=(e.attrs||[]).map($t),l=Bt(`
9
+ <div class="fxx-form">
10
+ <p class="fxx-note" data-part="desc"></p>
11
+ <div class="fxx-chips" data-part="chips"></div>
12
+ <div>
13
+ <div class="fxx-sub">The shortcode
14
+ <span data-help="The bare tag runs with every setting at its default; the full form spells them all out, ready to edit."></span></div>
15
+ <pre data-part="bare"></pre>
16
+ </div>
17
+ <div data-part="full-wrap">
18
+ <div class="fxx-sub">With every setting</div>
19
+ <pre data-part="full"></pre>
20
+ </div>
21
+ <div data-part="attrs-wrap">
22
+ <div class="fxx-sub">Settings</div>
23
+ <table class="fxx-attrs"><thead><tr><th>Attribute</th><th>Takes</th><th>Default</th></tr></thead><tbody data-part="attrs"></tbody></table>
24
+ </div>
25
+ <div class="fxx-actions">
26
+ <span class="fxx-spacer"></span>
27
+ <button type="button" class="btn btn-sm btn-ghost" data-act="close">Close</button>
28
+ <button type="button" class="btn btn-sm btn-outline" data-act="copy-full">Copy full</button>
29
+ <button type="button" class="btn btn-sm btn-primary" data-act="copy"><span data-icon="copy" data-icon-size="13"></span> Copy shortcode</button>
30
+ </div>
31
+ </div>`);l.querySelector('[data-part="desc"]').textContent=e.description||"";const qt=l.querySelector('[data-part="chips"]'),O=(c,u="")=>{const x=document.createElement("span");x.className=`fxx-chip ${u}`,x.textContent=c,qt.appendChild(x)};O(e.category,"is-info"),O(e.selfClosing?"Self-closing":"Wraps content"),e.previewable===!1&&O("Full page","is-warn"),(e.hosts||[]).length&&O(`Also as an attribute of ${e.hosts.map(c=>`[${c}]`).join(" ")}`);const z=w(e),G=w(e,{full:!0});l.querySelector('[data-part="bare"]').textContent=z,l.querySelector('[data-part="full"]').textContent=G,l.querySelector('[data-part="full-wrap"]').hidden=G===z;const Ot=l.querySelector('[data-part="attrs"]');ct.length||(l.querySelector('[data-part="attrs-wrap"]').hidden=!0);for(const c of ct){const u=document.createElement("tr"),x=document.createElement("td"),lt=document.createElement("code");lt.textContent=c.name;const it=document.createElement("small");it.textContent=c.label+(c.hint?` - ${c.hint}`:""),x.append(lt,it);const dt=document.createElement("td");dt.textContent=c.takes;const ut=document.createElement("td");ut.textContent=c.def,u.append(x,dt,ut),Ot.appendChild(u)}l.addEventListener("click",c=>{const u=c.target.closest("[data-act]")?.dataset.act;u==="close"?n.close():u==="copy"?k(z,"Shortcode"):u==="copy-full"&&k(G,"Shortcode")}),n.element.appendChild(l);const Dt=pt(l);I.scan(l),U.add(n),jt(n,()=>{Dt(),U.delete(n)}),n.open()}function kt(t){const e=bt(t);if(!e)return[];const a=w(e,{full:!0});return[{header:`${e.label} [${e.name}]`},{label:"Details\u2026",icon:"eye",action:()=>W(t)},{type:"divider"},{label:"Copy shortcode",icon:"copy",action:()=>k(w(e),"Shortcode")},{label:"Copy with every setting",icon:"copy",disabled:a===w(e),action:()=>k(a,"Shortcode")},{label:"Copy tag name",icon:"code",action:()=>k(e.name,"Tag name")}]}const v=i.querySelector(".fxx-list");v.innerHTML=It;const Y=i.querySelector(".fxx-cats");Y.innerHTML=Kt;const q=i.querySelector(".fxx-tabs"),Ct=E.tabs(q,{tabSelector:".tab-item",panelSelector:".tab-panel"}),Mt=M.applyBindings(tt,i,{methods:{save:()=>H(),discard:()=>St(),resetDefaults:()=>wt(),showGuide:t=>{Q.value=t}}});o(()=>Mt.dispose()),o(pt(i,{position:"bottom"}));const et=t=>{const e=t.target.closest("[data-cat]");e&&(P.value=e.dataset.cat)};Y.addEventListener("click",et),o(()=>Y.removeEventListener("click",et));try{const t=Number(S.get(vt));Number.isInteger(t)&&t>0&&t<3&&Ct.activate?.(t)}catch{}const Tt=[...q.querySelectorAll(":scope > .tab-list > .tab-item")],at=t=>{const e=Tt.indexOf(t.target.closest(".tab-item"));if(e>=0)try{S.set(vt,e)}catch{}};q.querySelector(".tab-list").addEventListener("click",at),o(()=>q.querySelector(".tab-list")?.removeEventListener("click",at));const Lt=E.contextMenu(v,{cursor:"glyph",match:".fxx-row",inherit:!1,density:"compact",items:t=>kt(t.dataset.name)});o(()=>Lt?.destroy?.());const st=t=>{const e=t.target.closest(".fxx-row");if(!e)return;const a=t.target.closest(".fxx-kebab");if(a){mt(a);return}W(e.dataset.name)},ot=t=>{const e=t.target.closest(".fxx-row");e&&(t.key==="F10"&&t.shiftKey?(t.preventDefault(),mt(e)):t.key==="Enter"&&t.target===e&&W(e.dataset.name))};v.addEventListener("click",st),v.addEventListener("keydown",ot),o(()=>{v.removeEventListener("click",st),v.removeEventListener("keydown",ot)});const nt=t=>{(t.ctrlKey||t.metaKey)&&t.key==="s"&&C()&&(t.preventDefault(),H())};document.addEventListener("keydown",nt),o(()=>document.removeEventListener("keydown",nt)),At(s,{id:"effects-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>H()});const rt=t=>{if(h.peek()){try{S.set(m,{form:K(),base:y.peek(),at:new Date().toISOString()})}catch{}t.preventDefault(),t.returnValue=""}};if(window.addEventListener("beforeunload",rt),o(()=>window.removeEventListener("beforeunload",rt)),f={dirty:()=>h.peek(),dispose(){if(F=!0,h.peek())try{S.set(m,{form:K(),base:y.peek(),at:new Date().toISOString()})}catch{}[...U].forEach(t=>{try{t.close()}catch{}}),r.splice(0).forEach(t=>{try{t()}catch{}})}},I.scan(i),await Et(),!C())return;let g=null;try{g=S.get(m)}catch{}if(g?.form){try{S.remove?S.remove(m):S.set(m,null)}catch{}const e=`You left unsaved effect settings.${g.base&&g.base!==y.peek()?" The saved settings have changed since - restoring puts yours over them.":""} Restore them?`;await E.confirm(e)&&C()&&T(p({...A(g.form).payload,...g.form}))}}};
@@ -1 +1,81 @@
1
- import{api as f}from"../api.js";const N={narrow:"Narrow",normal:"Normal",wide:"Wide",full:"Full"},B="display:block;font-size:.7rem;font-weight:600;color:var(--dm-text-muted,#aaa);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem;",S="width:100%;padding:.4rem .6rem;background:var(--dm-input-bg,#1a1a1a);border:1px solid var(--dm-border,#333);border-radius:4px;color:var(--dm-text,#eee);font-size:.9em;box-sizing:border-box;";function x(n,t){const e=document.createElement("div");e.style.cssText="display:flex;flex-direction:column;gap:.25rem;";const a=document.createElement("label");return a.style.cssText=B,a.textContent=n,e.appendChild(a),e.appendChild(t),e}function L(n,t){const e=document.createElement("input");return e.type="text",e.placeholder=n||"",e.value=t||"",e.style.cssText=S,e}function k(n,t){const e=document.createElement("label");e.style.cssText="display:flex;align-items:center;gap:.5rem;cursor:pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!!t;const o=document.createElement("span");return o.style.cssText="font-size:.9em;color:var(--dm-text,#eee);",o.textContent=n,e.appendChild(a),e.appendChild(o),{wrap:e,cb:a}}function F(n){const t=document.createElement("select");return t.style.cssText=S,[["narrow","Narrow (768px)"],["normal","Normal (1100px)"],["wide","Wide (75% of viewport, max 2000px)"],["full","Full width"]].forEach(([e,a])=>{const o=document.createElement("option");o.value=e,o.textContent=a,e===(n||"normal")&&(o.selected=!0),t.appendChild(o)}),t}function D(n){const t=document.createElement("div");t.style.cssText="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;";const e=document.createElement("input");e.type="color",e.value=n||"#ffffff",e.disabled=!n,e.style.cssText="width:38px;height:32px;padding:2px;border:1px solid var(--dm-border,#333);border-radius:4px;background:transparent;cursor:pointer;";const a=document.createElement("span");a.style.cssText="font-size:.85em;color:var(--dm-text-muted,#aaa);min-width:4rem;",a.textContent=n||"None";const o=document.createElement("button");o.type="button",o.textContent="Choose",o.style.cssText="padding:.25rem .6rem;font-size:.8em;background:transparent;border:1px solid var(--dm-border,#333);border-radius:4px;color:var(--dm-text-muted,#aaa);cursor:pointer;";const r=document.createElement("button");return r.type="button",r.textContent="Clear",r.style.cssText=o.style.cssText,r.disabled=!n,o.addEventListener("click",()=>{e.disabled=!1,r.disabled=!1,e.click()}),e.addEventListener("input",()=>{a.textContent=e.value,r.disabled=!1}),r.addEventListener("click",()=>{e.value="#ffffff",e.disabled=!0,r.disabled=!0,a.textContent="None"}),t.appendChild(e),t.appendChild(a),t.appendChild(o),t.appendChild(r),{wrap:t,getValue(){return e.disabled?"":e.value}}}function O(n,t,e){const a=document.createElement("div");a.className="card preset-card",a.style.cssText="cursor:pointer;transition:box-shadow .15s;",a.addEventListener("mouseenter",()=>{a.style.boxShadow="0 0 0 2px var(--dm-primary,#4f7fff)"}),a.addEventListener("mouseleave",()=>{a.style.boxShadow=""}),a.addEventListener("click",()=>e(n,t));const o=document.createElement("div");o.className="card-header",o.style.cssText="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;";const r=document.createElement("strong");if(r.textContent=t.label||n,o.appendChild(r),t.builtin){const s=document.createElement("span");s.className="badge",s.textContent="built-in",s.style.cssText="font-size:.65rem;",o.appendChild(s)}const d=document.createElement("span");d.className="badge badge-outline",d.textContent=N[t.width]||"Normal",d.style.cssText="margin-left:auto;font-size:.65rem;",o.appendChild(d),a.appendChild(o);const c=document.createElement("div");if(c.className="card-body",t.description){const s=document.createElement("p");s.className="text-muted",s.style.cssText="font-size:.85em;margin:0 0 .5rem;",s.textContent=t.description,c.appendChild(s)}const l=document.createElement("div");if(l.style.cssText="display:flex;gap:.75rem;flex-wrap:wrap;",[["Navbar",t.navbar],["Footer",t.footer],["Sidebar",t.sidebar]].forEach(([s,u])=>{const p=document.createElement("span");p.style.cssText=`font-size:.75em;color:${u?"var(--dm-success,#4caf50)":"var(--dm-text-muted,#666)"};display:flex;align-items:center;gap:.25rem;`,p.textContent=(u?"\u25CF ":"\u25CB ")+s,l.appendChild(p)}),c.appendChild(l),t.bgColor){const s=document.createElement("span");s.title=t.bgColor,s.style.cssText=`display:inline-block;width:14px;height:14px;border-radius:3px;background:${t.bgColor};border:1px solid var(--dm-border,#333);margin-top:.4rem;vertical-align:middle;`,c.appendChild(s)}return a.appendChild(c),a}function z(n,t,e,a,o){const r=E.slideover({title:e?"New Layout":"Edit Layout",size:"md"}),d=document.createElement("div");d.style.cssText="display:flex;flex-direction:column;gap:1rem;padding:1rem;";const c=L("e.g. Dark Landing",t.label||""),l=L("Optional description",t.description||""),s=F(t.width),u=k("Show Navbar",t.navbar!==!1),p=k("Show Footer",t.footer!==!1),g=k("Show Sidebar",t.sidebar===!0),C=D(t.bgColor||""),b=L("https://...",t.bgImage||""),v=L("e.g. no-hero wide-content",t.class||"");d.appendChild(x("Label",c)),d.appendChild(x("Description",l)),d.appendChild(x("Width",s));const y=document.createElement("div");y.style.cssText="display:flex;gap:1.5rem;flex-wrap:wrap;",y.appendChild(u.wrap),y.appendChild(p.wrap),y.appendChild(g.wrap),d.appendChild(y),d.appendChild(x("Background Colour",C.wrap)),d.appendChild(x("Background Image URL",b)),d.appendChild(x("Custom CSS Class",v));const w=document.createElement("div");w.style.cssText="display:flex;gap:.5rem;flex-wrap:wrap;padding-top:.5rem;border-top:1px solid var(--dm-border,#333);";const h=document.createElement("button");if(h.type="button",h.className="btn btn-primary",h.textContent="Save",w.appendChild(h),!e&&!t.builtin){const i=document.createElement("button");i.type="button",i.className="btn btn-danger",i.style.marginLeft="auto",i.textContent="Delete",i.addEventListener("click",async()=>{if(await E.confirm(`Delete the "${t.label||n}" layout?`))try{await f.layouts.remove(n),E.toast("Layout deleted.",{type:"success"}),r.close(),o(n)}catch(m){E.toast(m?.message||"Failed to delete layout.",{type:"error"})}}),w.appendChild(i)}d.appendChild(w),h.addEventListener("click",async()=>{const i=c.value.trim();if(!i){E.toast("Label is required.",{type:"warning"});return}const T={label:i,description:l.value.trim(),width:s.value,navbar:u.cb.checked,footer:p.cb.checked,sidebar:g.cb.checked,bgColor:C.getValue(),bgImage:b.value.trim(),class:v.value.trim()};try{let m;e?m=await f.layouts.create(T):m=await f.layouts.update(n,T),E.toast("Layout saved.",{type:"success"}),r.close(),a(e?m.key:n,m.preset||{...t,...T})}catch(m){E.toast(m?.message||"Failed to save layout.",{type:"error"})}}),r.setContent(d),r.open()}export const layoutsView={templateUrl:"/admin/js/templates/layouts.html",async onMount(n){E.tabs(n.find("#layouts-tabs").get(0));const t=E.loader(n.get(0),{type:"dots"});let e=await f.layouts.get().catch(()=>({}));const a=await f.layouts.getOptions().catch(()=>({spacerSize:8,spacerClass:""}));t.destroy();const o=n.find("#presets-grid");function r(){o.empty();const l=o.get(0);Object.entries(e).forEach(([s,u])=>{const p=O(s,u,(g,C)=>{z(g,C,!1,(b,v)=>{e[b]={...e[b],...v},r()},b=>{delete e[b],r()})});l.appendChild(p)}),I.scan(l)}r(),n.find("#create-layout-btn").on("click",()=>{z(null,{},!0,(l,s)=>{if(!l){E.toast("Layout saved but key missing - please refresh.",{type:"warning"});return}e[l]=s,r()},()=>{})});const d=n.find("#spacer-size-input"),c=n.find("#spacer-class-input");d.val(a.spacerSize??8),c.val(a.spacerClass??""),n.find("#save-options-btn").on("click",async()=>{const l=parseInt(d.val(),10)||8,s=c.val().trim();try{await f.layouts.saveOptions({spacerSize:l,spacerClass:s}),E.toast("Layout options saved.",{type:"success"})}catch{E.toast("Failed to save layout options.",{type:"error"})}})}};
1
+ import{api as L,apiRequest as Qe}from"../api.js";import{addBannerAction as Xe}from"../lib/plugin-chrome.js";import{helpPopovers as Me}from"../lib/help-popover.js";import{onceClosed as $e,openMenuAt as Oe,sidebarCount as Ze}from"../lib/tool-kit.js";import{FILTERS as et,WIDTHS as tt,WIDTH_HINTS as at,WIDTH_LABELS as st,WIDTH_SHARE as De,arrangeLayouts as lt,checkLayout as nt,checkOptions as ot,formFromPreset as it}from"../lib/layouts-arrange.js";const ae=/^\/layouts\/?(\?.*)?$/,Te="#/layouts",se="layouts.draft",N="layouts.options.draft",le="layouts.filter",Le=(s="")=>`
2
+ <span class="lyx-shape" aria-hidden="true" data-bind-style-background-color="bg${s}">
3
+ <span class="lyx-shape-nav" data-bind-hidden="!hasNav${s}"></span>
4
+ <span class="lyx-shape-body"><span class="lyx-shape-col" data-bind-style-width="share${s}"></span><span class="lyx-shape-side" data-bind-hidden="!hasSide${s}"></span></span>
5
+ <span class="lyx-shape-foot" data-bind-hidden="!hasFoot${s}"></span>
6
+ </span>`,rt=`
7
+ <div class="lyx-row" data-key="{{key}}" tabindex="0">
8
+ ${Le()}
9
+ <span class="lyx-main"><strong>{{label}}</strong><small>{{meta}}</small></span>
10
+ <span class="lyx-chips">
11
+ <span class="lyx-chip" data-bind-hidden="!builtin" title="Built in - can be edited, not deleted">Built in</span>
12
+ <span class="lyx-chip">{{widthLabel}}</span>
13
+ <span class="lyx-chip" data-bind-class="usedTone" title="Pages that use this layout">{{usedText}}</span>
14
+ </span>
15
+ <button type="button" class="lyx-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
16
+ </div>`,W=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Be(s){const r=document.createElement("template");return r.innerHTML=s.trim(),r.content.firstElementChild}const je=(s,r,i=`${r}s`)=>`${s} ${s===1?r:i}`,ct=s=>`#/pages/edit${s}`;let f=null,ze=!1;function ut(){ze||!window.R?.use||(ze=!0,R.use((s,r,i)=>!(ae.test(r?.path||"")&&!ae.test(s?.path||""))||!f?.dirty()?i():E.confirm("You have unsaved layout changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?i():history.replaceState(null,"",Te)})),M.subscribe("router:afterChange",({to:s}={})=>{if(f&&!ae.test(s?.path||"")){const r=f;f=null,r.dispose()}}))}export const layoutsView={templateUrl:"/admin/js/templates/layouts.html",async onMount(s){if(ut(),f){const e=f;f=null,e.dispose()}const r=[],i=e=>(r.push(e),e);let Y=!1;const d=s.get(0).querySelector(".lyx")||s.get(0),p=()=>!Y&&d.isConnected,y=M.observable({}),A=M.observable({byKey:{},missing:[]}),ne=M.observable(!0),U=M.observable("");let oe="all";try{oe=et.includes(S.get(le))?S.get(le):"all"}catch{}const k=M.observable(oe),ie=M.observable("");i(M.effect(()=>{const e=k.value;try{S.set(le,e)}catch{}}));const re=M.observable([]);i(M.effect(()=>{re.value=lt(y.value,A.value,{filter:k.value,query:ie.value})}));const Pe=M.computed(()=>{const e=Object.values(y.value),t=e.filter(n=>n.builtin).length;return{all:e.length,builtin:t,custom:e.length-t}}),V=M.computed(()=>A.value.missing||[]),Ne=M.computed(()=>{const e=V.value.length;return`${je(e,"page")} name${e===1?"s":""} a layout that no longer exists and show${e===1?"s":""} as Default.`}),H=M.observable({spacerSize:"8",spacerClass:""}),m=M.observable("8"),g=M.observable(""),G=M.observable(!1),ce=()=>({spacerSize:String(m.value??""),spacerClass:String(g.value??"")}),K=M.computed(()=>ot(ce())),x=M.computed(()=>{const e=H.value;return String(m.value??"")!==e.spacerSize||String(g.value??"")!==e.spacerClass}),ue=M.computed(()=>G.value||!x.value||Object.keys(K.value.errors).length>0),Ae=M.computed(()=>x.value?"Unsaved changes - Ctrl+S saves.":"Saved."),B=e=>({...y.peek()[e]||{},key:e}),J=e=>(A.peek().byKey||{})[e]||[];async function Q(){try{const[e,t]=await Promise.all([L.layouts.get(),Qe("/layouts/_usage",{method:"GET"}).catch(()=>({byKey:{},missing:[]}))]);if(!p())return;y.value=e&&typeof e=="object"?e:{},A.value=t&&typeof t=="object"?t:{byKey:{},missing:[]},U.value=""}catch(e){U.value=`Could not load the layouts: ${e.message||e}`}finally{ne.value=!1}}async function He(){const e=await L.layouts.getOptions().catch(()=>({spacerSize:8,spacerClass:""}));if(!p())return;const t={spacerSize:String(e?.spacerSize??8),spacerClass:String(e?.spacerClass??"")};H.value=t,m.value=t.spacerSize,g.value=t.spacerClass}async function de(){if(ue.peek())return;G.value=!0;const{payload:e}=K.peek();try{await L.layouts.saveOptions(e),H.value={spacerSize:String(e.spacerSize),spacerClass:e.spacerClass},m.value=String(e.spacerSize),g.value=e.spacerClass;try{S.remove?S.remove(N):S.set(N,null)}catch{}E.toast("Layout options saved.",{type:"success"})}catch(t){E.toast(`Not saved: ${t.message||t}`,{type:"error"})}finally{G.value=!1}}function Ie(){const e=H.peek();m.value=e.spacerSize,g.value=e.spacerClass}const pe=()=>Ze(Te);async function Ke(e){const t=B(e);if(t.builtin)return;const n=J(e).length,c=n?` ${je(n,"page")} use${n===1?"s":""} it and will show as Default.`:" No page uses it.";if(await E.confirm(`Delete the layout "${W(t.label||e)}"?${c}`))try{await L.layouts.remove(e),E.toast(`Deleted "${t.label||e}".`,{type:"success"}),pe(),p()&&await Q()}catch(o){E.toast(`Not deleted: ${o.message||o}`,{type:"error"})}}async function Re(e){const t=`layout: ${e}`;try{await navigator.clipboard.writeText(t),E.toast(`Copied "${t}" - paste it into a page's frontmatter.`,{type:"success"})}catch{E.toast(`Copy this into a page's frontmatter: ${t}`,{type:"info",duration:7e3})}}const C=new Set;let b=null,ye=!1;function be(e){const t=e===null,n=t?V.peek():J(e),c=t?null:B(e),o=E.slideover({title:t?"Pages on a missing layout":`Pages using ${W(c.label||e)}`,size:"sm",position:"right"}),j=Be(`
17
+ <div class="lyx-form">
18
+ <p class="lyx-note"></p>
19
+ <div class="lyx-pages"></div>
20
+ </div>`);j.querySelector(".lyx-note").textContent=t?"These pages name a layout that no longer exists, so they show as Default. Open one and choose a layout for it.":n.length?"Click a page to open it in the editor.":"No page uses this layout.";const F=j.querySelector(".lyx-pages");for(const l of n){const w=document.createElement("a");w.href=ct(l.urlPath);const z=document.createElement("span");z.textContent=l.title||l.urlPath;const q=document.createElement("small");q.textContent=[l.urlPath,t?`"${l.layout}"`:"",l.status==="draft"?"draft":""].filter(Boolean).join(" \xB7 "),w.append(z,q),w.addEventListener("click",()=>o.close()),F.appendChild(w)}o.element.appendChild(j),C.add(o),$e(o,()=>C.delete(o)),o.open()}function $(e=null,{from:t=null,draft:n=null}={}){const c=e?B(e):null;if(e&&!y.peek()[e]){E.toast(`There is no layout "${e}".`,{type:"warning"});return}const o=!c,j=c||(t?{...B(t),label:`${B(t).label||t} copy`,builtin:!1}:{}),F={...it(j),...n||{}},l=Object.fromEntries(Object.entries(F).map(([a,h])=>[a,M.observable(h)])),w=()=>Object.fromEntries(Object.keys(l).map(a=>[a,l[a].peek()])),z=a=>JSON.stringify(Object.fromEntries(Object.entries(a).map(([h,te])=>[h,typeof te=="boolean"?te:String(te??"")]))),q=z(F),Se=()=>z(w())!==q||!!n||!!(t&&o),_=M.observable(!1),P=M.computed(()=>nt(Object.fromEntries(Object.keys(l).map(a=>[a,l[a].value])),{isNew:o,taken:Object.keys(y.value)})),X=a=>M.computed(()=>P.value.errors[a]||""),We={hasNav:M.computed(()=>l.navbar.value!==!1),hasFoot:M.computed(()=>l.footer.value!==!1),hasSide:M.computed(()=>l.sidebar.value===!0),share:M.computed(()=>De[l.width.value]||De.normal),bg:M.computed(()=>l.useBg.value?l.bgColor.value:"")},Ee={...l,...We,saving:_,labelErr:X("label"),bgErr:X("bgColor"),classErr:X("cssClass"),keyHint:M.computed(()=>o?P.value.key?`Pages will name it "layout: ${P.value.key}".`:"":`Pages name it "layout: ${c.key}" - renaming does not change that.`),widthHint:M.computed(()=>at[l.width.value]||""),builtinNote:!o&&c.builtin===!0,noBg:M.computed(()=>!l.useBg.value),cannot:M.computed(()=>_.value||Object.keys(P.value.errors).length>0),saveText:o?"Create layout":"Save"},Ye=o?t?"Duplicate layout":"New layout":`Edit ${W(c.label||c.key)}`,u=E.slideover({title:Ye,size:"md",position:"right"}),T=Be(`
21
+ <div class="lyx-form">
22
+ <div class="lyx-preview">${Le(".value")}
23
+ <small class="lyx-hint" data-bind-text="keyHint.value"></small></div>
24
+ <p class="lyx-note" data-bind-hidden="!builtinNote">A built-in layout: you can change it, but not delete it.</p>
25
+ <div class="lyx-grid">
26
+ <div>
27
+ <label class="form-label" for="lyx-f-label">Name <span class="text-danger">*</span></label>
28
+ <input id="lyx-f-label" type="text" class="form-input" maxlength="60" placeholder="Dark landing" data-model="label.value">
29
+ <small class="lyx-err" data-bind-hidden="!labelErr.value" data-bind-text="labelErr.value"></small>
30
+ </div>
31
+ <div>
32
+ <label class="form-label" for="lyx-f-width">Width
33
+ <span data-help="How wide the page's content column is. The navbar and footer are not affected."></span></label>
34
+ <select id="lyx-f-width" class="form-input" data-model="width.value">
35
+ ${tt.map(a=>`<option value="${a}">${st[a]}</option>`).join("")}
36
+ </select>
37
+ <small class="lyx-hint" data-bind-text="widthHint.value"></small>
38
+ </div>
39
+ <div class="lyx-wide">
40
+ <label class="form-label" for="lyx-f-desc">Description</label>
41
+ <input id="lyx-f-desc" type="text" class="form-input" maxlength="200" placeholder="What it is for - shown in the page editor's picker" data-model="description.value">
42
+ </div>
43
+ <div class="lyx-wide">
44
+ <span class="form-label">Around the content
45
+ <span data-help="Whether pages on this layout show the site's navbar and footer, and the sidebar (for sites whose theme has one)."></span></span>
46
+ <div class="lyx-switches">
47
+ <label><input type="checkbox" class="form-switch" data-model="navbar.value"> Navbar</label>
48
+ <label><input type="checkbox" class="form-switch" data-model="footer.value"> Footer</label>
49
+ <label><input type="checkbox" class="form-switch" data-model="sidebar.value"> Sidebar</label>
50
+ </div>
51
+ </div>
52
+ <div>
53
+ <span class="form-label">Background colour
54
+ <span data-help="A colour behind the page's content. Off leaves the theme's own background."></span></span>
55
+ <div class="lyx-colour">
56
+ <input type="checkbox" class="form-switch" aria-label="Use a background colour" data-model="useBg.value">
57
+ <input type="color" aria-label="Background colour" data-model="bgColor.value" data-bind-disabled="noBg.value">
58
+ <small class="lyx-hint" data-bind-text="bg.value || 'Theme background'"></small>
59
+ </div>
60
+ <small class="lyx-err" data-bind-hidden="!bgErr.value" data-bind-text="bgErr.value"></small>
61
+ </div>
62
+ <div>
63
+ <label class="form-label" for="lyx-f-class">CSS class
64
+ <span data-help="Added to the page's body, for your own CSS (content/custom.css) to hook onto - e.g. no-hero."></span></label>
65
+ <input id="lyx-f-class" type="text" class="form-input font-mono" placeholder="e.g. no-hero" data-model="cssClass.value">
66
+ <small class="lyx-err" data-bind-hidden="!classErr.value" data-bind-text="classErr.value"></small>
67
+ </div>
68
+ <div class="lyx-wide">
69
+ <label class="form-label" for="lyx-f-img">Background image
70
+ <span data-help="An image address - /media/hero.jpg for one in the Media library, or a full https:// address."></span></label>
71
+ <input id="lyx-f-img" type="text" class="form-input" placeholder="/media/background.jpg" data-model="bgImage.value">
72
+ </div>
73
+ </div>
74
+ <div class="lyx-actions">
75
+ <small class="lyx-hint">Ctrl+Enter saves.</small>
76
+ <span class="lyx-spacer"></span>
77
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
78
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
79
+ <span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
80
+ </div>
81
+ </div>`);async function ke(){if(Ee.cannot.peek())return;_.value=!0;const{payload:a}=P.peek();try{o?await L.layouts.create(a):await L.layouts.update(c.key,a),E.toast(o?`Created "${a.label}".`:"Saved.",{type:"success"}),Z=!0,u.close(),pe(),p()&&await Q()}catch(h){E.toast(`Not saved: ${h.message||h}`,{type:"error"})}finally{_.value=!1}}u.element.appendChild(T);const Ue=Me(T),Ve=M.applyBindings(Ee,T,{methods:{save:ke,cancel:()=>u.close()}}),Ge=a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),ke())};T.addEventListener("keydown",Ge);const Ce={key:c?.key??null,dirty:Se,values:w};b=Ce,C.add(u),$e(u,()=>{Ve.dispose(),Ue(),C.delete(u),b===Ce&&(b=null)});const Je=u.close.bind(u);let Z=!1,ee=!1;u.close=async a=>{if(!Z&&!ye&&a!==!0&&Se()){if(ee)return;ee=!0;const h=await E.confirm("Discard the changes you have not saved?");if(ee=!1,!h)return}return Z=!0,Je()},I.scan(T),u.open(),T.querySelector("#lyx-f-label")?.focus()}function Fe(e){const t=y.peek()[e];if(!t)return[];const n=J(e).length;return[{header:t.label||e},{label:"Edit\u2026",icon:"edit",action:()=>$(e)},{label:n?`Pages using it (${n})\u2026`:"Pages using it",icon:"file-text",disabled:!n,action:()=>be(e)},{label:"Duplicate\u2026",icon:"copy",action:()=>$(null,{from:e})},{label:"Copy frontmatter line",icon:"clipboard",action:()=>Re(e)},...t.builtin?[]:[{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Ke(e)}]]}const O=d.querySelector(".lyx-list");O.innerHTML=rt;const ve=E.tabs(d.querySelector(".lyx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),qe=M.applyBindings({loading:ne,loadError:U,filter:k,query:ie,rows:re,counts:Pe,missing:V,missingText:Ne,spacerSize:m,spacerClass:g,optDirty:x,optCannot:ue,optStatus:Ae,optErrSize:M.computed(()=>K.value.errors.spacerSize||""),optErrClass:M.computed(()=>K.value.errors.spacerClass||"")},d,{methods:{showAll:()=>{k.value="all"},showBuiltin:()=>{k.value="builtin"},showCustom:()=>{k.value="custom"},showMissing:()=>be(null),saveOptions:()=>de(),resetOptions:()=>Ie()}});i(()=>qe.dispose()),i(Me(d,{position:"bottom"}));const _e=E.contextMenu(O,{cursor:"glyph",match:".lyx-row",inherit:!1,density:"compact",items:e=>Fe(e.dataset.key)});i(()=>_e?.destroy?.());const he=e=>{const t=e.target.closest(".lyx-row");if(!t)return;const n=e.target.closest(".lyx-kebab");if(n){Oe(n);return}$(t.dataset.key)},fe=e=>{const t=e.target.closest(".lyx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Oe(t)):e.key==="Enter"&&e.target===t&&$(t.dataset.key))};O.addEventListener("click",he),O.addEventListener("keydown",fe),i(()=>{O.removeEventListener("click",he),O.removeEventListener("keydown",fe)});const me=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||!p()||C.size||(e.preventDefault(),x.peek()&&de())};document.addEventListener("keydown",me,!0),i(()=>document.removeEventListener("keydown",me,!0)),Xe(s,{id:"layouts-new",icon:"plus",title:"New layout",onClick:()=>{ve.activate?.(0),$()}});const ge=e=>{(x.peek()||b?.dirty())&&(xe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ge),i(()=>window.removeEventListener("beforeunload",ge));function xe(){try{b?.dirty()&&S.set(se,{key:b.key,values:b.values(),at:new Date().toISOString()}),x.peek()&&S.set(N,{...ce(),at:new Date().toISOString()})}catch{}}if(f={dirty:()=>x.peek()||!!b?.dirty(),dispose(){Y=!0,xe(),ye=!0,[...C].forEach(e=>{try{e.close(!0)}catch{}}),r.splice(0).forEach(e=>{try{e()}catch{}})}},I.scan(d),await Promise.all([Q(),He()]),!p())return;I.scan(d);let D=null,v=null;try{D=S.get(N),v=S.get(se)}catch{}const we=e=>{try{S.remove?S.remove(e):S.set(e,null)}catch{}};if(D&&typeof D=="object"&&"spacerSize"in D&&(we(N),await E.confirm("You left unsaved spacer options. Put them back?")&&p()&&(m.value=String(D.spacerSize??""),g.value=String(D.spacerClass??""),ve.activate?.(1))),v?.values&&p()){we(se);const e=v.key&&y.peek()[v.key],t=e?`your changes to "${W(y.peek()[v.key].label||v.key)}"`:"the new layout you started";await E.confirm(`You left ${t} unsaved. Pick up where you left off?`)&&p()&&$(e?v.key:null,{draft:v.values})}}};
@@ -1 +1 @@
1
- import{api as s}from"../api.js";function f(o){return{string:"string",email:"email",tel:"string",number:"number",textarea:"textarea",select:"select",radio:"select",checkbox:"boolean","checkbox-group":"select",date:"string",time:"string",url:"string",hidden:"string"}[o]||"string"}export const myProfileView={templateUrl:"/admin/js/templates/my-profile.html",async onMount(o){const i=E.loader(o.get(0),{type:"dots"});let r,c;try{[r,c]=await Promise.all([s.auth.me().catch(()=>null),s.collections.get("user-profiles").catch(()=>null)])}finally{i.destroy()}if(!r){E.toast("Could not load your account details.",{type:"error"});return}const u={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Jane Smith"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"jane@example.com"}},password:{type:"password",required:!1,minLength:0,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Leave blank to keep your current password"}}},d={name:r.name||"",email:r.email||""};F.render("#account-form-container",u,d,{layout:"stacked",submitText:"Save Account",onSubmit:async t=>{if(t.password&&t.password.length<8)return E.toast("Password must be at least 8 characters.",{type:"warning"}),!1;const a={name:t.name,email:t.email};t.password&&(a.password=t.password);try{await s.auth.updateMe(a),E.toast("Account updated.",{type:"success"})}catch(e){return E.toast(`Save failed: ${e.message||"Unknown error"}`,{type:"error"}),!1}}});const p=c?.fields||[];if(p.length>0){o.find("#profile-card").show();const t={},a={};for(const e of p){const l={type:f(e.type),label:e.label||e.name};e.required&&(l.required=!0),e.placeholder&&(l.formConfig={placeholder:e.placeholder}),e.options&&Array.isArray(e.options)&&(l.options=e.options.map(n=>typeof n=="string"?{value:n,label:n}:n)),t[e.name]=l,a[e.name]=r.profile?.[e.name]??""}F.render("#profile-form-container",t,a,{layout:"stacked",submitText:"Save Profile",onSubmit:async e=>{try{await s.auth.updateMe({profile:e}),E.toast("Profile saved.",{type:"success"})}catch(m){return E.toast(`Save failed: ${m.message||"Unknown error"}`,{type:"error"}),!1}}})}Domma.icons.scan()}};
1
+ import{api as V}from"../api.js";import{addBannerAction as fe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{accountFacts as re,buildPayload as he,checkForm as se,draftOf as be,fieldKind as H,fieldOptions as U,formFromMe as W,initials as ye,snapshot as T}from"../lib/my-profile-arrange.js";const z=/^\/my-profile\/?(\?.*)?$/,y=t=>String(t??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Y=t=>{if(!t)return"";try{return D(t).format("D MMM YYYY")}catch{return String(t).slice(0,10)}};let $=null,le=!1;function $e(){le||!window.R?.use||(le=!0,R.use((t,o,r)=>!(z.test(o?.path||"")&&!z.test(t?.path||""))||!$?.dirty()?r():E.confirm("You have unsaved changes to your profile. Leave anyway? They are kept for your next visit - except a new password, which you would type again.").then(i=>{i?r():history.replaceState(null,"","#/my-profile")})),M.subscribe("router:afterChange",({to:t}={})=>{if($&&!z.test(t?.path||"")){const o=$;$=null,o.dispose()}}))}function ge(t,o){const r=H(t.type),m=`mpx-pf-${o}`,i=t.required?' <span class="text-danger">*</span>':"",v=t.help||t.description?` <span data-help="${y(t.help||t.description)}"></span>`:"",u=`<label class="form-label" for="${m}">${y(t.label||t.name)}${i}${v}</label>`,b=`<small class="mpx-err" data-bind-hidden="!pe_${o}.value" data-bind-text="pe_${o}.value"></small>`,s=t.placeholder?` placeholder="${y(t.placeholder)}"`:"";switch(r){case"textarea":return`<div class="mpx-wide">${u}<textarea id="${m}" class="form-input" rows="3"${s} data-model="pf_${o}.value"></textarea>${b}</div>`;case"select":return`<div>${u}<select id="${m}" class="form-input" data-model="pf_${o}.value"><option value="">-</option>${U(t).map(c=>`<option value="${y(c.value)}">${y(c.label)}</option>`).join("")}</select>${b}</div>`;case"checkbox":return`<div><label class="mpx-check"><input id="${m}" type="checkbox" data-model="pf_${o}.value"> ${y(t.label||t.name)}${i}</label>${v}${b}</div>`;case"multi":return`<div class="mpx-wide">${u}<div data-multi="${o}">${U(t).map((c,f)=>`<label class="mpx-check"><input type="checkbox" data-opt="${y(c.value)}" data-bind-checked="po_${o}_${f}.value"> ${y(c.label)}</label>`).join("")}</div>${b}</div>`;default:return`<div>${u}<input id="${m}" type="${r}" class="form-input"${s} data-model="pf_${o}.value">${b}</div>`}}export const myProfileView={templateUrl:"/admin/js/templates/my-profile.html",async onMount(t){if($e(),$){const e=$;$=null,e.dispose()}const o=[],r=e=>(o.push(e),e);let m=!1;const i=t.get(0).querySelector(".mpx")||t.get(0),v=()=>!m&&i.isConnected,u=M.observable(!0),b=M.observable(""),s=M.observable(null),c=M.observable(""),f=M.observable(""),w=M.observable(""),j=M.observable(""),ce=M.observable(!1),P=M.observable(!1),G=M.observable(!1);let p=[];const g=new Map,J=M.observable(0),h=M.observable(W({},[])),ie=()=>(J.value,Object.fromEntries([...g].map(([e,a])=>[e,a.value]))),ue=()=>Object.fromEntries([...g].map(([e,a])=>[e,a.peek()])),Q=()=>({name:c.value,email:f.value,password:w.value,confirm:j.value,profile:ie()}),X=()=>({name:c.peek(),email:f.peek(),password:w.peek(),confirm:j.peek(),profile:ue()}),B=M.computed(()=>se(Q(),p,{saved:h.value})),k=M.computed(()=>!u.value&&T(Q())!==T(h.value)),pe={loading:u,loadError:b,name:c,email:f,password:w,confirm:j,showPw:ce,hasProfile:G,err:B,dirty:k,cannotSave:M.computed(()=>P.value||!k.value||Object.keys(B.value).length>0),emailChanged:M.computed(()=>f.value.trim()!==""&&f.value.trim()!==h.value.email),pwMatch:M.computed(()=>w.value.length>=8&&w.value===j.value),avatar:M.computed(()=>ye(c.value||s.value?.name)),shownName:M.computed(()=>c.value.trim()||s.value?.name||""),shownEmail:M.computed(()=>f.value.trim()||s.value?.email||""),rolesText:M.computed(()=>re(s.value).roles.join(" + ")),accessText:M.computed(()=>re(s.value).access),factsText:M.computed(()=>{const e=s.value;return e?[e.createdAt&&`Member since ${Y(e.createdAt)}`,e.lastLogin&&`last signed in ${Y(e.lastLogin)}`,e.updatedAt&&`changed ${Y(e.updatedAt)}`,e.isActive===!1&&"account disabled"].filter(Boolean).join(" \xB7 "):""})},A=()=>`my-profile.draft.${s.peek()?.id||"me"}`;function N(e){c.value=e.name,f.value=e.email,w.value="",j.value="";for(const[a,l]of g)a in(e.profile||{})&&(l.value=e.profile[a])}async function q(){if(P.peek()||u.peek())return;const e=X();if(Object.keys(se(e,p,{saved:h.peek()})).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const a=he(h.peek(),e,p);if(!a){E.toast("Nothing has changed.",{type:"info"});return}P.value=!0;try{const l=await V.auth.updateMe(a.payload);if(!v())return;s.value=l;const C=W(l,p);h.value=C,N(C),F();try{S.set("auth_user",{...S.get("auth_user")||{},name:l.name,email:l.email})}catch{}const n=document.getElementById("topbar-user-name");n&&l.name&&(n.textContent=l.name);const d={name:"your name",email:"your email",password:"your password",profile:"your profile"};E.toast(`Saved ${a.parts.map(_=>d[_]).join(", ").replace(/, ([^,]*)$/," and $1")}.`,{type:"success"})}catch(l){E.toast(`Not saved: ${l.message||l}`,{type:"error"})}finally{P.value=!1}}async function de(){k.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!v()||(N(h.peek()),F()))}function Z(){if(!k.peek())return;const e=be(X());if(T({...e,password:"",confirm:""})!==T(h.peek()))try{S.set(A(),{form:e,at:new Date().toISOString()})}catch{}}function F(){try{S.remove?S.remove(A()):S.set(A(),null)}catch{}}const me=M.applyBindings(pe,i,{methods:{save:q,discard:de}});r(()=>me.dispose()),r(ne(i,{position:"bottom"})),fe(t,{id:"my-profile-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>q()});const ee=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!v()||(e.preventDefault(),e.stopImmediatePropagation(),q())};document.addEventListener("keydown",ee,!0),r(()=>document.removeEventListener("keydown",ee,!0));const te=e=>{k.peek()&&(Z(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",te),r(()=>window.removeEventListener("beforeunload",te)),$={dirty:()=>k.peek(),dispose(){m=!0,Z(),o.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let ae=null;try{const[e,a]=await Promise.all([V.auth.me(),V.collections.get("user-profiles").catch(()=>null)]);if(!v())return;s.value=e,ae=a}catch(e){if(!v())return;b.value=`Could not load your account: ${e.message||e}`,u.value=!1;return}p=(ae?.fields||[]).filter(e=>e&&e.name);const L=W(s.peek(),p),K=p.map((e,a)=>({f:e,i:a})).filter(({f:e})=>H(e.type)!=="hidden");if(p.forEach((e,a)=>g.set(e.name,M.observable(L.profile[e.name]))),K.length){const e=i.querySelector(".mpx-profile");e.innerHTML=K.map(({f:n,i:d})=>ge(n,d)).join("");const a={};p.forEach((n,d)=>{a[`pf_${d}`]=g.get(n.name),a[`pe_${d}`]=M.computed(()=>B.value[`profile.${n.name}`]||""),H(n.type)==="multi"&&U(n).forEach((_,O)=>{a[`po_${d}_${O}`]=M.computed(()=>(g.get(n.name).value||[]).includes(_.value))})});const l=M.applyBindings(a,e);r(()=>l.dispose());const C=n=>{const d=n.target.closest("[data-multi]");if(!d||!n.target.matches("input[data-opt]"))return;const _=g.get(p[Number(d.dataset.multi)].name),O=n.target.dataset.opt,oe=_.peek()||[];_.value=n.target.checked?[...new Set([...oe,O])]:oe.filter(ve=>ve!==O)};e.addEventListener("change",C),r(()=>e.removeEventListener("change",C)),r(ne(e,{position:"bottom"})),I.scan(e)}G.value=K.length>0,h.value=L,N(L),J.value++,u.value=!1;let x=null;try{x=S.get(A())}catch{}if(x?.form){F();const e=x.at?` (${Y(x.at)})`:"";await E.confirm(`You left unsaved changes to your profile${e}. Restore them?`)&&v()&&N({...L,...x.form,profile:{...L.profile,...x.form.profile||{}}})}}};
@@ -1 +1,18 @@
1
- import{api as p}from"../api.js";export const notificationsView={templateUrl:"/admin/js/templates/notifications.html",async onMount(o){const f=o.find("#notif-list"),C=o.find("#notif-empty"),v=E.loader(o.get(0),{type:"dots"});let c=[];try{c=await p.system.notifications.list()}catch{E.toast("Could not load notifications.",{type:"error"});return}finally{v.destroy()}if(!Array.isArray(c)||c.length===0){C.removeAttr("hidden"),Domma.icons.scan(o.get(0));return}c.forEach(e=>{e.unread&&p.system.notifications.markRead(e.id).catch(()=>{})}),c.forEach(e=>{const t=e.data||{},s=t.severity||"info",l=t.createdAt?new Date(t.createdAt).toLocaleString():"",d=s==="critical"?"danger":s==="warning"?"warning":s==="success"?"success":"info",n=document.createElement("div");n.className="card mb-3 notif-card",n.dataset.id=String(e.id);const i=document.createElement("div");i.className="card-header",i.style.cssText="display:flex;align-items:center;gap:.5rem;";const y=document.createElement("span");y.className=`badge badge-${d}`,y.textContent=s.toUpperCase();const g=document.createElement("strong");g.style.flex="1",g.textContent=t.title||"";const h=document.createElement("small");h.className="text-muted",h.textContent=l,i.appendChild(y),i.appendChild(g),i.appendChild(h);const r=document.createElement("div");r.className="card-body";const u=document.createElement("p");if(u.style.cssText="white-space:pre-wrap;margin:0 0 .75rem",u.textContent=t.body||"",r.appendChild(u),t.link){const a=document.createElement("a");a.href=t.link,a.target="_blank",a.rel="noopener",a.className="btn btn-sm btn-secondary",a.textContent="Open Link ";const x=document.createElement("span");x.dataset.icon="external-link",a.appendChild(x),r.appendChild(a)}const b=document.createElement("div");b.style.cssText="margin-top:.5rem;display:flex;gap:.5rem;";const m=document.createElement("button");m.className="btn btn-sm btn-secondary js-dismiss",m.dataset.id=String(e.id),m.textContent="Dismiss",b.appendChild(m),r.appendChild(b),n.appendChild(i),n.appendChild(r),f.get(0).appendChild(n)}),f.get(0).addEventListener("click",async e=>{const t=e.target.closest(".js-dismiss");if(!t)return;const s=t.dataset.id;try{await p.system.notifications.dismiss(s);const l=t.closest(".notif-card");l&&l.remove(),f.get(0).querySelectorAll(".notif-card").length===0&&C.removeAttr("hidden");const{count:d}=await p.system.notifications.unreadCount().catch(()=>({count:0})),n=$("#topbar-bell-badge");d>0?n.text(d>99?"99+":String(d)).removeAttr("hidden"):n.attr("hidden","").text("")}catch{E.toast("Could not dismiss notification.",{type:"error"})}}),Domma.icons.scan(o.get(0))}};
1
+ import{api as d,apiRequest as b}from"../api.js";import{addBannerAction as O}from"../lib/plugin-chrome.js";import{helpPopovers as Y}from"../lib/help-popover.js";import{openMenuAt as q,sidebarCount as z,viewLifetime as G}from"../lib/tool-kit.js";import{FILTERS as J,arrangeNotifications as Q,filterCounts as X,notificationRow as B}from"../lib/notifications-arrange.js";const P=/^#\/system\/notifications\/?(\?.*)?$/,Z="#/system/notifications",g="notifications.filter",tt=`
2
+ <div class="ntx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls" data-bind-aria-expanded="open ? 'true' : 'false'">
3
+ <span class="ntx-dot" aria-hidden="true"></span>
4
+ <span class="ntx-main">
5
+ <span class="ntx-head"><strong>{{title}}</strong>
6
+ <span class="ntx-chip" data-bind-class="'is-' + tone">{{severityLabel}}</span>
7
+ <span class="ntx-chip is-new" data-bind-hidden="!isNew">New</span></span>
8
+ <small>{{meta}}</small>
9
+ <span class="ntx-preview" data-bind-hidden="open">{{preview}}</span>
10
+ <span class="ntx-body" data-bind-hidden="!open">{{body}}</span>
11
+ <span class="ntx-actions" data-bind-hidden="!open || !link">
12
+ <a class="btn btn-sm btn-outline" href="{{link}}" target="_blank" rel="noopener">Open link \u2197</a>
13
+ </span>
14
+ </span>
15
+ <button type="button" class="ntx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
16
+ </div>`,et=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),o=G(P);async function p(){z(Z);try{const{count:s}=await d.system.notifications.unreadCount(),a=$("#topbar-bell-badge");s>0?a.text(s>99?"99+":String(s)).removeAttr("hidden"):a.attr("hidden","").text("")}catch{}}export const notificationsView={templateUrl:"/admin/js/templates/notifications.html",async onMount(s){o.reset();const a=s.get(0).querySelector(".ntx")||s.get(0),m=()=>P.test(location.hash)&&a.isConnected,i=M.observable([]),f=M.observable(!0),y=M.observable("");let v="all";try{v=J.includes(S.get(g))?S.get(g):"all"}catch{}const r=M.observable(v),w=M.observable(""),u=M.observable("");o.add(M.effect(()=>{const t=r.value;try{S.set(g,t)}catch{}}));const k=M.computed(()=>X(i.value));o.add(M.effect(()=>{const t=k.value;!f.value&&r.peek()!=="all"&&!t[r.peek()]&&(r.value="all")}));const x=M.observable([]);o.add(M.effect(()=>{const t=Date.now(),e=u.value;x.value=Q(i.value,{filter:r.value,query:w.value}).map(n=>B(n,{now:t,expanded:e}))}));const _=M.computed(()=>i.value.length?"No notifications match.":"No notifications - you're all caught up."),C=t=>i.peek().find(e=>String(e.id)===String(t)),U=(t,e)=>{i.value=i.peek().map(n=>String(n.id)===String(t)?e(n):n)},R=t=>{i.value=i.peek().filter(e=>String(e.id)!==String(t))};async function T(){try{const t=await d.system.notifications.list();if(!m())return;const e=Array.isArray(t)?t:[];if(i.value=e.map(n=>({...n,isNew:!!n.unread})),y.value="",e.some(n=>n.unread)){if(await b("/system/notifications/read-all",{method:"POST"}).catch(()=>{}),!m())return;i.value=i.peek().map(n=>({...n,unread:!1})),p()}}catch(t){y.value=`Could not load notifications: ${t.message||t}`}finally{f.value=!1}}async function D(t,e){try{e?await d.system.notifications.markRead(t.id):await b(`/system/notifications/${encodeURIComponent(t.id)}/unread`,{method:"POST"}),U(t.id,n=>({...n,unread:!e})),p()}catch(n){E.toast(`Not changed: ${n.message||n}`,{type:"error"})}}async function A(t){try{await d.system.notifications.dismiss(t.id),R(t.id),p()}catch(e){E.toast(`Could not dismiss it: ${e.message||e}`,{type:"error"})}}async function F(t){if(await E.confirm(`Delete "${et(t.data?.title||"this notification")}" for everyone? Dismissing hides it for you only.`))try{await d.system.notifications.remove(t.id),R(t.id),E.toast("Notification deleted.",{type:"success"}),p()}catch(e){E.toast(/403|forbidden|permission/i.test(String(e.message||e))?"Deleting needs the Notifications delete permission - Dismiss hides it for you.":`Could not delete it: ${e.message||e}`,{type:"error"})}}async function K(){const t=i.peek().length;if(!t){E.toast("Nothing to dismiss.",{type:"info"});return}if(await E.confirm(`Dismiss all ${t} notification${t===1?"":"s"}? They are hidden for you only.`))try{await b("/system/notifications/dismiss-all",{method:"POST"}),i.value=[],p()}catch(e){E.toast(`Could not dismiss them: ${e.message||e}`,{type:"error"})}}async function j(t){const e=t.data||{},n=[e.title,e.body,e.link].filter(Boolean).join(`
17
+
18
+ `);try{await navigator.clipboard.writeText(n),E.toast("Copied.",{type:"success"})}catch{E.toast("Copying is blocked here - select the text instead.",{type:"warning"})}}const h=t=>{u.value=u.peek()===String(t)?"":String(t)};function W(t){const e=C(t);if(!e)return[];const n=B(e);return[{header:n.title},{label:n.open||u.peek()===n.id?"Close":"Read it",icon:"eye",action:()=>h(t)},...n.link?[{label:"Open link",icon:"external-link",action:()=>window.open(n.link,"_blank","noopener")}]:[],{label:"Copy text",icon:"copy",action:()=>j(e)},e.unread?{label:"Mark as read",icon:"check",action:()=>D(e,!0)}:{label:"Mark as unread",icon:"mail",action:()=>D(e,!1)},{type:"divider"},{label:"Dismiss",icon:"x",action:()=>A(e)},{label:"Delete for everyone\u2026",icon:"trash",danger:!0,action:()=>F(e)}]}const c=a.querySelector(".ntx-list");c.innerHTML=tt;const l=t=>()=>{r.value=t},H=M.applyBindings({items:i,loading:f,loadError:y,filter:r,query:w,rows:x,counts:k,emptyText:_},a,{methods:{showAll:l("all"),showNew:l("new"),showCritical:l("critical"),showWarning:l("warning"),showInfo:l("info"),showSuccess:l("success")}});o.add(()=>H.dispose()),o.add(Y(a,{position:"bottom"}));const V=E.contextMenu(c,{cursor:"glyph",match:".ntx-row",inherit:!1,density:"compact",items:t=>W(t.dataset.id)});o.add(()=>V?.destroy?.());const L=t=>{const e=t.target.closest(".ntx-row");if(!e)return;const n=t.target.closest(".ntx-kebab");if(n){q(n);return}t.target.closest("a")||h(e.dataset.id)},N=t=>{const e=t.target.closest(".ntx-row");if(e){if(t.key==="F10"&&t.shiftKey)t.preventDefault(),q(e);else if(t.key==="Enter"&&t.target===e)h(e.dataset.id);else if(t.key==="Delete"&&t.target===e){const n=C(e.dataset.id);n&&A(n)}}};c.addEventListener("click",L),c.addEventListener("keydown",N),o.add(()=>{c.removeEventListener("click",L),c.removeEventListener("keydown",N)}),O(s,{id:"notifications-dismiss-all",icon:"check-circle",title:"Dismiss all",onClick:()=>K()}),O(s,{id:"notifications-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>{f.value=!0,T()}}),I.scan(a),await T(),m()&&I.scan(a)}};