domma-cms 0.116.7 → 0.117.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.
@@ -0,0 +1,183 @@
1
+ <div class="view-header">
2
+ <h1><span data-icon="code"></span> API &middot; 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 &gt; 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/&lt;path&gt;</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": "&lt;section&gt;…&lt;/section&gt;",
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 &gt; 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 &lt;access JWT&gt;</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&amp;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.</li>
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>
@@ -1,4 +1,4 @@
1
- import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as le,openMenuAt as re,sidebarCount as Me,viewLifetime as Le}from"../lib/tool-kit.js";import{FILTERS as Ae,arrangeTokens as $e,checkToken as Ne,countStates as Oe,scopesToText as Ie,toLocalInput as qe,tokenRow as Pe,tokenState as ce}from"../lib/api-tokens-arrange.js";const de=/^#\/api-tokens\/?(\?.*)?$/,Re="#/api-tokens",R="api-tokens.draft",H="api-tokens.filter",De=`
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>`,A=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(c){const d=document.createElement("template");return d.innerHTML=c.trim(),d.content.firstElementChild}async function ue(c){try{return await navigator.clipboard.writeText(c),!0}catch{return!1}}const l=Le(de);export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(c){l.reset();const d=c.get(0).querySelector(".atx")||c.get(0),$=()=>de.test(location.hash)&&d.isConnected,w=M.observable([]),j=M.observable([]),D=M.observable(!0),_=M.observable("");let U="all";try{U=Ae.includes(S.get(H))?S.get(H):"all"}catch{}const r=M.observable(U),b=M.observable(""),Y=M.observable("");l.add(M.effect(()=>{const e=r.value;try{S.set(H,e)}catch{}}));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=$e(w.value,{filter:r.value,project:b.value,query:Y.value},e).map(t=>Pe(t,e))}));const V=M.computed(()=>Oe(w.value)),fe=M.computed(()=>w.value.length?"No tokens match.":"No API tokens yet - New token in the banner makes one."),T=e=>w.peek().find(t=>t.id===e);async function J(){try{const[e,t]=await Promise.all([g.apiTokens.list(),g.projects.list().catch(()=>[])]);if(!$())return;w.value=Array.isArray(e)?e:[],j.value=Array.isArray(t)?t:[],_.value="",ke()}catch(e){_.value=`Could not load the tokens: ${e.message||e}`}finally{D.value=!1}}const N=async()=>{Me(Re),$()&&await J()};async function W(e,t){if(!(!t&&!await E.confirm(`Switch off "${A(e.name)}"? Callers using it are refused until it is switched back on.`)))try{await g.apiTokens.update(e.id,{enabled:t}),E.toast(`"${e.name}" ${t?"switched on":"switched off"}.`,{type:"success"}),await N()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function me(e){if(await E.confirm(`Revoke "${A(e.name)}"? Anything using it loses access at once. This cannot be undone - a new token would have a different value.`))try{await g.apiTokens.revoke(e.id),E.toast(`Revoked "${e.name}".`,{type:"success"}),await N()}catch(t){E.toast(`Not revoked: ${t.message||t}`,{type:"error"})}}let h=null,y=null;const C=new Set;let ve=!1;function G(e,t,a){const o=e.close.bind(e);let s=!1,u=!1;return e.close=async v=>{if(!s&&v!==!0&&t()){if(u)return;u=!0;const O=await E.confirm(a);if(u=!1,!O)return}return s=!0,o()},()=>{s=!0}}function L(e=null,t=null){const a=e?T(e):null;if(e&&!a){E.toast("That token is no longer there.",{type:"warning"});return}const o=!a,s=j.peek();if(o&&!s.length){E.toast("Make a project first - a token works inside one.",{type:"warning"});return}const u={name:a?.name??"",project:a?.project??(b.peek()||s[0]?.slug||""),expires:qe(a?.expiresAt),scopes:Ie(a?.scopes),enabled:a?a.enabled!==!1:!0,...t||{}},v=Object.fromEntries(Object.entries(u).map(([n,i])=>[n,M.observable(i)])),O=n=>JSON.stringify(Object.fromEntries(Object.entries(n).map(([i,z])=>[i,typeof z=="boolean"?z:String(z??"")]))),ee=()=>Object.fromEntries(Object.keys(v).map(n=>[n,v[n].peek()])),ge=O(u),te=()=>O(ee())!==ge||!!t,q=M.observable(!1),K=M.computed(()=>Ne(Object.fromEntries(Object.keys(v).map(n=>[n,v[n].value])),{isNew:o,projects:j.value.map(n=>n.slug)})),P=n=>M.computed(()=>K.value.errors[n]||""),Ee=a?ce(a):null,ae={...v,saving:q,isNew:o,notNew:!o,nameErr:P("name"),projectErr:P("project"),scopesErr:P("scopes"),expiresErr:P("expires"),cannot:M.computed(()=>q.value||Object.keys(K.value.errors).length>0),saveText:o?"Create token":"Save",fixedProject:a?`${a.project} - fixed for the token's life (revoke and make a new one to move it)`:"",hint:a?`dcms_\u2026${a.tokenHint||"????"}${Ee?.state==="expired"?" - expired: set a later date to bring it back":""}`:""},f=E.slideover({title:o?"New API token":`Edit ${A(a.name)}`,size:"md",position:"right"}),x=pe(`
11
+ </div>`,L=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(c){const d=document.createElement("template");return d.innerHTML=c.trim(),d.content.firstElementChild}async function ue(c){try{return await navigator.clipboard.writeText(c),!0}catch{return!1}}const l=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 per collection: the collection's slug, then a colon and the verbs it may use - create, read, update, delete. No verbs means all four. For example: jobs: read"></span></label>
31
- <textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read&#10;enquiries: create, read" data-model="scopes.value"></textarea>
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&#10;enquiries: create, read&#10;@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=x.querySelector("#atx-f-project");for(const n of s){const i=document.createElement("option");i.value=n.slug,i.textContent=`${n.name||n.slug} (${n.slug})`,ne.appendChild(i)}let B=()=>{};async function oe(){if(ae.cannot.peek())return;q.value=!0;const{payload:n}=K.peek();try{if(o){const i=await g.apiTokens.create(n);B(),f.close(),await N(),be(i?.plaintext,n.name)}else await g.apiTokens.update(a.id,n),E.toast("Saved.",{type:"success"}),B(),f.close(),await N()}catch(i){E.toast(`Not saved: ${i.message||i}`,{type:"error"})}finally{q.value=!1}}f.element.appendChild(x);const je=ie(x),Se=M.applyBindings(ae,x,{methods:{save:oe,cancel:()=>f.close()}});ne.value=v.project.peek();const Te=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),oe())};x.addEventListener("keydown",Te);const se={id:a?.id??null,dirty:te,values:ee};h=se,C.add(f),le(f,()=>{Se.dispose(),je(),C.delete(f),h===se&&(h=null)}),B=G(f,te,"Discard the changes you have not saved?"),I.scan(x),f.open(),x.querySelector("#atx-f-name")?.focus()}function be(e,t){if(!e){E.toast("The token was made, but its value did not come back - revoke it and make another.",{type:"error"});return}let a=!1;const o=E.slideover({title:"Token created",size:"sm",position:"right"}),s=pe(`
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,C.add(o),le(o,()=>{C.delete(o),y===u&&(y=null),e=null}),G(o,()=>!a,`You have not copied the token for "${A(t)}". Once this closes it can never be shown again. Close anyway?`),I.scan(s),o.open()}function he(e){const t=T(e);if(!t)return[];const{state:a}=ce(t);return[{header:t.name||"Token"},{label:"Edit\u2026",icon:"edit",action:()=>L(t.id)},t.enabled===!1?{label:"Switch on",icon:"play",action:()=>W(t,!0)}:{label:"Switch off\u2026",icon:"pause",disabled:a==="expired",action:()=>W(t,!1)},{type:"divider"},{label:"Copy its ending",icon:"copy",action:async()=>{E.toast(await ue(`dcms_\u2026${t.tokenHint||""}`)?"Copied the ending - the token itself is never shown again.":"Copy failed.",{type:"info"})}},{label:`Show only ${t.project}`,icon:"filter",disabled:b.peek()===t.project,action:()=>{b.value=t.project,ye()}},{type:"divider"},{label:"Revoke\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=d.querySelector(".atx-list");m.innerHTML=De;const k=d.querySelector(".atx-project"),ye=()=>{k&&(k.value=b.peek())};function ke(){if(!k)return;const e=b.peek();for(;k.options.length>1;)k.remove(1);for(const t of j.peek()){const a=document.createElement("option");a.value=t.slug,a.textContent=t.name||t.slug,k.appendChild(a)}k.value=e}const xe=M.applyBindings({tokens:w,projects:j,loading:D,loadError:_,filter:r,project:b,query:Y,rows:F,counts:V,emptyText:fe},d,{methods:{showAll:()=>{r.value="all"},showActive:()=>{r.value="active"},showExpiring:()=>{r.value="expiring"},showExpired:()=>{r.value="expired"},showDisabled:()=>{r.value="disabled"}}});l.add(()=>xe.dispose()),l.add(ie(d,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),l.add(M.effect(()=>{const e=V.value,t=r.peek();t!=="all"&&!D.value&&!e[t]&&(r.value="all")}));const we=E.contextMenu(m,{cursor:"glyph",match:".atx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.id)});l.add(()=>we?.destroy?.());const Q=e=>{const t=e.target.closest(".atx-row");if(!t)return;const a=e.target.closest(".atx-kebab");if(a){re(a);return}L(t.dataset.id)},X=e=>{const t=e.target.closest(".atx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};m.addEventListener("click",Q),m.addEventListener("keydown",X),l.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),Ce(c,{id:"api-tokens-new",icon:"plus",title:"New token",onClick:()=>L()});const Z=e=>{(h?.dirty()||y&&!y.copied())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),l.add(()=>window.removeEventListener("beforeunload",Z)),l.add(()=>{if(h?.dirty())try{S.set(R,{id:h.id,values:h.values(),at:new Date().toISOString()})}catch{}ve=!0,[...C].forEach(e=>{if(!(y&&e.__dmSecret&&!y.copied()))try{e.close(!0)}catch{}})}),I.scan(d),await J(),!$())return;let p=null;try{p=S.get(R)}catch{}if(p?.values){try{S.remove?S.remove(R):S.set(R,null)}catch{}const e=p.id&&T(p.id)?`your changes to "${A(T(p.id).name)}"`:"the new token you started";await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&L(p.id&&T(p.id)?p.id:null,p.values)}}};
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,s){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof s=="function"&&s(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const s=o.find("#tutorial-scaffolder-mount").get(0);s&&i(s,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 a=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","tools"],c=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","builder"],n=o=>o.split("-").map(s=>s.charAt(0).toUpperCase()+s.slice(1)).join("");for(const o of a)docViews["usage"+n(o)]=t(`usage-${o}`);for(const o of c)docViews["apiRef"+n(o)]=t(`api-${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}`);
@@ -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 C}from"./components.js";import{componentEditorView as N}from"./component-editor.js";import{myProfileView as T}from"./my-profile.js";import{rolesView as U}from"./roles.js";import{roleEditorView as q}from"./role-editor.js";import{effectsView as z}from"./effects.js";import{searchView as A}from"./search.js";import{notesView as B}from"./notes.js";import{todoView as F}from"./todo.js";import{calculatorView as H}from"../lib/calculator.js";import{analyticsView as I}from"./analytics.js";import{seoView as J}from"./seo.js";import{contactsView as K}from"./contacts.js";import{callNewView as O}from"./call-new.js";import{menusView as Q}from"./menus.js";import{contextMenusView as W}from"./context-menus.js";import{contextMenuEditorView as X}from"./context-menu-editor.js";import{menuEditorView as Y}from"./menu-editor.js";import{menuLocationsView as Z}from"./menu-locations.js";import{projectsView as _}from"./projects.js";import{projectDetailView as $}from"./project-detail.js";import{projectSettingsView as oo}from"./project-settings.js";import{apiTokensView as io}from"./api-tokens.js";import{apiEndpointsView as ro}from"./api-endpoints.js";import{apiEndpointEditorView as to}from"./api-endpoint-editor.js";const eo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:_,projectDetail:$,projectSettings:oo,apiTokens:io,apiEndpoints:ro,apiEndpointEditor:to,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:C,componentEditor:N,myProfile:T,roles:U,roleEditor:q,effects:z,search:A,notes:B,todo:F,calculator:H,analytics:I,seo:J,contacts:K,callNew:O,notifications:m,menus:Q,contextMenus:W,contextMenuEditor:X,menuEditor:Y,menuLocations:Z,menusRedirect:eo,pluginGuides:u,pluginGuide:E,...d};
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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.116.7",
3
+ "version": "0.117.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -1 +1 @@
1
- #view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface, #fff)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border, #e2e8f0);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border, #e2e8f0);color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary, #2563eb);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:var(--dm-primary-text, #fff);border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:color-mix(in srgb,var(--dm-primary-text, #fff) 15%,transparent)}.mail-item.is-selected{background:var(--dm-active-bg, rgba(15, 23, 42, .07));box-shadow:inset 3px 0 0 var(--dm-primary, #2563eb)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg, rgba(15, 23, 42, .07));border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border, #e2e8f0);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary, #2563eb)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface, #fff);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted, #64748b)}.mail-account:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-account.is-active{color:var(--dm-text, #1e293b);position:sticky;top:0;z-index:1;background:var(--dm-surface, #fff)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text, #1e293b)}.mail-folder:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-folder.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07));color:var(--dm-primary, #2563eb);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border, #e2e8f0);background:none;cursor:pointer;color:var(--dm-text, #1e293b)}.mail-item:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-item.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07))}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted, #64748b)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border, #e2e8f0);font-size:.75rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text, #1e293b)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted, #64748b)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface, #fff)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning, #8a6d1f);color:var(--dm-warning-text, #fff);border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-notice .btn{color:var(--dm-warning-text, #fff);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:color-mix(in srgb,var(--dm-warning-text, #fff) 15%,transparent);opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, #e2e8f0);background:var(--dm-background-alt, rgba(15, 23, 42, .03))}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted, #94a3b8);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px)}.mail-default-badge{margin-left:.4rem;font-size:.7rem;font-weight:500;vertical-align:middle}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);outline:2px solid var(--dm-primary, #2563eb);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted, #64748b);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary, #2563eb)}.mail-item-tick:hover{color:var(--dm-primary, #2563eb);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-high{color:var(--dm-danger, #dc2626)}.mail-item-meta .is-low{color:var(--dm-text-muted, #64748b);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted, #64748b);background:var(--dm-active-bg, rgba(15, 23, 42, .07));cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary, #2563eb)}.mail-thread-chip.is-unread{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff)}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border, #e2e8f0)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary, #2563eb);background:var(--dm-active-bg, rgba(15, 23, 42, .07))}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger, #dc2626)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border, #e2e8f0)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary, #2563eb)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}.mail-item-more{position:absolute;right:.4rem;bottom:.35rem;display:inline-flex;align-items:center;justify-content:center;min-width:1.4rem;height:1.4rem;border-radius:var(--dm-radius-sm, 3px);font-size:1rem;line-height:1;color:var(--dm-text-muted, #64748b);background:inherit;opacity:0;transition:opacity .12s ease}.mail-item:hover .mail-item-more,.mail-item:focus-visible .mail-item-more,.mail-item.is-active .mail-item-more{opacity:1}.mail-item-more:hover{color:var(--dm-primary, #2563eb)}.mail-section-intro{display:flex;align-items:center;gap:.35rem;margin:0 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border, #e2e8f0)}
1
+ #view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface, #fff)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border, #e2e8f0);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border, #e2e8f0);color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary, #2563eb);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:var(--dm-primary-text, #fff);border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:color-mix(in srgb,var(--dm-primary-text, #fff) 15%,transparent)}.mail-item.is-selected{background:var(--dm-active-bg, rgba(15, 23, 42, .07));box-shadow:inset 3px 0 0 var(--dm-primary, #2563eb)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg, rgba(15, 23, 42, .07));border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border, #e2e8f0);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary, #2563eb)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface, #fff);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted, #64748b)}.mail-account:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-account.is-active{color:var(--dm-text, #1e293b);position:sticky;top:0;z-index:1;background:var(--dm-surface, #fff)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text, #1e293b)}.mail-folder:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-folder.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07));color:var(--dm-primary, #2563eb);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border, #e2e8f0);background:none;cursor:pointer;color:var(--dm-text, #1e293b)}.mail-item:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-item.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07))}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted, #64748b)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border, #e2e8f0);font-size:.75rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text, #1e293b)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted, #64748b)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface, #fff)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning, #8a6d1f);color:var(--dm-warning-text, #fff);border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-notice .btn{color:var(--dm-warning-text, #fff);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:color-mix(in srgb,var(--dm-warning-text, #fff) 15%,transparent);opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, #e2e8f0);background:var(--dm-background-alt, rgba(15, 23, 42, .03))}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted, #94a3b8);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px)}.mail-default-badge{margin-left:.4rem;font-size:.7rem;font-weight:500;vertical-align:middle}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);outline:2px solid var(--dm-primary, #2563eb);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted, #64748b);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary, #2563eb)}.mail-item-tick:hover{color:var(--dm-primary, #2563eb);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-high{color:var(--dm-danger, #dc2626)}.mail-item-meta .is-low{color:var(--dm-text-muted, #64748b);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted, #64748b);background:var(--dm-active-bg, rgba(15, 23, 42, .07));cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary, #2563eb)}.mail-thread-chip.is-unread{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff)}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border, #e2e8f0)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary, #2563eb);background:var(--dm-active-bg, rgba(15, 23, 42, .07))}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger, #dc2626)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border, #e2e8f0)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary, #2563eb)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}.mail-item-more{position:absolute;right:.4rem;bottom:.35rem;display:inline-flex;align-items:center;justify-content:center;min-width:1.4rem;height:1.4rem;border-radius:var(--dm-radius-sm, 3px);font-size:1rem;line-height:1;color:var(--dm-text-muted, #64748b);background:inherit;opacity:0;transition:opacity .12s ease}.mail-item:hover .mail-item-more,.mail-item:focus-visible .mail-item-more,.mail-item.is-active .mail-item-more{opacity:1}.mail-item-more:hover{color:var(--dm-primary, #2563eb)}.mail-section-intro{display:flex;align-items:center;gap:.35rem;margin:0 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-pager-middle{display:flex;align-items:center;gap:.5rem;min-width:0}.mail-page-size{font:inherit;color:inherit;background:transparent;border:1px solid var(--dm-border, #e2e8f0);border-radius:4px;padding:.1rem .25rem}
@@ -34,7 +34,10 @@ const {
34
34
  unreadInSubtree
35
35
  } = await import(versioned('folder-tree.js'));
36
36
  const {imageSendersSection} = await import(versioned('image-senders-section.js'));
37
- const {createSettingsSection, READ_DELAY_KEY, readDelaySeconds, scheduleMarkRead} = await import(versioned('settings-section.js'));
37
+ const {
38
+ createSettingsSection, READ_DELAY_KEY, readDelaySeconds, scheduleMarkRead,
39
+ PAGE_SIZE_KEY, PAGE_SIZES, listPageSize, pageAfterResize
40
+ } = await import(versioned('settings-section.js'));
38
41
  const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
39
42
  const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
40
43
  const {fmtDate, fmtDateTime} = await import(new URL(`../ui/dates.js?v=${ASSET_VERSION}`, import.meta.url).href);
@@ -303,7 +306,19 @@ return {
303
306
  let threads = [];
304
307
  let page = 1;
305
308
  let total = 0;
306
- let limit = 50;
309
+ /**
310
+ * Messages per page as chosen under the list. Remembered: a long
311
+ * folder is read the same way every visit.
312
+ */
313
+ let pageSize = (() => {
314
+ try {
315
+ return listPageSize(S.get(PAGE_SIZE_KEY));
316
+ } catch {
317
+ return listPageSize(null);
318
+ }
319
+ })();
320
+ /** The page size the server actually used - what the pager counts in. */
321
+ let limit = pageSize;
307
322
  /**
308
323
  * The Contacts plugin's address book, or an empty list where it is not
309
324
  * installed. Held here rather than fetched per compose window: the
@@ -1463,7 +1478,8 @@ return {
1463
1478
  const params = new URLSearchParams({
1464
1479
  account: currentAccountId ?? '',
1465
1480
  folder: currentFolder,
1466
- page: String(page)
1481
+ page: String(page),
1482
+ limit: String(pageSize)
1467
1483
  });
1468
1484
  if (fresh) params.set('fresh', '1');
1469
1485
  const result = await api(`${BASE}/messages?${params}`);
@@ -1497,7 +1513,8 @@ return {
1497
1513
  const params = new URLSearchParams({
1498
1514
  account: currentAccountId ?? '',
1499
1515
  folder: currentFolder,
1500
- page: String(page)
1516
+ page: String(page),
1517
+ limit: String(pageSize)
1501
1518
  });
1502
1519
  const result = await api(`${BASE}/threads?${params}`);
1503
1520
 
@@ -1622,7 +1639,8 @@ return {
1622
1639
  const params = new URLSearchParams({
1623
1640
  account: currentAccountId ?? '',
1624
1641
  q: searchQuery,
1625
- page: String(page)
1642
+ page: String(page),
1643
+ limit: String(pageSize)
1626
1644
  });
1627
1645
  const result = await api(`${BASE}/search?${params}`);
1628
1646
 
@@ -2181,11 +2199,49 @@ return {
2181
2199
  loadMessages();
2182
2200
  });
2183
2201
 
2202
+ const size = document.createElement('select');
2203
+ size.className = 'mail-page-size';
2204
+ size.title = 'Messages per page';
2205
+ size.setAttribute('aria-label', 'Messages per page');
2206
+ for (const n of PAGE_SIZES) {
2207
+ const option = document.createElement('option');
2208
+ option.value = String(n);
2209
+ option.textContent = `${n} per page`;
2210
+ size.appendChild(option);
2211
+ }
2212
+ size.value = String(pageSize);
2213
+ size.addEventListener('change', () => setPageSize(Number(size.value)));
2214
+
2215
+ const middle = document.createElement('span');
2216
+ middle.className = 'mail-pager-middle';
2217
+ middle.appendChild(label);
2218
+ middle.appendChild(size);
2219
+
2184
2220
  pagerEl.appendChild(prev);
2185
- pagerEl.appendChild(label);
2221
+ pagerEl.appendChild(middle);
2186
2222
  pagerEl.appendChild(next);
2187
2223
  }
2188
2224
 
2225
+ /**
2226
+ * Change how many messages a page holds, keeping the first message of
2227
+ * the current page in view.
2228
+ *
2229
+ * @param {number} size
2230
+ * @returns {Promise<void>}
2231
+ */
2232
+ async function setPageSize(size) {
2233
+ const next = listPageSize(size);
2234
+ if (next === pageSize) return;
2235
+ page = pageAfterResize(page, limit, next);
2236
+ pageSize = next;
2237
+ try {
2238
+ S.set(PAGE_SIZE_KEY, pageSize);
2239
+ } catch {
2240
+ // Storage refused - the size still applies for this visit.
2241
+ }
2242
+ await loadMessages();
2243
+ }
2244
+
2189
2245
  // ---------------------------------------------------------------
2190
2246
  // Reader
2191
2247
  // ---------------------------------------------------------------
@@ -4942,7 +4998,7 @@ return {
4942
4998
  const folder = currentFolder;
4943
4999
  refreshing = true;
4944
5000
  try {
4945
- const params = new URLSearchParams({account, folder, page: '1'});
5001
+ const params = new URLSearchParams({account, folder, page: '1', limit: String(pageSize)});
4946
5002
  const result = await api(`${BASE}/messages?${params}`);
4947
5003
  if (!here() || currentAccountId !== account || currentFolder !== folder ||
4948
5004
  page !== 1 || searchQuery || selected.size) return;
@@ -1,6 +1,38 @@
1
1
  /** Browser preferences for the shared mail reader. */
2
2
  export const READ_DELAY_KEY = 'domma-mail.mark-read-seconds';
3
3
  export const DEFAULT_READ_DELAY_SECONDS = 1;
4
+ export const PAGE_SIZE_KEY = 'domma-mail.page-size';
5
+ /** The choices offered under the list. 200 is the server's maxListLimit. */
6
+ export const PAGE_SIZES = [25, 50, 100, 200];
7
+ export const DEFAULT_PAGE_SIZE = 50;
8
+
9
+ /**
10
+ * The remembered messages-per-page, or the default for anything that is not
11
+ * one of the offered sizes - a stale or hand-edited value must not ask the
12
+ * server for a page size the picker cannot show.
13
+ *
14
+ * @param {*} value
15
+ * @returns {number}
16
+ */
17
+ export function listPageSize(value) {
18
+ const size = Number(value);
19
+ return PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE;
20
+ }
21
+
22
+ /**
23
+ * The page that keeps the first message of the current page in view after the
24
+ * page size changes, so switching from 50 to 200 on page 3 lands on the page
25
+ * holding message 101 rather than jumping back to the newest.
26
+ *
27
+ * @param {number} page current 1-based page
28
+ * @param {number} oldSize
29
+ * @param {number} newSize
30
+ * @returns {number}
31
+ */
32
+ export function pageAfterResize(page, oldSize, newSize) {
33
+ const firstIndex = (Math.max(1, page) - 1) * oldSize;
34
+ return Math.floor(firstIndex / newSize) + 1;
35
+ }
4
36
 
5
37
  export function readDelaySeconds(value) {
6
38
  if (value === null || value === undefined || value === '') return DEFAULT_READ_DELAY_SECONDS;