domma-cms 1.8.36 → 1.8.38

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.
@@ -21,10 +21,9 @@
21
21
  <button type="button" class="tab-item">SEO <i class="stx-dot" data-bind-hidden="!tabs.value.seo" title="Changed"></i></button>
22
22
  <button type="button" class="tab-item">Footer <i class="stx-dot" data-bind-hidden="!tabs.value.footer" title="Changed"></i></button>
23
23
  <button type="button" class="tab-item">Email <i class="stx-dot" data-bind-hidden="!tabs.value.email" title="Changed"></i><i class="stx-dot is-bad" data-bind-hidden="!noSmtp.value || tabs.value.email" title="No SMTP host"></i></button>
24
- <button type="button" class="tab-item">Back to top <i class="stx-dot" data-bind-hidden="!tabs.value.backToTop" title="Changed"></i></button>
24
+ <button type="button" class="tab-item">Page navigation <i class="stx-dot" data-bind-hidden="!tabs.value.pageNavigation" title="Changed"></i></button>
25
25
  <button type="button" class="tab-item">Cookie consent <i class="stx-dot" data-bind-hidden="!tabs.value.cookies" title="Changed"></i></button>
26
26
  <button type="button" class="tab-item">Breadcrumbs <i class="stx-dot" data-bind-hidden="!tabs.value.breadcrumbs" title="Changed"></i></button>
27
- <button type="button" class="tab-item">Custom CSS <i class="stx-dot" data-bind-hidden="!tabs.value.css" title="Changed"></i></button>
28
27
  <button type="button" class="tab-item">Cache</button>
29
28
  </div>
30
29
 
@@ -35,6 +34,7 @@
35
34
  <span data-help-title="Needs attention" data-help="Settings that are saved but quietly wrong - the same list the badge on Site Settings in the sidebar counts. They go away once fixed and saved."></span></div>
36
35
  <div class="stx-problems" data-each="problems.value key=text"></div>
37
36
  </div>
37
+ <div class="stx-card-columns">
38
38
  <div class="stx-card">
39
39
  <div class="stx-card-title">Site</div>
40
40
  <div class="stx-grid">
@@ -75,6 +75,7 @@
75
75
  </div>
76
76
  </div>
77
77
  </div>
78
+ </div>
78
79
  <div class="stx-card stx-moved">
79
80
  <span data-icon="droplet"></span>
80
81
  <div class="stx-moved-text">
@@ -87,6 +88,7 @@
87
88
 
88
89
  <!-- Branding -->
89
90
  <div class="tab-panel">
91
+ <div class="stx-card-columns">
90
92
  <div class="stx-card"><div class="stx-card-title">Public site</div><div class="stx-grid">
91
93
  <div><label class="form-label" for="stx-brandText">Brand name</label>
92
94
  <input id="stx-brandText" type="text" class="form-input" placeholder="My Site" data-model="brandText.value">
@@ -120,10 +122,12 @@
120
122
  <div data-brand-icon="adminBrandIcon"></div>
121
123
  </div>
122
124
  </div><p class="text-muted">The logo takes priority over the icon. Leave both empty to use the default icon.</p></div>
125
+ </div>
123
126
  </div>
124
127
 
125
128
  <!-- Favicon -->
126
129
  <div class="tab-panel">
130
+ <div class="stx-card-columns">
127
131
  <div class="stx-card"><div class="stx-card-title">Generate favicon.ico</div>
128
132
  <p>Choose or upload an image. Generate a single ICO file with 16, 32, 48 and 256 pixel icons. Images keep their proportions with transparent padding.</p>
129
133
  <label class="form-label" for="stx-favicon-source">Source image</label>
@@ -142,6 +146,7 @@
142
146
  </div>
143
147
  <p class="text-muted">Save to apply the browser icon. Leave it empty to use the public logo.</p>
144
148
  </div>
149
+ </div>
145
150
  </div>
146
151
 
147
152
  <!-- SEO -->
@@ -170,6 +175,7 @@
170
175
 
171
176
  <!-- Footer -->
172
177
  <div class="tab-panel">
178
+ <div class="stx-card-columns">
173
179
  <div class="stx-card">
174
180
  <div class="stx-card-title">Footer</div>
175
181
  <div class="stx-grid">
@@ -248,6 +254,7 @@
248
254
  <input id="stx-s-yt" type="url" class="form-input" placeholder="https://youtube.com/@yourchannel" data-model="socialYoutube.value"></div>
249
255
  </div>
250
256
  </div>
257
+ </div>
251
258
  </div>
252
259
 
253
260
  <!-- Email -->
@@ -318,10 +325,11 @@
318
325
  </div>
319
326
  </div>
320
327
 
321
- <!-- Back to top -->
328
+ <!-- Page navigation -->
322
329
  <div class="tab-panel">
330
+ <div class="stx-card-columns">
323
331
  <div class="stx-card">
324
- <div class="stx-card-title">Back to top button
332
+ <div class="stx-card-title"><span class="stx-scroll-icon" data-icon="arrow-up"></span>Back to top
325
333
  <span data-help="A button that appears once a visitor has scrolled down, and takes them back to the top of the page."></span></div>
326
334
  <div class="stx-grid">
327
335
  <div class="stx-setting"><label class="form-label" for="stx-btt-on">Show the button</label>
@@ -354,6 +362,41 @@
354
362
  </div>
355
363
  </div>
356
364
  </div>
365
+ <div class="stx-card">
366
+ <div class="stx-card-title"><span class="stx-scroll-icon" data-icon="arrow-down"></span>Go to bottom
367
+ <span data-help="A matching down-arrow button that takes visitors to the bottom of the page. It hides when they are already near the bottom."></span></div>
368
+ <div class="stx-grid">
369
+ <div class="stx-setting"><label class="form-label" for="stx-gtb-on">Show the button</label>
370
+ <input id="stx-gtb-on" type="checkbox" class="form-switch" data-model="gtbEnabled.value"></div>
371
+ <div class="stx-setting"><label class="form-label" for="stx-gtb-smooth">Smooth scroll</label>
372
+ <input id="stx-gtb-smooth" type="checkbox" class="form-switch" data-model="gtbSmooth.value"></div>
373
+ <div class="stx-wide">
374
+ <label class="form-label" for="stx-gtb-label">Label <small class="stx-muted">(optional - empty is icon-only)</small></label>
375
+ <input id="stx-gtb-label" type="text" class="form-input" placeholder="Go to bottom" data-model="gtbLabel.value">
376
+ </div>
377
+ <div>
378
+ <label class="form-label" for="stx-gtb-thr">Hide near bottom (px)
379
+ <span data-help="How close to the bottom the visitor must be before this button hides."></span></label>
380
+ <input id="stx-gtb-thr" type="number" class="form-input" placeholder="300" data-model="gtbThreshold.value">
381
+ </div>
382
+ <div>
383
+ <label class="form-label" for="stx-gtb-pos">Position</label>
384
+ <select id="stx-gtb-pos" class="form-select" data-model="gtbPosition.value">
385
+ <option value="bottom-right">Bottom right</option>
386
+ <option value="bottom-left">Bottom left</option>
387
+ </select>
388
+ </div>
389
+ <div>
390
+ <label class="form-label" for="stx-gtb-off">Side offset (px)</label>
391
+ <input id="stx-gtb-off" type="number" class="form-input" placeholder="32" data-model="gtbOffset.value">
392
+ </div>
393
+ <div>
394
+ <label class="form-label" for="stx-gtb-boff">Bottom offset (px)</label>
395
+ <input id="stx-gtb-boff" type="number" class="form-input" placeholder="32" data-model="gtbBottomOffset.value">
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
357
400
  </div>
358
401
 
359
402
  <!-- Cookie consent -->
@@ -418,16 +461,22 @@
418
461
 
419
462
  <!-- Breadcrumbs -->
420
463
  <div class="tab-panel">
464
+ <div class="stx-card-columns">
421
465
  <div class="stx-card">
422
466
  <div class="stx-card-title">Breadcrumb trail
423
467
  <span data-help-title="Breadcrumbs" data-help="A small frosted pill fixed to a corner of every public page, showing where the page sits (Home › Services › Web). A page can switch it off with “Show Breadcrumbs” in the page editor."></span></div>
424
468
  <div class="stx-grid">
425
- <div class="stx-wide stx-setting"><label class="form-label" for="stx-bc-on">Show breadcrumbs on every page</label>
469
+ <div class="stx-wide stx-setting"><label class="form-label" for="stx-bc-on">Show breadcrumbs</label>
426
470
  <input id="stx-bc-on" type="checkbox" class="form-switch" data-model="bcEnabled.value"></div>
427
- <div><label class="form-label" for="stx-bc-home">Home label</label>
471
+ <div class="stx-wide"><label class="form-label" for="stx-bc-home">Home label</label>
428
472
  <input id="stx-bc-home" type="text" class="form-input" placeholder="Home" data-model="bcHomeLabel.value"></div>
429
- <div></div>
430
- <div>
473
+ </div>
474
+ <p class="stx-hint">Shows the path from Home to the current page. Individual pages can hide their trail in the page editor.</p>
475
+ </div>
476
+ <div class="stx-card">
477
+ <div class="stx-card-title">Placement</div>
478
+ <div class="stx-grid">
479
+ <div class="stx-wide">
431
480
  <span class="form-label">Corner</span>
432
481
  <div class="stx-corners" role="group" aria-label="Corner">
433
482
  <button type="button" data-on-click="corner('TL')" data-bind-class="bcPosition.value === 'TL' && 'is-on'">↖ Top left</button>
@@ -436,7 +485,7 @@
436
485
  <button type="button" data-on-click="corner('BR')" data-bind-class="bcPosition.value === 'BR' && 'is-on'">Bottom right ↘</button>
437
486
  </div>
438
487
  </div>
439
- <div class="stx-pair">
488
+ <div class="stx-pair stx-wide">
440
489
  <div><label class="form-label" for="stx-bc-x">From the side (px)</label>
441
490
  <input id="stx-bc-x" type="number" class="form-input" min="0" max="500" placeholder="8" data-model="bcOffsetX.value"></div>
442
491
  <div><label class="form-label" for="stx-bc-y">From the top/bottom (px)</label>
@@ -444,15 +493,6 @@
444
493
  </div>
445
494
  </div>
446
495
  </div>
447
- </div>
448
-
449
- <!-- Custom CSS -->
450
- <div class="tab-panel">
451
- <div class="stx-card">
452
- <div class="stx-card-title">Custom CSS
453
- <span data-help="Added to the head of every public page, after the theme - so it has the last word. Saved with everything else (Save, or Ctrl+S). Up to 100 KB."></span></div>
454
- <textarea class="form-input stx-css" spellcheck="false" placeholder="/* Add your custom CSS here */"></textarea>
455
- <small class="stx-hint" data-bind-text="cssSize.value"></small>
456
496
  </div>
457
497
  </div>
458
498
 
@@ -502,6 +542,10 @@
502
542
  .stx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .65rem; margin-top: .75rem; }
503
543
  .stx-card-title { display: flex; align-items: center; gap: .4rem; font-weight: 600; flex-wrap: wrap; }
504
544
  .stx-card-title .btn { display: inline-flex; align-items: center; gap: .3rem; font-weight: 500; }
545
+ .stx-card-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
546
+ .stx-card-columns > .stx-card { min-width: 0; }
547
+ .stx-scroll-icon { display: inline-flex; padding: .35rem; border-radius: 50%; background: var(--dm-surface-raised); }
548
+ @media (max-width: 900px) { .stx-card-columns { grid-template-columns: minmax(0, 1fr); } }
505
549
  .stx-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1rem; }
506
550
  .stx-wide { grid-column: 1 / -1; }
507
551
  .stx-pair { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
@@ -530,7 +574,6 @@
530
574
  .stx-corners button:nth-child(-n+2) { border-bottom: 1px solid var(--dm-border); }
531
575
  .stx-corners button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
532
576
  .stx-corners button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
533
- .stx-css { width: 100%; min-height: 420px; font-family: var(--dm-font-mono, monospace); font-size: var(--dm-text-sm); }
534
577
  .stx-chips { display: flex; gap: .35rem; flex-wrap: wrap; }
535
578
  .stx-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;
536
579
  background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
@@ -1,13 +1,11 @@
1
- import{api as K,apiRequest as w}from"../api.js";import{placeTools as $e}from"../lib/sidebar-grouping.js";import{addBannerAction as be}from"../lib/plugin-chrome.js";import{helpPopovers as Pe}from"../lib/help-popover.js";import{openMenuAt as Q,sidebarCount as ge}from"../lib/tool-kit.js";import{changedTabs as Be,describeCacheKey as X,draftOf as De,expiresIn as He,formFromSettings as je,homeValue as Ke,payloadFromForm as _e,siteUrlError as ye,settingsProblems as qe,snapshot as ke,TAB_FIELDS as Ue}from"../lib/site-settings-arrange.js";import{pickMedia as Fe}from"../lib/media-picker.js";import{faviconGenerator as ze}from"../lib/favicon-generator.js";import{makeIconInput as Ne}from"../lib/shortcode-modal.js";import{fmtDateTime as Ve}from"../lib/dates.js";const Z=/^\/settings(\?.*)?$/,we="#/settings",T="settings.draft",We=`
1
+ import{api as j,apiRequest as C}from"../api.js";import{placeTools as Le}from"../lib/sidebar-grouping.js";import{addBannerAction as pe}from"../lib/plugin-chrome.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{openMenuAt as Y,sidebarCount as me}from"../lib/tool-kit.js";import{changedTabs as Re,describeCacheKey as G,draftOf as Ie,expiresIn as Ae,formFromSettings as $e,homeValue as Pe,payloadFromForm as je,siteUrlError as fe,settingsProblems as De,snapshot as he,TAB_FIELDS as He}from"../lib/site-settings-arrange.js";import{pickMedia as Be}from"../lib/media-picker.js";import{faviconGenerator as qe}from"../lib/favicon-generator.js";import{makeIconInput as Ue}from"../lib/shortcode-modal.js";import{fmtDateTime as _e}from"../lib/dates.js";const J=/^\/settings(\?.*)?$/,ve="#/settings",w="settings.draft",Fe=`
2
2
  <div class="stx-problem">
3
3
  <span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
4
4
  <span>{{text}}</span>
5
5
  <button type="button" class="btn btn-sm btn-ghost" data-go="{{tab}}">Go there</button>
6
- </div>`,Ye=`
6
+ </div>`,Ke=`
7
7
  <div class="stx-row" data-id="{{id}}" tabindex="0">
8
8
  <span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
9
9
  <span class="stx-chip">{{expires}}</span>
10
10
  <button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
11
- </div>`,Ge=["general","branding","favicon","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function Je(l,p){const[r,L]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),c=(C,m)=>{for(const i of C||[]){if(!i||i.hidden||i.restricted)continue;const f=typeof i.url=="string"?i.url:"";f.startsWith("#/")&&f.length>2&&!a.has(f.slice(1))&&a.set(f.slice(1),`${m?`${m} \u203A `:""}${i.text||f}`),c(i.items,i.url?m:i.text||m)}};c($e(r?.items||[],L||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),l.replaceChildren();for(const[C,m]of a){const i=document.createElement("option");i.value=C,i.textContent=m,l.appendChild(i)}}let g=null,Ee=!1;function Qe(){Ee||!window.R?.use||(Ee=!0,R.use((l,p,r)=>!(Z.test(p?.path||"")&&!Z.test(l?.path||""))||!g?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(a=>{a?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(g&&!Z.test(l?.path||"")){const p=g;g=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Qe(),g){const e=g;g=null,e.dispose()}const p=[],r=e=>(p.push(e),e);let L=!1;const a=l.get(0).querySelector(".stx")||l.get(0),c=()=>!L&&a.isConnected,C=E.loader(l.get(0),{type:"dots"});let m,i;try{[m,i]=await Promise.all([K.settings.get(),w("/settings/custom-css").then(e=>e?.css||"").catch(()=>null)])}catch(e){C.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(C.destroy(),!a.isConnected)return;const f=i!==null,ee=M.observable(m||{}),te=je(m||{},i||""),n={};for(const[e,t]of Object.entries(te))n[e]=M.observable(t);const _=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),se=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),y=M.observable(te),q=M.computed(()=>ke(y.value)),O=M.computed(()=>ke(_())!==q.value),A=M.observable(!1),$=M.observable(""),ne=M.observable(!1);K.plugins.policy().then(e=>{c()&&(ne.value=e?.signedOnly===!1)}).catch(()=>{});const U=M.observable(!1),ae=M.computed(()=>{const e=new Set(Be(_(),y.value));return Object.fromEntries(Object.keys(Ue).map(t=>[t,e.has(t)]))}),F=M.observable([]);r(M.effect(()=>{F.value=qe(_()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const oe=M.observable(""),P=M.observable(!1),B=M.observable(""),D=M.observable(""),v=M.observable(null),z=M.observable([]),N=M.observable(""),V=M.observable(!0),W=M.observable(""),re=M.observable([]);r(M.effect(()=>{const e=N.value.trim().toLowerCase(),t=Date.now();re.value=[...z.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(o=>String(o).toLowerCase().includes(e))).map(s=>{const o=X(s.key);return{id:s.key,path:o.path,meta:[o.page?`as ${o.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:He(s.expiresAt,t)}})}));const ie=ze(n.favicon,n.brandLogo,c),Se={...ie.data,...n,saving:A,dirty:O,tabs:ae,problems:F,testTo:oe,testing:P,testResult:B,testTone:D,cacheQuery:N,cacheLoading:V,cacheError:W,cacheRows:re,errorText:$,problemsText:M.computed(()=>F.value.length?"yes":""),siteUrlError:M.computed(()=>ye(n.siteUrl.value)),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),mayAllowInsecureTLS:ne,dirtyEmail:M.computed(()=>ae.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cssSize:M.computed(()=>{if(!f)return"Could not load content/custom.css - reload to try again. It will not be saved until it loads.";const e=new Blob([String(n.css.value||"")]).size/1024;return`${e.toFixed(1)} KB of 100 KB${e>100?" - too big to save":""}`}),cacheOn:M.computed(()=>v.value?.enabled===!0),cacheState:M.computed(()=>{const e=v.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=v.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=v.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>v.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>N.value.trim()?"Nothing matches.":v.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function Y(){if(A.peek())return;const e=se(),t=y.peek(),s=String(e.css)!==String(t.css);if(s&&!f){E.toast("The custom CSS did not load, so it cannot be saved - reload the screen first.",{type:"error"});return}const o=ye(e.siteUrl);if(o){E.toast(`Site URL: ${o}`,{type:"error"});return}A.value=!0,$.value="";const h=String(e.title).trim()!==String(t.title).trim();try{await K.settings.save(_e(e,ee.peek(),{adminHomeReady:U.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const d=await K.settings.get().catch(()=>null);if(!c())return;d&&(ee.value=d);const J={...e,smtpPass:""};n.smtpPass.value="",y.value=J;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(h||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(ve=>e[ve]!==t[ve]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),ge(we),E.toast("Settings saved.",{type:"success"})}catch(d){$.value=d.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{A.value=!1}}async function Ce(){await E.confirm("Throw away your unsaved settings?")&&(ce(y.peek()),$.value="")}function ce(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);u&&u.getValue()!==String(n.css.peek())?u.setValue(String(n.css.peek())):!u&&b&&(b.value=String(n.css.peek()))}async function Me(){if(!P.peek()){P.value=!0,D.value="",B.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:oe.peek().trim()||void 0})});if(!c())return;B.value=e?.message||"Test email sent.",D.value="is-ok"}catch(e){if(!c())return;B.value=e.message||"Could not send the test email.",D.value="is-bad"}finally{P.value=!1}}}async function j(){V.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!c())return;v.value=e,z.value=Array.isArray(t?.entries)?t.entries:[],W.value=""}catch(e){if(!c())return;W.value=`Could not read the cache: ${e.message||e}`}finally{V.value=!1}}async function xe(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await w("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}c()&&await j()}}async function Te(){const e=v.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{v.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),ge(we)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}c()&&await j()}}const le=e=>z.peek().find(t=>t.key===e);function Le(e){const t=le(e);if(!t)return[];const s=X(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}a.querySelector(".stx-problems").innerHTML=We;const x=a.querySelector(".stx-list");x.innerHTML=Ye;const Oe=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Re=M.applyBindings(Se,a,{methods:{...ie.methods,save:Y,discard:Ce,sendTest:Me,refreshCache:j,clearCache:xe,toggleCache:Te,pickBrandImage:async e=>{const t=await Fe({title:"Choose a branding image",current:n[e].peek()});t&&c()&&(n[e].value=t)},clearBrandImage:e=>{n[e].value=""},corner:e=>{n.bcPosition.value=e}}});r(()=>Re.dispose());for(const e of a.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,s=Ne("Choose an icon",n[t].peek());s.input.id=`stx-${t}`,s.input.classList.add("form-input");const o=()=>{n[t].value=s.input.value};s.input.addEventListener("input",o),r(()=>s.input.removeEventListener("input",o)),r(M.effect(()=>{s.input.value=n[t].value})),e.appendChild(s.el)}r(Pe(a,{position:"bottom"}));const Ie=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Ge.indexOf(t.dataset.go);s>=0&&Oe.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Ie);const G=a.querySelector("#stx-admin-home");Je(G,Ke(n.adminHome.peek())).then(()=>{c()&&(G.value=n.adminHome.peek(),U.value=!0)}).catch(()=>{});const b=a.querySelector(".stx-css");b.value=String(n.css.peek());let u=null;if(!f)b.disabled=!0;else if(E.editor)try{u=E.editor(b,{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0,onChange:e=>{n.css.value=e}})}catch{u=null}if(u)u._editorEl&&u._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=u._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,o=t.selectionStart,h=s.lastIndexOf(`
12
- `,o-1)+1,d=s.indexOf(`
13
- `,o),J=d===-1?s.slice(h):s.slice(h,d+1);navigator.clipboard?.writeText(J).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,h)+(d===-1?"":s.slice(d+1)),t.selectionStart=t.selectionEnd=h,t.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})):E.toast("Line copied",{type:"info",duration:1500})});else{const e=()=>{n.css.value=b.value};b.addEventListener("input",e),r(()=>b.removeEventListener("input",e))}r(()=>{try{u?.destroy?.()}catch{}});const Ae=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Le(e.dataset.id)});r(()=>Ae?.destroy?.());const ue=e=>{const t=le(e.dataset.id),s=t&&X(t.key);s?.page?window.open(s.path,"_blank","noopener"):Q(e)},de=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Q(s);return}ue(t)},pe=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Q(t)):e.key==="Enter"&&e.target===t&&ue(t))};x.addEventListener("click",de),x.addEventListener("keydown",pe),r(()=>{x.removeEventListener("click",de),x.removeEventListener("keydown",pe)}),be(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),be(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>Y()});const me=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),Y())};document.addEventListener("keydown",me,!0),r(()=>document.removeEventListener("keydown",me,!0));const fe=()=>{if(O.peek())try{S.set(T,{form:De(se()),base:q.peek(),at:new Date().toISOString()})}catch{}},he=e=>{O.peek()&&(fe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",he),r(()=>window.removeEventListener("beforeunload",he)),g={dirty:()=>O.peek(),dispose(){L=!0,fe(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),j();let k=null;try{k=S.get(T)}catch{}if(k?.form){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=k.base&&k.base!==q.peek(),t=k.at?` (${Ve(k.at)})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&c()){const o={...y.peek()};for(const[h,d]of Object.entries(k.form))h in o&&h!=="smtpPass"&&(o[h]=d);f||(o.css=y.peek().css),ce(o),U.peek()&&(G.value=n.adminHome.peek())}}}};
11
+ </div>`,Ne=["general","branding","favicon","seo","footer","email","pageNavigation","cookies","breadcrumbs","cache"];async function ze(l,u){const[r,k]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),n=new Map([["/","Dashboard (built in)"]]),i=(b,d)=>{for(const o of b||[]){if(!o||o.hidden||o.restricted)continue;const m=typeof o.url=="string"?o.url:"";m.startsWith("#/")&&m.length>2&&!n.has(m.slice(1))&&n.set(m.slice(1),`${d?`${d} \u203A `:""}${o.text||m}`),i(o.items,o.url?d:o.text||d)}};i(Le(r?.items||[],k||[]),""),n.has(u)||n.set(u,`${u} (not in the sidebar)`),l.replaceChildren();for(const[b,d]of n){const o=document.createElement("option");o.value=b,o.textContent=d,l.appendChild(o)}}let h=null,be=!1;function Ve(){be||!window.R?.use||(be=!0,R.use((l,u,r)=>!(J.test(u?.path||"")&&!J.test(l?.path||""))||!h?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(n=>{n?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(h&&!J.test(l?.path||"")){const u=h;h=null,u.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Ve(),h){const e=h;h=null,e.dispose()}const u=[],r=e=>(u.push(e),e);let k=!1;const n=l.get(0).querySelector(".stx")||l.get(0),i=()=>!k&&n.isConnected,b=E.loader(l.get(0),{type:"dots"});let d;try{d=await j.settings.get()}catch(e){b.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(b.destroy(),!n.isConnected)return;const o=M.observable(d||{}),m=$e(d||{}),a={};for(const[e,t]of Object.entries(m))a[e]=M.observable(t);const D=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),Q=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),g=M.observable(m),B=M.computed(()=>he(g.value)),x=M.computed(()=>he(D())!==B.value),T=M.observable(!1),L=M.observable(""),X=M.observable(!1);j.plugins.policy().then(e=>{i()&&(X.value=e?.signedOnly===!1)}).catch(()=>{});const q=M.observable(!1),Z=M.computed(()=>{const e=new Set(Re(D(),g.value));return Object.fromEntries(Object.keys(He).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=De(D()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ee=M.observable(""),O=M.observable(!1),A=M.observable(""),$=M.observable(""),p=M.observable(null),_=M.observable([]),F=M.observable(""),K=M.observable(!0),N=M.observable(""),te=M.observable([]);r(M.effect(()=>{const e=F.value.trim().toLowerCase(),t=Date.now();te.value=[..._.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(c=>String(c).toLowerCase().includes(e))).map(s=>{const c=G(s.key);return{id:s.key,path:c.path,meta:[c.page?`as ${c.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:Ae(s.expiresAt,t)}})}));const se=qe(a.favicon,a.brandLogo,i),ge={...se.data,...a,saving:T,dirty:x,tabs:Z,problems:U,testTo:ee,testing:O,testResult:A,testTone:$,cacheQuery:F,cacheLoading:K,cacheError:N,cacheRows:te,errorText:L,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>fe(a.siteUrl.value)),noSmtp:M.computed(()=>!String(a.smtpHost.value||"").trim()),mayAllowInsecureTLS:X,dirtyEmail:M.computed(()=>Z.value.email),seoLength:M.computed(()=>{const e=String(a.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cacheOn:M.computed(()=>p.value?.enabled===!0),cacheState:M.computed(()=>{const e=p.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=p.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=p.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>p.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>F.value.trim()?"Nothing matches.":p.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function z(){if(T.peek())return;const e=Q(),t=g.peek(),s=fe(e.siteUrl);if(s){E.toast(`Site URL: ${s}`,{type:"error"});return}T.value=!0,L.value="";const c=String(e.title).trim()!==String(t.title).trim();try{await j.settings.save(je(e,o.peek(),{adminHomeReady:q.peek()}));const f=await j.settings.get().catch(()=>null);if(!i())return;f&&(o.value=f);const W={...e,smtpPass:""};a.smtpPass.value="",g.value=W;try{S.remove?S.remove(w):S.set(w,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(c||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(de=>e[de]!==t[de]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),me(ve),E.toast("Settings saved.",{type:"success"})}catch(f){L.value=f.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{T.value=!1}}async function ye(){await E.confirm("Throw away your unsaved settings?")&&(ae(g.peek()),L.value="")}function ae(e){for(const[t,s]of Object.entries(e))a[t]&&(a[t].value=s)}async function we(){if(!O.peek()){O.value=!0,$.value="",A.value="Sending\u2026";try{const e=await C("/settings/test-email",{method:"POST",body:JSON.stringify({to:ee.peek().trim()||void 0})});if(!i())return;A.value=e?.message||"Test email sent.",$.value="is-ok"}catch(e){if(!i())return;A.value=e.message||"Could not send the test email.",$.value="is-bad"}finally{O.value=!1}}}async function P(){K.value=!0;try{const[e,t]=await Promise.all([C("/cache/status").catch(()=>null),C("/cache/keys")]);if(!i())return;p.value=e,_.value=Array.isArray(t?.entries)?t.entries:[],N.value=""}catch(e){if(!i())return;N.value=`Could not read the cache: ${e.message||e}`}finally{K.value=!1}}async function ke(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await C("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await P()}}async function Ee(){const e=p.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{p.value=await C("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),me(ve)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await P()}}const ne=e=>_.peek().find(t=>t.key===e);function Se(e){const t=ne(e);if(!t)return[];const s=G(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}n.querySelector(".stx-problems").innerHTML=Fe;const y=n.querySelector(".stx-list");y.innerHTML=Ke;const Me=E.tabs(n.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Ce=M.applyBindings(ge,n,{methods:{...se.methods,save:z,discard:ye,sendTest:we,refreshCache:P,clearCache:ke,toggleCache:Ee,pickBrandImage:async e=>{const t=await Be({title:"Choose a branding image",current:a[e].peek()});t&&i()&&(a[e].value=t)},clearBrandImage:e=>{a[e].value=""},corner:e=>{a.bcPosition.value=e}}});r(()=>Ce.dispose());for(const e of n.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,s=Ue("Choose an icon",a[t].peek());s.input.id=`stx-${t}`,s.input.classList.add("form-input");const c=()=>{a[t].value=s.input.value};s.input.addEventListener("input",c),r(()=>s.input.removeEventListener("input",c)),r(M.effect(()=>{s.input.value=a[t].value})),e.appendChild(s.el)}r(Oe(n,{position:"bottom"}));const xe=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Ne.indexOf(t.dataset.go);s>=0&&Me.activate(s)};n.querySelector(".stx-problems").addEventListener("click",xe);const V=n.querySelector("#stx-admin-home");ze(V,Pe(a.adminHome.peek())).then(()=>{i()&&(V.value=a.adminHome.peek(),q.value=!0)}).catch(()=>{});const Te=E.contextMenu(y,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Se(e.dataset.id)});r(()=>Te?.destroy?.());const oe=e=>{const t=ne(e.dataset.id),s=t&&G(t.key);s?.page?window.open(s.path,"_blank","noopener"):Y(e)},re=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Y(s);return}oe(t)},ie=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Y(t)):e.key==="Enter"&&e.target===t&&oe(t))};y.addEventListener("click",re),y.addEventListener("keydown",ie),r(()=>{y.removeEventListener("click",re),y.removeEventListener("keydown",ie)}),pe(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),pe(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>z()});const ce=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),z())};document.addEventListener("keydown",ce,!0),r(()=>document.removeEventListener("keydown",ce,!0));const le=()=>{if(x.peek())try{S.set(w,{form:Ie(Q()),base:B.peek(),at:new Date().toISOString()})}catch{}},ue=e=>{x.peek()&&(le(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ue),r(()=>window.removeEventListener("beforeunload",ue)),h={dirty:()=>x.peek(),dispose(){k=!0,le(),u.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(n),P();let v=null;try{v=S.get(w)}catch{}if(v?.form){try{S.remove?S.remove(w):S.set(w,null)}catch{}const e=v.base&&v.base!==B.peek(),t=v.at?` (${_e(v.at)})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&i()){const c={...g.peek()};for(const[f,W]of Object.entries(v.form))f in c&&f!=="smtpPass"&&(c[f]=W);ae(c),q.peek()&&(V.value=a.adminHome.peek())}}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.36",
3
+ "version": "1.8.38",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -77,23 +77,23 @@
77
77
  },
78
78
  "dependencies": {
79
79
  "@fastify/compress": "^8.3.1",
80
- "@fastify/cors": "^11.2.0",
81
- "@fastify/helmet": "^13.0.2",
82
- "@fastify/jwt": "^10.0.0",
83
- "@fastify/multipart": "^9.3.0",
80
+ "@fastify/cors": "^11.3.1",
81
+ "@fastify/helmet": "^13.2.0",
82
+ "@fastify/jwt": "^10.2.5",
83
+ "@fastify/multipart": "^10.1.5",
84
84
  "@fastify/rate-limit": "^10.3.0",
85
- "@fastify/static": "9.1.1",
85
+ "@fastify/static": "10.1.7",
86
86
  "bcryptjs": "^3.0.3",
87
87
  "domma-js": "0.53.3",
88
88
  "dotenv": "^17.2.3",
89
- "fastify": "5.8.5",
89
+ "fastify": "5.12.7",
90
90
  "gray-matter": "^4.0.3",
91
- "imapflow": "^2.0.5",
92
- "mailparser": "^3.9.28",
91
+ "imapflow": "^2.8.0",
92
+ "mailparser": "^3.12.2",
93
93
  "marked": "^15.0.0",
94
- "nodemailer": "8.0.5",
95
- "sanitize-html": "^2.17.0",
96
- "sharp": "^0.34.5",
94
+ "nodemailer": "10.2.0",
95
+ "sanitize-html": "^2.18.1",
96
+ "sharp": "^0.35.5",
97
97
  "uuid": "13.0.1"
98
98
  },
99
99
  "optionalDependencies": {
package/public/js/btt.js CHANGED
@@ -1 +1 @@
1
- (function(){"use strict";var o=window.__CMS_SITE__&&window.__CMS_SITE__.backToTop;if(!o)return;var d="back-to-top-btn",p=o.scrollThreshold||300,c=o.position||"bottom-right",r=o.label||"",u=o.smooth!==!1,n=o.offset||32,b=o.bottomOffset||n,t=document.createElement("button");t.id=d,t.type="button",t.setAttribute("aria-label","Back to top"),t.setAttribute("title","Back to top");var s="http://www.w3.org/2000/svg",e=document.createElementNS(s,"svg");e.setAttribute("viewBox","0 0 24 24"),e.setAttribute("width","20"),e.setAttribute("height","20"),e.setAttribute("fill","none"),e.setAttribute("stroke","currentColor"),e.setAttribute("stroke-width","2"),e.setAttribute("stroke-linecap","round"),e.setAttribute("stroke-linejoin","round");var a=document.createElementNS(s,"path");if(a.setAttribute("d","M12 19V5M5 12l7-7 7 7"),e.appendChild(a),t.appendChild(e),r){var i=document.createElement("span");i.textContent=r,i.style.marginLeft="6px",t.appendChild(i)}var l=c!=="bottom-left";Object.assign(t.style,{position:"fixed",bottom:b+"px",right:l?n+"px":"auto",left:l?"auto":n+"px",zIndex:"9999",display:"flex",alignItems:"center",padding:"10px",borderRadius:"50%",border:"none",cursor:"pointer",background:"rgba(0,0,0,0.5)",color:"#fff",backdropFilter:"blur(4px)",opacity:"0",transform:"translateY(12px)",transition:"opacity .25s, transform .25s",pointerEvents:"none"}),r&&(t.style.borderRadius="999px",t.style.padding="10px 16px"),document.body.appendChild(t);function f(){t.style.opacity="1",t.style.transform="translateY(0)",t.style.pointerEvents="auto"}function v(){t.style.opacity="0",t.style.transform="translateY(12px)",t.style.pointerEvents="none"}window.addEventListener("scroll",function(){window.scrollY>p?f():v()},{passive:!0}),t.addEventListener("click",function(){window.scrollTo({top:0,behavior:u?"smooth":"auto"})})})();
1
+ (function(){"use strict";var c=window.__CMS_SITE__||{},a=[],h="http://www.w3.org/2000/svg";function f(t,e){var n=Number(t);return t!=null&&t!==""&&Number.isFinite(n)?Math.max(0,n):e}function p(){return Math.max(0,document.documentElement.scrollHeight-window.innerHeight)}function v(t,e){if(!(!t||t.enabled===!1)){var n=e?"Go to bottom":"Back to top",s=e?"go-to-bottom-btn":"back-to-top-btn";if(!document.getElementById(s)){var o=document.createElement("button");o.id=s,o.type="button",o.setAttribute("aria-label",n),o.title=n;var r=document.createElementNS(h,"svg");Object.entries({viewBox:"0 0 24 24",width:"20",height:"20",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"}).forEach(function(l){r.setAttribute(l[0],l[1])});var i=document.createElementNS(h,"path");if(i.setAttribute("d",e?"M12 5v14m-7-7 7 7 7-7":"M12 19V5M5 12l7-7 7 7"),r.appendChild(i),o.appendChild(r),t.label){var m=document.createElement("span");m.textContent=t.label,m.style.marginLeft="6px",o.appendChild(m)}var b=f(t.offset,32),x=f(t.bottomOffset,b),u=t.position!=="bottom-left";Object.assign(o.style,{position:"fixed",bottom:x+"px",right:u?b+"px":"auto",left:u?"auto":b+"px",zIndex:"9999",display:"flex",alignItems:"center",padding:t.label?"10px 16px":"10px",borderRadius:t.label?"999px":"50%",border:"none",cursor:"pointer",background:"rgba(0,0,0,0.5)",color:"#fff",backdropFilter:"blur(4px)",opacity:"0",transform:"translateY(12px)",transition:"opacity .25s, transform .25s",pointerEvents:"none"}),o.tabIndex=-1,document.body.appendChild(o),o.addEventListener("click",function(){var l=document.documentElement.classList.contains("dm-reduced-motion")||document.body.classList.contains("dm-reduced-motion")||window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches;window.scrollTo({top:e?p():0,behavior:t.smooth!==!1&&!l?"smooth":"auto"})}),a.push({btn:o,down:e,right:u,offset:b,bottomOffset:x,threshold:f(t.scrollThreshold,300)})}}}if(v(c.backToTop,!1),v(c.goToBottom,!0),!a.length)return;function d(){var t=p();if(a.forEach(function(r){var i=t>0&&(r.down?t-window.scrollY>r.threshold:window.scrollY>r.threshold);r.btn.style.opacity=i?"1":"0",r.btn.style.transform=i?"translateY(0)":"translateY(12px)",r.btn.style.pointerEvents=i?"auto":"none",r.btn.tabIndex=i?0:-1,r.btn.setAttribute("aria-hidden",String(!i))}),a.length===2){var e=a[0],n=a[1];if(e.right===n.right&&Math.abs(e.offset-n.offset)<Math.max(e.btn.offsetWidth,n.btn.offsetWidth)){var s=n.btn.offsetHeight||40,o=Math.abs(e.bottomOffset-n.bottomOffset)<s+12;e.btn.style.bottom=(o?n.bottomOffset+s+12:e.bottomOffset)+"px"}}}window.addEventListener("scroll",d,{passive:!0}),window.addEventListener("resize",d),typeof ResizeObserver=="function"&&new ResizeObserver(d).observe(document.body),d()})();
@@ -20,6 +20,7 @@
20
20
  * Password reset (the link, where it may point, the email) is services/passwordReset.js.
21
21
  */
22
22
  import crypto from 'node:crypto';
23
+ import {frameworkVersions} from '../../services/frameworkVersions.js';
23
24
  import {config, getConfig} from '../../config.js';
24
25
  import {getRegisteredSidebarItems, hooks} from '../../services/hooks.js';
25
26
  import {getMenu} from '../../services/menus.js';
@@ -254,7 +255,7 @@ export async function authRoutes(fastify) {
254
255
  // Dashboard Pro's "Open on my boards" - for a user the admin does not confine.
255
256
  if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/dashboards';
256
257
  // adminBrand: the admin's name and mark for the top bar, sidebar header and tab title.
257
- return {permissions, adminScope, adminHome, adminBrand: adminBrandFor(getConfig('site'))};
258
+ return {permissions, adminScope, adminHome, versions: frameworkVersions(), adminBrand: adminBrandFor(getConfig('site'))};
258
259
  });
259
260
 
260
261
  // GET /api/auth/permissions-registry - returns the full permissions registry,
@@ -108,7 +108,7 @@ export async function settingsRoutes(fastify) {
108
108
  'title', 'tagline', 'description', 'logo', 'favicon',
109
109
  'theme', 'adminTheme', 'fontFamily', 'fontSize',
110
110
  'smtp', 'footer', 'analytics', 'social', 'locale',
111
- 'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
111
+ 'layoutOptions', 'seo', 'backToTop', 'goToBottom', 'cookieConsent', 'breadcrumbs', 'autoTheme',
112
112
  'adminBrand',
113
113
  // The screen `#/` (Dashboard) opens - see services/adminHome.js.
114
114
  'adminHome',
package/server/server.js CHANGED
@@ -212,9 +212,9 @@ await app.register(staticPlugin, {
212
212
  prefix: '/admin/',
213
213
  decorateReply: false,
214
214
  cacheControl: false,
215
- setHeaders: (res, filePath) => {
215
+ setHeaders: (reply, filePath) => {
216
216
  const noStore = /\.(?:js|mjs|css)$/i.test(filePath);
217
- res.setHeader('Cache-Control', noStore ? 'no-store' : 'no-cache');
217
+ reply.header('Cache-Control', noStore ? 'no-store' : 'no-cache');
218
218
  }
219
219
  });
220
220
 
@@ -418,8 +418,8 @@ await app.register(staticPlugin, {
418
418
  root: mediaDir,
419
419
  prefix: '/media/',
420
420
  decorateReply: false,
421
- setHeaders: (res) => {
422
- res.setHeader('X-Content-Type-Options', 'nosniff');
421
+ setHeaders: (reply) => {
422
+ reply.header('X-Content-Type-Options', 'nosniff');
423
423
  }
424
424
  });
425
425
 
@@ -0,0 +1,11 @@
1
+ import {createRequire} from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+
4
+ /** Domma bundles its pinned Reactive dependency into the served JS distribution. */
5
+ export function frameworkVersions() {
6
+ try {
7
+ const pkg = require('domma-js/package.json');
8
+ const reactive = pkg.dependencies?.['domma-reactive'] || pkg.devDependencies?.['domma-reactive'];
9
+ return {js: pkg.version, reactive: /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(reactive || '') ? reactive : null};
10
+ } catch { return {}; }
11
+ }
@@ -523,6 +523,7 @@ export async function renderPage(page, opts = {}) {
523
523
  social: site.social || null
524
524
  },
525
525
  site.backToTop?.enabled ? { backToTop: site.backToTop } : {},
526
+ site.goToBottom?.enabled ? { goToBottom: site.goToBottom } : {},
526
527
  site.cookieConsent?.enabled ? {cookieConsent: site.cookieConsent} : {},
527
528
  autoTheme ? {autoTheme} : {}
528
529
  )).replace(/<\/script>/gi, '<\\/script>'),
@@ -545,7 +546,7 @@ export async function renderPage(page, opts = {}) {
545
546
  searchAssets.bodyTag,
546
547
  switcherAssets.bodyTag,
547
548
  buildAnalyticsTag(),
548
- site.backToTop?.enabled ? '<script src="/public/js/btt.js" defer></script>' : '',
549
+ (site.backToTop?.enabled || site.goToBottom?.enabled) ? '<script src="/public/js/btt.js?v=20261022-page-navigation" defer></script>' : '',
549
550
  site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js" defer></script>' : '',
550
551
  (page.usedComponents || [])
551
552
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)