kempo 4.2.10 → 4.2.12

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,509 @@
1
+ <!--
2
+ owner: system
3
+ locked: true
4
+ name:
5
+ author:
6
+ created:
7
+ updated:
8
+ -->
9
+ <!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <link rel="icon" type="image/png" sizes="48x48" href="/media/icon48.png">
15
+ <link rel="manifest" href="/manifest.json">
16
+ <title>Pages - Admin</title>
17
+ <link rel="stylesheet" href="/kempo-css/kempo.min.css">
18
+ <script src="/admin/init.js"></script>
19
+
20
+ </head>
21
+ <body>
22
+ <!--
23
+ locked: true
24
+ -->
25
+
26
+ <k-aside
27
+ main="push"
28
+ state="expanded"
29
+ style="--aside_padding: 0;"
30
+ persistent-id="admin-nav"
31
+ >
32
+ <k-aside-toggle>
33
+ <h3 class="m0">Kempo Admin</h3>
34
+ </k-aside-toggle>
35
+ <k-aside-item
36
+ icon="preview"
37
+ href="/"
38
+ no-expand
39
+ >Site</k-aside-item>
40
+ <k-aside-item
41
+ icon="cards"
42
+ href="/admin"
43
+ no-expand
44
+ >Dashboard</k-aside-item>
45
+ <k-aside-menu
46
+ icon="manage-accounts"
47
+ label="Accounts"
48
+ >
49
+ <k-aside-item
50
+ icon="person"
51
+ href="/admin/accounts/users"
52
+ no-expand
53
+ >Users</k-aside-item>
54
+ <k-aside-item
55
+ icon="group"
56
+ href="/admin/accounts/groups"
57
+ no-expand
58
+ >Groups</k-aside-item>
59
+ <k-aside-item
60
+ icon="lock"
61
+ href="/admin/accounts/permissions"
62
+ no-expand
63
+ >Permissions</k-aside-item>
64
+ </k-aside-menu>
65
+ <k-aside-menu
66
+ icon="cards_stack"
67
+ label="Content"
68
+ >
69
+ <k-aside-item
70
+ icon="browse"
71
+ href="/admin/content/templates"
72
+ >Templates</k-aside-item>
73
+ <k-aside-item
74
+ icon="docs"
75
+ href="/admin/content/pages"
76
+ >Pages</k-aside-item>
77
+ <k-aside-item
78
+ icon="content"
79
+ href="/admin/content/fragments"
80
+ >Fragments</k-aside-item>
81
+ <k-aside-item
82
+ icon="globe"
83
+ href="/admin/content/globals"
84
+ >Global Content</k-aside-item>
85
+ </k-aside-menu>
86
+ <k-aside-item
87
+ icon="settings"
88
+ href="/admin/settings"
89
+ no-expand
90
+ >Settings</k-aside-item>
91
+ <k-aside-item
92
+ icon="extension"
93
+ href="/admin/extensions"
94
+ no-expand
95
+ >Extensions</k-aside-item>
96
+ <hr>
97
+ <k-aside-spacer></k-aside-spacer>
98
+ <k-theme-switcher
99
+ labels
100
+ style="--padding:var(--spacer_h);margin:var(--spacer_h) auto"
101
+ ></k-theme-switcher>
102
+ </k-aside>
103
+ <script type="module">
104
+ import '/kempo-ui/components/ThemeSwitcher.js';
105
+ const items = document.querySelectorAll('k-aside-item');
106
+ const currentPath = window.location.pathname;
107
+
108
+ items.forEach(item => {
109
+ const href = item.getAttribute('href');
110
+ if (!href) return;
111
+ const isExact = href === '/admin' || href === '/';
112
+ const match = isExact
113
+ ? (currentPath === href || currentPath === href + '/')
114
+ : (currentPath === href || currentPath.startsWith(href + '/'));
115
+ if (match) {
116
+ item.setAttribute('active', '');
117
+ const menu = item.closest('k-aside-menu');
118
+ if (menu) menu.setAttribute('open', '');
119
+ }
120
+ });
121
+ </script>
122
+
123
+ <k-main>
124
+
125
+ <script
126
+ type="module"
127
+ src="/kempo-ui/components/Table.js"
128
+ ></script>
129
+ <script
130
+ type="module"
131
+ src="/kempo-ui/components/tableControls/FirstPage.js"
132
+ ></script>
133
+ <script
134
+ type="module"
135
+ src="/kempo-ui/components/tableControls/PrevPage.js"
136
+ ></script>
137
+ <script
138
+ type="module"
139
+ src="/kempo-ui/components/tableControls/NextPage.js"
140
+ ></script>
141
+ <script
142
+ type="module"
143
+ src="/kempo-ui/components/tableControls/LastPage.js"
144
+ ></script>
145
+ <script
146
+ type="module"
147
+ src="/kempo-ui/components/tableControls/PageSelect.js"
148
+ ></script>
149
+ <script
150
+ type="module"
151
+ src="/kempo-ui/components/tableControls/PageSize.js"
152
+ ></script>
153
+ <script
154
+ type="module"
155
+ src="/kempo-ui/components/tableControls/DeleteRecord.js"
156
+ ></script>
157
+ <script
158
+ type="module"
159
+ src="/kempo-ui/components/tableControls/DeleteSelected.js"
160
+ ></script>
161
+ <script
162
+ type="module"
163
+ src="/kempo-ui/components/Icon.js"
164
+ ></script>
165
+ <script
166
+ type="module"
167
+ src="/admin/components/TableRowLink.js"
168
+ ></script>
169
+ <script
170
+ type="module"
171
+ src="/kempo/components/Permission.js"
172
+ ></script>
173
+
174
+ <div
175
+ class="d-f mb"
176
+ style="align-items: center; gap: var(--spacer);"
177
+ >
178
+ <h1 class="flex mb0">Pages</h1>
179
+ <k-permission has="system:pages:create">
180
+ <button
181
+ id="createPageBtn"
182
+ class="primary"
183
+ >Create Page</button>
184
+ </k-permission>
185
+ </div>
186
+
187
+ <h2>Custom Pages</h2>
188
+ <k-table
189
+ id="customTable"
190
+ enable-pages
191
+ page-size="25"
192
+ page-size-options="[10,25,50,100]"
193
+ placeholder="No custom pages have been created yet."
194
+ >
195
+ <admin-table-row-link
196
+ slot="before"
197
+ url-pattern="/admin/content/pages/edit?path={editPath}"
198
+ field-name="editPath"
199
+ ></admin-table-row-link>
200
+ <div
201
+ slot="top"
202
+ class="d-f full"
203
+ style="align-items: center;"
204
+ >
205
+ <div>
206
+ <k-permission has="system:pages:delete">
207
+ <k-tc-delete-selected></k-tc-delete-selected>
208
+ </k-permission>
209
+ </div>
210
+ </div>
211
+ <div
212
+ class="d-f full"
213
+ style="align-items: center;"
214
+ >
215
+ <div>
216
+ <k-tc-first-page></k-tc-first-page>
217
+ <k-tc-prev-page></k-tc-prev-page>
218
+ <k-tc-next-page></k-tc-next-page>
219
+ <k-tc-last-page></k-tc-last-page>
220
+ </div>
221
+ <div class="flex"></div>
222
+ <div><k-tc-page-select></k-tc-page-select></div>
223
+ <div class="flex"></div>
224
+ <div><k-tc-page-size></k-tc-page-size></div>
225
+ </div>
226
+ </k-table>
227
+
228
+ <h2>Extension Pages</h2>
229
+ <k-table
230
+ id="extensionTable"
231
+ enable-pages
232
+ page-size="25"
233
+ page-size-options="[10,25,50,100]"
234
+ placeholder="No extension pages installed."
235
+ >
236
+ <admin-table-row-link
237
+ slot="before"
238
+ url-pattern="/admin/pages/edit?path={editPath}"
239
+ field-name="editPath"
240
+ ></admin-table-row-link>
241
+ <div
242
+ class="d-f full"
243
+ style="align-items: center;"
244
+ >
245
+ <div>
246
+ <k-tc-first-page></k-tc-first-page>
247
+ <k-tc-prev-page></k-tc-prev-page>
248
+ <k-tc-next-page></k-tc-next-page>
249
+ <k-tc-last-page></k-tc-last-page>
250
+ </div>
251
+ <div class="flex"></div>
252
+ <div><k-tc-page-select></k-tc-page-select></div>
253
+ <div class="flex"></div>
254
+ <div><k-tc-page-size></k-tc-page-size></div>
255
+ </div>
256
+ </k-table>
257
+
258
+ <h2>System Pages</h2>
259
+ <k-table
260
+ id="systemTable"
261
+ enable-pages
262
+ page-size="25"
263
+ page-size-options="[10,25,50,100]"
264
+ placeholder="No system pages found."
265
+ >
266
+ <admin-table-row-link
267
+ slot="before"
268
+ url-pattern="/admin/pages/edit?path={editPath}"
269
+ field-name="editPath"
270
+ ></admin-table-row-link>
271
+ <div
272
+ class="d-f full"
273
+ style="align-items: center;"
274
+ >
275
+ <div>
276
+ <k-tc-first-page></k-tc-first-page>
277
+ <k-tc-prev-page></k-tc-prev-page>
278
+ <k-tc-next-page></k-tc-next-page>
279
+ <k-tc-last-page></k-tc-last-page>
280
+ </div>
281
+ <div class="flex"></div>
282
+ <div><k-tc-page-select></k-tc-page-select></div>
283
+ <div class="flex"></div>
284
+ <div><k-tc-page-size></k-tc-page-size></div>
285
+ </div>
286
+ </k-table>
287
+
288
+ <script type="module">
289
+ import { listPages, listTemplates, listDirectories, createPage, deletePages, getAllCurrentUserPermissions } from '/kempo/sdk.js';
290
+ import { html } from '/kempo-ui/lit-all.min.js';
291
+ import Dialog from '/kempo-ui/components/Dialog.js';
292
+ import Toast from '/kempo-ui/components/Toast.js';
293
+
294
+ await window.customElements.whenDefined('k-table');
295
+
296
+ const [, permData] = await getAllCurrentUserPermissions();
297
+ const myPerms = new Set(permData?.permissions ?? []);
298
+
299
+ const fields = [
300
+ { name: 'lockedBadge', label: '', size: 40, formatter: v => v },
301
+ { name: 'name', label: 'Name', size: 200 },
302
+ { name: 'url', label: 'URL', size: 250 },
303
+ { name: 'author', label: 'Author', size: 150 },
304
+ { name: 'description', label: 'Description', size: 300 },
305
+ { name: 'updatedAt', label: 'Updated', size: 190, formatter: v => v ? new Date(v).toLocaleString() : '' },
306
+ { name: 'createdAt', label: 'Created', size: 190, formatter: v => v ? new Date(v).toLocaleString() : '' }
307
+ ];
308
+
309
+ const extensionFields = [
310
+ { name: 'owner', label: 'Extension', size: 120 },
311
+ ...fields
312
+ ];
313
+
314
+ const $customTable = document.getElementById('customTable');
315
+ const $extensionTable = document.getElementById('extensionTable');
316
+ const $systemTable = document.getElementById('systemTable');
317
+
318
+ const loadPages = async () => {
319
+ const [error, data] = await listPages();
320
+ if(error){
321
+ Toast.error(error.msg || 'Failed to load pages');
322
+ return;
323
+ }
324
+ const toEditPath = p => ({ ...p, editPath: p.file.replace(/\.page\.html$/, ''), lockedBadge: p.locked ? '<k-icon name="lock"></k-icon>' : '' });
325
+ const sortPages = pages => pages.slice().sort((a, b) => {
326
+ const aPath = a.file.replace(/\.page\.html$/, '');
327
+ const bPath = b.file.replace(/\.page\.html$/, '');
328
+ const aDir = aPath.includes('/') ? aPath.split('/').slice(0, -1).join('/') : '';
329
+ const bDir = bPath.includes('/') ? bPath.split('/').slice(0, -1).join('/') : '';
330
+ if(aPath === 'index' || aPath === '') return -1;
331
+ if(bPath === 'index' || bPath === '') return 1;
332
+ if(aDir !== bDir){
333
+ if(!aDir) return -1;
334
+ if(!bDir) return 1;
335
+ return aDir.localeCompare(bDir);
336
+ }
337
+ return aPath.localeCompare(bPath);
338
+ });
339
+ const custom = sortPages(data.pages.filter(p => p.owner === 'custom').map(toEditPath));
340
+ const extension = sortPages(data.pages.filter(p => p.owner !== 'custom' && p.owner !== 'system').map(toEditPath));
341
+ const system = sortPages(data.pages.filter(p => p.owner === 'system').map(toEditPath));
342
+ $customTable.setData({ records: custom, fields });
343
+ $extensionTable.setData({ records: extension, fields: extensionFields });
344
+ $systemTable.setData({ records: system, fields });
345
+ };
346
+
347
+ if(myPerms.has('system:pages:delete')){
348
+ $customTable.setAttribute('request-delete', '');
349
+ $customTable.setAttribute('enable-selection', '');
350
+ const del = document.createElement('k-tc-delete-record');
351
+ del.setAttribute('slot', 'after');
352
+ $customTable.appendChild(del);
353
+ }
354
+
355
+ await loadPages();
356
+
357
+ $customTable.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
358
+ const locked = records.filter(r => r.locked);
359
+ if(locked.length){
360
+ Toast.error(locked.length === 1 ? `"${locked[0].title || locked[0].file}" is locked and cannot be deleted.` : `${locked.length} selected pages are locked and cannot be deleted.`);
361
+ reject();
362
+ return;
363
+ }
364
+ const count = records.length;
365
+ const msg = count === 1
366
+ ? `Delete "${records[0].title || records[0].file}"? This action cannot be undone.`
367
+ : `Delete ${count} pages? This action cannot be undone.`;
368
+ Dialog.confirm(msg, async (confirmed) => {
369
+ if(!confirmed){ reject(); return; }
370
+ const files = records.map(r => r.file);
371
+ const [error] = await deletePages(files);
372
+ if(error){
373
+ reject();
374
+ Toast.error(error.msg || 'Failed to delete');
375
+ return;
376
+ }
377
+ approve();
378
+ Toast.success(count === 1 ? 'Page deleted' : `${count} pages deleted`);
379
+ });
380
+ });
381
+
382
+ /*
383
+ Create Page Dialog
384
+ */
385
+
386
+ const slugify = name => name
387
+ .toLowerCase()
388
+ .trim()
389
+ .replace(/[^\w\s-]/g, '')
390
+ .replace(/[\s_]+/g, '-')
391
+ .replace(/-+/g, '-')
392
+ .replace(/^-|-$/g, '');
393
+
394
+ document.getElementById('createPageBtn')?.addEventListener('click', async () => {
395
+ const [[tplError, tplData], [dirError, dirData]] = await Promise.all([
396
+ listTemplates(),
397
+ listDirectories()
398
+ ]);
399
+ if(tplError){
400
+ Toast.error(tplError.msg || 'Failed to load templates');
401
+ return;
402
+ }
403
+ if(dirError){
404
+ Toast.error(dirError.msg || 'Failed to load directories');
405
+ return;
406
+ }
407
+
408
+ const directories = dirData.directories;
409
+
410
+ const templateOptions = tplData.templates
411
+ .map(t => html`<option value="${t.name}" ?selected=${t.name === 'default'}>${t.name} (${t.directory || '/'})</option>`);
412
+
413
+ const isValidDir = p => /^\/[a-zA-Z0-9_\-\[\]\/]*$/.test(p) && !p.includes('..') && !p.includes('//');
414
+ const dirToInternal = p => { const t = (p || '/').replace(/^\//, '').replace(/\/$/, ''); return t || '.'; };
415
+
416
+ const openLearnMore = () => Dialog.alert(html`
417
+ <div class="p">
418
+ <p>Directory paths define where a page lives in the URL structure.</p>
419
+ <p><strong>Examples:</strong></p>
420
+ <ul>
421
+ <li><code>/</code> — root, e.g. <code>/about</code></li>
422
+ <li><code>/blog</code> — e.g. <code>/blog/my-post</code></li>
423
+ <li><code>/blog/[slug]</code> — dynamic segment; <code>[slug]</code> matches any value</li>
424
+ </ul>
425
+ <p>Dynamic segments like <code>[token]</code> allow a single page to handle many URLs. The segment name is available to the page as a URL parameter.</p>
426
+ <p>You can nest segments: <code>/store/[category]/[id]</code></p>
427
+ </div>
428
+ `, null, { title: 'About Directory Paths' });
429
+
430
+ const $dialog = Dialog.create(html`
431
+ <div class="p">
432
+ <div class="mb">
433
+ <label class="d-b mb-sm">Location</label>
434
+ <input type="text" id="dlg-directory" class="full" list="dlg-dir-list" value="/" placeholder="/">
435
+ <datalist id="dlg-dir-list">
436
+ ${directories.map(d => html`<option value="${d === '.' ? '/' : '/' + d}">`)}
437
+ </datalist>
438
+ <p id="dlg-dir-error" class="mt-sm tc-danger d-n"><small>Invalid path — use format: /path/to/directory</small></p>
439
+ <p class="mt-sm muted"><small>Supports dynamic segments e.g. <code>/blog/[slug]</code>. <button class="link" @click="${openLearnMore}">Learn more</button></small></p>
440
+ </div>
441
+ <div class="mb">
442
+ <label class="d-b mb-sm">Page Name</label>
443
+ <input type="text" id="dlg-name" class="full" placeholder="My New Page" required>
444
+ <p class="mt-sm muted"><small id="dlg-slug"></small></p>
445
+ </div>
446
+ <div>
447
+ <label class="d-b mb-sm">Template</label>
448
+ <select id="dlg-template" class="full">
449
+ ${templateOptions}
450
+ </select>
451
+ </div>
452
+ </div>
453
+ `, {
454
+ title: 'Create Page',
455
+ confirmText: 'Create',
456
+ confirmAction: async () => {
457
+ const name = $dialog.querySelector('#dlg-name').value.trim();
458
+ const dirInput = $dialog.querySelector('#dlg-directory').value.trim() || '/';
459
+ const template = $dialog.querySelector('#dlg-template').value;
460
+
461
+ if(!name){
462
+ Toast.error('Page name is required');
463
+ return;
464
+ }
465
+
466
+ if(!isValidDir(dirInput)){
467
+ Toast.error('Invalid directory path');
468
+ return;
469
+ }
470
+
471
+ const directory = dirToInternal(dirInput);
472
+
473
+ const [error, data] = await createPage({
474
+ directory: directory === '.' ? '' : directory,
475
+ name,
476
+ template
477
+ });
478
+
479
+ if(error){
480
+ Toast.error(error.msg || 'Failed to create page');
481
+ return;
482
+ }
483
+
484
+ Toast.success('Page created — redirecting to editor...');
485
+ setTimeout(() => {
486
+ window.location.href = `/admin/pages/edit?path=${encodeURIComponent(data.file.replace(/\.page\.html$/, ''))}`;
487
+ }, 1500);
488
+ },
489
+ cancelText: 'Cancel'
490
+ });
491
+
492
+ const updateSlug = () => {
493
+ const slug = slugify($dialog.querySelector('#dlg-name').value);
494
+ const dir = dirToInternal($dialog.querySelector('#dlg-directory').value);
495
+ const prefix = dir === '.' ? '/' : '/' + dir + '/';
496
+ $dialog.querySelector('#dlg-slug').textContent = slug ? prefix + slug : '';
497
+ };
498
+ $dialog.querySelector('#dlg-directory').addEventListener('input', e => {
499
+ $dialog.querySelector('#dlg-dir-error').classList.toggle('d-n', isValidDir(e.target.value || '/'));
500
+ updateSlug();
501
+ });
502
+ $dialog.querySelector('#dlg-name').addEventListener('input', updateSlug);
503
+ });
504
+ </script>
505
+
506
+ </k-main>
507
+
508
+ </body>
509
+ </html>
@@ -0,0 +1,132 @@
1
+ <!--
2
+ owner: system
3
+ locked: true
4
+ name:
5
+ author:
6
+ created:
7
+ updated:
8
+ -->
9
+ <!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <link rel="icon" type="image/png" sizes="48x48" href="/media/icon48.png">
15
+ <link rel="manifest" href="/manifest.json">
16
+ <title>Edit Template - Admin</title>
17
+ <link rel="stylesheet" href="/kempo-css/kempo.min.css">
18
+ <script src="/admin/init.js"></script>
19
+
20
+ <script type="module" src="/admin/components/TemplateEditor.js"></script>
21
+
22
+ </head>
23
+ <body>
24
+ <!--
25
+ locked: true
26
+ -->
27
+
28
+ <k-aside
29
+ main="push"
30
+ state="expanded"
31
+ style="--aside_padding: 0;"
32
+ persistent-id="admin-nav"
33
+ >
34
+ <k-aside-toggle>
35
+ <h3 class="m0">Kempo Admin</h3>
36
+ </k-aside-toggle>
37
+ <k-aside-item
38
+ icon="preview"
39
+ href="/"
40
+ no-expand
41
+ >Site</k-aside-item>
42
+ <k-aside-item
43
+ icon="cards"
44
+ href="/admin"
45
+ no-expand
46
+ >Dashboard</k-aside-item>
47
+ <k-aside-menu
48
+ icon="manage-accounts"
49
+ label="Accounts"
50
+ >
51
+ <k-aside-item
52
+ icon="person"
53
+ href="/admin/accounts/users"
54
+ no-expand
55
+ >Users</k-aside-item>
56
+ <k-aside-item
57
+ icon="group"
58
+ href="/admin/accounts/groups"
59
+ no-expand
60
+ >Groups</k-aside-item>
61
+ <k-aside-item
62
+ icon="lock"
63
+ href="/admin/accounts/permissions"
64
+ no-expand
65
+ >Permissions</k-aside-item>
66
+ </k-aside-menu>
67
+ <k-aside-menu
68
+ icon="cards_stack"
69
+ label="Content"
70
+ >
71
+ <k-aside-item
72
+ icon="browse"
73
+ href="/admin/content/templates"
74
+ >Templates</k-aside-item>
75
+ <k-aside-item
76
+ icon="docs"
77
+ href="/admin/content/pages"
78
+ >Pages</k-aside-item>
79
+ <k-aside-item
80
+ icon="content"
81
+ href="/admin/content/fragments"
82
+ >Fragments</k-aside-item>
83
+ <k-aside-item
84
+ icon="globe"
85
+ href="/admin/content/globals"
86
+ >Global Content</k-aside-item>
87
+ </k-aside-menu>
88
+ <k-aside-item
89
+ icon="settings"
90
+ href="/admin/settings"
91
+ no-expand
92
+ >Settings</k-aside-item>
93
+ <k-aside-item
94
+ icon="extension"
95
+ href="/admin/extensions"
96
+ no-expand
97
+ >Extensions</k-aside-item>
98
+ <hr>
99
+ <k-aside-spacer></k-aside-spacer>
100
+ <k-theme-switcher
101
+ labels
102
+ style="--padding:var(--spacer_h);margin:var(--spacer_h) auto"
103
+ ></k-theme-switcher>
104
+ </k-aside>
105
+ <script type="module">
106
+ import '/kempo-ui/components/ThemeSwitcher.js';
107
+ const items = document.querySelectorAll('k-aside-item');
108
+ const currentPath = window.location.pathname;
109
+
110
+ items.forEach(item => {
111
+ const href = item.getAttribute('href');
112
+ if (!href) return;
113
+ const isExact = href === '/admin' || href === '/';
114
+ const match = isExact
115
+ ? (currentPath === href || currentPath === href + '/')
116
+ : (currentPath === href || currentPath.startsWith(href + '/'));
117
+ if (match) {
118
+ item.setAttribute('active', '');
119
+ const menu = item.closest('k-aside-menu');
120
+ if (menu) menu.setAttribute('open', '');
121
+ }
122
+ });
123
+ </script>
124
+
125
+ <k-main>
126
+
127
+ <admin-template-editor></admin-template-editor>
128
+
129
+ </k-main>
130
+
131
+ </body>
132
+ </html>