domma-cms 0.116.8 → 0.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -4
- package/admin/js/app.js +2 -2
- package/admin/js/lib/api-tokens-arrange.js +3 -3
- package/admin/js/lib/content-api-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +1 -1
- package/admin/js/lib/menu-rows.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +1 -1
- package/admin/js/templates/api-reference.html +2 -1
- package/admin/js/templates/api-tokens.html +3 -3
- package/admin/js/templates/content-api.html +208 -0
- package/admin/js/templates/docs/api-content.html +183 -0
- package/admin/js/templates/docs/api-external.html +2 -1
- package/admin/js/templates/docs/usage-editions.html +1 -1
- package/admin/js/templates/menu-editor.html +6 -0
- package/admin/js/views/api-tokens.js +6 -6
- package/admin/js/views/content-api.js +7 -0
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +14 -14
- package/package.json +1 -1
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/site.js +1 -1
- package/server/routes/api/collections.js +10 -17
- package/server/routes/api/content-api.js +70 -0
- package/server/routes/api/content-public.js +299 -0
- package/server/routes/public.js +17 -68
- package/server/server.js +11 -4
- package/server/services/apiTokens.js +61 -5
- package/server/services/content.js +4 -4
- package/server/services/contentApi.js +248 -0
- package/server/services/menuRender.js +9 -3
- package/server/services/menus.js +4 -1
- package/server/services/pagePublishing.js +85 -0
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/renderer.js +12 -2
- package/server/services/search.js +31 -12
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<h1><span data-icon="code"></span> API · Content API</h1>
|
|
3
|
+
<a href="#/api-reference" class="btn btn-ghost btn-sm"><span data-icon="arrow-left"></span> API overview</a>
|
|
4
|
+
</div>
|
|
5
|
+
<style>
|
|
6
|
+
.method-badge {
|
|
7
|
+
display: inline-block;
|
|
8
|
+
font-size: 11px;
|
|
9
|
+
font-weight: 700;
|
|
10
|
+
letter-spacing: 0.5px;
|
|
11
|
+
padding: 2px 7px;
|
|
12
|
+
border-radius: 4px;
|
|
13
|
+
font-family: var(--dm-font-mono, monospace);
|
|
14
|
+
margin-right: 6px;
|
|
15
|
+
vertical-align: middle;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.method-get {
|
|
19
|
+
background: color-mix(in srgb, var(--dm-success) 15%, transparent);
|
|
20
|
+
color: var(--dm-success);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.method-post {
|
|
24
|
+
background: color-mix(in srgb, var(--dm-primary) 15%, transparent);
|
|
25
|
+
color: var(--dm-primary);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.method-put {
|
|
29
|
+
background: color-mix(in srgb, var(--dm-warning) 15%, transparent);
|
|
30
|
+
color: var(--dm-warning-dark);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.method-patch {
|
|
34
|
+
background: color-mix(in srgb, var(--dm-info) 15%, transparent);
|
|
35
|
+
color: var(--dm-info);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.method-delete {
|
|
39
|
+
background: color-mix(in srgb, var(--dm-danger) 15%, transparent);
|
|
40
|
+
color: var(--dm-danger);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.endpoint-path {
|
|
44
|
+
font-family: var(--dm-font-mono, monospace);
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.auth-note {
|
|
49
|
+
font-size: 12px;
|
|
50
|
+
color: var(--dm-text-muted, #6b7280);
|
|
51
|
+
margin: 4px 0 12px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.auth-note code {
|
|
55
|
+
font-size: 11px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.docs-body h3 {
|
|
59
|
+
margin-top: 24px;
|
|
60
|
+
margin-bottom: 8px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.docs-body h3:first-child {
|
|
64
|
+
margin-top: 0;
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
<div class="row">
|
|
70
|
+
<div class="col-12">
|
|
71
|
+
<div class="docs-body">
|
|
72
|
+
<p>The Content API is read-only, headless access to what the site publishes - pages, menus, the site settings
|
|
73
|
+
public pages already show, and search - as JSON at <code>/api/content/v1</code>. Where the
|
|
74
|
+
<a href="#/docs/api/external">External API</a> exposes collection <em>data</em>, this exposes
|
|
75
|
+
<em>content</em>.</p>
|
|
76
|
+
<p><strong>It is off until you turn it on</strong> at <a href="#/content-api">System > Content API</a>
|
|
77
|
+
(saved in <code>config/content-api.json</code>); until then every address answers <code>404</code>.</p>
|
|
78
|
+
|
|
79
|
+
<h2>Endpoints</h2>
|
|
80
|
+
<table class="table table-sm">
|
|
81
|
+
<thead><tr><th>Method</th><th>Path</th><th>Returns</th></tr></thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
<tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/pages</code></td><td><code>{ pages, total, page, limit }</code> - summaries, no bodies</td></tr>
|
|
84
|
+
<tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/pages/<path></code></td><td>One page. <code>/pages/</code> is the home page; a directory path serves its index</td></tr>
|
|
85
|
+
<tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/menus/:location</code></td><td><code>{ location, slug, name, items }</code></td></tr>
|
|
86
|
+
<tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/site</code></td><td>Title, brand, footer, social links, SEO defaults, theme</td></tr>
|
|
87
|
+
<tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/search?q=</code></td><td><code>{ results: [{ urlPath, title, description, tags, snippet, score }] }</code></td></tr>
|
|
88
|
+
</tbody>
|
|
89
|
+
</table>
|
|
90
|
+
|
|
91
|
+
<h2>Which pages</h2>
|
|
92
|
+
<p>A page the API serves is exactly a page the site would serve the same caller - the two make one decision.</p>
|
|
93
|
+
<ul>
|
|
94
|
+
<li>Only published pages. Drafts, scheduled and status-less pages answer <code>404</code>.</li>
|
|
95
|
+
<li>Visibility is the site's: a role-gated page is <code>403</code> to a caller who could not open it.</li>
|
|
96
|
+
<li>A page in a switched-off project is <code>404</code>.</li>
|
|
97
|
+
<li>Front matter <code>api: false</code> keeps a page off the API - not served, listed or searched. It still shows on the site.</li>
|
|
98
|
+
</ul>
|
|
99
|
+
|
|
100
|
+
<h3>Page list parameters</h3>
|
|
101
|
+
<table class="table table-sm">
|
|
102
|
+
<thead><tr><th>Parameter</th><th>Default</th><th>Description</th></tr></thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
<tr><td><code>page</code></td><td><code>1</code></td><td>Page number</td></tr>
|
|
105
|
+
<tr><td><code>limit</code></td><td><code>50</code></td><td>Page size, capped at the site's page size limit (default 200). <code>0</code> means the default</td></tr>
|
|
106
|
+
<tr><td><code>prefix</code></td><td>-</td><td>Only pages whose path starts with this, e.g. <code>/services</code></td></tr>
|
|
107
|
+
<tr><td><code>tag</code> / <code>category</code></td><td>-</td><td>Only pages with this tag, or in this category</td></tr>
|
|
108
|
+
<tr><td><code>project</code></td><td>-</td><td>Only pages in this project</td></tr>
|
|
109
|
+
<tr><td><code>sort</code> / <code>order</code></td><td>site order</td><td><code>title</code>, <code>updatedAt</code> or <code>createdAt</code>; <code>order=desc</code> reverses</td></tr>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
<p>The list can be switched off on its own (single pages stay), so a caller then has to know a page's path.</p>
|
|
113
|
+
|
|
114
|
+
<h3>A page</h3>
|
|
115
|
+
<pre class="code-block"><code class="language-json">GET /api/content/v1/pages/about?format=html (html | markdown | both)
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
"urlPath": "/about", "title": "About", "description": "…", "tags": ["company"],
|
|
119
|
+
"category": "general", "layout": "default", "createdAt": "…", "updatedAt": "…",
|
|
120
|
+
"seo": { "title": "…", "description": "…", "image": "…" },
|
|
121
|
+
"html": "<section>…</section>",
|
|
122
|
+
"requires": { "domma": true, "components": ["tabs", "effects"] },
|
|
123
|
+
"perViewer": false
|
|
124
|
+
}</code></pre>
|
|
125
|
+
<ul>
|
|
126
|
+
<li><code>html</code> is the page body (no navbar or footer), shortcodes already rendered. <code>markdown</code> is the source, shortcodes included. A format the site does not offer answers <code>400</code>.</li>
|
|
127
|
+
<li><code>requires</code> names the Domma behaviour the HTML relies on - tabs, accordions, carousels, countdowns, tooltips, reveals, effects, forms and custom components. Without Domma's CSS and JS those parts do nothing.</li>
|
|
128
|
+
<li>Only the front matter keys shown are returned - never your own custom keys.</li>
|
|
129
|
+
<li><code>perViewer: true</code> - the body was rendered for this caller and must not be shared between users.</li>
|
|
130
|
+
<li>Drafts: in role mode only, <code>?preview=1</code> from someone with <code>pages.read</code> returns unpublished pages, never cached.</li>
|
|
131
|
+
</ul>
|
|
132
|
+
|
|
133
|
+
<h3>Menus, site settings and search</h3>
|
|
134
|
+
<ul>
|
|
135
|
+
<li><strong>Menus</strong> - <code>?page=/about</code> resolves page-bound menus as on that page. Items carry only what a navbar needs (<code>type</code>, <code>text</code>, <code>url</code>, <code>icon</code>, <code>target</code>, <code>size</code>, <code>badge</code>, <code>items</code>) and are filtered for the caller. The admin sidebar and page overlays are never served.</li>
|
|
136
|
+
<li><strong>Site</strong> - title, brand, footer (hidden links stay hidden), social links, SEO defaults and theme. Never mail settings, the site URL or anything from the admin.</li>
|
|
137
|
+
<li><strong>Search</strong> - the site's own search. A query shorter than the site's minimum is <code>400</code>; with search off in System > Search it is <code>404</code>.</li>
|
|
138
|
+
</ul>
|
|
139
|
+
|
|
140
|
+
<h2>Access</h2>
|
|
141
|
+
<table class="table table-sm">
|
|
142
|
+
<thead><tr><th>Who may call it</th><th>Credential</th><th>Pages rendered as</th></tr></thead>
|
|
143
|
+
<tbody>
|
|
144
|
+
<tr><td>Public</td><td>None</td><td>An anonymous visitor</td></tr>
|
|
145
|
+
<tr><td>API token (default)</td><td><code>Authorization: Bearer dcms_…</code> - a JWT is refused</td><td>An anonymous visitor</td></tr>
|
|
146
|
+
<tr><td>A role</td><td><code>Authorization: Bearer <access JWT></code> of that role or more senior. The site's login cookie is never accepted</td><td>That user</td></tr>
|
|
147
|
+
</tbody>
|
|
148
|
+
</table>
|
|
149
|
+
<ul>
|
|
150
|
+
<li>A token reads only the pages of <strong>its own project</strong> - single pages, the list and search. A page in another project is <code>404</code>. Menus and site settings are site-wide, so any valid token may read them.</li>
|
|
151
|
+
<li>Scopes narrow a token: in the token editor, a line of <code>@pages</code>, <code>@menus</code>, <code>@site</code> or <code>@search</code> (read-only, no verbs). A token with no scopes covers everything in its project; one whose scopes name only collections gets no content.</li>
|
|
152
|
+
<li>A role the site does not have is refused when you save the settings.</li>
|
|
153
|
+
</ul>
|
|
154
|
+
|
|
155
|
+
<h2>Responses</h2>
|
|
156
|
+
<table class="table table-sm">
|
|
157
|
+
<thead><tr><th>Case</th><th>Status</th><th>Cache-Control</th></tr></thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
<tr><td>Public</td><td><code>200</code></td><td><code>public, max-age=60</code> + ETag</td></tr>
|
|
160
|
+
<tr><td>API token</td><td><code>200</code></td><td><code>private, no-cache</code></td></tr>
|
|
161
|
+
<tr><td>Role, or a draft</td><td><code>200</code></td><td><code>no-store</code></td></tr>
|
|
162
|
+
<tr><td>Off, missing, unpublished, disabled project, <code>api: false</code></td><td><code>404</code></td><td><code>no-store</code></td></tr>
|
|
163
|
+
<tr><td>No or invalid credential</td><td><code>401</code></td><td><code>no-store</code></td></tr>
|
|
164
|
+
<tr><td>Role too junior, scope or visibility refuses</td><td><code>403</code></td><td><code>no-store</code></td></tr>
|
|
165
|
+
<tr><td>Format not offered, query too short</td><td><code>400</code></td><td><code>no-store</code></td></tr>
|
|
166
|
+
</tbody>
|
|
167
|
+
</table>
|
|
168
|
+
|
|
169
|
+
<h2>Calling it</h2>
|
|
170
|
+
<pre class="code-block"><code class="language-bash"># Public mode
|
|
171
|
+
curl 'https://example.com/api/content/v1/pages?prefix=/blog&limit=10'
|
|
172
|
+
|
|
173
|
+
# Token mode
|
|
174
|
+
curl -H 'Authorization: Bearer dcms_0123...cdef' \
|
|
175
|
+
'https://example.com/api/content/v1/pages/about?format=both'</code></pre>
|
|
176
|
+
<p>A browser on <strong>another domain</strong> can call it only when that origin is allowed by
|
|
177
|
+
<code>cors.origin</code> in <code>config/server.json</code>. With a token or a role the browser sends a
|
|
178
|
+
preflight first; allowed origins are answered automatically. Calls from a server are unaffected.</p>
|
|
179
|
+
<p>Try any page as an anonymous caller would get it - before switching the API on - on the
|
|
180
|
+
<a href="#/content-api">Content API</a> screen's <strong>Try it</strong> tab.</p>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
@@ -150,7 +150,8 @@
|
|
|
150
150
|
later.</li>
|
|
151
151
|
<li>Optionally add <strong>scopes</strong>, one line per collection: <code>jobs: read</code>,
|
|
152
152
|
<code>enquiries: create, read</code>. No lines means every collection in the project; a collection with no
|
|
153
|
-
verbs means all four
|
|
153
|
+
verbs means all four. A line of <code>@pages</code>, <code>@menus</code>, <code>@site</code> or
|
|
154
|
+
<code>@search</code> is a <a href="#/docs/api/content">Content API</a> scope - read-only, no verbs.</li>
|
|
154
155
|
<li>Optionally set <strong>expires</strong>. From then on every call with it is refused; the sidebar badge warns
|
|
155
156
|
14 days ahead and turns red once one has expired.</li>
|
|
156
157
|
<li>Save and <strong>copy the token now</strong> - <code>dcms_</code> followed by 64 hex characters. It is never
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
<li><strong>Design</strong> - built-in themes, menus, right-click menus and a visitor theme switcher.</li>
|
|
31
31
|
<li><strong>Data</strong> - collections on file storage, saved views, a form builder, and public collection
|
|
32
32
|
displays.</li>
|
|
33
|
-
<li><strong>API</strong> - the <code>/api/v1</code> collection API, API tokens and API Builder endpoints.</li>
|
|
33
|
+
<li><strong>API</strong> - the <code>/api/v1</code> collection API, the <code>/api/content/v1</code> Content API, API tokens and API Builder endpoints.</li>
|
|
34
34
|
<li><strong>Admin</strong> - dashboard, notifications, in-admin docs, projects, roles and permissions.</li>
|
|
35
35
|
<li><strong>Built-in Tools</strong> - Notes, Todo, Analytics, Contacts and SEO. Each can be switched off in
|
|
36
36
|
the <a href="#/plugins">Marketplace</a> under "Built-in Tools".</li>
|
|
@@ -212,6 +212,12 @@
|
|
|
212
212
|
</div>
|
|
213
213
|
<small class="form-hint">Used by the colour, underline, overline, tint, glow and pill effects. The underline and overline effects fall back to the item's own text colour when this is unset.</small>
|
|
214
214
|
</div>
|
|
215
|
+
<div class="form-group">
|
|
216
|
+
<label for="me-class">CSS classes
|
|
217
|
+
<span data-help-title="CSS classes" data-help="Your own class names, separated by spaces, added to the menu's outer element - the navbar, the footer column or the [menu] nav. Style them in Custom CSS. Each item can carry classes of its own too, in its editor."></span></label>
|
|
218
|
+
<input type="text" id="me-class" name="class" class="form-input" placeholder="site-nav is-compact" autocomplete="off" spellcheck="false">
|
|
219
|
+
<small class="form-hint">Letters, digits, hyphens and underscores; no dots. For styling in Custom CSS.</small>
|
|
220
|
+
</div>
|
|
215
221
|
<div class="form-group">
|
|
216
222
|
<label for="me-fontFamily">Font family</label>
|
|
217
223
|
<input type="text" id="me-fontFamily" name="fontFamily" class="form-input" placeholder="Inter">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as g}from"../api.js";import{addBannerAction as
|
|
1
|
+
import{api as g}from"../api.js";import{addBannerAction as Te}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 Ae}from"../lib/tool-kit.js";import{FILTERS as Le,arrangeTokens as $e,checkToken as Ie,countStates as Oe,scopesToText as Ne,toLocalInput as Pe,tokenRow as qe,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
2
|
<div class="atx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="atx-icon" aria-hidden="true"><span data-icon="key" data-icon-size="16"></span></span>
|
|
4
4
|
<span class="atx-main"><strong>{{name}}</strong><small><code>{{hint}}</code> \xB7 {{meta}}</small></span>
|
|
@@ -8,7 +8,7 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
|
|
|
8
8
|
</span>
|
|
9
9
|
<span class="atx-used" data-bind-text="used"></span>
|
|
10
10
|
<button type="button" class="atx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
11
|
-
</div>`,
|
|
11
|
+
</div>`,L=c=>String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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=Ae(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,x=M.observable([]),j=M.observable([]),D=M.observable(!0),_=M.observable("");let U="all";try{U=Le.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(x.value,{filter:r.value,project:b.value,query:Y.value},e).map(t=>qe(t,e))}));const V=M.computed(()=>Oe(x.value)),fe=M.computed(()=>x.value.length?"No tokens match.":"No API tokens yet - New token in the banner makes one."),C=e=>x.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;x.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 O=async()=>{Me(Re),$()&&await J()};async function W(e,t){if(!(!t&&!await E.confirm(`Switch off "${L(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 O()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function me(e){if(await E.confirm(`Revoke "${L(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 O()}catch(t){E.toast(`Not revoked: ${t.message||t}`,{type:"error"})}}let h=null,y=null;const T=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 N=await E.confirm(a);if(u=!1,!N)return}return s=!0,o()},()=>{s=!0}}function A(e=null,t=null){const a=e?C(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:Pe(a?.expiresAt),scopes:Ne(a?.scopes),enabled:a?a.enabled!==!1:!0,...t||{}},v=Object.fromEntries(Object.entries(u).map(([n,i])=>[n,M.observable(i)])),N=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=N(u),te=()=>N(ee())!==ge||!!t,P=M.observable(!1),K=M.computed(()=>Ie(Object.fromEntries(Object.keys(v).map(n=>[n,v[n].value])),{isNew:o,projects:j.value.map(n=>n.slug)})),q=n=>M.computed(()=>K.value.errors[n]||""),Ee=a?ce(a):null,ae={...v,saving:P,isNew:o,notNew:!o,nameErr:q("name"),projectErr:q("project"),scopesErr:q("scopes"),expiresErr:q("expires"),cannot:M.computed(()=>P.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 ${L(a.name)}`,size:"md",position:"right"}),w=pe(`
|
|
12
12
|
<div class="atx-form">
|
|
13
13
|
<div>
|
|
14
14
|
<label class="form-label" for="atx-f-name">Name <span class="text-danger">*</span></label>
|
|
@@ -27,8 +27,8 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
|
|
|
27
27
|
</div>
|
|
28
28
|
<div>
|
|
29
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
|
|
31
|
-
<textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read enquiries: create, read" data-model="scopes.value"></textarea>
|
|
30
|
+
<span data-help-title="Scopes" data-help="Leave empty and the token may use every collection in its project that allows token access, and the Content API where it is set to API tokens. Otherwise one line each: a 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. For the Content API, a line of @pages, @menus, @site or @search - read-only, no verbs. A token with only collection lines gets no content."></span></label>
|
|
31
|
+
<textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read enquiries: create, read @pages" data-model="scopes.value"></textarea>
|
|
32
32
|
<small class="atx-err" data-bind-hidden="!scopesErr.value" data-bind-text="scopesErr.value"></small>
|
|
33
33
|
</div>
|
|
34
34
|
<div>
|
|
@@ -49,7 +49,7 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
|
|
|
49
49
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
|
|
50
50
|
<span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
|
|
51
51
|
</div>
|
|
52
|
-
</div>`),ne=
|
|
52
|
+
</div>`),ne=w.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;P.value=!0;const{payload:n}=K.peek();try{if(o){const i=await g.apiTokens.create(n);B(),f.close(),await O(),be(i?.plaintext,n.name)}else await g.apiTokens.update(a.id,n),E.toast("Saved.",{type:"success"}),B(),f.close(),await O()}catch(i){E.toast(`Not saved: ${i.message||i}`,{type:"error"})}finally{P.value=!1}}f.element.appendChild(w);const je=ie(w),Se=M.applyBindings(ae,w,{methods:{save:oe,cancel:()=>f.close()}});ne.value=v.project.peek();const Ce=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),oe())};w.addEventListener("keydown",Ce);const se={id:a?.id??null,dirty:te,values:ee};h=se,T.add(f),le(f,()=>{Se.dispose(),je(),T.delete(f),h===se&&(h=null)}),B=G(f,te,"Discard the changes you have not saved?"),I.scan(w),f.open(),w.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
53
|
<div class="atx-secret">
|
|
54
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
55
|
<code class="atx-token"></code>
|
|
@@ -59,4 +59,4 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
|
|
|
59
59
|
<button type="button" class="btn btn-sm btn-primary atx-copy"><span data-icon="copy" data-icon-size="13"></span> Copy</button>
|
|
60
60
|
<button type="button" class="btn btn-sm btn-ghost atx-done">Done</button>
|
|
61
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,
|
|
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,T.add(o),le(o,()=>{T.delete(o),y===u&&(y=null),e=null}),G(o,()=>!a,`You have not copied the token for "${L(t)}". Once this closes it can never be shown again. Close anyway?`),I.scan(s),o.open()}function he(e){const t=C(e);if(!t)return[];const{state:a}=ce(t);return[{header:t.name||"Token"},{label:"Edit\u2026",icon:"edit",action:()=>A(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 we=M.applyBindings({tokens:x,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(()=>we.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 xe=E.contextMenu(m,{cursor:"glyph",match:".atx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.id)});l.add(()=>xe?.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}A(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&&A(t.dataset.id))};m.addEventListener("click",Q),m.addEventListener("keydown",X),l.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),Te(c,{id:"api-tokens-new",icon:"plus",title:"New token",onClick:()=>A()});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,[...T].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&&C(p.id)?`your changes to "${L(C(p.id).name)}"`:"the new token you started";await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&A(p.id&&C(p.id)?p.id:null,p.values)}}};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import{apiRequest as m}from"../api.js";import{addBannerAction as le}from"../lib/plugin-chrome.js";import{helpPopovers as we}from"../lib/help-popover.js";import{openMenuAt as ue,sidebarCount as Ee}from"../lib/tool-kit.js";import{SHAPES as Se,whileLoading as Me}from"../lib/loading.js";import{fmtDateTime as xe}from"../lib/dates.js";import{accessOptions as Ce,roleLadder as Te}from"../lib/collection-editor-arrange.js";import{checkForm as Ae,curlExample as Oe,endpointRows as Pe,formFromSettings as g,liveChips as Le,normalisePath as Ie,payloadFromForm as b,previewRow as Re,saveNotes as $e,snapshot as H}from"../lib/content-api-arrange.js";const B=/^\/content-api(\?.*)?$/,je="#/content-api",y="content-api.draft",qe='<span class="cax-chip" data-bind-class="cls">{{text}}</span>',De=`
|
|
2
|
+
<div class="cax-row" data-bind-class="cls" data-path="{{path}}" tabindex="0">
|
|
3
|
+
<span class="cax-method">{{method}}</span>
|
|
4
|
+
<span class="cax-main"><code>{{path}}</code><small>{{label}}</small></span>
|
|
5
|
+
<span class="cax-chip" data-bind-class="chipCls">{{stateText}}</span>
|
|
6
|
+
<button type="button" class="cax-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
|
|
7
|
+
</div>`;let l=null,pe=!1;function Fe(){pe||!window.R?.use||(pe=!0,R.use((c,i,s)=>!(B.test(i?.path||"")&&!B.test(c?.path||""))||!l?.dirty()?s():E.confirm("You have unsaved Content API settings. Leave anyway? They are kept as a draft for your next visit.").then(a=>{a?s():history.replaceState(null,"","#/content-api")})),M.subscribe("router:afterChange",({to:c}={})=>{if(l&&!B.test(c?.path||"")){const i=l;l=null,i.dispose()}}))}function J(c,i){const s=()=>E.toast(`${i} copied.`,{type:"success"}),d=()=>E.toast(c,{type:"info",duration:8e3});navigator.clipboard?.writeText?navigator.clipboard.writeText(c).then(s,d):d()}export const contentApiView={templateUrl:"/admin/js/templates/content-api.html",async onMount(c){if(Fe(),l){const e=l;l=null,e.dispose()}const i=[],s=e=>(i.push(e),e);let d=!1;const a=c.get(0).querySelector(".cax")||c.get(0),w=()=>!d&&a.isConnected,Y=M.observable(!0);let $,j=null,x=[];try{[$,j,x]=await Me(a.querySelector(".dm-loading-slot"),Promise.all([m("/content-api/settings"),m("/content-api/_count").catch(()=>null),m("/collections/roles/entries?limit=1000").then(Te).catch(()=>[])]),Se.rows)}catch(e){E.toast(`Could not load the Content API settings: ${e.message||e}`,{type:"error"});return}if(!a.isConnected)return;const h=M.observable($),W=g($),r={};for(const[e,t]of Object.entries(W))r[e]=M.observable(t);const C=()=>Object.fromEntries(Object.keys(r).map(e=>[e,r[e].peek()])),k=()=>Object.fromEntries(Object.keys(r).map(e=>[e,r[e].value])),T=M.observable(H(W)),A=M.computed(()=>H(k())!==T.value),O=M.observable(!1),V=M.observable(j?{served:j.count}:null),z=M.observable([]);s(M.effect(()=>{z.value=Le(h.value,V.value,x).map(e=>({text:e.text,cls:e.tone?`is-${e.tone}`:""}))}));const q=M.computed(()=>Ae(k(),x).access||""),de=M.computed(()=>$e(k()).join(" ")),G=M.computed(()=>Pe(location.origin,b(k())).map(e=>({...e,stateText:e.state==="on"?"On":e.note||"Off",cls:e.state==="off"?"is-off":"",chipCls:e.state==="on"?"is-ok":""}))),Q=a.querySelector("#cax-access"),P=e=>{Q.replaceChildren(...Ce(x,e).map(t=>new Option(t.label,t.value))),Q.value=e};P(r.access.peek());const D=M.observable("/"),F=M.observable("html"),N=M.observable(!1),_=M.observable(""),X=M.observable(""),Z=M.observable(""),ee=M.observable(""),te=M.observable(""),oe=M.observable(""),K=M.observable(""),ne=M.computed(()=>Oe(location.origin,D.value,b(k()),F.value));let L=0;async function ve(){const e=++L,t=Ie(D.peek());N.value=!0,K.value="";try{const o=await m(`/content-api/preview?path=${encodeURIComponent(t)}&format=${encodeURIComponent(F.peek())}&as=anon`);if(e!==L||!w())return;const n=Re(o);_.value=n.statusText,X.value=`is-${n.tone}`,Z.value=n.requiresText,ee.value=n.why,te.value=n.offeredNote,oe.value=JSON.stringify(o.body,null,2)}catch(o){if(e!==L)return;_.value="",K.value=`Could not fetch the page: ${o.message||o}`}finally{e===L&&(N.value=!1)}}async function U(){if(!O.peek()){if(q.peek()){E.toast(q.peek(),{type:"error"});return}O.value=!0;try{const e=await m("/content-api/settings",{method:"PUT",body:JSON.stringify(b(C()))});if(!w())return;const t=e?.settings||b(C());h.value=t;const o=g(t);for(const[p,f]of Object.entries(o))r[p].value=f;P(o.access),T.value=H(o);try{S.remove?S.remove(y):S.set(y,null)}catch{}Ee(je);const n=await m("/content-api/_count").catch(()=>null);n&&w()&&(V.value={served:n.count}),E.toast(t.enabled?"Saved - the Content API is on.":"Saved - the Content API is off.",{type:"success"})}catch(e){E.toast(`Not saved: ${e.message||e}`,{type:"error"})}finally{O.value=!1}}}function fe(){const e=g(h.peek());for(const[t,o]of Object.entries(e))r[t].value=o;P(e.access)}a.querySelector(".cax-chips").innerHTML=qe;const v=a.querySelector(".cax-endpoints");v.innerHTML=De;const me={...r,loading:Y,dirty:A,saving:O,chips:z,accessErr:q,notesText:de,endpoints:G,tryPath:D,tryFormat:F,running:N,tryStatus:_,tryTone:X,tryRequires:Z,tryWhy:ee,tryOffered:te,tryJson:oe,tryError:K,curl:ne};Y.value=!1;const be=E.tabs(a.querySelector(".cax-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),ye=M.applyBindings(me,a,{methods:{save:()=>U(),discard:()=>fe(),run:()=>ve(),copyCurl:()=>J(ne.peek(),"curl command")}});s(()=>ye.dispose()),s(we(a,{position:"bottom"}));const he=e=>G.peek().find(t=>t.path===e),ke=e=>{const t=he(e.dataset.path);if(!t)return[];const o=b(C()),n=o.access==="public"?`curl '${t.url}'`:`curl -H 'Authorization: Bearer ${o.access==="token"?"dcms_\u2026":"<access JWT>"}' '${t.url}'`,p=[{header:t.path},{label:"Copy address",icon:"copy",action:()=>J(t.url,"Address")},{label:"Copy curl command",icon:"code",action:()=>J(n,"curl command")}],f=b(g(h.peek()));return f.enabled&&f.access==="public"&&t.state==="on"&&p.push({label:"Open in a new tab",icon:"external-link",action:()=>window.open(t.url,"_blank","noopener")}),p},ge=E.contextMenu(v,{cursor:"glyph",match:".cax-row",inherit:!1,density:"compact",items:ke});s(()=>ge?.destroy?.());const se=e=>{const t=e.target.closest(".cax-kebab");t&&ue(t)},ae=e=>{const t=e.target.closest(".cax-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ue(t))};v.addEventListener("click",se),v.addEventListener("keydown",ae),s(()=>{v.removeEventListener("click",se),v.removeEventListener("keydown",ae)}),le(c,{id:"content-api-tokens",icon:"key",title:"API Tokens",onClick:()=>{location.hash="#/api-tokens"}}),le(c,{id:"content-api-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>U()});const ce=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),U())};document.addEventListener("keydown",ce,!0),s(()=>document.removeEventListener("keydown",ce,!0));const re=()=>{if(A.peek())try{S.set(y,{form:C(),base:T.peek(),at:new Date().toISOString()})}catch{}},ie=e=>{A.peek()&&(re(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ie),s(()=>window.removeEventListener("beforeunload",ie)),l={dirty:()=>A.peek(),dispose(){d=!0,re(),i.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a);let u=null;try{u=S.get(y)}catch{}if(u?.form){try{S.remove?S.remove(y):S.set(y,null)}catch{}const e=u.base&&u.base!==T.peek(),t=u.at?` (${xe(u.at)})`:"",o=e?`You left unsaved Content API settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved Content API settings${t}. Restore them?`;if(await E.confirm(o)&&w()){const n={...g(h.peek()),...u.form};for(const[p,f]of Object.entries(n))r[p]&&(r[p].value=f);P(n.access)}}}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,
|
|
1
|
+
import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,n){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof n=="function"&&n(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const n=o.find("#tutorial-scaffolder-mount").get(0);n&&i(n,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const c=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","tools"],a=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","content","builder"],s=o=>o.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join("");for(const o of c)docViews["usage"+s(o)]=t(`usage-${o}`);for(const o of a)docViews["apiRef"+s(o)]=t(`api-${o}`);
|
package/admin/js/views/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as a}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as d}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as
|
|
1
|
+
import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as a}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as d}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as A}from"./components.js";import{componentEditorView as C}from"./component-editor.js";import{myProfileView as N}from"./my-profile.js";import{rolesView as T}from"./roles.js";import{roleEditorView as U}from"./role-editor.js";import{effectsView as q}from"./effects.js";import{searchView as z}from"./search.js";import{contentApiView as B}from"./content-api.js";import{notesView as F}from"./notes.js";import{todoView as H}from"./todo.js";import{calculatorView as I}from"../lib/calculator.js";import{analyticsView as J}from"./analytics.js";import{seoView as K}from"./seo.js";import{contactsView as O}from"./contacts.js";import{callNewView as Q}from"./call-new.js";import{menusView as W}from"./menus.js";import{contextMenusView as X}from"./context-menus.js";import{contextMenuEditorView as Y}from"./context-menu-editor.js";import{menuEditorView as Z}from"./menu-editor.js";import{menuLocationsView as _}from"./menu-locations.js";import{projectsView as $}from"./projects.js";import{projectDetailView as oo}from"./project-detail.js";import{projectSettingsView as io}from"./project-settings.js";import{apiTokensView as ro}from"./api-tokens.js";import{apiEndpointsView as to}from"./api-endpoints.js";import{apiEndpointEditorView as eo}from"./api-endpoint-editor.js";const mo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:$,projectDetail:oo,projectSettings:io,apiTokens:ro,apiEndpoints:to,apiEndpointEditor:eo,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,layouts:p,media:n,login:s,users:f,userEditor:w,plugins:c,pluginCode:V,documentation:a,tutorials:l,apiReference:g,collections:v,collectionEditor:b,collectionEntries:h,forms:y,formEditor:j,formSubmissions:k,viewsList:L,viewEditor:x,viewPreview:M,actionsList:D,actionEditor:G,proDocs:P,blocks:R,blockEditor:S,components:A,componentEditor:C,myProfile:N,roles:T,roleEditor:U,effects:q,search:z,contentApi:B,notes:F,todo:H,calculator:I,analytics:J,seo:K,contacts:O,callNew:Q,notifications:m,menus:W,contextMenus:X,contextMenuEditor:Y,menuEditor:Z,menuLocations:_,menusRedirect:mo,pluginGuides:u,pluginGuide:E,...d};
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as j}from"../api.js";import{colourToCss as kt,HIGHLIGHT_HOVER as st,HIGHLIGHT_ACTIVE as lt,HIGHLIGHT_HOVER_LABELS as it,HIGHLIGHT_ACTIVE_LABELS as rt,cssClassProblems as ct,normaliseCssClasses as St}from"/public/js/menu-decor.mjs?v=20261009-menu-classes";import{openIconPicker as $t}from"../lib/card-builder.js";import{sortableMenuRows as xt}from"../lib/menu-drag.js";import{placeTools as Et,ensureLockedTools as At,isLockedUrl as Ct,groupKeyFor as Lt,slugify as _t,GROUP_ICON as It,TOOLS_FOLDER_KEY as Ht}from"../lib/sidebar-grouping.js";import{flatten as dt,nest as Rt,uid as _e}from"../lib/menu-rows.js";import{addBannerAction as We}from"../lib/plugin-chrome.js";import{helpPopovers as Tt}from"../lib/help-popover.js";import{onceClosed as jt,openMenuAt as qe,sidebarCount as Dt}from"../lib/tool-kit.js";import{addChild as Nt,bindingFrom as Ot,duplicateRow as Pt,indentRow as Ft,insertBelow as Bt,isStructural as ne,menuFromForm as zt,menuProblems as Mt,moveRow as ut,outdentRow as Kt,siblingsOf as Wt,snapshot as Ie}from"../lib/menu-editor-arrange.js";import{fmtDateTime as qt}from"../lib/dates.js";const Ut=760,Gt="admin-sidebar",Vt=/^\/menus\/(new|edit\/[^?#]+)(\?.*)?$/;function b(t){return String(t||"").replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<")}const He=t=>String(t??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Yt(t,c){let l=0,i=c.find(s=>s.id===t);for(;i&&i.parentId;)l++,i=c.find(s=>s.id===i.parentId);return l}function mt(t){const c=new Set,l=new Set,i=s=>{for(const g of t.filter(w=>w.parentId===s))!g.type&&Ct(g.url)&&!l.has(g.url)&&(l.add(g.url),c.add(g.id)),i(g.id)};return i(null),c}const Ue='<button type="button" class="btn btn-sm btn-ghost mex-kebab" aria-label="Actions" title="Actions (right-click, or Shift+F10)">\u22EE</button>';function Xt(t,c,l=!1){const i=l?mt(c):new Set,s=[];function g(w){for(const f of c.filter(P=>P.parentId===w)){const _=`style="margin-left:${Yt(f.id,c)*28}px"`;if(f.type==="separator"){s.push(`
|
|
2
2
|
<div class="menu-tree-row menu-tree-row--separator" data-id="${f.id}" tabindex="0" ${_}>
|
|
3
3
|
<span class="menu-tree-grip" data-icon="grip-vertical" data-icon-size="18" title="Drag to move"></span>
|
|
4
4
|
<span class="me-sep-line"><span class="me-sep-label">Separator</span></span>
|
|
5
|
-
${
|
|
6
|
-
</div>`);continue}if(f.type==="spacer"){const
|
|
5
|
+
${Ue}
|
|
6
|
+
</div>`);continue}if(f.type==="spacer"){const te=["xs","sm","md","lg","xl","flex"].map(F=>`<option value="${F}"${(f.size||"md")===F?" selected":""}>${F==="flex"?"flexible":F}</option>`).join("");s.push(`
|
|
7
7
|
<div class="menu-tree-row menu-tree-row--spacer" data-id="${f.id}" tabindex="0" ${_}>
|
|
8
8
|
<span class="menu-tree-grip" data-icon="grip-vertical" data-icon-size="18" title="Drag to move"></span>
|
|
9
9
|
<span class="me-spacer-line">
|
|
10
10
|
<span class="me-spacer-label">Spacer</span>
|
|
11
|
-
<select class="form-input me-spacer-size" title="Spacer size - flexible fills the leftover room" aria-label="Spacer size">${
|
|
11
|
+
<select class="form-input me-spacer-size" title="Spacer size - flexible fills the leftover room" aria-label="Spacer size">${te}</select>
|
|
12
12
|
</span>
|
|
13
|
-
${
|
|
14
|
-
</div>`);continue}const
|
|
13
|
+
${Ue}
|
|
14
|
+
</div>`);continue}const Y=f.badge&&(f.badge.text||f.badge.countFrom)?`<span class="dm-menu-badge">${b(String(f.badge.text||"#"))}</span>`:"",A=f.tool,h=A&&(A.persisted||f.touched),K=A?A.orphan?' <span class="badge badge-secondary me-tool-badge" title="A placeholder for a Tool that is not enabled - it returns here when it is">Not enabled</span>':` <span class="badge badge-secondary me-tool-badge" title="${h?"A Tool, placed here by this menu":"A Tool in its default place - move or edit it to place it here"}">${b(A.source||"Tool")}${h?"":" \xB7 default"}</span>`:"",oe=i.has(f.id),ee=kt(f.colour),W=ee?` style="color:${b(ee)}"`:"",ae=oe?'<button type="button" class="btn btn-sm btn-ghost" disabled title="Always shown - it can be moved, not hidden" aria-label="Always shown"><span data-icon="lock"></span></button>':`<button type="button" class="btn btn-sm btn-ghost me-hidden ${f.hidden?"active":""}" title="${f.hidden?"Hidden - click to show":"Hide"}" aria-label="${f.hidden?"Show":"Hide"}" aria-pressed="${f.hidden?"true":"false"}"><span data-icon="${f.hidden?"eye-off":"eye"}"></span></button>`;s.push(`
|
|
15
15
|
<div class="menu-tree-row menu-tree-row--slim${f.hidden?" menu-tree-row--hidden":""}${A?" menu-tree-row--tool":""}${A&&!h?" menu-tree-row--default":""}" data-id="${f.id}" tabindex="0" ${_}>
|
|
16
16
|
<span class="menu-tree-grip" data-icon="grip-vertical" data-icon-size="18" title="Drag to move"></span>
|
|
17
|
-
<span class="me-row-label${!f.text&&!A?" mex-untitled":""}"${
|
|
18
|
-
<span class="me-row-url">${
|
|
19
|
-
${
|
|
17
|
+
<span class="me-row-label${!f.text&&!A?" mex-untitled":""}"${W}>${b(f.text||A?.label||"(no label - click to add one)")}${Y}${K}</span>
|
|
18
|
+
<span class="me-row-url">${b(f.url||"")}</span>
|
|
19
|
+
${ae}
|
|
20
20
|
<button type="button" class="btn btn-sm btn-ghost me-edit" title="Edit" aria-label="Edit"><span data-icon="edit"></span></button>
|
|
21
|
-
${
|
|
22
|
-
</div>`),g(f.id)}}g(null),t.innerHTML=i.join("")||'<p class="mex-note">No items yet - Add item, or right-click an item for more.</p>',I.scan(t)}function $(t,d={},...s){const l=document.createElement(t);Object.assign(l,d);for(const i of s)i!=null&&l.append(i);return l}function T(t,d,s){const l=$("label",{className:"form-field"},$("span",{className:"form-label",textContent:t}),d);return s&&l.append($("small",{className:"form-hint",textContent:s,style:"display:block;font-size:.78em;opacity:.7;margin-top:.25rem;line-height:1.35"})),l}function Vt(t,d){const s=[["","- Everyone -"],["public","Public - anyone, incl. logged-out"],...(d||[]).map(i=>[i.name,`${i.label||i.name} and above`]),...(d||[]).filter(i=>i.level!==0).map(i=>[`=${i.name}`,`${i.label||i.name} only`]),["private","Private - super-admin only"]];t&&!s.some(([i])=>i===t)&&s.push([t,`${t} (custom)`]);const l=Q(s,t||"");return l.name="visibility",l}function Q(t,d){const s=$("select",{className:"form-select"});return t.forEach(([l,i])=>s.add(new Option(i,l))),s.value=d??"",s}function _e(t,d){const s=typeof d=="string"&&d.startsWith("#"),l=Q([["","- none -"],["primary","primary"],["success","success"],["danger","danger"],["warning","warning"],["info","info"],["neutral","neutral"],["__custom__","Custom\u2026"]],s?"__custom__":d||""),i=$("input",{type:"color",className:"form-input me-colour-hex",value:s?d:"#000000"});i.style.display=s?"":"none",l.addEventListener("change",()=>{i.style.display=l.value==="__custom__"?"":"none"});const g=T(t,l);return g.append(i),g._read=()=>l.value==="__custom__"?i.value:l.value,g}function Yt(t,d){const s=$("input",{className:"form-input me-icon",name:"icon",value:d||""}),l=$("span",{className:"me-icon-preview"});d&&l.setAttribute("data-icon",d);const i=$("button",{type:"button",className:"btn btn-sm btn-ghost",textContent:"Browse\u2026"}),g=k=>{k?l.setAttribute("data-icon",k):l.removeAttribute("data-icon"),Domma.icons.scan(l.parentNode||l)};return i.addEventListener("click",()=>kt(k=>{s.value=k,g(k)})),s.addEventListener("input",()=>g(s.value.trim())),T(t,$("div",{className:"me-icon-field"},s,i,l))}function Ce(...t){return $("div",{className:"me-field-grid"},...t)}async function Xt(t,d,s,l,i){const g=t.text||t.tool?.label||"",k=r=>$("h4",{className:"me-form-section",textContent:r}),f=T("Label",$("input",{className:"form-input",name:"text",value:t.text||""}),"The text shown for this item."),N=$("input",{className:"form-input",name:"url",value:t.url||""}),_=T("URL",N,t.tool||t.locked?"A Tool's address is its identity, so it cannot be changed here.":"Where it links. Must start with /, #, https://, or mailto: . Leave blank for a parent that only groups sub-items.");(t.tool||t.locked)&&(N.readOnly=!0);const V=Yt("Icon",t.icon||""),A=Vt(t.visibility||"",l),h=T("Visibility",A,t.locked?"Always in the sidebar for everyone who may use it, so it cannot be restricted here.":'Who can see this item. "Role and above" shows it to that role and anyone more senior; "Role only" shows it to holders of that role alone (and the super-admin). Blank = everyone.');t.locked&&(A.disabled=!0);const B=Q([["","- no permission gate -"],...(d||[]).map(r=>[r.key,r.label||r.key])],t.permission||"");B.name="permission";const ae=T("Permission",B,t.tool||t.locked?"A Tool is guarded by its own permission, which always applies.":"Hide unless the viewer holds this specific permission. Independent of Visibility - both must pass.");(t.tool||t.locked)&&(B.disabled=!0);const Z=T("Badge text",$("input",{className:"form-input",name:"badgeText",value:t.badge?.text||""})),z=Q([["","- none -"],...(s||[]).map(r=>[r.slug,r.title||r.slug])],t.badge?.countFrom||"");z.name="badgeCount";const ie=T("Badge count from",z,"Show a live count of this collection\u2019s entries as the badge."),ee=_e("Badge colour",t.badge?.variant||""),O=Q([["link","Link"],["pill","Pill"]],t.pill?"pill":"link");O.name="renderAs";const He=T("Render as",O,"Pill draws the item as a rounded chip. Pill styling applies to top-level navbar items."),K=T("Pill style",Q([["filled","Filled"],["outline","Outline"]],t.pill?.style||"filled"));K.querySelector("select").name="pillStyle";const me=_e("Pill colour",t.pill?.variant||""),se=$("div",{className:"me-pill-wrap"},Ce(K,me));se.style.display=t.pill?"":"none",O.addEventListener("change",()=>{se.style.display=O.value==="pill"?"":"none"});const m=_e("Text colour",t.colour||""),j=Q([["","- use the menu default -"],...tt.map(r=>[r,ot[r]||r])],t.highlight?.hover||"");j.name="hlHover";const le=T("Hover effect",j,"Overrides the menu-wide hover effect for this item only."),pe=Q([["","- use the menu default -"],...nt.map(r=>[r,at[r]||r])],t.highlight?.active||"");pe.name="hlActive";const be=T("When selected",pe,"How this item is marked when it points at the page being viewed."),P=_e("Highlight colour",t.highlight?.accent||""),x=$("button",{className:"btn btn-primary",textContent:"Apply",title:"Apply to the item (Ctrl+Enter) - Save the menu to keep it"}),W=$("div",{className:"me-item-form"},k("Link"),f,_,V,k("Access"),Ce(h,ae),k("Badge"),Ce(Z,ee),ie,k("Appearance"),He,se,m,k("Highlighting"),Ce(le,be),P,x),b=E.slideover({title:g?`Edit ${Le(g)}`:"Edit menu item",size:"lg",position:"right"});b.element.appendChild(W);const Y=()=>JSON.stringify([...W.querySelectorAll("input, select, textarea")].map(r=>r.type==="checkbox"?r.checked:r.value)),te=Y();let q=!1,re=!1;Rt(b);const ye=b.close.bind(b);b.close=async r=>{if(!q&&r!==!0&&Y()!==te){if(re)return;re=!0;const U=await E.confirm("Discard the changes to this item?");if(re=!1,!U)return}return q=!0,ye()},W.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),x.click())}),b.open();const fe=b.element.closest(".dm-slideover")||b.element.querySelector(".dm-slideover")||b.element;fe.style.setProperty("width",Kt+"px","important"),fe.style.setProperty("max-width","95vw","important"),Domma.icons.scan(W);const L=r=>W.querySelector(`[name="${r}"]`)?.value||"";x.addEventListener("click",()=>{t.text=L("text"),t.icon=L("icon"),t.locked||(t.visibility=L("visibility")),!t.tool&&!t.locked?(t.url=L("url"),t.permission=L("permission")):t.touched=!0;const r=L("badgeText"),U=L("badgeCount"),ne=ee._read();if(t.badge=r||U?{...r&&{text:r},...U&&{countFrom:U},...ne&&{variant:ne}}:null,L("renderAs")==="pill"){const he=me._read();t.pill={style:L("pillStyle")||"filled",...he&&{variant:he}}}else t.pill=null;t.colour=m._read();const ce=L("hlHover"),de=L("hlActive"),ue=P._read();t.highlight=ce||de||ue?{...ce&&{hover:ce},...de&&{active:de},...ue&&{accent:ue}}:null,q=!0,b.close(!0),i()})}let F=null,ct=!1;function Jt(){ct||!window.R?.use||(ct=!0,R.use((t,d,s)=>{const l=d?.path||"";return!(qt.test(l)&&(t?.path||"")!==l)||!F?.dirty()?s():E.confirm(`You have unsaved changes to the menu "${Le(F.name())}". Leave anyway? They are kept as a draft for your next visit.`).then(g=>{g?s():history.replaceState(null,"",`#${l}`)})}),M.subscribe("router:afterChange",({to:t}={})=>{if(F&&(t?.path||"")!==F.path){const d=F;F=null,d.dispose()}}))}function dt(t,d){const s=t.querySelector(".dm-plugin-banner-name"),l=s?.querySelector(".dm-plugin-banner-sep");if(!(!s||!l)){for(;l.nextSibling;)l.nextSibling.remove();s.appendChild(document.createTextNode(d))}}export const menuEditorView={templateUrl:"/admin/js/templates/menu-editor.html",async onMount(t){if(Jt(),F){const e=F;F=null,e.dispose()}const d=[],s=e=>(d.push(e),e);let l=!1;const i=t.get(0).querySelector(".mex")||t.get(0),g=()=>!l&&i.isConnected,k=location.hash.replace(/^#/,""),f=location.hash.match(/^#\/menus\/edit\/([^?#]+)/),N=f?decodeURIComponent(f[1]):null,_=!N,V=`menu-editor.draft.${N||"new"}`,A=E.loader(t.get(0),{type:"dots"});let h,B,ae,Z,z,ie,ee;try{if(_)h={slug:"",name:"",description:"",items:[]};else try{h=await C.menus.get(N)}catch(e){E.toast(`Failed to load menu: ${e.message||e}`,{type:"error"}),location.hash="#/menus";return}[B,ae,Z,z,ie,ee]=await Promise.all([C.projects.list().catch(()=>[]),H.get("/api/auth/permissions-registry").catch(()=>({resources:[]})),C.collections.list().catch(()=>[]),C.get("/collections/roles/entries?limit=100").catch(()=>null),C.menuLocations.registry().catch(()=>[]),C.pages.list().catch(()=>[])])}finally{A.destroy()}if(!i.isConnected)return;const He=(Array.isArray(z)?z:z?.entries||[]).map(e=>({name:e.data?.name,label:e.data?.label,level:e.data?.level??99})).filter(e=>e.name).sort((e,n)=>n.level-e.level),K=!_&&N===Wt;let me=[];if(K&&(me=await H.get("/api/sidebar/registered-items").catch(()=>(E.toast("Could not load the Tools - they are not shown, and saving keeps their places as they are.",{type:"warning",duration:8e3}),[])),!i.isConnected))return;const se=e=>it(K?xt($t(e||[],me,{forEditor:!0}),{leaveInHiddenFolders:!0}):e);let m=se(h.items);const j=M.observable(!1),le=M.observable(!1),pe=M.observable(""),be=M.observable(0);let P="";const x=i.querySelector("#me-items-list");function W(){Gt(x,m,K),be.value=m.filter(e=>!oe(e)).length}const b=()=>{W(),G()},Y=()=>K?rt(m):new Set,te=e=>m.find(n=>n.id===e);function q(e){requestAnimationFrame(()=>x.querySelector(`.menu-tree-row[data-id="${CSS.escape(e)}"]`)?.focus())}function re(e){const n=v=>m.find(c=>!c.type&&!c.url&&!c.tool&&(c.key||"").toLowerCase()===v)||m.find(c=>!c.type&&!c.url&&!c.tool&&!c.key&&(c.text||"").toLowerCase()===v);let a=n(String(e.tool.folder||"").toLowerCase())||n(Ct);const p=String(e.tool.group||"").trim();if(a&&p){const v=At(a.key||Lt(a.text),p),c=u=>u!==e&&!u.type&&!u.url&&!u.tool;let w=m.find(u=>c(u)&&(u.key||"").toLowerCase()===v)||m.find(u=>c(u)&&u.parentId===a.id&&(!u.key||u.key.toLowerCase()===v)&&(u.text||"").toLowerCase()===p.toLowerCase());w||([w]=it([{text:p,key:v,icon:_t,synthesised:!0}],a.id),m.push(w)),a=w}e.parentId=a?a.id:null,m.splice(m.indexOf(e),1),m.push(e),Object.assign(e,{text:e.tool.defaults.text,icon:e.tool.defaults.icon,visibility:"",hidden:!1,badge:null,pill:null,colour:"",highlight:null,touched:!1}),e.tool={...e.tool,persisted:!1}}const ye=e=>!Y().has(e.id)&&!(e.tool&&!e.tool.orphan);async function fe(e){const n=te(e);if(!n||!ye(n))return;const a=u=>{let J=u;for(;J;){if(J.id===e)return!0;J=m.find(D=>D.id===J.parentId)}return!1},p=m.filter(u=>u.id!==e&&a(u));if(p.length&&!await E.confirm(`Remove "${Le(n.text||n.type||"this item")}" and the ${p.length} item${p.length===1?"":"s"} beneath it?`))return;const v=Y(),c=p.filter(u=>u.tool),w=p.filter(u=>!u.tool&&v.has(u.id));m=m.filter(u=>!a(u)||c.includes(u)||w.includes(u));for(const u of w)u.parentId=n.parentId??null;for(const u of c)u.tool.orphan?u.parentId=n.parentId??null:re(u);b()}function L(e){const n=te(e);!n||oe(n)||Y().has(e)||(n.hidden=!n.hidden,n.touched=!0,b())}function r(e){const n=te(e);!n||oe(n)||(n.locked=K&&Y().has(n.id),Xt(n,ae.resources||[],Z,He,()=>{b(),q(e)}))}function U(e){const n={id:Ee(),parentId:null,...e==="separator"?{type:e}:e==="spacer"?{type:e,size:"md"}:{text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1}};m.push(n),b(),oe(n)?q(n.id):r(n.id)}const ne=e=>n=>{e(n)&&(b(),q(n))},ce=ne(e=>lt(m,e,-1)),de=ne(e=>lt(m,e,1)),ue=ne(e=>Ot(m,e)),he=ne(e=>Bt(m,e));function ut(e){const n=te(e);if(!n)return[];const a=oe(n),p=Y().has(e),v=zt(m,e),c=v.findIndex(D=>D.id===e),w=n.tool,u=w&&(w.persisted||n.touched),J=D=>()=>{const ge=Pt(m,e,D,Ee());b(),ge&&!oe(ge)?r(ge.id):ge&&q(ge.id)};return[{header:a?n.type==="separator"?"Separator":"Spacer":n.text||w?.label||"(untitled)"},...a?[]:[{label:"Edit\u2026",icon:"edit",action:()=>r(e)}],{type:"divider"},{label:"Add item below",icon:"plus",action:J("item")},{label:"Add sub-item",icon:"corner-down-right",disabled:a,action:()=>{const D=jt(m,e,Ee());b(),D&&r(D.id)}},{label:"Add separator below",icon:"minus",action:J("separator")},{label:"Add spacer below",icon:"move",action:J("spacer")},{type:"divider"},{label:"Move up",icon:"arrow-up",shortcut:"Alt+\u2191",disabled:c<=0,action:()=>ce(e)},{label:"Move down",icon:"arrow-down",shortcut:"Alt+\u2193",disabled:c<0||c>=v.length-1,action:()=>de(e)},{label:"Indent",icon:"chevron-right",shortcut:"Alt+\u2192",disabled:c<=0||oe(v[c-1]),action:()=>ue(e)},{label:"Outdent",icon:"chevron-left",shortcut:"Alt+\u2190",disabled:n.parentId==null,action:()=>he(e)},{type:"divider"},...a?[]:[{label:n.hidden?"Show":"Hide",icon:n.hidden?"eye":"eye-off",disabled:p,action:()=>L(e)}],{label:"Duplicate",icon:"copy",disabled:!!w,action:()=>{const D=Nt(m,e,Ee);b(),D&&q(D.id)}},...w&&!w.orphan&&u?[{label:"Back to its default place",icon:"rotate-ccw",action:()=>{re(n),b()}}]:[],{label:p?"Remove - always in the sidebar":w&&!w.orphan?"Remove - a Tool is hidden, not removed":"Remove\u2026",icon:"trash",danger:!0,disabled:!ye(n),action:()=>fe(e)}]}const mt=E.contextMenu(x,{cursor:"glyph",match:".menu-tree-row",inherit:!1,density:"compact",items:e=>ut(e.dataset.id)});s(()=>mt?.destroy?.());const pt=St(x,()=>m,b,{items:".menu-tree-row",handle:".menu-tree-grip"});s(()=>pt?.destroy());const Ke=e=>{const n=e.target.closest(".menu-tree-row");if(!n)return;const a=n.dataset.id;if(e.target.closest(".mex-kebab")){ze(e.target.closest(".mex-kebab"));return}if(e.target.closest(".me-hidden")){L(a);return}if(e.target.closest(".me-edit")){r(a);return}e.target.closest("select, .menu-tree-grip, button")||r(a)},We=e=>{const n=e.target.closest(".menu-tree-row");if(!n||e.target!==n){n&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ze(n));return}const a=n.dataset.id;if(e.key==="F10"&&e.shiftKey)e.preventDefault(),ze(n);else if(e.key==="Enter")e.preventDefault(),r(a);else if(e.key==="Delete")e.preventDefault(),fe(a);else if(e.altKey&&e.key==="ArrowUp")e.preventDefault(),ce(a);else if(e.altKey&&e.key==="ArrowDown")e.preventDefault(),de(a);else if(e.altKey&&e.key==="ArrowRight")e.preventDefault(),ue(a);else if(e.altKey&&e.key==="ArrowLeft")e.preventDefault(),he(a);else if(e.key==="ArrowDown"||e.key==="ArrowUp"){const p=[...x.querySelectorAll(".menu-tree-row")],v=p.indexOf(n)+(e.key==="ArrowDown"?1:-1);p[v]&&(e.preventDefault(),p[v].focus())}},qe=e=>{if(!e.target.matches(".me-spacer-size"))return;const n=te(e.target.closest(".menu-tree-row")?.dataset.id);n&&(n.size=e.target.value,G())};x.addEventListener("click",Ke),x.addEventListener("keydown",We),x.addEventListener("change",qe),s(()=>{x.removeEventListener("click",Ke),x.removeEventListener("keydown",We),x.removeEventListener("change",qe)});const o=e=>t.find(e),we=o("#me-float-accent"),Ie=o("#me-float-accent-hex"),Re=()=>{Ie.get(0).style.display=we.val()==="__custom__"?"":"none"},ft=()=>we.val()==="__custom__"?Ie.val():we.val(),ke=o("#me-highlight-accent"),Te=o("#me-highlight-accent-hex"),Ue=()=>{Te.get(0).style.display=ke.val()==="__custom__"?"":"none"},ht=()=>ke.val()==="__custom__"?Te.val():ke.val(),Ge=()=>{const e=o("#me-position").val()==="floating";o(".dm-float-field").each(function(){this.style.display=e?"":"none"}),e&&Re();const n=o("#me-orientation").val()==="vertical";o(".dm-vertical-field").each(function(){this.style.display=n?"":"none"});const a=!!(o("#me-float-collapsible").get(0)||{}).checked;o(".dm-float-collapsed-field").each(function(){this.style.display=e&&a?"":"none"})},Ve=(e,n,a,p)=>{const v=o(e).get(0);v&&(v.innerHTML="",v.add(new Option(p,"")),n.forEach(c=>v.add(new Option(a[c]||c,c))))};Ve("#me-highlight-hover",tt,ot,"- theme default -"),Ve("#me-highlight-active",nt,at,"- theme default -");const je=o("#me-bind-slot"),De=o("#me-bind-projects"),Se=o("#me-bind-pages");je.html('<option value="">- not bound -</option>'+(ie||[]).map(e=>`<option value="${y(e.slot)}">${y(e.label||e.slot)} (${y(e.slot)})</option>`).join("")),De.html(B.map(e=>`<option value="${y(e.slug)}">${y(e.name||e.slug)}</option>`).join("")),o("#me-bind-page-pick").html('<option value="">- pick a page -</option>'+ee.map(e=>{const n=e.urlPath||e.url||"";return n?`<option value="${y(n)}">${y(e.title||n)} - ${y(n)}</option>`:""}).join(""));const Ye=()=>Dt({slot:je.val(),projects:Array.from(De.get(0).selectedOptions).map(e=>e.value),pagesText:Se.val()});function Ne(){const e=Ye(),n=o("#me-bind-summary");if(!e){n.html('<span class="text-muted">Not bound - this menu renders only where it is mapped or placed by a shortcode.</span>');return}const a=[];e.projects.length&&a.push(`pages in ${e.projects.map(c=>`<code>${y(c)}</code>`).join(", ")}`),e.pages.length&&a.push(`pages matching ${e.pages.map(c=>`<code>${y(c)}</code>`).join(", ")}`);const p=e.pages.filter(c=>!/^\/[^\s?#*]*(\/\*)?$|^\/\*$/.test(c)),v=p.length?`<div class="text-danger" style="margin-top:.4rem">Not valid page patterns: ${p.map(c=>`<code>${y(c)}</code>`).join(", ")}</div>`:"";n.html(a.length?`Takes over the <code>${y(e.slot)}</code> slot on ${a.join(" and ")}.${v}`:`<span class="text-warning">Slot <code>${y(e.slot)}</code> selected but no project or page chosen - the binding will never match.</span>`)}const{getProjectFromHash:vt}=await import("../lib/project-context.js");if(!g())return;const gt=_?vt():null;o("#me-project").html('<option value="">- none -</option>'+B.map(e=>`<option value="${y(e.slug)}">${y(e.name||e.slug)}</option>`).join(""));function Oe(e){m=se(e.items),o("#me-variant").val(e.variant||""),o("#me-position").val(e.position||"");const n=o("#me-appearOnHover").get(0);n&&(n.checked=e.appearOnHover===!0),o("#me-float-anchor").val(e.float?.anchor||"TL"),o("#me-float-offsetX").val(e.float?.offsetX??""),o("#me-float-offsetY").val(e.float?.offsetY??""),o("#me-float-width").val(e.float?.width??""),o("#me-float-radius").val(e.float?.radius??""),o("#me-float-shadow").val(e.float?.shadow||""),o("#me-float-opacity").val(e.float?.opacity??""),o("#me-float-title").val(e.float?.title||"");for(const[c,w]of[["#me-float-collapsible",e.float?.collapsible],["#me-float-collapsed",e.float?.collapsed],["#me-float-movable",e.float?.movable],["#me-float-resizable",e.float?.resizable]]){const u=o(c).get(0);u&&(u.checked=w===!0)}const a=e.float?.accent||"";we.val(a.startsWith("#")?"__custom__":a),a.startsWith("#")&&Ie.val(a),Re(),o("#me-orientation").val(e.orientation||""),o("#me-align").val(e.align||""),o("#me-side").val(e.side||"left"),o("#me-highlight-hover").val(e.highlight?.hover||""),o("#me-highlight-active").val(e.highlight?.active||"");const p=e.highlight?.accent||"";ke.val(p.startsWith("#")?"__custom__":p),p.startsWith("#")&&Te.val(p),Ue(),o("#me-fontFamily").val(e.style?.fontFamily||""),o("#me-fontSize").val(e.style?.fontSize||""),o("#me-fontWeight").val(e.style?.fontWeight||""),o("#me-letterSpacing").val(e.style?.letterSpacing||""),o("#me-iconSize").val(e.style?.iconSize||""),je.val(e.binding?.slot||"");const v=new Set(e.binding?.projects||[]);Array.from(De.get(0).options).forEach(c=>{c.selected=v.has(c.value)}),Se.val((e.binding?.pages||[]).join(`
|
|
23
|
-
`)),o("#me-slug").val(e.slug||""),o("#me-name").val(e.name||""),o("#me-description").val(e.description||""),o("#me-project").val(e.meta?.project||
|
|
24
|
-
`).map(a=>a.trim()).filter(Boolean);n.includes(e)||n.push(e),
|
|
25
|
-
`)),
|
|
21
|
+
${Ue}
|
|
22
|
+
</div>`),g(f.id)}}g(null),t.innerHTML=s.join("")||'<p class="mex-note">No items yet - Add item, or right-click an item for more.</p>',I.scan(t)}function y(t,c={},...l){const i=document.createElement(t);Object.assign(i,c);for(const s of l)s!=null&&i.append(s);return i}function T(t,c,l){const i=y("label",{className:"form-field"},y("span",{className:"form-label",textContent:t}),c);return l&&i.append(y("small",{className:"form-hint",textContent:l,style:"display:block;font-size:.78em;opacity:.7;margin-top:.25rem;line-height:1.35"})),i}function Jt(t,c){const l=[["","- Everyone -"],["public","Public - anyone, incl. logged-out"],...(c||[]).map(s=>[s.name,`${s.label||s.name} and above`]),...(c||[]).filter(s=>s.level!==0).map(s=>[`=${s.name}`,`${s.label||s.name} only`]),["private","Private - super-admin only"]];t&&!l.some(([s])=>s===t)&&l.push([t,`${t} (custom)`]);const i=Z(l,t||"");return i.name="visibility",i}function Z(t,c){const l=y("select",{className:"form-select"});return t.forEach(([i,s])=>l.add(new Option(s,i))),l.value=c??"",l}function Re(t,c){const l=typeof c=="string"&&c.startsWith("#"),i=Z([["","- none -"],["primary","primary"],["success","success"],["danger","danger"],["warning","warning"],["info","info"],["neutral","neutral"],["__custom__","Custom\u2026"]],l?"__custom__":c||""),s=y("input",{type:"color",className:"form-input me-colour-hex",value:l?c:"#000000"});s.style.display=l?"":"none",i.addEventListener("change",()=>{s.style.display=i.value==="__custom__"?"":"none"});const g=T(t,i);return g.append(s),g._read=()=>i.value==="__custom__"?s.value:i.value,g}function Qt(t,c){const l=y("input",{className:"form-input me-icon",name:"icon",value:c||""}),i=y("span",{className:"me-icon-preview"});c&&i.setAttribute("data-icon",c);const s=y("button",{type:"button",className:"btn btn-sm btn-ghost",textContent:"Browse\u2026"}),g=w=>{w?i.setAttribute("data-icon",w):i.removeAttribute("data-icon"),Domma.icons.scan(i.parentNode||i)};return s.addEventListener("click",()=>$t(w=>{l.value=w,g(w)})),l.addEventListener("input",()=>g(l.value.trim())),T(t,y("div",{className:"me-icon-field"},l,s,i))}function Te(...t){return y("div",{className:"me-field-grid"},...t)}async function Zt(t,c,l,i,s){const g=t.text||t.tool?.label||"",w=u=>y("h4",{className:"me-form-section",textContent:u}),f=T("Label",y("input",{className:"form-input",name:"text",value:t.text||""}),"The text shown for this item."),P=y("input",{className:"form-input",name:"url",value:t.url||""}),_=T("URL",P,t.tool||t.locked?"A Tool's address is its identity, so it cannot be changed here.":"Where it links. Must start with /, #, https://, or mailto: . Leave blank for a parent that only groups sub-items.");(t.tool||t.locked)&&(P.readOnly=!0);const Y=Qt("Icon",t.icon||""),A=Jt(t.visibility||"",i),h=T("Visibility",A,t.locked?"Always in the sidebar for everyone who may use it, so it cannot be restricted here.":'Who can see this item. "Role and above" shows it to that role and anyone more senior; "Role only" shows it to holders of that role alone (and the super-admin). Blank = everyone.');t.locked&&(A.disabled=!0);const K=Z([["","- no permission gate -"],...(c||[]).map(u=>[u.key,u.label||u.key])],t.permission||"");K.name="permission";const oe=T("Permission",K,t.tool||t.locked?"A Tool is guarded by its own permission, which always applies.":"Hide unless the viewer holds this specific permission. Independent of Visibility - both must pass.");(t.tool||t.locked)&&(K.disabled=!0);const ee=T("Badge text",y("input",{className:"form-input",name:"badgeText",value:t.badge?.text||""})),W=Z([["","- none -"],...(l||[]).map(u=>[u.slug,u.title||u.slug])],t.badge?.countFrom||"");W.name="badgeCount";const ae=T("Badge count from",W,"Show a live count of this collection\u2019s entries as the badge."),te=Re("Badge colour",t.badge?.variant||""),F=Z([["link","Link"],["pill","Pill"]],t.pill?"pill":"link");F.name="renderAs";const je=T("Render as",F,"Pill draws the item as a rounded chip. Pill styling applies to top-level navbar items."),q=T("Pill style",Z([["filled","Filled"],["outline","Outline"]],t.pill?.style||"filled"));q.querySelector("select").name="pillStyle";const me=Re("Pill colour",t.pill?.variant||""),se=y("div",{className:"me-pill-wrap"},Te(q,me));se.style.display=t.pill?"":"none",F.addEventListener("change",()=>{se.style.display=F.value==="pill"?"":"none"});const m=Re("Text colour",t.colour||""),D=Z([["","- use the menu default -"],...st.map(u=>[u,it[u]||u])],t.highlight?.hover||"");D.name="hlHover";const le=T("Hover effect",D,"Overrides the menu-wide hover effect for this item only."),pe=Z([["","- use the menu default -"],...lt.map(u=>[u,rt[u]||u])],t.highlight?.active||"");pe.name="hlActive";const Se=T("When selected",pe,"How this item is marked when it points at the page being viewed."),B=Re("Highlight colour",t.highlight?.accent||""),C=y("input",{className:"form-input",name:"class",value:t.class||"",placeholder:"nav-cta push-right",autocomplete:"off",spellcheck:!1}),ie=T("CSS classes",C,"Your own class names, separated by spaces, added to this item's <li> - target the link with .your-class > a. Style them in Custom CSS."),L=y("small",{className:"text-danger",style:"display:none;margin-top:.25rem"});ie.append(L);const U=y("button",{className:"btn btn-primary",textContent:"Apply",title:"Apply to the item (Ctrl+Enter) - Save the menu to keep it"}),N=y("div",{className:"me-item-form"},w("Link"),f,_,Y,w("Access"),Te(h,oe),w("Badge"),Te(ee,te),ae,w("Appearance"),je,se,m,w("Highlighting"),Te(le,Se),B,w("Styling"),ie,U),x=E.slideover({title:g?`Edit ${He(g)}`:"Edit menu item",size:"lg",position:"right"});x.element.appendChild(N);const fe=()=>JSON.stringify([...N.querySelectorAll("input, select, textarea")].map(u=>u.type==="checkbox"?u.checked:u.value)),$e=fe();let re=!1,ce=!1;jt(x);const G=x.close.bind(x);x.close=async u=>{if(!re&&u!==!0&&fe()!==$e){if(ce)return;ce=!0;const X=await E.confirm("Discard the changes to this item?");if(ce=!1,!X)return}return re=!0,G()},N.addEventListener("keydown",u=>{(u.ctrlKey||u.metaKey)&&u.key==="Enter"&&(u.preventDefault(),U.click())}),x.open();const de=x.element.closest(".dm-slideover")||x.element.querySelector(".dm-slideover")||x.element;de.style.setProperty("width",Ut+"px","important"),de.style.setProperty("max-width","95vw","important"),Domma.icons.scan(N);const k=u=>N.querySelector(`[name="${u}"]`)?.value||"";U.addEventListener("click",()=>{const u=ct(k("class"));if(u.length){L.textContent=u.join("; "),L.style.display="block",C.focus();return}t.class=St(k("class")),t.text=k("text"),t.icon=k("icon"),t.locked||(t.visibility=k("visibility")),!t.tool&&!t.locked?(t.url=k("url"),t.permission=k("permission")):t.touched=!0;const X=k("badgeText"),ue=k("badgeCount"),he=te._read();if(t.badge=X||ue?{...X&&{text:X},...ue&&{countFrom:ue},...he&&{variant:he}}:null,k("renderAs")==="pill"){const ye=me._read();t.pill={style:k("pillStyle")||"filled",...ye&&{variant:ye}}}else t.pill=null;t.colour=m._read();const ve=k("hlHover"),ge=k("hlActive"),be=B._read();t.highlight=ve||ge||be?{...ve&&{hover:ve},...ge&&{active:ge},...be&&{accent:be}}:null,re=!0,x.close(!0),s()})}let z=null,pt=!1;function en(){pt||!window.R?.use||(pt=!0,R.use((t,c,l)=>{const i=c?.path||"";return!(Vt.test(i)&&(t?.path||"")!==i)||!z?.dirty()?l():E.confirm(`You have unsaved changes to the menu "${He(z.name())}". Leave anyway? They are kept as a draft for your next visit.`).then(g=>{g?l():history.replaceState(null,"",`#${i}`)})}),M.subscribe("router:afterChange",({to:t}={})=>{if(z&&(t?.path||"")!==z.path){const c=z;z=null,c.dispose()}}))}function ft(t,c){const l=t.querySelector(".dm-plugin-banner-name"),i=l?.querySelector(".dm-plugin-banner-sep");if(!(!l||!i)){for(;i.nextSibling;)i.nextSibling.remove();l.appendChild(document.createTextNode(c))}}export const menuEditorView={templateUrl:"/admin/js/templates/menu-editor.html",async onMount(t){if(en(),z){const e=z;z=null,e.dispose()}const c=[],l=e=>(c.push(e),e);let i=!1;const s=t.get(0).querySelector(".mex")||t.get(0),g=()=>!i&&s.isConnected,w=location.hash.replace(/^#/,""),f=location.hash.match(/^#\/menus\/edit\/([^?#]+)/),P=f?decodeURIComponent(f[1]):null,_=!P,Y=`menu-editor.draft.${P||"new"}`,A=E.loader(t.get(0),{type:"dots"});let h,K,oe,ee,W,ae,te;try{if(_)h={slug:"",name:"",description:"",items:[]};else try{h=await j.menus.get(P)}catch(e){E.toast(`Failed to load menu: ${e.message||e}`,{type:"error"}),location.hash="#/menus";return}[K,oe,ee,W,ae,te]=await Promise.all([j.projects.list().catch(()=>[]),H.get("/api/auth/permissions-registry").catch(()=>({resources:[]})),j.collections.list().catch(()=>[]),j.get("/collections/roles/entries?limit=100").catch(()=>null),j.menuLocations.registry().catch(()=>[]),j.pages.list().catch(()=>[])])}finally{A.destroy()}if(!s.isConnected)return;const je=(Array.isArray(W)?W:W?.entries||[]).map(e=>({name:e.data?.name,label:e.data?.label,level:e.data?.level??99})).filter(e=>e.name).sort((e,n)=>n.level-e.level),q=!_&&P===Gt;let me=[];if(q&&(me=await H.get("/api/sidebar/registered-items").catch(()=>(E.toast("Could not load the Tools - they are not shown, and saving keeps their places as they are.",{type:"warning",duration:8e3}),[])),!s.isConnected))return;const se=e=>dt(q?At(Et(e||[],me,{forEditor:!0}),{leaveInHiddenFolders:!0}):e);let m=se(h.items);const D=M.observable(!1),le=M.observable(!1),pe=M.observable(""),Se=M.observable(0);let B="";const C=s.querySelector("#me-items-list");function ie(){Xt(C,m,q),Se.value=m.filter(e=>!ne(e)).length}const L=()=>{ie(),V()},U=()=>q?mt(m):new Set,N=e=>m.find(n=>n.id===e);function x(e){requestAnimationFrame(()=>C.querySelector(`.menu-tree-row[data-id="${CSS.escape(e)}"]`)?.focus())}function fe(e){const n=v=>m.find(r=>!r.type&&!r.url&&!r.tool&&(r.key||"").toLowerCase()===v)||m.find(r=>!r.type&&!r.url&&!r.tool&&!r.key&&(r.text||"").toLowerCase()===v);let a=n(String(e.tool.folder||"").toLowerCase())||n(Ht);const p=String(e.tool.group||"").trim();if(a&&p){const v=Lt(a.key||_t(a.text),p),r=d=>d!==e&&!d.type&&!d.url&&!d.tool;let $=m.find(d=>r(d)&&(d.key||"").toLowerCase()===v)||m.find(d=>r(d)&&d.parentId===a.id&&(!d.key||d.key.toLowerCase()===v)&&(d.text||"").toLowerCase()===p.toLowerCase());$||([$]=dt([{text:p,key:v,icon:It,synthesised:!0}],a.id),m.push($)),a=$}e.parentId=a?a.id:null,m.splice(m.indexOf(e),1),m.push(e),Object.assign(e,{text:e.tool.defaults.text,icon:e.tool.defaults.icon,visibility:"",hidden:!1,badge:null,pill:null,colour:"",highlight:null,touched:!1}),e.tool={...e.tool,persisted:!1}}const $e=e=>!U().has(e.id)&&!(e.tool&&!e.tool.orphan);async function re(e){const n=N(e);if(!n||!$e(n))return;const a=d=>{let Q=d;for(;Q;){if(Q.id===e)return!0;Q=m.find(O=>O.id===Q.parentId)}return!1},p=m.filter(d=>d.id!==e&&a(d));if(p.length&&!await E.confirm(`Remove "${He(n.text||n.type||"this item")}" and the ${p.length} item${p.length===1?"":"s"} beneath it?`))return;const v=U(),r=p.filter(d=>d.tool),$=p.filter(d=>!d.tool&&v.has(d.id));m=m.filter(d=>!a(d)||r.includes(d)||$.includes(d));for(const d of $)d.parentId=n.parentId??null;for(const d of r)d.tool.orphan?d.parentId=n.parentId??null:fe(d);L()}function ce(e){const n=N(e);!n||ne(n)||U().has(e)||(n.hidden=!n.hidden,n.touched=!0,L())}function G(e){const n=N(e);!n||ne(n)||(n.locked=q&&U().has(n.id),Zt(n,oe.resources||[],ee,je,()=>{L(),x(e)}))}function de(e){const n={id:_e(),parentId:null,...e==="separator"?{type:e}:e==="spacer"?{type:e,size:"md"}:{text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1}};m.push(n),L(),ne(n)?x(n.id):G(n.id)}const k=e=>n=>{e(n)&&(L(),x(n))},u=k(e=>ut(m,e,-1)),X=k(e=>ut(m,e,1)),ue=k(e=>Ft(m,e)),he=k(e=>Kt(m,e));function ve(e){const n=N(e);if(!n)return[];const a=ne(n),p=U().has(e),v=Wt(m,e),r=v.findIndex(O=>O.id===e),$=n.tool,d=$&&($.persisted||n.touched),Q=O=>()=>{const ke=Bt(m,e,O,_e());L(),ke&&!ne(ke)?G(ke.id):ke&&x(ke.id)};return[{header:a?n.type==="separator"?"Separator":"Spacer":n.text||$?.label||"(untitled)"},...a?[]:[{label:"Edit\u2026",icon:"edit",action:()=>G(e)}],{type:"divider"},{label:"Add item below",icon:"plus",action:Q("item")},{label:"Add sub-item",icon:"corner-down-right",disabled:a,action:()=>{const O=Nt(m,e,_e());L(),O&&G(O.id)}},{label:"Add separator below",icon:"minus",action:Q("separator")},{label:"Add spacer below",icon:"move",action:Q("spacer")},{type:"divider"},{label:"Move up",icon:"arrow-up",shortcut:"Alt+\u2191",disabled:r<=0,action:()=>u(e)},{label:"Move down",icon:"arrow-down",shortcut:"Alt+\u2193",disabled:r<0||r>=v.length-1,action:()=>X(e)},{label:"Indent",icon:"chevron-right",shortcut:"Alt+\u2192",disabled:r<=0||ne(v[r-1]),action:()=>ue(e)},{label:"Outdent",icon:"chevron-left",shortcut:"Alt+\u2190",disabled:n.parentId==null,action:()=>he(e)},{type:"divider"},...a?[]:[{label:n.hidden?"Show":"Hide",icon:n.hidden?"eye":"eye-off",disabled:p,action:()=>ce(e)}],{label:"Duplicate",icon:"copy",disabled:!!$,action:()=>{const O=Pt(m,e,_e);L(),O&&x(O.id)}},...$&&!$.orphan&&d?[{label:"Back to its default place",icon:"rotate-ccw",action:()=>{fe(n),L()}}]:[],{label:p?"Remove - always in the sidebar":$&&!$.orphan?"Remove - a Tool is hidden, not removed":"Remove\u2026",icon:"trash",danger:!0,disabled:!$e(n),action:()=>re(e)}]}const ge=E.contextMenu(C,{cursor:"glyph",match:".menu-tree-row",inherit:!1,density:"compact",items:e=>ve(e.dataset.id)});l(()=>ge?.destroy?.());const be=xt(C,()=>m,L,{items:".menu-tree-row",handle:".menu-tree-grip"});l(()=>be?.destroy());const ye=e=>{const n=e.target.closest(".menu-tree-row");if(!n)return;const a=n.dataset.id;if(e.target.closest(".mex-kebab")){qe(e.target.closest(".mex-kebab"));return}if(e.target.closest(".me-hidden")){ce(a);return}if(e.target.closest(".me-edit")){G(a);return}e.target.closest("select, .menu-tree-grip, button")||G(a)},Ge=e=>{const n=e.target.closest(".menu-tree-row");if(!n||e.target!==n){n&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),qe(n));return}const a=n.dataset.id;if(e.key==="F10"&&e.shiftKey)e.preventDefault(),qe(n);else if(e.key==="Enter")e.preventDefault(),G(a);else if(e.key==="Delete")e.preventDefault(),re(a);else if(e.altKey&&e.key==="ArrowUp")e.preventDefault(),u(a);else if(e.altKey&&e.key==="ArrowDown")e.preventDefault(),X(a);else if(e.altKey&&e.key==="ArrowRight")e.preventDefault(),ue(a);else if(e.altKey&&e.key==="ArrowLeft")e.preventDefault(),he(a);else if(e.key==="ArrowDown"||e.key==="ArrowUp"){const p=[...C.querySelectorAll(".menu-tree-row")],v=p.indexOf(n)+(e.key==="ArrowDown"?1:-1);p[v]&&(e.preventDefault(),p[v].focus())}},Ve=e=>{if(!e.target.matches(".me-spacer-size"))return;const n=N(e.target.closest(".menu-tree-row")?.dataset.id);n&&(n.size=e.target.value,V())};C.addEventListener("click",ye),C.addEventListener("keydown",Ge),C.addEventListener("change",Ve),l(()=>{C.removeEventListener("click",ye),C.removeEventListener("keydown",Ge),C.removeEventListener("change",Ve)});const o=e=>t.find(e),xe=o("#me-float-accent"),De=o("#me-float-accent-hex"),Ne=()=>{De.get(0).style.display=xe.val()==="__custom__"?"":"none"},ht=()=>xe.val()==="__custom__"?De.val():xe.val(),Ee=o("#me-highlight-accent"),Oe=o("#me-highlight-accent-hex"),Ye=()=>{Oe.get(0).style.display=Ee.val()==="__custom__"?"":"none"},vt=()=>Ee.val()==="__custom__"?Oe.val():Ee.val(),Xe=()=>{const e=o("#me-position").val()==="floating";o(".dm-float-field").each(function(){this.style.display=e?"":"none"}),e&&Ne();const n=o("#me-orientation").val()==="vertical";o(".dm-vertical-field").each(function(){this.style.display=n?"":"none"});const a=!!(o("#me-float-collapsible").get(0)||{}).checked;o(".dm-float-collapsed-field").each(function(){this.style.display=e&&a?"":"none"})},Je=(e,n,a,p)=>{const v=o(e).get(0);v&&(v.innerHTML="",v.add(new Option(p,"")),n.forEach(r=>v.add(new Option(a[r]||r,r))))};Je("#me-highlight-hover",st,it,"- theme default -"),Je("#me-highlight-active",lt,rt,"- theme default -");const Pe=o("#me-bind-slot"),Fe=o("#me-bind-projects"),Ae=o("#me-bind-pages");Pe.html('<option value="">- not bound -</option>'+(ae||[]).map(e=>`<option value="${b(e.slot)}">${b(e.label||e.slot)} (${b(e.slot)})</option>`).join("")),Fe.html(K.map(e=>`<option value="${b(e.slug)}">${b(e.name||e.slug)}</option>`).join("")),o("#me-bind-page-pick").html('<option value="">- pick a page -</option>'+te.map(e=>{const n=e.urlPath||e.url||"";return n?`<option value="${b(n)}">${b(e.title||n)} - ${b(n)}</option>`:""}).join(""));const Qe=()=>Ot({slot:Pe.val(),projects:Array.from(Fe.get(0).selectedOptions).map(e=>e.value),pagesText:Ae.val()});function Be(){const e=Qe(),n=o("#me-bind-summary");if(!e){n.html('<span class="text-muted">Not bound - this menu renders only where it is mapped or placed by a shortcode.</span>');return}const a=[];e.projects.length&&a.push(`pages in ${e.projects.map(r=>`<code>${b(r)}</code>`).join(", ")}`),e.pages.length&&a.push(`pages matching ${e.pages.map(r=>`<code>${b(r)}</code>`).join(", ")}`);const p=e.pages.filter(r=>!/^\/[^\s?#*]*(\/\*)?$|^\/\*$/.test(r)),v=p.length?`<div class="text-danger" style="margin-top:.4rem">Not valid page patterns: ${p.map(r=>`<code>${b(r)}</code>`).join(", ")}</div>`:"";n.html(a.length?`Takes over the <code>${b(e.slot)}</code> slot on ${a.join(" and ")}.${v}`:`<span class="text-warning">Slot <code>${b(e.slot)}</code> selected but no project or page chosen - the binding will never match.</span>`)}const{getProjectFromHash:gt}=await import("../lib/project-context.js");if(!g())return;const bt=_?gt():null;o("#me-project").html('<option value="">- none -</option>'+K.map(e=>`<option value="${b(e.slug)}">${b(e.name||e.slug)}</option>`).join(""));function ze(e){m=se(e.items),o("#me-variant").val(e.variant||""),o("#me-position").val(e.position||"");const n=o("#me-appearOnHover").get(0);n&&(n.checked=e.appearOnHover===!0),o("#me-float-anchor").val(e.float?.anchor||"TL"),o("#me-float-offsetX").val(e.float?.offsetX??""),o("#me-float-offsetY").val(e.float?.offsetY??""),o("#me-float-width").val(e.float?.width??""),o("#me-float-radius").val(e.float?.radius??""),o("#me-float-shadow").val(e.float?.shadow||""),o("#me-float-opacity").val(e.float?.opacity??""),o("#me-float-title").val(e.float?.title||"");for(const[r,$]of[["#me-float-collapsible",e.float?.collapsible],["#me-float-collapsed",e.float?.collapsed],["#me-float-movable",e.float?.movable],["#me-float-resizable",e.float?.resizable]]){const d=o(r).get(0);d&&(d.checked=$===!0)}const a=e.float?.accent||"";xe.val(a.startsWith("#")?"__custom__":a),a.startsWith("#")&&De.val(a),Ne(),o("#me-orientation").val(e.orientation||""),o("#me-align").val(e.align||""),o("#me-side").val(e.side||"left"),o("#me-class").val(e.class||""),o("#me-highlight-hover").val(e.highlight?.hover||""),o("#me-highlight-active").val(e.highlight?.active||"");const p=e.highlight?.accent||"";Ee.val(p.startsWith("#")?"__custom__":p),p.startsWith("#")&&Oe.val(p),Ye(),o("#me-fontFamily").val(e.style?.fontFamily||""),o("#me-fontSize").val(e.style?.fontSize||""),o("#me-fontWeight").val(e.style?.fontWeight||""),o("#me-letterSpacing").val(e.style?.letterSpacing||""),o("#me-iconSize").val(e.style?.iconSize||""),Pe.val(e.binding?.slot||"");const v=new Set(e.binding?.projects||[]);Array.from(Fe.get(0).options).forEach(r=>{r.selected=v.has(r.value)}),Ae.val((e.binding?.pages||[]).join(`
|
|
23
|
+
`)),o("#me-slug").val(e.slug||""),o("#me-name").val(e.name||""),o("#me-description").val(e.description||""),o("#me-project").val(e.meta?.project||bt||""),Xe(),Be(),ie()}const we=e=>!!(o(e).get(0)||{}).checked;function Ce(){const e={variant:o("#me-variant").val(),position:o("#me-position").val(),appearOnHover:we("#me-appearOnHover"),floatAnchor:o("#me-float-anchor").val(),floatOffsetX:o("#me-float-offsetX").val(),floatOffsetY:o("#me-float-offsetY").val(),floatWidth:o("#me-float-width").val(),floatRadius:o("#me-float-radius").val(),floatShadow:o("#me-float-shadow").val(),floatAccent:ht(),floatOpacity:o("#me-float-opacity").val(),floatTitle:o("#me-float-title").val(),floatCollapsible:we("#me-float-collapsible"),floatCollapsed:we("#me-float-collapsed"),floatMovable:we("#me-float-movable"),floatResizable:we("#me-float-resizable"),highlightHover:o("#me-highlight-hover").val(),highlightActive:o("#me-highlight-active").val(),highlightAccent:vt(),class:o("#me-class").val(),orientation:o("#me-orientation").val(),align:o("#me-align").val(),side:o("#me-side").val(),fontFamily:o("#me-fontFamily").val(),fontSize:o("#me-fontSize").val(),fontWeight:o("#me-fontWeight").val(),letterSpacing:o("#me-letterSpacing").val(),iconSize:o("#me-iconSize").val()},n={slug:o("#me-slug").val(),name:o("#me-name").val(),description:o("#me-description").val(),project:o("#me-project").val()};return zt(e,n,Qe(),Rt(m),h.meta||{})}const Ze=e=>[...Mt(e),...ct(e.class).map(n=>`CSS classes: ${n} (Behaviour tab).`)];let et=!1,Me=null;function V(){if(!B)return;const e=Ce();Me=e,D.value=Ie(e)!==B,pe.value=D.peek()||et?Ze(e).join(" "):""}const Le=e=>{e.target.closest("#me-items-list")||(e.target.matches("#me-position, #me-orientation, #me-float-collapsible")&&Xe(),e.target.matches("#me-float-accent")&&Ne(),e.target.matches("#me-highlight-accent")&&Ye(),e.target.closest("#me-binding")&&Be(),V())};s.addEventListener("input",Le),s.addEventListener("change",Le),l(()=>{s.removeEventListener("input",Le),s.removeEventListener("change",Le)}),o("#me-bind-page-add").on("click",()=>{const e=o("#me-bind-page-pick").val();if(!e)return;const n=Ae.val().split(`
|
|
24
|
+
`).map(a=>a.trim()).filter(Boolean);n.includes(e)||n.push(e),Ae.val(n.join(`
|
|
25
|
+
`)),Be(),V()}),ze(h),B=Ie(Ce()),V();const tt=()=>{try{S.remove?S.remove(Y):S.set(Y,null)}catch{}};async function Ke(){if(le.peek())return;const e=Ce(),n=Ze(e);if(n.length){et=!0,V(),E.toast(n.join(" "),{type:"warning",duration:7e3});return}le.value=!0;try{if(_)await j.menus.create(e);else if(e.slug!==h.slug){await j.menus.create(e);const a=await j.menuLocations.get();let p=!1;for(const[v,r]of Object.entries(a))r===h.slug&&(a[v]=e.slug,p=!0);p&&await j.menuLocations.save(a),await j.menus.remove(h.slug)}else await j.menus.update(h.slug,e);if(B=Ie(e),D.value=!1,tt(),Dt("#/menus"),E.toast("Saved.",{type:"success"}),_||e.slug!==h.slug)location.hash="#/menus/edit/"+encodeURIComponent(e.slug);else{h={...h,...e};for(const a of m)a.tool&&a.touched&&(a.tool={...a.tool,persisted:!0});ie(),B=Ie(Ce()),V(),ft(t.get(0),e.name||e.slug)}if(q)try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(a){E.toast(`Save failed: ${a.message||a}`,{type:"error"})}finally{le.value=!1}}async function yt(){!D.peek()||!await E.confirm("Throw away the changes you have not saved?")||(ze(h),V())}E.tabs(o("#me-tabs").get(0));const wt=M.applyBindings({dirty:D,saving:le,problems:pe,itemCount:Se},s,{methods:{save:()=>Ke(),discard:()=>yt(),addItem:()=>de("item"),addSeparator:()=>de("separator"),addSpacer:()=>de("spacer")}});l(()=>wt.dispose()),l(Tt(s,{position:"bottom"})),ft(t.get(0),_?"New menu":h.name||h.slug),We(t,{id:"menu-editor-back",icon:"arrow-left",title:"All menus",onClick:()=>{location.hash="#/menus"}}),We(t,{id:"menu-editor-locations",icon:"map-pin",title:"Menu locations",onClick:()=>{location.hash="#/menu-locations"}}),We(t,{id:"menu-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>Ke()});const nt=e=>{if(!(!(e.ctrlKey||e.metaKey)||e.key!=="s")){if(e.preventDefault(),e.stopImmediatePropagation(),document.querySelector(".me-item-form")?.offsetParent){E.toast("Apply the item first (Ctrl+Enter), then Save.",{type:"info"});return}Ke()}};document.addEventListener("keydown",nt,!0),l(()=>document.removeEventListener("keydown",nt,!0));const ot=()=>{if(!(!D.peek()||!Me))try{S.set(Y,{menu:Me,base:B,at:new Date().toISOString()})}catch{}},at=e=>{D.peek()&&(ot(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",at),l(()=>window.removeEventListener("beforeunload",at)),z={path:w,dirty:()=>D.peek(),name:()=>o("#me-name").val()||h.name||h.slug||"new menu",dispose(){i=!0,ot(),c.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(s);let J=null;try{J=S.get(Y)}catch{}if(J?.menu){tt();const e=J.base&&J.base!==B,n=J.at?` (${qt(J.at)})`:"",a=_?"the new menu you started":`the menu "${He(h.name||h.slug)}"`,p=e?`You left unsaved changes to ${a}${n}, but the saved menu has changed since. Restore your version anyway?`:`You left unsaved changes to ${a}${n}. Restore them?`;await E.confirm(p)&&g()&&(ze({...J.menu,meta:{...h.meta||{},...J.menu.meta||{}}}),V())}}};
|