domma-cms 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/admin-brand.js +1 -1
- package/admin/js/lib/google-analytics-panel.js +9 -0
- package/admin/js/lib/google-business-callback.js +1 -0
- package/admin/js/lib/google-business-import.js +3 -0
- package/admin/js/lib/google-business-panel.js +32 -0
- package/admin/js/lib/seo-defaults.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +5 -5
- package/admin/js/lib/site-search-section.js +1 -0
- package/admin/js/lib/site-settings-arrange.js +3 -3
- package/admin/js/lib/topbar-account.js +1 -1
- package/admin/js/lib/topbar-cache.js +6 -0
- package/admin/js/lib/topbar-notifications.js +4 -4
- package/admin/js/templates/docs/usage-site-settings.html +20 -7
- package/admin/js/templates/menu-editor.html +1 -1
- package/admin/js/templates/settings.html +104 -63
- package/admin/js/views/analytics.js +8 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +50 -42
- package/admin/js/views/settings.js +3 -3
- package/docs/DommaFeatures.md +3 -2
- package/docs/configuration.md +53 -8
- package/docs/google-business-profile.md +44 -0
- package/docs/screenshots/google-business-profile-review.png +0 -0
- package/docs/screenshots/notifications-cache.png +0 -0
- package/docs/screenshots/site-settings-search.png +0 -0
- package/package.json +1 -1
- package/public/css/motion.css +1 -0
- package/public/js/motion-control.mjs +1 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/analytics.js +19 -0
- package/server/routes/api/google-business.js +64 -0
- package/server/routes/api/seo.js +14 -3
- package/server/routes/api/settings.js +23 -2
- package/server/services/adminBrand.js +1 -0
- package/server/services/googleBusiness.js +225 -0
- package/server/services/motionSettings.js +107 -0
- package/server/services/renderer.js +36 -6
- package/server/templates/page.html +2 -2
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
<button type="button" class="tab-item active">General <i class="stx-dot" data-bind-hidden="!tabs.value.general" title="Changed"></i></button>
|
|
19
19
|
<button type="button" class="tab-item">Branding <i class="stx-dot" data-bind-hidden="!tabs.value.branding" title="Changed"></i></button>
|
|
20
20
|
<button type="button" class="tab-item">Favicon <i class="stx-dot" data-bind-hidden="!tabs.value.favicon" title="Changed"></i></button>
|
|
21
|
-
<button type="button" class="tab-item">SEO <i class="stx-dot" data-bind-hidden="!tabs.value.seo" title="Changed"></i></button>
|
|
22
21
|
<button type="button" class="tab-item">Footer <i class="stx-dot" data-bind-hidden="!tabs.value.footer" title="Changed"></i></button>
|
|
23
22
|
<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
23
|
<button type="button" class="tab-item">Page navigation <i class="stx-dot" data-bind-hidden="!tabs.value.pageNavigation" title="Changed"></i></button>
|
|
25
24
|
<button type="button" class="tab-item">Cookie consent <i class="stx-dot" data-bind-hidden="!tabs.value.cookies" title="Changed"></i></button>
|
|
26
|
-
<button type="button" class="tab-item">Analytics <i class="stx-dot" data-bind-hidden="!tabs.value.analytics" title="Changed"></i></button>
|
|
27
25
|
<button type="button" class="tab-item">Breadcrumbs <i class="stx-dot" data-bind-hidden="!tabs.value.breadcrumbs" title="Changed"></i></button>
|
|
26
|
+
<button type="button" class="tab-item">Reduce motion <i class="stx-dot" data-bind-hidden="!tabs.value.motion" title="Changed"></i></button>
|
|
28
27
|
<button type="button" class="tab-item">Cache</button>
|
|
28
|
+
<button type="button" class="tab-item" data-site-search-tab>Search <i class="stx-dot" data-site-search-dirty hidden title="Changed"></i></button>
|
|
29
29
|
</div>
|
|
30
30
|
|
|
31
31
|
<!-- General -->
|
|
@@ -91,6 +91,9 @@
|
|
|
91
91
|
<div class="tab-panel">
|
|
92
92
|
<div class="stx-card-columns">
|
|
93
93
|
<div class="stx-card"><div class="stx-card-title"><span data-icon="globe" data-icon-size="16" aria-hidden="true"></span>Public site</div><div class="stx-grid">
|
|
94
|
+
<div class="stx-setting stx-wide"><label class="form-label" for="stx-brand-enabled">Show public site branding
|
|
95
|
+
<span data-help="Shows the name and mark in the navbar and footer. Off hides them and keeps the saved values for next time."></span></label>
|
|
96
|
+
<input id="stx-brand-enabled" type="checkbox" class="form-switch" data-model="brandEnabled.value"></div>
|
|
94
97
|
<div><label class="form-label" for="stx-brandText">Brand name</label>
|
|
95
98
|
<input id="stx-brandText" type="text" class="form-input" placeholder="My Site" data-model="brandText.value">
|
|
96
99
|
</div>
|
|
@@ -110,6 +113,9 @@
|
|
|
110
113
|
</div><p class="text-muted">The logo takes priority over the icon. Set the browser icon in the Favicon tab.</p></div>
|
|
111
114
|
<div class="stx-card"><div class="stx-card-title"><span data-icon="layout" data-icon-size="16" aria-hidden="true"></span>Admin
|
|
112
115
|
<span data-help-title="Admin name and mark" data-help="The name and logo or icon at the top of the admin - in the top bar, the sidebar and the browser tab - for everyone who signs in. Leave the name empty to use the site title."></span></div><div class="stx-grid">
|
|
116
|
+
<div class="stx-setting stx-wide"><label class="form-label" for="stx-admin-brand-enabled">Show admin branding
|
|
117
|
+
<span data-help="Shows the name and mark in the admin top bar and sidebar, and the name in browser tab titles. Off hides them and keeps the saved values."></span></label>
|
|
118
|
+
<input id="stx-admin-brand-enabled" type="checkbox" class="form-switch" data-model="adminBrandEnabled.value"></div>
|
|
113
119
|
<div><label class="form-label" for="stx-adminBrandTitle">Admin name</label>
|
|
114
120
|
<input id="stx-adminBrandTitle" type="text" class="form-input" placeholder="Uses the site title when empty" data-model="adminBrandTitle.value">
|
|
115
121
|
</div>
|
|
@@ -150,30 +156,6 @@
|
|
|
150
156
|
</div>
|
|
151
157
|
</div>
|
|
152
158
|
|
|
153
|
-
<!-- SEO -->
|
|
154
|
-
<div class="tab-panel">
|
|
155
|
-
<div class="stx-card">
|
|
156
|
-
<div class="stx-card-title"><span data-icon="search" data-icon-size="16" aria-hidden="true"></span>Search engines
|
|
157
|
-
<span data-help-title="SEO defaults" data-help="Used on every page that does not set its own. A page's title becomes “Page title” + separator + default site title."></span></div>
|
|
158
|
-
<div class="stx-grid">
|
|
159
|
-
<div>
|
|
160
|
-
<label class="form-label" for="stx-seo-title">Default site title</label>
|
|
161
|
-
<input id="stx-seo-title" type="text" class="form-input" placeholder="My Site" data-model="seoTitle.value">
|
|
162
|
-
</div>
|
|
163
|
-
<div>
|
|
164
|
-
<label class="form-label" for="stx-seo-sep">Title separator</label>
|
|
165
|
-
<input id="stx-seo-sep" type="text" class="form-input" placeholder=" | " data-model="seoSeparator.value">
|
|
166
|
-
</div>
|
|
167
|
-
<div class="stx-wide">
|
|
168
|
-
<label class="form-label" for="stx-seo-desc">Default meta description
|
|
169
|
-
<span data-help="The summary search engines show under a page that has no description of its own. About 150 characters is what they display."></span></label>
|
|
170
|
-
<input id="stx-seo-desc" type="text" class="form-input" placeholder="Brief description of your site" data-model="seoDescription.value">
|
|
171
|
-
<small class="stx-hint" data-bind-text="seoLength.value"></small>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
</div>
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
159
|
<!-- Footer -->
|
|
178
160
|
<div class="tab-panel">
|
|
179
161
|
<div class="stx-card-columns">
|
|
@@ -203,28 +185,8 @@
|
|
|
203
185
|
<span data-help="Blocks (Content › Blocks) shown side by side at the top of the footer on every page, by name, in order - for example wp-footer-1, wp-footer-2. Import from WordPress puts a site's footer widget areas here. Leave it empty for none; a name that is not a block is left out."></span></label>
|
|
204
186
|
<input id="stx-footer-blocks" type="text" class="form-input" maxlength="400" placeholder="wp-footer-1, wp-footer-2" data-model="footerBlocks.value">
|
|
205
187
|
</div>
|
|
206
|
-
<
|
|
207
|
-
<
|
|
208
|
-
<span data-help="An accessibility switch in the footer that lets visitors turn animations off. Turning this off only hides the switch - visitors who already chose it, and anyone whose browser asks for reduced motion, still get a calmer site."></span></label>
|
|
209
|
-
<input id="stx-motion" type="checkbox" class="form-switch" data-model="footerMotionToggle.value">
|
|
210
|
-
</div>
|
|
211
|
-
<div class="stx-setting">
|
|
212
|
-
<label class="form-label" for="stx-motion-default">Reduced motion for new visitors
|
|
213
|
-
<span data-help="What a visitor who has not used the switch gets. Follow their device honours the system setting (the accessible default). Off shows every animation until they ask for less; On starts calm. A visitor's own choice always wins."></span></label>
|
|
214
|
-
<select id="stx-motion-default" class="form-input" data-model="footerMotionDefault.value">
|
|
215
|
-
<option value="system">Follow their device</option>
|
|
216
|
-
<option value="off">Off - full motion</option>
|
|
217
|
-
<option value="on">On - calm</option>
|
|
218
|
-
</select>
|
|
219
|
-
</div>
|
|
220
|
-
<div class="stx-setting">
|
|
221
|
-
<label class="form-label" for="stx-motion-control">The control
|
|
222
|
-
<span data-help="How the Reduce motion control looks in the footer."></span></label>
|
|
223
|
-
<select id="stx-motion-control" class="form-input" data-model="footerMotionControl.value">
|
|
224
|
-
<option value="switch">A switch</option>
|
|
225
|
-
<option value="checkbox">A checkbox</option>
|
|
226
|
-
</select>
|
|
227
|
-
</div>
|
|
188
|
+
<p class="stx-wide stx-hint">The “Reduce motion” control now has a tab of its own:
|
|
189
|
+
<button type="button" class="stx-inline-link" data-on-click="goTab('motion')">Reduce motion</button>.</p>
|
|
228
190
|
<div>
|
|
229
191
|
<label class="form-label" for="stx-footer-class">Footer CSS classes
|
|
230
192
|
<span data-help-title="Footer CSS classes" data-help="Your own class names, separated by spaces, added to the site footer. Style them in Custom CSS. The footer's menus take classes of their own in Menus."></span></label>
|
|
@@ -532,21 +494,6 @@
|
|
|
532
494
|
</div>
|
|
533
495
|
</div>
|
|
534
496
|
|
|
535
|
-
<!-- Analytics -->
|
|
536
|
-
<div class="tab-panel">
|
|
537
|
-
<div class="stx-card">
|
|
538
|
-
<div class="stx-card-title"><span data-icon="bar-chart" data-icon-size="16" aria-hidden="true"></span>Google Analytics
|
|
539
|
-
<span data-help-title="Google Analytics" data-help="Paste the measurement ID of your Google Analytics 4 web stream (Admin > Data streams). Google Analytics sets cookies, so it loads only for visitors who accept Analytics cookies in the cookie banner - the banner is shown whenever an ID is set. Leave it empty to switch Google Analytics off. The built-in Analytics Tool is separate and needs no cookies."></span></div>
|
|
540
|
-
<div class="stx-grid">
|
|
541
|
-
<div class="stx-wide"><label class="form-label" for="stx-ga-id">Measurement ID</label>
|
|
542
|
-
<input id="stx-ga-id" type="text" class="form-input" placeholder="G-XXXXXXXXXX" autocomplete="off" spellcheck="false" data-model="gaId.value">
|
|
543
|
-
<small class="text-danger" data-bind-hidden="!gaIdError.value" data-bind-text="gaIdError.value"></small></div>
|
|
544
|
-
</div>
|
|
545
|
-
<p class="stx-note">Loads only after a visitor accepts Analytics cookies. Visitors who reject them, or have not answered yet, are not sent to Google.</p>
|
|
546
|
-
<p class="stx-note" data-bind-hidden="!gaForcesBanner.value">The cookie banner is switched off under Cookie consent, but it will still be shown while Google Analytics is set - it is how visitors give consent.</p>
|
|
547
|
-
</div>
|
|
548
|
-
</div>
|
|
549
|
-
|
|
550
497
|
<!-- Breadcrumbs -->
|
|
551
498
|
<div class="tab-panel">
|
|
552
499
|
<div class="stx-card-columns">
|
|
@@ -584,6 +531,91 @@
|
|
|
584
531
|
</div>
|
|
585
532
|
</div>
|
|
586
533
|
|
|
534
|
+
<!-- Reduce motion -->
|
|
535
|
+
<div class="tab-panel">
|
|
536
|
+
<div class="stx-card-columns">
|
|
537
|
+
<div class="stx-card">
|
|
538
|
+
<div class="stx-card-title"><span data-icon="pause" data-icon-size="16" aria-hidden="true"></span>The control
|
|
539
|
+
<span data-help-title="Reduce motion" data-help="An accessibility control that lets visitors turn animations off. Turning it off only hides the control - visitors who already chose it, and anyone whose browser asks for reduced motion, still get a calmer site."></span></div>
|
|
540
|
+
<div class="stx-grid">
|
|
541
|
+
<div class="stx-wide stx-setting"><label class="form-label" for="stx-mt-on">Show it</label>
|
|
542
|
+
<input id="stx-mt-on" type="checkbox" class="form-switch" data-model="mtEnabled.value"></div>
|
|
543
|
+
<div class="stx-wide"><label class="form-label" for="stx-mt-label">Label
|
|
544
|
+
<span data-help="The words on the control, and what a screen reader announces. Up to 40 characters."></span></label>
|
|
545
|
+
<input id="stx-mt-label" type="text" class="form-input" maxlength="40" placeholder="Reduce motion" data-model="mtLabel.value"></div>
|
|
546
|
+
<div class="stx-wide stx-setting">
|
|
547
|
+
<label class="form-label" for="stx-motion-default">Reduced motion for new visitors
|
|
548
|
+
<span data-help="What a visitor who has not used the control gets. Follow their device honours the system setting (the accessible default). Off shows every animation until they ask for less; On starts calm. A visitor's own choice always wins."></span></label>
|
|
549
|
+
<select id="stx-motion-default" class="form-input" data-model="mtDefault.value">
|
|
550
|
+
<option value="system">Follow their device</option>
|
|
551
|
+
<option value="off">Off - full motion</option>
|
|
552
|
+
<option value="on">On - calm</option>
|
|
553
|
+
</select>
|
|
554
|
+
</div>
|
|
555
|
+
<div class="stx-wide stx-setting" data-bind-hidden="mtPlacement.value !== 'footer'">
|
|
556
|
+
<label class="form-label" for="stx-motion-control">Footer style
|
|
557
|
+
<span data-help="How the Reduce motion control looks in the footer."></span></label>
|
|
558
|
+
<select id="stx-motion-control" class="form-input" data-model="mtControl.value">
|
|
559
|
+
<option value="switch">A switch</option>
|
|
560
|
+
<option value="checkbox">A checkbox</option>
|
|
561
|
+
</select>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="stx-wide">
|
|
564
|
+
<label class="form-label" for="stx-mt-class">CSS classes
|
|
565
|
+
<span data-help="Your own class names, separated by spaces, added to the control wherever it sits. Style them in Custom CSS."></span></label>
|
|
566
|
+
<input id="stx-mt-class" type="text" class="form-input" placeholder="motion-quiet" autocomplete="off" spellcheck="false" data-model="mtClass.value">
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
<div class="stx-card">
|
|
571
|
+
<div class="stx-card-title"><span data-icon="layout" data-icon-size="16" aria-hidden="true"></span>Placement
|
|
572
|
+
<span data-help-title="Placement" data-help="Footer puts it at the foot of every page, as it always was. Floating button is a disc fixed to a corner of the window, kept clear of Search, Back to top and the theme switcher. Navbar adds it at the end of the navbar - on a page with no navbar, or on a phone when the navbar shows only its menu button, it floats instead."></span></div>
|
|
573
|
+
<div class="stx-grid">
|
|
574
|
+
<div class="stx-wide">
|
|
575
|
+
<span class="form-label">Where it goes</span>
|
|
576
|
+
<div class="stx-segments" role="group" aria-label="Where it goes">
|
|
577
|
+
<button type="button" data-on-click="motionPlacement('footer')" data-bind-class="mtPlacement.value === 'footer' && 'is-on'">Footer</button>
|
|
578
|
+
<button type="button" data-on-click="motionPlacement('floating')" data-bind-class="mtPlacement.value === 'floating' && 'is-on'">Floating button</button>
|
|
579
|
+
<button type="button" data-on-click="motionPlacement('navbar')" data-bind-class="mtPlacement.value === 'navbar' && 'is-on'">Navbar</button>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
<div class="stx-setting stx-wide" data-bind-hidden="mtPlacement.value === 'footer'">
|
|
583
|
+
<label class="form-label" for="stx-mt-style">Button
|
|
584
|
+
<span data-help="An icon on its own (the label is still read out by screen readers), or the icon with the label beside it."></span></label>
|
|
585
|
+
<select id="stx-mt-style" class="form-input" data-model="mtTriggerStyle.value">
|
|
586
|
+
<option value="icon">Icon</option>
|
|
587
|
+
<option value="icon-text">Icon and label</option>
|
|
588
|
+
</select>
|
|
589
|
+
</div>
|
|
590
|
+
<div class="stx-wide" data-bind-hidden="mtPlacement.value !== 'floating'">
|
|
591
|
+
<span class="form-label">Corner</span>
|
|
592
|
+
<div class="stx-corners" role="group" aria-label="Corner">
|
|
593
|
+
<button type="button" data-on-click="motionCorner('TL')" data-bind-class="mtPosition.value === 'TL' && 'is-on'">↖ Top left</button>
|
|
594
|
+
<button type="button" data-on-click="motionCorner('TR')" data-bind-class="mtPosition.value === 'TR' && 'is-on'">Top right ↗</button>
|
|
595
|
+
<button type="button" data-on-click="motionCorner('BL')" data-bind-class="mtPosition.value === 'BL' && 'is-on'">↙ Bottom left</button>
|
|
596
|
+
<button type="button" data-on-click="motionCorner('BR')" data-bind-class="mtPosition.value === 'BR' && 'is-on'">Bottom right ↘</button>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
<div class="stx-pair stx-wide" data-bind-hidden="mtPlacement.value !== 'floating'">
|
|
600
|
+
<div><label class="form-label" for="stx-mt-x">From the side (px)</label>
|
|
601
|
+
<input id="stx-mt-x" type="number" class="form-input" min="0" max="500" placeholder="32" data-model="mtOffsetX.value"></div>
|
|
602
|
+
<div><label class="form-label" for="stx-mt-y">From the top/bottom (px)</label>
|
|
603
|
+
<input id="stx-mt-y" type="number" class="form-input" min="0" max="500" placeholder="32" data-model="mtOffsetY.value"></div>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="stx-pair stx-wide" data-bind-hidden="mtPlacement.value !== 'floating' || mtTriggerStyle.value !== 'icon'">
|
|
606
|
+
<div><label class="form-label" for="stx-mt-size">Size <span class="stx-readout" data-bind-text="mtSizeText.value"></span>
|
|
607
|
+
<span data-help="How big the disc is (32-96 px). The glyph inside scales with it. 44 px and up is comfortable to tap on a phone."></span></label>
|
|
608
|
+
<input id="stx-mt-size" type="range" min="32" max="96" step="2" data-model="mtIconSize.value"></div>
|
|
609
|
+
<div><label class="form-label" for="stx-mt-radius">Corners <span class="stx-readout" data-bind-text="mtRadiusText.value"></span>
|
|
610
|
+
<span data-help="0 is a square; the top of the range is a circle, and it is always half the size - so it moves when the size does."></span></label>
|
|
611
|
+
<input id="stx-mt-radius" type="range" min="0" step="1" data-model="mtIconRadius.value"></div>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
<p class="stx-hint" data-bind-hidden="mtPlacement.value !== 'floating'">If Search, Back to top or the theme switcher sits in the same corner, the disc stacks clear of it, further from the edge.</p>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
|
|
587
619
|
<!-- Cache -->
|
|
588
620
|
<div class="tab-panel">
|
|
589
621
|
<div class="stx-card">
|
|
@@ -612,6 +644,7 @@
|
|
|
612
644
|
<div class="stx-list" data-each="cacheRows.value key=id"></div>
|
|
613
645
|
</div>
|
|
614
646
|
</div>
|
|
647
|
+
<div class="tab-panel" data-site-search-panel><p role="status">Open Search to load its settings.</p></div>
|
|
615
648
|
</div>
|
|
616
649
|
</div>
|
|
617
650
|
|
|
@@ -662,6 +695,14 @@
|
|
|
662
695
|
.stx-corners button:nth-child(-n+2) { border-bottom: 1px solid var(--dm-border); }
|
|
663
696
|
.stx-corners button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
|
|
664
697
|
.stx-corners button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
|
|
698
|
+
.stx-segments { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
|
|
699
|
+
.stx-segments button { border: 0; background: none; color: var(--dm-text); padding: .45rem .8rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
|
|
700
|
+
.stx-segments button + button { border-left: 1px solid var(--dm-border); }
|
|
701
|
+
.stx-segments button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
|
|
702
|
+
.stx-segments button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
|
|
703
|
+
.stx-readout { color: var(--dm-text-muted); font-weight: 400; font-variant-numeric: tabular-nums; }
|
|
704
|
+
.stx-pair input[type="range"] { width: 100%; }
|
|
705
|
+
.stx-inline-link { background: none; border: 0; padding: 0; font: inherit; color: var(--dm-primary); text-decoration: underline; cursor: pointer; }
|
|
665
706
|
.stx-chips { display: flex; gap: .35rem; flex-wrap: wrap; }
|
|
666
707
|
.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;
|
|
667
708
|
background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as h,getToken as fe,refreshAccessToken as ye}from"../api.js";import{addBannerAction as ge}from"../lib/plugin-chrome.js";import{helpPopovers as ae}from"../lib/help-popover.js";import{mountGoogleAnalytics as we}from"../lib/google-analytics-panel.js";import{onceClosed as Me,openMenuAt as se,settingsStore as ke,sidebarCount as ne,viewLifetime as Ee}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as Re,journeyRows as Se,kpis as Ce,LIMITS as Ae,normaliseSettings as oe,pageEditHash as Le,RANGE_LABEL as q,RANGES as Te,seriesRange as xe,siteAddress as Pe,tabCounts as $e,topRows as je}from"../lib/analytics-shape.js";import{fmtDate as He}from"../lib/dates.js";import{SHAPES as Ie,showLoading as Ue}from"../lib/loading.js";const De="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js",Oe=3e4,le=ke("analytics.settings",oe),_e=`
|
|
2
2
|
<div class="an-row" tabindex="0" data-url="{{url}}">
|
|
3
3
|
<span class="an-bar-fill" style="width: {{bar}}"></span>
|
|
4
4
|
<span class="an-rank">{{rank}}</span>
|
|
@@ -12,18 +12,20 @@ import{apiRequest as b,getToken as be,refreshAccessToken as he}from"../api.js";i
|
|
|
12
12
|
<span class="an-url" title="{{label}}">{{label}}</span>
|
|
13
13
|
<span class="an-num">{{count}}</span>
|
|
14
14
|
<button type="button" class="an-kebab" aria-label="Page actions" title="Actions">\u22EE</button>
|
|
15
|
-
</div>`,
|
|
15
|
+
</div>`,Ne=`
|
|
16
16
|
<div class="an-row" tabindex="0" data-url="{{url}}">
|
|
17
17
|
<span class="an-url" title="{{url}}">{{url}}</span>
|
|
18
18
|
<span class="an-num" title="Views per hour today, against the usual {{baseline}}">{{hits}}/h</span>
|
|
19
19
|
<span class="an-share">\xD7{{ratio}}</span>
|
|
20
20
|
<button type="button" class="an-kebab" aria-label="Page actions" title="Actions">\u22EE</button>
|
|
21
|
-
</div>`;function
|
|
21
|
+
</div>`;function Ve(){return window.Chart?Promise.resolve():new Promise((v,n)=>{const a=document.createElement("script");a.src=De,a.async=!0,a.onload=()=>v(),a.onerror=()=>n(new Error("chart.js load failed")),document.head.appendChild(a)})}function qe(v){const n=document.createElement("template");return n.innerHTML=v.trim(),n.content.firstElementChild}async function Be(v){const n=m=>fetch("/api/analytics/export.csv?range="+encodeURIComponent(v),{headers:m?{Authorization:"Bearer "+m}:{}});let a=await n(fe());if(a.status===401&&(a=await n(await ye())),!a.ok)throw new Error("export failed");const l=await a.blob(),d=(a.headers.get("Content-Disposition")||"").match(/filename="?([^"]+)"?/),u=URL.createObjectURL(l),c=document.createElement("a");c.href=u,c.download=d?d[1]:"analytics.csv",document.body.appendChild(c),c.click(),c.remove(),URL.revokeObjectURL(u)}const o=Ee(/^#\/analytics\b/);export const analyticsView={templateUrl:"/admin/js/templates/analytics.html",async onMount(v){o.reset();const n=v.get(0);let a=!0;o.add(()=>{a=!1});const l=M.observable(le.read()),d=M.observable(l.peek().range),u=M.observable("pages"),c=M.observable(""),m=M.observable(null),G=M.observable([]),w=M.observable(null),T=M.observable([]),f=M.observable(null),k=M.observable(""),y=M.observable(""),x=M.observable(null),P=M.observable(null),$=M.observable(null),j=M.observable(!0),F=M.observable(!!window.Chart);let W=new Set;const J=M.observable([]),z=M.observable("");o.add(M.effect(()=>{const e=m.value,{rows:t,matched:s}=je(e?.top,{filter:c.value,limit:l.value.limit,total:e?.total});J.value=t,z.value=e?e.top.length?s?s>t.length?`Showing ${t.length} of ${s} pages - the cog changes how many.`:"":"No pages match that filter.":"No page views recorded in this range yet.":""}));const K=M.observable([]),Y=M.observable([]),Z=M.observable([]);o.add(M.effect(()=>{const e=Se(w.value);K.value=e.entry,Y.value=e.exit,Z.value=e.paths}));const ie={range:d,section:u,filter:c,spikes:T,pageRows:J,pagesNote:z,entryRows:K,exitRows:Y,pathRows:Z,loadError:k,liveError:y,loading:j,counts:M.computed(()=>$e(m.value,w.value,j.value?null:T.value)),freshness:M.computed(()=>x.value?`Last updated ${D(x.value).format("HH:mm:ss")} \xB7 ${q[$.value]||$.value}${k.value?" \xB7 Figures are stale":""}`:k.value?"Figures unavailable":"Loading figures\u2026"),kpi:M.computed(()=>Ce(m.value,w.value)),isAllTime:M.computed(()=>d.value==="all"),sessionsCount:M.computed(()=>w.value?.totalSessions??0),liveText:M.computed(()=>y.value?f.value===null?"Active now unavailable":`${f.value} active \xB7 stale`:f.value===null?"Active now: -":`${f.value} active now`),liveClass:M.computed(()=>!y.value&&f.value?"is-on":""),liveTitle:M.computed(()=>(P.value?`Last updated ${D(P.value).format("HH:mm:ss")}. `:"")+(y.value||(l.value.live?"Refreshes every 30 seconds":"Live refresh is off - see settings")))};o.add(M.effect(()=>le.write(l.value)));const re=(e,t)=>{l.value=oe({...l.peek(),[e]:t})};let Q=Ue(n.querySelector(".an-loading"),Ie.rows);const X=async()=>{const e=Q;Q=null,e&&await e.done(),a&&(j.value=!1)};let H=0;async function C(){const e=d.peek(),t=++H;try{const[s,i,g,S]=await Promise.all([h("/analytics/stats?range="+encodeURIComponent(e)),h("/analytics/series?range="+encodeURIComponent(xe(e))),h("/analytics/journeys?range="+encodeURIComponent(e)),h("/analytics/spikes")]);if(await X(),!a||t!==H)return;m.value=s,G.value=i?.series??[],w.value=g,T.value=(S?.items??[]).map(p=>({...p,key:p.url})),$.value=e,x.value=new Date().toISOString(),k.value=""}catch{await X(),a&&t===H&&(k.value="Could not refresh analytics. Previous figures, if shown, are retained.")}}async function ce(){try{const e=await h("/pages");a&&(W=new Set((Array.isArray(e)?e:[]).map(t=>t.urlPath).filter(Boolean)))}catch{}}let U=0;async function O(){const e=++U;try{const t=await h("/analytics/realtime");if(!a||e!==U)return;f.value=t?.activeSessions??0,P.value=new Date().toISOString(),y.value="",ne("#/analytics")}catch{a&&e===U&&(y.value="Live visitor count is unavailable. Retry to refresh it.")}}let ee=!0;o.add(M.effect(()=>{if(d.value,ee){ee=!1;return}C()}));let _=null;o.add(M.effect(()=>{clearInterval(_),_=l.value.live?setInterval(()=>{document.hidden||O()},Oe):null})),o.add(()=>clearInterval(_));const te=n.querySelector(".an-chart");let A=null;o.add(()=>{A?.destroy(),A=null}),o.add(M.effect(()=>{const e=G.value,t=l.value.chart;if(!F.value||!window.Chart||!te.isConnected)return;const s=getComputedStyle(document.documentElement),i=s.getPropertyValue("--dm-primary").trim()||"rgb(99, 102, 241)",g=s.getPropertyValue("--dm-text-muted").trim()||"#888",S=`color-mix(in srgb, ${s.getPropertyValue("--dm-border").trim()||"#888"} 45%, transparent)`;A?.destroy(),A=new window.Chart(te,{type:t,data:{labels:e.map(p=>He(p.date).slice(0,5)),datasets:[{label:"Page views",data:e.map(p=>p.hits),borderColor:i,backgroundColor:t==="bar"?i:`color-mix(in srgb, ${i} 18%, transparent)`,fill:t==="line",tension:.3,borderWidth:2,pointRadius:e.length>40?0:2,borderRadius:3}]},options:{responsive:!0,maintainAspectRatio:!1,animation:!1,plugins:{legend:{display:!1}},scales:{y:{beginAtZero:!0,ticks:{precision:0,color:g},grid:{color:S}},x:{ticks:{maxRotation:0,autoSkip:!0,maxTicksLimit:10,color:g},grid:{display:!1}}}}})}));let b=null;o.add(()=>{try{b?.close(!0)}catch{}});function de(){if(!b){b=!0;try{ue()}catch(e){throw b=null,e}}}function ue(){const e=E.slideover({title:"Analytics settings",size:"md",position:"right"}),t=qe(`
|
|
22
22
|
<div class="an-panel-form">
|
|
23
|
+
<section data-google-analytics></section>
|
|
24
|
+
<h4>Display preferences</h4>
|
|
23
25
|
<div>
|
|
24
26
|
<label class="form-label" for="an-set-range">Open on</label>
|
|
25
27
|
<select id="an-set-range" class="form-input" data-model="range.value">
|
|
26
|
-
${
|
|
28
|
+
${Te.map(r=>`<option value="${r}">${q[r]}</option>`).join("")}
|
|
27
29
|
</select>
|
|
28
30
|
</div>
|
|
29
31
|
<div>
|
|
@@ -31,7 +33,7 @@ import{apiRequest as b,getToken as be,refreshAccessToken as he}from"../api.js";i
|
|
|
31
33
|
<span data-help="How many pages the Top pages list shows. The filter still searches all of them, and the CSV export always has every page."></span>
|
|
32
34
|
</label>
|
|
33
35
|
<select id="an-set-limit" class="form-input" data-model="limit.value">
|
|
34
|
-
${
|
|
36
|
+
${Ae.map(r=>`<option value="${r}">${r||"All"}</option>`).join("")}
|
|
35
37
|
</select>
|
|
36
38
|
</div>
|
|
37
39
|
<div>
|
|
@@ -59,4 +61,4 @@ import{apiRequest as b,getToken as be,refreshAccessToken as he}from"../api.js";i
|
|
|
59
61
|
<span class="spacer"></span>
|
|
60
62
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
|
|
61
63
|
</div>
|
|
62
|
-
</div>`),s=(
|
|
64
|
+
</div>`),s=(r,he=V=>V)=>M.computed({read:()=>l.value[r],write:V=>re(r,he(V))}),i={range:s("range"),limit:s("limit",Number),chart:s("chart"),live:s("live"),confirmReset:s("confirmReset")};e.element.appendChild(t);const g=ae(t),S=M.applyBindings(i,t,{methods:{done:()=>e.close(),reset:()=>{l.value={...Re}}}}),p=we(t.querySelector("[data-google-analytics]"));Me(e,()=>{p.dispose(),S.dispose(),g(),b===e&&(b=null)});const be=e.close.bind(e);e.close=async r=>{!r&&p.dirty()&&!await E.confirm("Close with unsaved Google Analytics changes? They will be kept as a draft in this browser.")||be(r)},b=e,e.open()}ge(v,{id:"analytics-settings",icon:"settings",title:"Analytics settings",onClick:de});const R=[...n.querySelectorAll(".an-list")];R[0].innerHTML=_e,R[1].innerHTML=B,R[2].innerHTML=B,R[3].innerHTML=B,R[4].innerHTML=Ne,o.add(ae(n,{position:"bottom"}));const pe=M.applyBindings(ie,n,{methods:{refresh:()=>{C(),O()},showPages:()=>{u.value="pages"},showJourneys:()=>{u.value="journeys"},showSpikes:()=>{u.value="spikes"}}});o.add(()=>pe.dispose());const L=e=>Pe(e,location.origin),N=e=>{const t=L(e);t?window.open(t,"_blank","noopener"):E.toast("That address is not on this site, so it is not opened.",{type:"warning"})},ve=E.contextMenu(n,{cursor:"glyph",match:".an-row",inherit:!1,density:"compact",items:e=>{const t=e.dataset.url,s=Le(t,W);return[{header:t},{label:"Open on the site",icon:"external-link",disabled:!L(t),action:()=>N(t)},...s?[{label:"Edit the page",icon:"edit",action:()=>{location.hash=s}}]:[],{label:"Copy address",icon:"copy",disabled:!L(t),action:async()=>{const i=L(t);if(i)try{await navigator.clipboard.writeText(i),E.toast("Address copied.",{type:"success"})}catch{E.toast(i,{type:"info",duration:8e3})}}},{label:"Show only this page",icon:"filter",disabled:u.peek()==="pages"&&c.peek()===t,action:()=>{u.value="pages",c.value=t}}]}});o.add(()=>ve?.destroy?.());const me=E.contextMenu(n.querySelector(".an-bar-end"),{cursor:"glyph",match:".an-more",inherit:!1,density:"compact",items:()=>[{label:`Export ${q[d.peek()].toLowerCase()} as CSV`,icon:"download",action:async()=>{try{await Be(d.peek())}catch{E.toast("Export failed.",{type:"error"})}}},{type:"divider"},{label:"Reset all statistics\u2026",icon:"trash",danger:!0,action:async()=>{if(!(l.peek().confirmReset&&!await E.confirm("Delete every analytics counter for this site? This cannot be undone.")))try{if(await h("/analytics/stats",{method:"DELETE"}),!a)return;E.toast("Analytics reset.",{type:"success"}),ne("#/analytics"),C()}catch{E.toast("Reset failed.",{type:"error"})}}}]});o.add(()=>me?.destroy?.()),n.addEventListener("click",e=>{const t=e.target.closest(".an-kebab, .an-more");if(t){se(t);return}const s=e.target.closest(".an-row");s?.dataset.url&&N(s.dataset.url)}),n.addEventListener("keydown",e=>{const t=e.target.closest(".an-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),se(t)):e.key==="Enter"&&e.target===t&&t.dataset.url&&N(t.dataset.url))}),I.scan(n),Ve().then(()=>{a&&(F.value=!0)},()=>{a&&E.toast("The chart library could not be loaded; figures still work.",{type:"warning"})}),ce(),await Promise.all([C(),O()])}};
|
package/admin/js/views/search.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as A}from"../api.js";import{helpPopovers as ge}from"../lib/help-popover.js";import{openMenuAt as oe,sidebarCount as ye}from"../lib/tool-kit.js";import{POSITION_LABEL as xe,clampNotes as Se,draftQuestion as we,formFromSettings as D,keptDraft as ke,searchDraftKey as Me,liveChips as Ee,maxRadius as Te,payloadFromForm as l,previewState as ne,radiusForSize as Le,resultRow as Re,snapshot as d}from"../lib/search-settings-arrange.js";import{fmtDateTime as Pe}from"../lib/dates.js";import{SHAPES as ze,whileLoading as qe}from"../lib/loading.js";const Oe="#/search",Ae='<span class="ssx-chip" data-bind-class="cls">{{text}}</span>',De=`
|
|
2
2
|
<div class="ssx-row" data-url="{{url}}" tabindex="0">
|
|
3
3
|
<span class="ssx-rank">{{rank}}</span>
|
|
4
4
|
<span class="ssx-main"><strong>{{title}}</strong><small>{{meta}}</small><span class="ssx-snippet">{{snippet}}</span></span>
|
|
5
5
|
<span class="ssx-chip">{{score}}</span>
|
|
6
6
|
<button type="button" class="ssx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
7
|
-
</div>`;
|
|
7
|
+
</div>`;export async function mountSearchSettings(H,{onDirty:ae=()=>{},onReady:re=()=>{}}={}){const K=[],r=e=>(K.push(e),e);let B=!1;const a=H.get(0).querySelector(".ssx")||H.get(0),T=()=>!B&&a.isConnected;let L="";try{L=S.get("auth_user")?.id||""}catch{}const c=Me(L),b=a.querySelector(".ssx-loading"),Q=a.querySelector(".ssx-body");let $,C=null;try{[$,C]=await qe(b,Promise.all([A("/search/settings"),A("/search/_count").catch(()=>null)]),ze.rows)}catch(e){if(!a.isConnected)return;b&&(b.hidden=!0);const t=a.querySelector(".ssx-failed");t&&(t.textContent=`Could not load the search settings: ${e.message||e}. Reload the page to try again.`,t.hidden=!1);return}if(!a.isConnected)return;b&&(b.hidden=!0),Q&&(Q.hidden=!1);const R=M.observable($),V=D($),s={};for(const[e,t]of Object.entries(V))s[e]=M.observable(t);const h=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),p=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),g=M.observable(d(V)),f=M.computed(()=>d(p())!==g.value);r(M.effect(()=>ae(f.value)));const P=M.observable(!1);let z=g.peek();const J=M.observable(C?{searchable:C.count}:null),Y=M.observable([]);r(M.effect(()=>{Y.value=Ee(R.value,J.value).map(e=>({text:e.text,cls:e.tone?`is-${e.tone}`:""}))}));let m=Number(s.iconSize.peek())||52;r(M.effect(()=>{const e=Number(s.iconSize.value)||52;if(e===m)return;const t=Le(s.iconRadius.peek(),m,e);m=e,String(t)!==String(s.iconRadius.peek())&&(s.iconRadius.value=String(t))}));const ie=M.computed(()=>Te(s.iconSize.value)),G=a.querySelector("#ssx-radius");r(M.effect(()=>{G&&(G.max=String(ie.value))}));const y=M.observable(""),x=M.observable([]),q=M.observable(""),O=M.observable(""),ce=M.computed(()=>ne(y.value,s.minQueryLength.value).note);let N=0,_=null;async function le(){const e=ne(y.peek(),s.minQueryLength.peek()),t=++N;if(!e.run){x.value=[],O.value="",q.value="";return}const n=l(h()).maxResults;try{const o=await A(`/search/preview?q=${encodeURIComponent(y.peek().trim())}&max=${n}`);if(t!==N||!T())return;x.value=(o.results||[]).map(Re),q.value="";const i=x.peek().length;O.value=i?`${i} result${i===1?"":"s"} (showing up to ${o.maxResults}) from ${o.coverage?.searchable??"?"} searchable pages.`:`Nothing found in ${o.coverage?.searchable??"?"} searchable pages.`,o.coverage&&(J.value=o.coverage)}catch(o){if(t!==N)return;x.value=[],O.value="",q.value=`Search failed: ${o.message||o}`}}r(M.effect(()=>{y.value,s.maxResults.value,clearTimeout(_),_=setTimeout(le,l(h()).debounceMs)})),r(()=>clearTimeout(_));async function W(){if(P.peek())return;P.value=!0;const e=h(),t=d(e);try{const o=(await A("/search/settings",{method:"PUT",body:JSON.stringify(l(e))}))?.settings||l(e),i=D(o);if(!T()){ue(d(i),t);return}if(R.value=o,d(h())===t){m=Number(i.iconSize)||52;for(const[be,he]of Object.entries(i))s[be].value=he}return g.value=d(i),z=g.peek(),f.peek()||w(),ye(Oe),E.toast(o.enabled?"Search settings saved.":"Saved - search is off on the public site.",{type:"success"}),!0}catch(n){return E.toast(`Search not saved: ${n.message||n}`,{type:"error"}),!1}finally{P.value=!1}}function w(){if(c)try{S.remove?S.remove(c):S.set(c,null)}catch{}}function ue(...e){if(!c)return;let t=null;try{t=S.get(c)}catch{return}if(t?.form)try{e.includes(d(t.form))&&w()}catch{}}function X(){const e=D(R.peek());m=Number(e.iconSize)||52;for(const[t,n]of Object.entries(e))s[t].value=n}a.querySelector(".ssx-chips").innerHTML=Ae;const v=a.querySelector(".ssx-list");v.innerHTML=De;const k=e=>`${e}px`,pe={...s,dirty:f,saving:P,chips:Y,query:y,results:x,previewNote:ce,previewError:q,previewMeta:O,clampText:M.computed(()=>Se(p()).join(" ")),sizeText:M.computed(()=>k(l(p()).iconSize)),radiusText:M.computed(()=>k(l(p()).iconRadius)),sizePx:M.computed(()=>k(l(p()).iconSize)),radiusPx:M.computed(()=>k(l(p()).iconRadius)),glyphPx:M.computed(()=>k(Math.round(l(p()).iconSize*20/52))),positionText:M.computed(()=>xe[s.position.value]||s.position.value)},de=E.tabs(a.querySelector(".ssx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});r(()=>de.destroy?.());const fe=M.applyBindings(pe,a,{methods:{save:()=>W(),discard:()=>X(),useNavbar:()=>{s.displayMode.value="navbar"},useFloating:()=>{s.displayMode.value="floating"},setPos:e=>{s.position.value=e}}});r(()=>fe.dispose()),r(ge(a,{position:"bottom"}));const me=e=>[{header:e},{label:"Open on the site",icon:"external-link",action:()=>window.open(e,"_blank","noopener")},{label:"Edit the page",icon:"edit",action:()=>{location.hash=`#/pages/edit${e}`}},{label:"Copy address",icon:"copy",action:()=>{const t=new URL(e,location.origin).href;navigator.clipboard?.writeText(t).then(()=>E.toast("Address copied.",{type:"success"}),()=>E.toast(t,{type:"info",duration:8e3}))}}],ve=E.contextMenu(v,{cursor:"glyph",match:".ssx-row",inherit:!1,density:"compact",items:e=>me(e.dataset.url)});r(()=>ve?.destroy?.());const Z=e=>{const t=e.target.closest(".ssx-row");if(!t)return;const n=e.target.closest(".ssx-kebab");if(n){oe(n);return}window.open(t.dataset.url,"_blank","noopener")},ee=e=>{const t=e.target.closest(".ssx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),oe(t)):e.key==="Enter"&&e.target===t&&(e.preventDefault(),e.stopPropagation(),window.open(t.dataset.url,"_blank","noopener")))};v.addEventListener("click",Z),v.addEventListener("keydown",ee),r(()=>{v.removeEventListener("click",Z),v.removeEventListener("keydown",ee)});let j=null;try{j=c?S.get(c):null}catch{}const u=ke(j,g.peek(),L);let F=!u;const te=()=>{if(c)if(f.peek())try{S.set(c,{owner:L,form:h(),was:z,at:new Date().toISOString()})}catch{}else F&&w()},se=e=>{f.peek()&&(te(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",se),r(()=>window.removeEventListener("beforeunload",se));const U={save:W,discard:X,dirty:()=>f.peek(),dispose(){B=!0,te(),K.splice(0).reverse().forEach(e=>{try{e()}catch{}})}};if(re(U),!T())return U;if(I.scan(a),!u&&j&&w(),u){const e=we({when:u.at?Pe(u.at):"",stale:u.stale});if(await new Promise(n=>setTimeout(n,0)),await E.confirm(e.text,{title:e.title,confirmText:e.confirmText,cancelText:e.cancelText}))w(),F=!0;else if(T()){F=!0;const n={...D(R.peek()),...u.form};m=Number(n.iconSize)||52;for(const[o,i]of Object.entries(n))s[o]&&(s[o].value=i);z=u.was||z}}return U}export const searchView={template:"",onMount(){location.replace("#/settings?tab=search")}};
|