kempo 4.6.2 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/app-public/account/index.page.html +2 -0
- package/app-public/nav.fragment.html +2 -1
- package/app-templates/emails/email.template.html +14 -0
- package/app-templates/emails/reset-password.page.html +19 -0
- package/app-templates/emails/test.page.html +21 -0
- package/app-templates/emails/verify-email.page.html +18 -0
- package/bin/cli.js +18 -33
- package/dist/admin/components/Dashboard.js +2 -2
- package/dist/admin/components/Dashboard.js.map +1 -1
- package/dist/admin/components/EmailTemplates.js +25 -0
- package/dist/admin/components/EmailTemplates.js.map +1 -0
- package/dist/admin/components/Emails.js +49 -0
- package/dist/admin/components/Emails.js.map +1 -0
- package/dist/admin/components/Menus.js +103 -0
- package/dist/admin/components/Menus.js.map +1 -0
- package/dist/admin/components/settings/AdminSettings.js +7 -0
- package/dist/admin/components/settings/AdminSettings.js.map +1 -0
- package/dist/admin/components/settings/BooleanSetting.js +2 -0
- package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
- package/dist/admin/components/settings/JsonSetting.js +2 -0
- package/dist/admin/components/settings/JsonSetting.js.map +1 -0
- package/dist/admin/components/settings/NumberSetting.js +2 -0
- package/dist/admin/components/settings/NumberSetting.js.map +1 -0
- package/dist/admin/components/settings/SecretSetting.js +2 -0
- package/dist/admin/components/settings/SecretSetting.js.map +1 -0
- package/dist/admin/components/settings/Setting.js +2 -0
- package/dist/admin/components/settings/Setting.js.map +1 -0
- package/dist/admin/components/settings/SettingEditor.js +2 -0
- package/dist/admin/components/settings/SettingEditor.js.map +1 -0
- package/dist/admin/components/settings/SettingsPanel.js +24 -0
- package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
- package/dist/admin/components/settings/StringSetting.js +2 -0
- package/dist/admin/components/settings/StringSetting.js.map +1 -0
- package/dist/admin/components/settings/humanize.js +2 -0
- package/dist/admin/components/settings/humanize.js.map +1 -0
- package/dist/admin/content/menus/index.page.html +11 -0
- package/dist/admin/emails/index.page.html +14 -0
- package/dist/admin/icons/mail.svg +1 -0
- package/dist/admin/nav.fragment.html +12 -0
- package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
- package/dist/admin/settings/index.page.html +6 -289
- package/dist/kempo/api/email-templates/GET.js +2 -0
- package/dist/kempo/api/email-templates/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
- package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
- package/dist/kempo/api/email-templates/file/GET.js +2 -0
- package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/PUT.js +2 -0
- package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
- package/dist/kempo/api/email-templates/preview/GET.js +2 -0
- package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
- package/dist/kempo/api/emails/GET.js +2 -0
- package/dist/kempo/api/emails/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/GET.js +2 -0
- package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
- package/dist/kempo/api/emails/test/POST.js +2 -0
- package/dist/kempo/api/emails/test/POST.js.map +1 -0
- package/dist/kempo/api/fragments/DELETE.js +1 -1
- package/dist/kempo/api/fragments/DELETE.js.map +1 -1
- package/dist/kempo/api/fragments/GET.js +1 -1
- package/dist/kempo/api/fragments/GET.js.map +1 -1
- package/dist/kempo/api/fragments/POST.js +1 -1
- package/dist/kempo/api/fragments/POST.js.map +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/file/GET.js +1 -1
- package/dist/kempo/api/fragments/file/GET.js.map +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
- package/dist/kempo/api/fragments/file/PUT.js +1 -1
- package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
- package/dist/kempo/api/globals/DELETE.js +1 -1
- package/dist/kempo/api/globals/DELETE.js.map +1 -1
- package/dist/kempo/api/globals/GET.js +1 -1
- package/dist/kempo/api/globals/GET.js.map +1 -1
- package/dist/kempo/api/globals/POST.js +1 -1
- package/dist/kempo/api/globals/POST.js.map +1 -1
- package/dist/kempo/api/globals/disable/PUT.js +1 -1
- package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/enable/PUT.js +1 -1
- package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/entry/GET.js +1 -1
- package/dist/kempo/api/globals/entry/GET.js.map +1 -1
- package/dist/kempo/api/globals/entry/PUT.js +1 -1
- package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
- package/dist/kempo/api/globals/lock/PUT.js +1 -1
- package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
- package/dist/kempo/api/menus/DELETE.js +2 -0
- package/dist/kempo/api/menus/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/GET.js +2 -0
- package/dist/kempo/api/menus/GET.js.map +1 -0
- package/dist/kempo/api/menus/POST.js +2 -0
- package/dist/kempo/api/menus/POST.js.map +1 -0
- package/dist/kempo/api/menus/adopt/PUT.js +2 -0
- package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/DELETE.js +2 -0
- package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/items/POST.js +2 -0
- package/dist/kempo/api/menus/items/POST.js.map +1 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/order/PUT.js +2 -0
- package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
- package/dist/kempo/api/menus/location/PUT.js +2 -0
- package/dist/kempo/api/menus/location/PUT.js.map +1 -0
- package/dist/kempo/api/pages/DELETE.js +1 -1
- package/dist/kempo/api/pages/DELETE.js.map +1 -1
- package/dist/kempo/api/pages/GET.js +1 -1
- package/dist/kempo/api/pages/GET.js.map +1 -1
- package/dist/kempo/api/pages/POST.js +1 -1
- package/dist/kempo/api/pages/POST.js.map +1 -1
- package/dist/kempo/api/pages/directories/GET.js +1 -1
- package/dist/kempo/api/pages/directories/GET.js.map +1 -1
- package/dist/kempo/api/pages/disable/PUT.js +1 -1
- package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/enable/PUT.js +1 -1
- package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/file/GET.js +1 -1
- package/dist/kempo/api/pages/file/GET.js.map +1 -1
- package/dist/kempo/api/pages/file/PATCH.js +1 -1
- package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
- package/dist/kempo/api/pages/file/PUT.js +1 -1
- package/dist/kempo/api/pages/file/PUT.js.map +1 -1
- package/dist/kempo/api/pages/lock/PUT.js +1 -1
- package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
- package/dist/kempo/api/pages/rescan/POST.js +1 -1
- package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
- package/dist/kempo/api/pages/search/GET.js +1 -1
- package/dist/kempo/api/pages/search/GET.js.map +1 -1
- package/dist/kempo/api/pages/templates/GET.js +1 -1
- package/dist/kempo/api/pages/templates/GET.js.map +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
- package/dist/kempo/api/templates/DELETE.js +1 -1
- package/dist/kempo/api/templates/DELETE.js.map +1 -1
- package/dist/kempo/api/templates/GET.js +1 -1
- package/dist/kempo/api/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/POST.js +1 -1
- package/dist/kempo/api/templates/POST.js.map +1 -1
- package/dist/kempo/api/templates/disable/PUT.js +1 -1
- package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/enable/PUT.js +1 -1
- package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/file/GET.js +1 -1
- package/dist/kempo/api/templates/file/GET.js.map +1 -1
- package/dist/kempo/api/templates/file/PATCH.js +1 -1
- package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
- package/dist/kempo/api/templates/file/PUT.js +1 -1
- package/dist/kempo/api/templates/file/PUT.js.map +1 -1
- package/dist/kempo/api/templates/lock/PUT.js +1 -1
- package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
- package/dist/kempo/components/DashboardWidget.js +4 -4
- package/dist/kempo/components/DashboardWidget.js.map +1 -1
- package/dist/kempo/icons/campaign.svg +1 -0
- package/dist/kempo/icons/fields.svg +1 -0
- package/dist/kempo/icons/open_in_new.svg +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +69 -12
- package/package.json +3 -4
- package/server/db/schema.js +56 -0
- package/server/sdk.js +26 -0
- package/server/utils/auth/requestPasswordReset.js +9 -3
- package/server/utils/auth/verifyEmail.js +10 -1
- package/server/utils/dashboard/coreWidgets.js +1 -0
- package/server/utils/dashboard/helpers.js +6 -0
- package/server/utils/email/defaultSender.js +2 -0
- package/server/utils/email/dispatchEmail.js +56 -0
- package/server/utils/email/emailSettings.js +21 -0
- package/server/utils/email/emailTemplatePaths.js +36 -0
- package/server/utils/email/getEmail.js +14 -0
- package/server/utils/email/getSenderEmail.js +11 -0
- package/server/utils/email/getSenderFrom.js +18 -0
- package/server/utils/email/isValidSender.js +4 -0
- package/server/utils/email/listEmailTemplates.js +23 -0
- package/server/utils/email/listEmails.js +35 -0
- package/server/utils/email/maintainEmails.js +46 -0
- package/server/utils/email/pruneEmails.js +29 -0
- package/server/utils/email/readEmailTemplate.js +28 -0
- package/server/utils/email/renderEmailTemplate.js +63 -0
- package/server/utils/email/resetEmailTemplate.js +15 -0
- package/server/utils/email/retryEmail.js +21 -0
- package/server/utils/email/saveEmailTemplate.js +38 -0
- package/server/utils/email/sendEmail.js +22 -32
- package/server/utils/email/sendEmailFromTemplate.js +9 -27
- package/server/utils/extensions/aliases.js +71 -0
- package/server/utils/extensions/checkClaims.js +32 -0
- package/server/utils/extensions/claims.js +54 -0
- package/server/utils/extensions/createTablesFromSchema.js +13 -20
- package/server/utils/extensions/enableExtensionTree.js +8 -0
- package/server/utils/extensions/installExtension.js +32 -0
- package/server/utils/extensions/readKempoConfig.js +2 -2
- package/server/utils/extensions/scopeCache.js +14 -1
- package/server/utils/extensions/scopes.js +72 -0
- package/server/utils/extensions/uninstallExtensionTree.js +2 -1
- package/server/utils/extensions/updateExtension.js +35 -0
- package/server/utils/global-content/createGlobalContent.js +8 -18
- package/server/utils/global-content/deleteGlobalContent.js +1 -16
- package/server/utils/global-content/disableGlobalContent.js +1 -16
- package/server/utils/global-content/enableGlobalContent.js +1 -16
- package/server/utils/global-content/helpers.js +40 -6
- package/server/utils/global-content/listGlobalContent.js +2 -2
- package/server/utils/global-content/setGlobalContentLocked.js +1 -16
- package/server/utils/global-content/updateGlobalContent.js +1 -16
- package/server/utils/hooks/triggerHook.js +17 -4
- package/server/utils/menus/adoptMenuItem.js +58 -0
- package/server/utils/menus/assignMenuLocation.js +47 -0
- package/server/utils/menus/deleteMenu.js +27 -0
- package/server/utils/menus/deleteMenuItems.js +27 -0
- package/server/utils/menus/findMenuLocations.js +41 -0
- package/server/utils/menus/findNavItems.js +74 -0
- package/server/utils/menus/findPages.js +73 -0
- package/server/utils/menus/listMenus.js +103 -0
- package/server/utils/menus/markup.js +55 -0
- package/server/utils/menus/reorderMenuItems.js +36 -0
- package/server/utils/menus/saveMenu.js +54 -0
- package/server/utils/menus/saveMenuItem.js +122 -0
- package/server/utils/menus/setMenuItemEnabled.js +31 -0
- package/server/utils/menus/store.js +90 -0
- package/server/utils/scheduler/helpers.js +36 -0
- package/server/utils/scheduler/listJobs.js +11 -0
- package/server/utils/scheduler/registerJobs.js +37 -0
- package/server/utils/scheduler/runJobs.js +90 -0
- package/server/utils/scheduler/scheduler.js +37 -0
- package/server/utils/scheduler/unregisterJobs.js +16 -0
- package/server/utils/settings/ensureEncryptionKey.js +132 -0
- package/server/utils/settings/listSettings.js +10 -5
- package/server/utils/settings/secretCrypto.js +5 -1
- package/server/utils/settings/setSetting.js +4 -0
- package/server/utils/settings/settingRules.js +21 -0
- package/server/utils/settings/systemSettings.js +40 -0
- package/server/utils/setup/seedDatabase.js +5 -60
- package/server/utils/setup/systemPermissions.js +75 -0
- package/dist/kempo/icons/create_new_folder.svg +0 -1
- package/dist/kempo/icons/credit_card.svg +0 -1
- package/dist/kempo/icons/login.svg +0 -1
- package/dist/kempo/icons/payments.svg +0 -1
- package/dist/kempo/icons/toll.svg +0 -1
- package/dist/kempo/icons/wallet.svg +0 -1
package/README.md
CHANGED
|
@@ -179,6 +179,7 @@ Kempo provides these tables out of the box:
|
|
|
179
179
|
| `notification` | Notifications raised by core or extensions: title, message, level, actions, dedupe key |
|
|
180
180
|
| `notificationRecipient` | One row per person a notification was sent to, with their own read, handled and dismissed state |
|
|
181
181
|
| `dashboardWidget` | Widgets extensions add to the admin dashboard: element, module script, size limits, permission |
|
|
182
|
+
| `scheduledJob` | Recurring jobs extensions declare with `jobs`: interval, next run, and the lease that stops two processes running one |
|
|
182
183
|
|
|
183
184
|
## Frontend SDK
|
|
184
185
|
|
|
@@ -365,9 +366,15 @@ The admin home page is a grid of widgets one to four blocks wide and one to thre
|
|
|
365
366
|
|
|
366
367
|
Core and extensions can notify users with `createNotification`: to specific users, everyone holding a permission, or a group. Each person has their own read state and history, a notification can carry buttons that link somewhere or call an API as the signed-in user, and a `dedupeKey` keeps a repeating event to one notification. The admin has a bell with an unread count and a Notifications page; a public site can use `<k-notification-bell>`. Existing sites need `npx drizzle-kit push` to add the two new tables. See [docs/notifications.md](docs/notifications.md).
|
|
367
368
|
|
|
369
|
+
## Scheduled Jobs, Aliases and Email Templates for Extensions
|
|
370
|
+
|
|
371
|
+
Extensions can declare `jobs` in `kempo-config.json` and kempo runs them on a timer (several processes sharing a database run each once per interval), `aliases` to answer on a short address such as `/cart`, and ship their own email templates as `owner/name`. Existing sites need `npx drizzle-kit push` to add the `scheduledJob` table. See [docs/extensions/creating-extensions.md](docs/extensions/creating-extensions.md).
|
|
372
|
+
|
|
368
373
|
## Email
|
|
369
374
|
|
|
370
|
-
Kempo
|
|
375
|
+
Kempo does not send email itself. It renders the email, saves it in a log (**Admin > Emails** shows every one, with whether it was sent and why not), and hands it to an **email extension**: `kempo-resend` sends through Resend, `kempo-smtp` through any SMTP server (a Hostinger or Google mailbox, Amazon SES, a Postfix of your own). Install one from Admin > Extensions and enter its settings on the Settings screen; with none installed, emails are logged and fail with "No email extension is installed".
|
|
376
|
+
|
|
377
|
+
The address emails are sent from is the **Email from** setting (`system:email_from`); until it is set, `EMAIL_FROM` from `.env` is used, and it must be on a domain your email service allows. A failed email is tried again (`email_max_retries`, 1 by default) and the log is kept for `email_retention_days` (30 by default; `email_retention_by_type` sets it per kind of email). See [docs/extensions/creating-extensions.md](docs/extensions/creating-extensions.md) to write an email extension.
|
|
371
378
|
|
|
372
379
|
Built-in email templates (in `templates/emails/`):
|
|
373
380
|
- `verify-email.html` — Email verification link
|
|
@@ -28,10 +28,12 @@
|
|
|
28
28
|
<div class="mb">
|
|
29
29
|
<strong>Member Since:</strong> <k-user-created-at></k-user-created-at>
|
|
30
30
|
</div>
|
|
31
|
+
<location name="account-details" label="After the Account Details" />
|
|
31
32
|
<div class="mt-lg">
|
|
32
33
|
<k-permission has="system:admin:access">
|
|
33
34
|
<a href="{{pathToRoot}}admin" class="btn primary">Admin Panel</a>
|
|
34
35
|
</k-permission>
|
|
36
|
+
<location name="account-links" label="Account Links" />
|
|
35
37
|
<button id="changePasswordBtn" class="btn">Change Password</button>
|
|
36
38
|
<k-logout-btn redirect="{{pathToRoot}}login" button-class="btn danger">Logout</k-logout-btn>
|
|
37
39
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
updated: 2026-
|
|
2
|
+
updated: 2026-10-16T23:11:11.700Z
|
|
3
3
|
-->
|
|
4
4
|
<fragment>
|
|
5
5
|
<nav
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
/>
|
|
20
20
|
<strong>Kempo</strong>
|
|
21
21
|
</a>
|
|
22
|
+
<location name="main-nav-links" label="Main menu" menu="true" />
|
|
22
23
|
<div style="flex: 1"></div>
|
|
23
24
|
<a
|
|
24
25
|
href="{{pathToRoot}}account/"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<location name="head" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div style="max-width: 600px; margin: 20px auto; padding: 40px; border-radius: 8px;">
|
|
11
|
+
<location />
|
|
12
|
+
</div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
owner: system
|
|
3
|
+
-->
|
|
4
|
+
<page template="email" title="Reset Your Password">
|
|
5
|
+
<content>
|
|
6
|
+
<h2>Reset Your Password</h2>
|
|
7
|
+
<if condition="name"><p>Hi {{name}},</p></if>
|
|
8
|
+
<if condition="!name"><p>Hello,</p></if>
|
|
9
|
+
<p>We received a request to reset your password. Click the button below to choose a new password:</p>
|
|
10
|
+
<div style="text-align: center; margin: 24px 0;">
|
|
11
|
+
<a href="{{resetLink}}" style="display: inline-block; padding: 14px 28px; background-color: rgb(51, 102, 255); color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">Reset Password</a>
|
|
12
|
+
</div>
|
|
13
|
+
<p class="small" style="word-break: break-all; padding: 12px; border-radius: 4px;">Or copy and paste this link into your browser:<br><strong>{{resetLink}}</strong></p>
|
|
14
|
+
<p><strong>This link will expire in 1 hour.</strong></p>
|
|
15
|
+
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid; font-size: 13px;">
|
|
16
|
+
<p>If you didn't request a password reset, you can safely ignore this email. Your password will not be changed.</p>
|
|
17
|
+
</div>
|
|
18
|
+
</content>
|
|
19
|
+
</page>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
owner: system
|
|
3
|
+
-->
|
|
4
|
+
<page template="email" title="Test Email">
|
|
5
|
+
<content>
|
|
6
|
+
<div style="background-color: #007bff; color: white; padding: 20px; text-align: center; border-radius: 5px;">
|
|
7
|
+
<h1>Test Email</h1>
|
|
8
|
+
</div>
|
|
9
|
+
<div style="padding: 20px; background-color: #f9f9f9; border-radius: 5px; margin-top: 20px;">
|
|
10
|
+
<p>Hello<if condition="name"> {{name}}</if>!</p>
|
|
11
|
+
<p>This is a test email from your Kempo application.</p>
|
|
12
|
+
<p>If you received this, the templating system is working correctly!</p>
|
|
13
|
+
<if condition="timestamp">
|
|
14
|
+
<p><strong>Sent at:</strong> {{timestamp}}</p>
|
|
15
|
+
</if>
|
|
16
|
+
</div>
|
|
17
|
+
<div style="margin-top: 20px; text-align: center; color: #666; font-size: 12px;">
|
|
18
|
+
<p>This is an automated test message from Kempo.</p>
|
|
19
|
+
</div>
|
|
20
|
+
</content>
|
|
21
|
+
</page>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
owner: system
|
|
3
|
+
-->
|
|
4
|
+
<page template="email" title="Verify Your Email">
|
|
5
|
+
<content>
|
|
6
|
+
<h2>Verify Your Email</h2>
|
|
7
|
+
<if condition="name"><p>Hi {{name}},</p></if>
|
|
8
|
+
<if condition="!name"><p>Hello,</p></if>
|
|
9
|
+
<p>Thank you for signing up! Please verify your email address by clicking the button below:</p>
|
|
10
|
+
<div style="text-align: center; margin: 24px 0;">
|
|
11
|
+
<a href="{{verificationLink}}" style="display: inline-block; padding: 14px 28px; background-color: rgb(51, 102, 255); color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">Verify Email</a>
|
|
12
|
+
</div>
|
|
13
|
+
<p class="small" style="word-break: break-all; padding: 12px; border-radius: 4px;">Or copy and paste this link into your browser:<br><strong>{{verificationLink}}</strong></p>
|
|
14
|
+
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid; font-size: 13px;">
|
|
15
|
+
<p>If you didn't create an account, you can safely ignore this email.</p>
|
|
16
|
+
</div>
|
|
17
|
+
</content>
|
|
18
|
+
</page>
|
package/bin/cli.js
CHANGED
|
@@ -267,40 +267,20 @@ if(adminSetupChoice === 'browser'){
|
|
|
267
267
|
adminPassword = adminAnswers.adminPassword || generatedAdminPassword;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
let emailApiKey = '';
|
|
271
270
|
let emailFrom = 'noreply@example.com';
|
|
272
|
-
let emailServiceSkipped = false;
|
|
273
271
|
|
|
272
|
+
/*
|
|
273
|
+
Kempo does not send email itself: an email extension (kempo-resend, kempo-smtp...) does, and is installed from the admin once the
|
|
274
|
+
site is running. Only the address emails are sent from is asked here; it can be changed later on the admin Settings screen.
|
|
275
|
+
*/
|
|
274
276
|
if(!useDefaults){
|
|
275
|
-
const {
|
|
276
|
-
type: '
|
|
277
|
-
name: '
|
|
278
|
-
message: '
|
|
279
|
-
|
|
280
|
-
{ name: 'Resend (recommended — https://resend.com)', value: 'resend' },
|
|
281
|
-
{ name: 'Skip for now', value: 'skip' }
|
|
282
|
-
]
|
|
277
|
+
const { fromEmail } = await inquirer.prompt([{
|
|
278
|
+
type: 'input',
|
|
279
|
+
name: 'fromEmail',
|
|
280
|
+
message: 'The address emails are sent from:',
|
|
281
|
+
default: 'noreply@example.com'
|
|
283
282
|
}]);
|
|
284
|
-
|
|
285
|
-
if(emailService === 'resend'){
|
|
286
|
-
const emailAnswers = await inquirer.prompt([
|
|
287
|
-
{
|
|
288
|
-
type: 'input',
|
|
289
|
-
name: 'apiKey',
|
|
290
|
-
message: 'Resend API key:'
|
|
291
|
-
},
|
|
292
|
-
{
|
|
293
|
-
type: 'input',
|
|
294
|
-
name: 'fromEmail',
|
|
295
|
-
message: 'From email address:',
|
|
296
|
-
default: 'noreply@example.com'
|
|
297
|
-
}
|
|
298
|
-
]);
|
|
299
|
-
emailApiKey = emailAnswers.apiKey;
|
|
300
|
-
emailFrom = emailAnswers.fromEmail;
|
|
301
|
-
} else {
|
|
302
|
-
emailServiceSkipped = true;
|
|
303
|
-
}
|
|
283
|
+
emailFrom = fromEmail;
|
|
304
284
|
}
|
|
305
285
|
|
|
306
286
|
/*
|
|
@@ -393,10 +373,12 @@ writeFileSync(join(projectDir, '.env'),
|
|
|
393
373
|
`# Database
|
|
394
374
|
DATABASE_URL=${databaseUrl}
|
|
395
375
|
|
|
396
|
-
# Email
|
|
397
|
-
RESEND_API_KEY=${emailApiKey || 'your_resend_api_key_here'}
|
|
376
|
+
# Email: the address emails are sent from (also a setting on the admin Settings screen). Sending needs an email extension such as kempo-resend or kempo-smtp.
|
|
398
377
|
EMAIL_FROM=${emailFrom}
|
|
399
378
|
|
|
379
|
+
# Encrypts the API credentials saved in the admin. Back this up with your database: if it is lost they cannot be recovered.
|
|
380
|
+
SETTINGS_ENCRYPTION_KEY=${crypto.randomBytes(32).toString('hex')}
|
|
381
|
+
|
|
400
382
|
# Server
|
|
401
383
|
PORT=9876
|
|
402
384
|
NODE_ENV=development
|
|
@@ -521,7 +503,10 @@ ${adminSetupChoice === 'browser' ? `Create your admin user in the browser:
|
|
|
521
503
|
` : `Admin credentials:
|
|
522
504
|
Email: ${adminEmail}
|
|
523
505
|
Password: ${adminPassword}
|
|
524
|
-
`}
|
|
506
|
+
`}
|
|
507
|
+
Note: Kempo does not send email itself. Install an email extension (kempo-resend or kempo-smtp)
|
|
508
|
+
from Admin > Extensions when you want the site to send email.
|
|
509
|
+
|
|
525
510
|
Next steps:
|
|
526
511
|
npm run dev
|
|
527
512
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import t from"/kempo-ui/components/LightComponent.js";import{html as e,nothing as i,repeat as s,keyed as a}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Icon.js";import"/kempo-ui/components/Dialog.js";import"/kempo-ui/components/Sortable.js";import{getDashboardWidgets as n}from"/kempo/sdk.js";import{resolveLayout as
|
|
1
|
+
import t from"/kempo-ui/components/LightComponent.js";import{html as e,nothing as i,repeat as s,keyed as a}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Icon.js";import"/kempo-ui/components/Dialog.js";import"/kempo-ui/components/Sortable.js";import{getDashboardWidgets as n}from"/kempo/sdk.js";import{resolveLayout as o,serializeLayout as l,reorder as r,readStored as d,writeStored as h,clearStored as c}from"./dashboardLayout.js";const m="min(32rem, 70vw)",u=(t,e)=>Array.from({length:e-t+1},(e,i)=>t+i);export default class p extends t{static properties={heading:{type:String},layout:{state:!0},draft:{state:!0},sortVersion:{state:!0},columns:{state:!0},loading:{state:!0},failure:{state:!0},failures:{state:!0}};#t=[];#e=[];#i=!1;#s=new Map;#a=new Set;#n=null;constructor(){super(),this.heading="",this.layout=[],this.draft=[],this.sortVersion=0,this.columns=4,this.loading=!0,this.failure="",this.failures={}}connectedCallback(){super.connectedCallback(),this.classList.add("d-b"),this.#n=new ResizeObserver(([t])=>{const{width:e}=t.contentRect,i=e>=880?4:e>=560?2:1;i!==this.columns&&(this.columns=i)}),this.#n.observe(this),this.load()}disconnectedCallback(){super.disconnectedCallback(),this.#n?.disconnect()}updated(t){super.updated(t),this.layout.forEach(({widget:t,visible:e,width:i,height:s})=>{const a=this.#s.get(t.id);e&&a&&(a.width=Math.min(i,this.columns),a.height=s)})}load=async()=>{const[t,e]=await n();this.loading=!1,t?this.failure=`Could not load the dashboard: ${t.msg}`:(this.failure="",this.#t=e.widgets,this.#e=e.unavailable??[],this.layout=o(this.#t,d()),this.mountVisible())};mount=async({widget:t})=>{if(!this.#s.has(t.id)&&!this.#a.has(t.id)){this.#a.add(t.id);try{if(await(import(t.module)),!customElements.get(t.element))throw new Error(`${t.module} did not define <${t.element}>`);const e=document.createElement(t.element);e.definition=t,this.#s.set(t.id,e)}catch(e){this.failures={...this.failures,[t.id]:`is not available: its script ${t.module} could not be loaded, which usually means the extension that provides it needs updating.`},console.error(`Dashboard widget ${t.id} failed to load:`,e)}this.#a.delete(t.id),this.requestUpdate()}};mountVisible=()=>this.layout.filter(({visible:t})=>t).forEach(this.mount);commit=t=>{const e=l(t,{stored:this.#i?null:d(),unavailable:this.#e});this.layout=t,JSON.stringify(e)===JSON.stringify(l(o(this.#t,null)))?c():h(e),t.filter(({visible:t})=>!t).forEach(({widget:t})=>{if(this.#s.delete(t.id),this.failures[t.id]){const{[t.id]:e,...i}=this.failures;this.failures=i}}),this.mountVisible()};openCustomize=()=>{this.#i=!1,this.draft=this.layout.map(t=>({...t})),this.sortVersion++,this.querySelector("k-dialog").open()};save=()=>this.commit(this.draft);resetDraft=()=>{this.#i=!0,this.draft=o(this.#t,null),this.sortVersion++};changeDraft=(t,e)=>{this.draft=this.draft.map(i=>i.widget.id===t?{...i,...e}:i)};handleSort=t=>{this.draft=r(this.draft,[...t.currentTarget.children].map(t=>t.dataset.widgetId)),this.sortVersion++};renderDraftRow=({widget:t,visible:s,width:a,height:n})=>e`
|
|
2
2
|
<k-sortable-item class="mbh" data-widget-id=${t.id}>
|
|
3
3
|
<div style="display: flex; flex-wrap: nowrap; align-items: center; gap: var(--spacer); width: ${m}; min-height: 3.5rem;">
|
|
4
4
|
<div class="flex" style="display: flex; flex-wrap: nowrap; align-items: center; gap: var(--spacer_h); min-width: 0;">
|
|
5
|
-
${t.icon?e`<k-icon class=${s?"":"tc-muted"} name=${t.icon}></k-icon>`:i}
|
|
5
|
+
${t.iconSrc?e`<k-icon class=${s?"":"tc-muted"} src=${t.iconSrc}></k-icon>`:t.icon?e`<k-icon class=${s?"":"tc-muted"} name=${t.icon}></k-icon>`:i}
|
|
6
6
|
<strong class=${s?"":"tc-muted"}>${t.title}</strong>
|
|
7
7
|
</div>
|
|
8
8
|
<select style="width: 4.5rem;" aria-label="${t.title} width" ?disabled=${!s} @change=${e=>this.changeDraft(t.id,{width:Number(e.target.value)})}>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"src\\admin\\components\\Dashboard.js","names":["LightComponent","html","nothing","repeat","keyed","getDashboardWidgets","resolveLayout","serializeLayout","reorder","readStored","writeStored","clearStored","ROW_WIDTH","range","from","to","Array","length","_","index","Dashboard","static","heading","type","String","layout","state","draft","sortVersion","columns","loading","failure","failures","definitions","unavailable","resetting","elements","Map","pending","Set","observer","constructor","super","this","connectedCallback","classList","add","ResizeObserver","entry","width","contentRect","observe","load","disconnectedCallback","disconnect","updated","changed","forEach","widget","visible","height","element","get","id","Math","min","async","error","data","msg","widgets","mountVisible","mount","has","module","customElements","Error","document","createElement","definition","set","console","delete","requestUpdate","filter","commit","stored","JSON","stringify","dropped","rest","openCustomize","map","querySelector","open","save","resetDraft","changeDraft","changes","handleSort","event","currentTarget","children","item","dataset","widgetId","renderDraftRow","icon","title","Number","target","value","minWidth","maxWidth","minHeight","maxHeight","renderDraftHeader","renderCustomizeDialog","renderCell","renderLightDom","define"],"sources":["0"],"sourcesContent":["import LightComponent from '/kempo-ui/components/LightComponent.js';\r\nimport { html, nothing, repeat, keyed } from '/kempo-ui/lit-all.min.js';\r\nimport '/kempo-ui/components/Icon.js';\r\nimport '/kempo-ui/components/Dialog.js';\r\nimport '/kempo-ui/components/Sortable.js';\r\nimport { getDashboardWidgets } from '/kempo/sdk.js';\r\nimport { resolveLayout, serializeLayout, reorder, readStored, writeStored, clearStored } from './dashboardLayout.js';\r\n\r\n// Wide enough for the columns; narrower screens shrink it rather than scroll.\r\nconst ROW_WIDTH = 'min(32rem, 70vw)';\r\n\r\nconst range = (from, to) => Array.from({ length: to - from + 1 }, (_, index) => from + index);\r\n\r\n/*\r\n The admin dashboard: a grid four blocks wide of widgets one to four blocks wide and one to three tall.\r\n\r\n The server only says which widgets exist and their size bounds. Which of them are shown, in what order\r\n and at what size is the user's own and lives in this browser's localStorage, changed through the\r\n Customize dialog and applied only when it is saved. A widget's script is not imported, and so nothing\r\n it needs is fetched, until it is both on the user's dashboard and rendered; a widget the user hides is\r\n torn down and starts fresh when it is shown again.\r\n\r\n Attributes: `heading`, the title shown above the grid.\r\n*/\r\nexport default class Dashboard extends LightComponent {\r\n static properties = {\r\n heading: { type: String },\r\n layout: { state: true },\r\n draft: { state: true },\r\n sortVersion: { state: true },\r\n columns: { state: true },\r\n loading: { state: true },\r\n failure: { state: true },\r\n failures: { state: true }\r\n };\r\n\r\n #definitions = [];\r\n #unavailable = [];\r\n #resetting = false;\r\n #elements = new Map();\r\n #pending = new Set();\r\n #observer = null;\r\n\r\n constructor(){\r\n super();\r\n this.heading = '';\r\n this.layout = [];\r\n this.draft = [];\r\n this.sortVersion = 0;\r\n this.columns = 4;\r\n this.loading = true;\r\n this.failure = '';\r\n this.failures = {};\r\n }\r\n\r\n /*\r\n Lifecycle Callbacks\r\n */\r\n\r\n connectedCallback(){\r\n super.connectedCallback();\r\n this.classList.add('d-b');\r\n this.#observer = new ResizeObserver(([entry]) => {\r\n const { width } = entry.contentRect;\r\n const columns = width >= 880 ? 4 : width >= 560 ? 2 : 1;\r\n if(columns !== this.columns) this.columns = columns;\r\n });\r\n this.#observer.observe(this);\r\n this.load();\r\n }\r\n\r\n disconnectedCallback(){\r\n super.disconnectedCallback();\r\n this.#observer?.disconnect();\r\n }\r\n\r\n updated(changed){\r\n super.updated(changed);\r\n this.layout.forEach(({ widget, visible, width, height }) => {\r\n const element = this.#elements.get(widget.id);\r\n if(!visible || !element) return;\r\n element.width = Math.min(width, this.columns);\r\n element.height = height;\r\n });\r\n }\r\n\r\n /*\r\n Public Methods\r\n */\r\n\r\n load = async () => {\r\n const [error, data] = await getDashboardWidgets();\r\n this.loading = false;\r\n\r\n if(error){\r\n this.failure = `Could not load the dashboard: ${error.msg}`;\r\n return;\r\n }\r\n\r\n this.failure = '';\r\n this.#definitions = data.widgets;\r\n this.#unavailable = data.unavailable ?? [];\r\n this.layout = resolveLayout(this.#definitions, readStored());\r\n this.mountVisible();\r\n };\r\n\r\n /*\r\n Utility Functions\r\n */\r\n\r\n /*\r\n Imports a widget's module and creates its element, once. The element fetches its own data when it\r\n connects to the page, so nothing is requested for a widget that is hidden.\r\n */\r\n mount = async ({ widget }) => {\r\n if(this.#elements.has(widget.id) || this.#pending.has(widget.id)) return;\r\n this.#pending.add(widget.id);\r\n\r\n try {\r\n await import(widget.module);\r\n if(!customElements.get(widget.element)){\r\n throw new Error(`${widget.module} did not define <${widget.element}>`);\r\n }\r\n const element = document.createElement(widget.element);\r\n element.definition = widget;\r\n this.#elements.set(widget.id, element);\r\n } catch(error) {\r\n this.failures = { ...this.failures, [widget.id]: `is not available: its script ${widget.module} could not be loaded, which usually means the extension that provides it needs updating.` };\r\n console.error(`Dashboard widget ${widget.id} failed to load:`, error);\r\n }\r\n\r\n this.#pending.delete(widget.id);\r\n this.requestUpdate();\r\n };\r\n\r\n mountVisible = () => this.layout.filter(({ visible }) => visible).forEach(this.mount);\r\n\r\n /*\r\n Applies a layout, remembers it, and mounts anything newly shown. Widgets no longer shown are released\r\n here so showing them again loads fresh data. A layout identical to the defaults is not stored, so the\r\n user keeps following the defaults as they change.\r\n */\r\n commit = layout => {\r\n const stored = serializeLayout(layout, { stored: this.#resetting ? null : readStored(), unavailable: this.#unavailable });\r\n this.layout = layout;\r\n\r\n if(JSON.stringify(stored) === JSON.stringify(serializeLayout(resolveLayout(this.#definitions, null)))){\r\n clearStored();\r\n } else {\r\n writeStored(stored);\r\n }\r\n\r\n layout.filter(({ visible }) => !visible).forEach(({ widget }) => {\r\n this.#elements.delete(widget.id);\r\n if(this.failures[widget.id]){\r\n const { [widget.id]: dropped, ...rest } = this.failures;\r\n this.failures = rest;\r\n }\r\n });\r\n\r\n this.mountVisible();\r\n };\r\n\r\n /*\r\n Event Handlers\r\n */\r\n\r\n openCustomize = () => {\r\n this.#resetting = false;\r\n this.draft = this.layout.map(entry => ({ ...entry }));\r\n this.sortVersion++;\r\n this.querySelector('k-dialog').open();\r\n };\r\n\r\n save = () => this.commit(this.draft);\r\n\r\n resetDraft = () => {\r\n this.#resetting = true;\r\n this.draft = resolveLayout(this.#definitions, null);\r\n this.sortVersion++;\r\n };\r\n\r\n changeDraft = (id, changes) => {\r\n this.draft = this.draft.map(entry => entry.widget.id === id ? { ...entry, ...changes } : entry);\r\n };\r\n\r\n /*\r\n The sortable moves its own items in the DOM. The new order is read from there and the list is then\r\n rebuilt from the draft (the `keyed` version changes) so Lit never disagrees with the DOM.\r\n */\r\n handleSort = event => {\r\n this.draft = reorder(this.draft, [...event.currentTarget.children].map(item => item.dataset.widgetId));\r\n this.sortVersion++;\r\n };\r\n\r\n /*\r\n Rendering\r\n */\r\n\r\n /*\r\n The item sizes its content to fit, so the row sets its own width. Its drag handle is 3.5rem tall, so\r\n the row is the same height with its controls centered, and the columns have fixed widths that the\r\n header row above the list repeats. `.d-f` is avoided because it wraps.\r\n */\r\n renderDraftRow = ({ widget, visible, width, height }) => html`\r\n <k-sortable-item class=\"mbh\" data-widget-id=${widget.id}>\r\n <div style=\"display: flex; flex-wrap: nowrap; align-items: center; gap: var(--spacer); width: ${ROW_WIDTH}; min-height: 3.5rem;\">\r\n <div class=\"flex\" style=\"display: flex; flex-wrap: nowrap; align-items: center; gap: var(--spacer_h); min-width: 0;\">\r\n ${widget.icon ? html`<k-icon class=${visible ? '' : 'tc-muted'} name=${widget.icon}></k-icon>` : nothing}\r\n <strong class=${visible ? '' : 'tc-muted'}>${widget.title}</strong>\r\n </div>\r\n <select style=\"width: 4.5rem;\" aria-label=\"${widget.title} width\" ?disabled=${!visible} @change=${event => this.changeDraft(widget.id, { width: Number(event.target.value) })}>\r\n ${range(widget.minWidth, widget.maxWidth).map(value => html`<option value=${value} ?selected=${value === width}>${value}</option>`)}\r\n </select>\r\n <select style=\"width: 4.5rem;\" aria-label=\"${widget.title} height\" ?disabled=${!visible} @change=${event => this.changeDraft(widget.id, { height: Number(event.target.value) })}>\r\n ${range(widget.minHeight, widget.maxHeight).map(value => html`<option value=${value} ?selected=${value === height}>${value}</option>`)}\r\n </select>\r\n <button\r\n class=\"no-btn pq\"\r\n style=\"width: 2.5rem;\"\r\n title=${visible ? 'Hide' : 'Show'}\r\n aria-label=\"${visible ? 'Hide' : 'Show'} ${widget.title}\"\r\n @click=${() => this.changeDraft(widget.id, { visible: !visible })}\r\n ><k-icon name=${visible ? 'hide' : 'show'}></k-icon></button>\r\n </div>\r\n </k-sortable-item>\r\n `;\r\n\r\n /*\r\n Column titles for the rows. The left offset is the sortable item's border and drag handle, so the\r\n columns line up with the rows below.\r\n */\r\n renderDraftHeader = () => html`\r\n <div class=\"small tc-muted mbh\" style=\"display: flex; flex-wrap: nowrap; gap: var(--spacer); width: calc(${ROW_WIDTH} + 3.7rem); padding-left: 3.7rem;\">\r\n <span class=\"flex\">Widget</span>\r\n <span style=\"width: 4.5rem;\">Width</span>\r\n <span style=\"width: 4.5rem;\">Height</span>\r\n <span style=\"width: 2.5rem;\"></span>\r\n </div>\r\n `;\r\n\r\n renderCustomizeDialog = () => html`\r\n <k-dialog\r\n confirm-text=\"Save\"\r\n confirm-classes=\"primary ml\"\r\n cancel-text=\"Cancel\"\r\n .confirmAction=${this.save}\r\n >\r\n <h5 slot=\"title\" class=\"pyh px m0\">Customize dashboard</h5>\r\n <div class=\"p\">\r\n <p class=\"small tc-muted\">Drag to reorder. Hidden widgets are not loaded. Sizes are in blocks; the dashboard is four blocks wide.</p>\r\n ${this.renderDraftHeader()}\r\n ${keyed(this.sortVersion, html`\r\n <k-sortable @sort=${this.handleSort}>${this.draft.map(this.renderDraftRow)}</k-sortable>\r\n `)}\r\n <button class=\"mt\" @click=${this.resetDraft}><k-icon name=\"restart_alt\"></k-icon> Reset to defaults</button>\r\n </div>\r\n </k-dialog>\r\n `;\r\n\r\n /*\r\n The grid assigns the spans and row height, so they are set here as values rather than classes: they\r\n depend on each user's saved sizes and the width the grid currently has. A widget wider than the\r\n columns available is drawn as wide as the grid is; its saved width is kept for when there is room.\r\n */\r\n renderCell = ({ widget, width, height }) => {\r\n const element = this.#elements.get(widget.id);\r\n const failure = this.failures[widget.id];\r\n return html`\r\n <div\r\n data-widget-id=${widget.id}\r\n style=\"grid-column: span ${Math.min(width, this.columns)}; grid-row: span ${height}; min-width: 0;\"\r\n >\r\n <section class=\"b r\" style=\"height: 100%; min-height: 0; overflow: hidden;\">\r\n ${failure\r\n ? html`<p class=\"p tc-danger\" role=\"alert\"><strong>${widget.title}</strong> ${failure}</p>`\r\n : element ?? html`<p class=\"p tc-muted\"><strong>${widget.title}</strong> Loading…</p>`}\r\n </section>\r\n </div>\r\n `;\r\n };\r\n\r\n renderLightDom(){\r\n if(this.failure) return html`<p class=\"tc-danger\" role=\"alert\">${this.failure}</p>`;\r\n if(this.loading) return html`<p class=\"tc-muted\">Loading…</p>`;\r\n\r\n const visible = this.layout.filter(entry => entry.visible);\r\n\r\n return html`\r\n <div class=\"d-f mb\" style=\"align-items: center;\">\r\n <h1 class=\"flex m0\">${this.heading}</h1>\r\n <button @click=${this.openCustomize}><k-icon name=\"edit\"></k-icon> Customize</button>\r\n </div>\r\n ${visible.length ? nothing : html`<p class=\"tc-muted\">No widgets are shown. Choose Customize to add some.</p>`}\r\n <div class=\"d-g\" style=\"grid-template-columns: repeat(${this.columns}, minmax(0, 1fr)); grid-auto-rows: 14rem; grid-auto-flow: dense; gap: var(--spacer);\">\r\n ${repeat(visible, entry => entry.widget.id, this.renderCell)}\r\n </div>\r\n ${this.renderCustomizeDialog()}\r\n `;\r\n }\r\n}\r\n\r\ncustomElements.define('k-dashboard', Dashboard);\r\n"],"mappings":"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","ignoreList":[]}
|
|
1
|
+
{"version":3,"file":"src\\admin\\components\\Dashboard.js","names":["LightComponent","html","nothing","repeat","keyed","getDashboardWidgets","resolveLayout","serializeLayout","reorder","readStored","writeStored","clearStored","ROW_WIDTH","range","from","to","Array","length","_","index","Dashboard","static","heading","type","String","layout","state","draft","sortVersion","columns","loading","failure","failures","definitions","unavailable","resetting","elements","Map","pending","Set","observer","constructor","super","this","connectedCallback","classList","add","ResizeObserver","entry","width","contentRect","observe","load","disconnectedCallback","disconnect","updated","changed","forEach","widget","visible","height","element","get","id","Math","min","async","error","data","msg","widgets","mountVisible","mount","has","module","customElements","Error","document","createElement","definition","set","console","delete","requestUpdate","filter","commit","stored","JSON","stringify","dropped","rest","openCustomize","map","querySelector","open","save","resetDraft","changeDraft","changes","handleSort","event","currentTarget","children","item","dataset","widgetId","renderDraftRow","iconSrc","icon","title","Number","target","value","minWidth","maxWidth","minHeight","maxHeight","renderDraftHeader","renderCustomizeDialog","renderCell","renderLightDom","define"],"sources":["0"],"sourcesContent":["import LightComponent from '/kempo-ui/components/LightComponent.js';\r\nimport { html, nothing, repeat, keyed } from '/kempo-ui/lit-all.min.js';\r\nimport '/kempo-ui/components/Icon.js';\r\nimport '/kempo-ui/components/Dialog.js';\r\nimport '/kempo-ui/components/Sortable.js';\r\nimport { getDashboardWidgets } from '/kempo/sdk.js';\r\nimport { resolveLayout, serializeLayout, reorder, readStored, writeStored, clearStored } from './dashboardLayout.js';\r\n\r\n// Wide enough for the columns; narrower screens shrink it rather than scroll.\r\nconst ROW_WIDTH = 'min(32rem, 70vw)';\r\n\r\nconst range = (from, to) => Array.from({ length: to - from + 1 }, (_, index) => from + index);\r\n\r\n/*\r\n The admin dashboard: a grid four blocks wide of widgets one to four blocks wide and one to three tall.\r\n\r\n The server only says which widgets exist and their size bounds. Which of them are shown, in what order\r\n and at what size is the user's own and lives in this browser's localStorage, changed through the\r\n Customize dialog and applied only when it is saved. A widget's script is not imported, and so nothing\r\n it needs is fetched, until it is both on the user's dashboard and rendered; a widget the user hides is\r\n torn down and starts fresh when it is shown again.\r\n\r\n Attributes: `heading`, the title shown above the grid.\r\n*/\r\nexport default class Dashboard extends LightComponent {\r\n static properties = {\r\n heading: { type: String },\r\n layout: { state: true },\r\n draft: { state: true },\r\n sortVersion: { state: true },\r\n columns: { state: true },\r\n loading: { state: true },\r\n failure: { state: true },\r\n failures: { state: true }\r\n };\r\n\r\n #definitions = [];\r\n #unavailable = [];\r\n #resetting = false;\r\n #elements = new Map();\r\n #pending = new Set();\r\n #observer = null;\r\n\r\n constructor(){\r\n super();\r\n this.heading = '';\r\n this.layout = [];\r\n this.draft = [];\r\n this.sortVersion = 0;\r\n this.columns = 4;\r\n this.loading = true;\r\n this.failure = '';\r\n this.failures = {};\r\n }\r\n\r\n /*\r\n Lifecycle Callbacks\r\n */\r\n\r\n connectedCallback(){\r\n super.connectedCallback();\r\n this.classList.add('d-b');\r\n this.#observer = new ResizeObserver(([entry]) => {\r\n const { width } = entry.contentRect;\r\n const columns = width >= 880 ? 4 : width >= 560 ? 2 : 1;\r\n if(columns !== this.columns) this.columns = columns;\r\n });\r\n this.#observer.observe(this);\r\n this.load();\r\n }\r\n\r\n disconnectedCallback(){\r\n super.disconnectedCallback();\r\n this.#observer?.disconnect();\r\n }\r\n\r\n updated(changed){\r\n super.updated(changed);\r\n this.layout.forEach(({ widget, visible, width, height }) => {\r\n const element = this.#elements.get(widget.id);\r\n if(!visible || !element) return;\r\n element.width = Math.min(width, this.columns);\r\n element.height = height;\r\n });\r\n }\r\n\r\n /*\r\n Public Methods\r\n */\r\n\r\n load = async () => {\r\n const [error, data] = await getDashboardWidgets();\r\n this.loading = false;\r\n\r\n if(error){\r\n this.failure = `Could not load the dashboard: ${error.msg}`;\r\n return;\r\n }\r\n\r\n this.failure = '';\r\n this.#definitions = data.widgets;\r\n this.#unavailable = data.unavailable ?? [];\r\n this.layout = resolveLayout(this.#definitions, readStored());\r\n this.mountVisible();\r\n };\r\n\r\n /*\r\n Utility Functions\r\n */\r\n\r\n /*\r\n Imports a widget's module and creates its element, once. The element fetches its own data when it\r\n connects to the page, so nothing is requested for a widget that is hidden.\r\n */\r\n mount = async ({ widget }) => {\r\n if(this.#elements.has(widget.id) || this.#pending.has(widget.id)) return;\r\n this.#pending.add(widget.id);\r\n\r\n try {\r\n await import(widget.module);\r\n if(!customElements.get(widget.element)){\r\n throw new Error(`${widget.module} did not define <${widget.element}>`);\r\n }\r\n const element = document.createElement(widget.element);\r\n element.definition = widget;\r\n this.#elements.set(widget.id, element);\r\n } catch(error) {\r\n this.failures = { ...this.failures, [widget.id]: `is not available: its script ${widget.module} could not be loaded, which usually means the extension that provides it needs updating.` };\r\n console.error(`Dashboard widget ${widget.id} failed to load:`, error);\r\n }\r\n\r\n this.#pending.delete(widget.id);\r\n this.requestUpdate();\r\n };\r\n\r\n mountVisible = () => this.layout.filter(({ visible }) => visible).forEach(this.mount);\r\n\r\n /*\r\n Applies a layout, remembers it, and mounts anything newly shown. Widgets no longer shown are released\r\n here so showing them again loads fresh data. A layout identical to the defaults is not stored, so the\r\n user keeps following the defaults as they change.\r\n */\r\n commit = layout => {\r\n const stored = serializeLayout(layout, { stored: this.#resetting ? null : readStored(), unavailable: this.#unavailable });\r\n this.layout = layout;\r\n\r\n if(JSON.stringify(stored) === JSON.stringify(serializeLayout(resolveLayout(this.#definitions, null)))){\r\n clearStored();\r\n } else {\r\n writeStored(stored);\r\n }\r\n\r\n layout.filter(({ visible }) => !visible).forEach(({ widget }) => {\r\n this.#elements.delete(widget.id);\r\n if(this.failures[widget.id]){\r\n const { [widget.id]: dropped, ...rest } = this.failures;\r\n this.failures = rest;\r\n }\r\n });\r\n\r\n this.mountVisible();\r\n };\r\n\r\n /*\r\n Event Handlers\r\n */\r\n\r\n openCustomize = () => {\r\n this.#resetting = false;\r\n this.draft = this.layout.map(entry => ({ ...entry }));\r\n this.sortVersion++;\r\n this.querySelector('k-dialog').open();\r\n };\r\n\r\n save = () => this.commit(this.draft);\r\n\r\n resetDraft = () => {\r\n this.#resetting = true;\r\n this.draft = resolveLayout(this.#definitions, null);\r\n this.sortVersion++;\r\n };\r\n\r\n changeDraft = (id, changes) => {\r\n this.draft = this.draft.map(entry => entry.widget.id === id ? { ...entry, ...changes } : entry);\r\n };\r\n\r\n /*\r\n The sortable moves its own items in the DOM. The new order is read from there and the list is then\r\n rebuilt from the draft (the `keyed` version changes) so Lit never disagrees with the DOM.\r\n */\r\n handleSort = event => {\r\n this.draft = reorder(this.draft, [...event.currentTarget.children].map(item => item.dataset.widgetId));\r\n this.sortVersion++;\r\n };\r\n\r\n /*\r\n Rendering\r\n */\r\n\r\n /*\r\n The item sizes its content to fit, so the row sets its own width. Its drag handle is 3.5rem tall, so\r\n the row is the same height with its controls centered, and the columns have fixed widths that the\r\n header row above the list repeats. `.d-f` is avoided because it wraps.\r\n */\r\n renderDraftRow = ({ widget, visible, width, height }) => html`\r\n <k-sortable-item class=\"mbh\" data-widget-id=${widget.id}>\r\n <div style=\"display: flex; flex-wrap: nowrap; align-items: center; gap: var(--spacer); width: ${ROW_WIDTH}; min-height: 3.5rem;\">\r\n <div class=\"flex\" style=\"display: flex; flex-wrap: nowrap; align-items: center; gap: var(--spacer_h); min-width: 0;\">\r\n ${widget.iconSrc ? html`<k-icon class=${visible ? '' : 'tc-muted'} src=${widget.iconSrc}></k-icon>` : widget.icon ? html`<k-icon class=${visible ? '' : 'tc-muted'} name=${widget.icon}></k-icon>` : nothing}\r\n <strong class=${visible ? '' : 'tc-muted'}>${widget.title}</strong>\r\n </div>\r\n <select style=\"width: 4.5rem;\" aria-label=\"${widget.title} width\" ?disabled=${!visible} @change=${event => this.changeDraft(widget.id, { width: Number(event.target.value) })}>\r\n ${range(widget.minWidth, widget.maxWidth).map(value => html`<option value=${value} ?selected=${value === width}>${value}</option>`)}\r\n </select>\r\n <select style=\"width: 4.5rem;\" aria-label=\"${widget.title} height\" ?disabled=${!visible} @change=${event => this.changeDraft(widget.id, { height: Number(event.target.value) })}>\r\n ${range(widget.minHeight, widget.maxHeight).map(value => html`<option value=${value} ?selected=${value === height}>${value}</option>`)}\r\n </select>\r\n <button\r\n class=\"no-btn pq\"\r\n style=\"width: 2.5rem;\"\r\n title=${visible ? 'Hide' : 'Show'}\r\n aria-label=\"${visible ? 'Hide' : 'Show'} ${widget.title}\"\r\n @click=${() => this.changeDraft(widget.id, { visible: !visible })}\r\n ><k-icon name=${visible ? 'hide' : 'show'}></k-icon></button>\r\n </div>\r\n </k-sortable-item>\r\n `;\r\n\r\n /*\r\n Column titles for the rows. The left offset is the sortable item's border and drag handle, so the\r\n columns line up with the rows below.\r\n */\r\n renderDraftHeader = () => html`\r\n <div class=\"small tc-muted mbh\" style=\"display: flex; flex-wrap: nowrap; gap: var(--spacer); width: calc(${ROW_WIDTH} + 3.7rem); padding-left: 3.7rem;\">\r\n <span class=\"flex\">Widget</span>\r\n <span style=\"width: 4.5rem;\">Width</span>\r\n <span style=\"width: 4.5rem;\">Height</span>\r\n <span style=\"width: 2.5rem;\"></span>\r\n </div>\r\n `;\r\n\r\n renderCustomizeDialog = () => html`\r\n <k-dialog\r\n confirm-text=\"Save\"\r\n confirm-classes=\"primary ml\"\r\n cancel-text=\"Cancel\"\r\n .confirmAction=${this.save}\r\n >\r\n <h5 slot=\"title\" class=\"pyh px m0\">Customize dashboard</h5>\r\n <div class=\"p\">\r\n <p class=\"small tc-muted\">Drag to reorder. Hidden widgets are not loaded. Sizes are in blocks; the dashboard is four blocks wide.</p>\r\n ${this.renderDraftHeader()}\r\n ${keyed(this.sortVersion, html`\r\n <k-sortable @sort=${this.handleSort}>${this.draft.map(this.renderDraftRow)}</k-sortable>\r\n `)}\r\n <button class=\"mt\" @click=${this.resetDraft}><k-icon name=\"restart_alt\"></k-icon> Reset to defaults</button>\r\n </div>\r\n </k-dialog>\r\n `;\r\n\r\n /*\r\n The grid assigns the spans and row height, so they are set here as values rather than classes: they\r\n depend on each user's saved sizes and the width the grid currently has. A widget wider than the\r\n columns available is drawn as wide as the grid is; its saved width is kept for when there is room.\r\n */\r\n renderCell = ({ widget, width, height }) => {\r\n const element = this.#elements.get(widget.id);\r\n const failure = this.failures[widget.id];\r\n return html`\r\n <div\r\n data-widget-id=${widget.id}\r\n style=\"grid-column: span ${Math.min(width, this.columns)}; grid-row: span ${height}; min-width: 0;\"\r\n >\r\n <section class=\"b r\" style=\"height: 100%; min-height: 0; overflow: hidden;\">\r\n ${failure\r\n ? html`<p class=\"p tc-danger\" role=\"alert\"><strong>${widget.title}</strong> ${failure}</p>`\r\n : element ?? html`<p class=\"p tc-muted\"><strong>${widget.title}</strong> Loading…</p>`}\r\n </section>\r\n </div>\r\n `;\r\n };\r\n\r\n renderLightDom(){\r\n if(this.failure) return html`<p class=\"tc-danger\" role=\"alert\">${this.failure}</p>`;\r\n if(this.loading) return html`<p class=\"tc-muted\">Loading…</p>`;\r\n\r\n const visible = this.layout.filter(entry => entry.visible);\r\n\r\n return html`\r\n <div class=\"d-f mb\" style=\"align-items: center;\">\r\n <h1 class=\"flex m0\">${this.heading}</h1>\r\n <button @click=${this.openCustomize}><k-icon name=\"edit\"></k-icon> Customize</button>\r\n </div>\r\n ${visible.length ? nothing : html`<p class=\"tc-muted\">No widgets are shown. Choose Customize to add some.</p>`}\r\n <div class=\"d-g\" style=\"grid-template-columns: repeat(${this.columns}, minmax(0, 1fr)); grid-auto-rows: 14rem; grid-auto-flow: dense; gap: var(--spacer);\">\r\n ${repeat(visible, entry => entry.widget.id, this.renderCell)}\r\n </div>\r\n ${this.renderCustomizeDialog()}\r\n `;\r\n }\r\n}\r\n\r\ncustomElements.define('k-dashboard', Dashboard);\r\n"],"mappings":"OAAOA,MAAoB,wDAClBC,aAAMC,YAASC,WAAQC,MAAa,iCACtC,qCACA,uCACA,iEACEC,MAA2B,wCAC3BC,qBAAeC,aAAiBC,gBAASC,iBAAYC,iBAAaC,MAAmB,uBAG9F,MAAMC,EAAY,mBAEZC,EAAQ,CAACC,EAAMC,IAAOC,MAAMF,KAAK,CAAEG,OAAQF,EAAKD,EAAO,GAAK,CAACI,EAAGC,IAAUL,EAAOK,kBAaxE,MAAMC,UAAkBpB,EACrCqB,kBAAoB,CAClBC,QAAS,CAAEC,KAAMC,QACjBC,OAAQ,CAAEC,OAAO,GACjBC,MAAO,CAAED,OAAO,GAChBE,YAAa,CAAEF,OAAO,GACtBG,QAAS,CAAEH,OAAO,GAClBI,QAAS,CAAEJ,OAAO,GAClBK,QAAS,CAAEL,OAAO,GAClBM,SAAU,CAAEN,OAAO,IAGrBO,GAAe,GACfC,GAAe,GACfC,IAAa,EACbC,GAAY,IAAIC,IAChBC,GAAW,IAAIC,IACfC,GAAY,KAEZ,WAAAC,GACEC,QACAC,KAAKrB,QAAU,GACfqB,KAAKlB,OAAS,GACdkB,KAAKhB,MAAQ,GACbgB,KAAKf,YAAc,EACnBe,KAAKd,QAAU,EACfc,KAAKb,SAAU,EACfa,KAAKZ,QAAU,GACfY,KAAKX,SAAW,CAAC,CACnB,CAMA,iBAAAY,GACEF,MAAME,oBACND,KAAKE,UAAUC,IAAI,OACnBH,MAAKH,EAAY,IAAIO,eAAe,EAAEC,MACpC,MAAMC,MAAEA,GAAUD,EAAME,YAClBrB,EAAUoB,GAAS,IAAM,EAAIA,GAAS,IAAM,EAAI,EACnDpB,IAAYc,KAAKd,UAASc,KAAKd,QAAUA,KAE9Cc,MAAKH,EAAUW,QAAQR,MACvBA,KAAKS,MACP,CAEA,oBAAAC,GACEX,MAAMW,uBACNV,MAAKH,GAAWc,YAClB,CAEA,OAAAC,CAAQC,GACNd,MAAMa,QAAQC,GACdb,KAAKlB,OAAOgC,QAAQ,EAAGC,SAAQC,UAASV,QAAOW,aAC7C,MAAMC,EAAUlB,MAAKP,EAAU0B,IAAIJ,EAAOK,IACtCJ,GAAYE,IAChBA,EAAQZ,MAAQe,KAAKC,IAAIhB,EAAON,KAAKd,SACrCgC,EAAQD,OAASA,IAErB,CAMAR,KAAOc,UACL,MAAOC,EAAOC,SAAc/D,IAC5BsC,KAAKb,SAAU,EAEZqC,EACDxB,KAAKZ,QAAU,iCAAiCoC,EAAME,OAIxD1B,KAAKZ,QAAU,GACfY,MAAKV,EAAemC,EAAKE,QACzB3B,MAAKT,EAAekC,EAAKlC,aAAe,GACxCS,KAAKlB,OAASnB,EAAcqC,MAAKV,EAAcxB,KAC/CkC,KAAK4B,iBAWPC,MAAQN,OAASR,aACf,IAAGf,MAAKP,EAAUqC,IAAIf,EAAOK,MAAOpB,MAAKL,EAASmC,IAAIf,EAAOK,IAA7D,CACApB,MAAKL,EAASQ,IAAIY,EAAOK,IAEzB,IAEE,gBADaL,EAAOgB,UAChBC,eAAeb,IAAIJ,EAAOG,SAC5B,MAAM,IAAIe,MAAM,GAAGlB,EAAOgB,0BAA0BhB,EAAOG,YAE7D,MAAMA,EAAUgB,SAASC,cAAcpB,EAAOG,SAC9CA,EAAQkB,WAAarB,EACrBf,MAAKP,EAAU4C,IAAItB,EAAOK,GAAIF,EAChC,CAAE,MAAMM,GACNxB,KAAKX,SAAW,IAAKW,KAAKX,SAAU,CAAC0B,EAAOK,IAAK,gCAAgCL,EAAOgB,kGACxFO,QAAQd,MAAM,oBAAoBT,EAAOK,qBAAsBI,EACjE,CAEAxB,MAAKL,EAAS4C,OAAOxB,EAAOK,IAC5BpB,KAAKwC,eAjBmE,GAoB1EZ,aAAe,IAAM5B,KAAKlB,OAAO2D,OAAO,EAAGzB,aAAcA,GAASF,QAAQd,KAAK6B,OAO/Ea,OAAS5D,IACP,MAAM6D,EAAS/E,EAAgBkB,EAAQ,CAAE6D,OAAQ3C,MAAKR,EAAa,KAAO1B,IAAcyB,YAAaS,MAAKT,IAC1GS,KAAKlB,OAASA,EAEX8D,KAAKC,UAAUF,KAAYC,KAAKC,UAAUjF,EAAgBD,EAAcqC,MAAKV,EAAc,QAC5FtB,IAEAD,EAAY4E,GAGd7D,EAAO2D,OAAO,EAAGzB,cAAeA,GAASF,QAAQ,EAAGC,aAElD,GADAf,MAAKP,EAAU8C,OAAOxB,EAAOK,IAC1BpB,KAAKX,SAAS0B,EAAOK,IAAI,CAC1B,MAAQ,CAACL,EAAOK,IAAK0B,KAAYC,GAAS/C,KAAKX,SAC/CW,KAAKX,SAAW0D,CAClB,IAGF/C,KAAK4B,gBAOPoB,cAAgB,KACdhD,MAAKR,GAAa,EAClBQ,KAAKhB,MAAQgB,KAAKlB,OAAOmE,IAAI5C,IAAS,IAAMA,KAC5CL,KAAKf,cACLe,KAAKkD,cAAc,YAAYC,QAGjCC,KAAO,IAAMpD,KAAK0C,OAAO1C,KAAKhB,OAE9BqE,WAAa,KACXrD,MAAKR,GAAa,EAClBQ,KAAKhB,MAAQrB,EAAcqC,MAAKV,EAAc,MAC9CU,KAAKf,eAGPqE,YAAc,CAAClC,EAAImC,KACjBvD,KAAKhB,MAAQgB,KAAKhB,MAAMiE,IAAI5C,GAASA,EAAMU,OAAOK,KAAOA,EAAK,IAAKf,KAAUkD,GAAYlD,IAO3FmD,WAAaC,IACXzD,KAAKhB,MAAQnB,EAAQmC,KAAKhB,MAAO,IAAIyE,EAAMC,cAAcC,UAAUV,IAAIW,GAAQA,EAAKC,QAAQC,WAC5F9D,KAAKf,eAYP8E,eAAiB,EAAGhD,SAAQC,UAASV,QAAOW,YAAa3D,CAAI;kDACbyD,EAAOK;sGAC6CnD;;YAE1F8C,EAAOiD,QAAU1G,CAAI,iBAAiB0D,EAAU,GAAK,kBAAkBD,EAAOiD,oBAAsBjD,EAAOkD,KAAO3G,CAAI,iBAAiB0D,EAAU,GAAK,mBAAmBD,EAAOkD,iBAAmB1G;0BACrLyD,EAAU,GAAK,cAAcD,EAAOmD;;qDAETnD,EAAOmD,2BAA2BlD,aAAmByC,GAASzD,KAAKsD,YAAYvC,EAAOK,GAAI,CAAEd,MAAO6D,OAAOV,EAAMW,OAAOC;YAChKnG,EAAM6C,EAAOuD,SAAUvD,EAAOwD,UAAUtB,IAAIoB,GAAS/G,CAAI,iBAAiB+G,eAAmBA,IAAU/D,KAAS+D;;qDAEvEtD,EAAOmD,4BAA4BlD,aAAmByC,GAASzD,KAAKsD,YAAYvC,EAAOK,GAAI,CAAEH,OAAQkD,OAAOV,EAAMW,OAAOC;YAClKnG,EAAM6C,EAAOyD,UAAWzD,EAAO0D,WAAWxB,IAAIoB,GAAS/G,CAAI,iBAAiB+G,eAAmBA,IAAUpD,KAAUoD;;;;;kBAK7GrD,EAAU,OAAS;wBACbA,EAAU,OAAS,UAAUD,EAAOmD;mBACzC,IAAMlE,KAAKsD,YAAYvC,EAAOK,GAAI,CAAEJ,SAAUA;wBACzCA,EAAU,OAAS;;;IASzC0D,kBAAoB,IAAMpH,CAAI;+GAC+EW;;;;;;IAQ7G0G,sBAAwB,IAAMrH,CAAI;;;;;uBAKb0C,KAAKoD;;;;;UAKlBpD,KAAK0E;UACLjH,EAAMuC,KAAKf,YAAa3B,CAAI;8BACR0C,KAAKwD,cAAcxD,KAAKhB,MAAMiE,IAAIjD,KAAK+D;;oCAEjC/D,KAAKqD;;;IAUvCuB,WAAa,EAAG7D,SAAQT,QAAOW,aAC7B,MAAMC,EAAUlB,MAAKP,EAAU0B,IAAIJ,EAAOK,IACpChC,EAAUY,KAAKX,SAAS0B,EAAOK,IACrC,OAAO9D,CAAI;;yBAEUyD,EAAOK;mCACGC,KAAKC,IAAIhB,EAAON,KAAKd,4BAA4B+B;;;YAGxE7B,EACE9B,CAAI,+CAA+CyD,EAAOmD,kBAAkB9E,QAC5E8B,GAAW5D,CAAI,iCAAiCyD,EAAOmD;;;OAMnE,cAAAW,GACE,GAAG7E,KAAKZ,QAAS,OAAO9B,CAAI,qCAAqC0C,KAAKZ,cACtE,GAAGY,KAAKb,QAAS,OAAO7B,CAAI,0CAE5B,MAAM0D,EAAUhB,KAAKlB,OAAO2D,OAAOpC,GAASA,EAAMW,SAElD,OAAO1D,CAAI;;8BAEe0C,KAAKrB;yBACVqB,KAAKgD;;QAEtBhC,EAAQ1C,OAASf,EAAUD,CAAI;8DACuB0C,KAAKd;UACzD1B,EAAOwD,EAASX,GAASA,EAAMU,OAAOK,GAAIpB,KAAK4E;;QAEjD5E,KAAK2E;KAEX,EAGF3C,eAAe8C,OAAO,cAAerG","ignoreList":[]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import t from"/kempo-ui/components/ShadowComponent.js";import{html as e,nothing as i}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Icon.js";import"/kempo-ui/components/Dialog.js";import"/kempo-ui/components/CodeEditor.js";import s from"/kempo-ui/components/Toast.js";import{listEmailTemplates as a,getEmailTemplate as o,saveEmailTemplate as r,resetEmailTemplate as n}from"/kempo/sdk.js";export default class l extends t{static properties={owner:{type:String},previewUrl:{type:String,attribute:"preview-url"},templates:{state:!0},error:{state:!0},editing:{state:!0},saving:{state:!0}};constructor(){super(),this.owner="",this.previewUrl="/kempo/api/email-templates/preview",this.templates=null,this.error="",this.editing=null,this.saving=!1}connectedCallback(){super.connectedCallback(),this.load()}load=async()=>{const[t,e]=await a(this.owner);t?this.error=t.msg:this.templates=e.templates};edit=async t=>{const[e,i]=await o(t);e?s.error(e.msg):(this.editing=i,await this.updateComplete,this.renderRoot.querySelector("#emailDialog").open())};confirmSave=t=>{t.keepDialogOpen=!0,this.save()};save=async()=>{this.saving=!0;const[t]=await r({template:this.editing.template,source:this.renderRoot.querySelector("#emailEditor").value});this.saving=!1,t?s.error(t.msg):(this.renderRoot.querySelector("#emailDialog").close(),s.success("Email template saved"),await this.load())};reset=async()=>{this.saving=!0;const[t]=await n(this.editing.template);this.saving=!1,t?s.error(t.msg):(this.renderRoot.querySelector("#emailDialog").close(),s.success("Email template reset to the default"),await this.load())};renderDialog(){const t=this.editing;return t?e`
|
|
2
|
+
<k-dialog id="emailDialog" style="--width: 60rem;" confirm-text=${this.saving?"Saving…":"Save"} confirm-classes="primary ml" cancel-text="Cancel" .confirmAction=${this.confirmSave}>
|
|
3
|
+
<h2 slot="title" class="h5 pyh px m0">${t.title||t.name}</h2>
|
|
4
|
+
<div class="p">
|
|
5
|
+
<p class="tc-muted mb">${t.template}. Saving keeps your copy in <strong>templates/emails</strong>, which is used from the next email and is not changed by updates. A template that does not render is not saved.</p>
|
|
6
|
+
<k-code-editor id="emailEditor" class="r b mb" language="html" .value=${t.source} style="height: 55vh; min-height: 300px;"></k-code-editor>
|
|
7
|
+
${t.overridden?e`<button type="button" class="danger" ?disabled=${this.saving} @click=${this.reset}>Reset to default</button>`:i}
|
|
8
|
+
</div>
|
|
9
|
+
</k-dialog>
|
|
10
|
+
`:i}render(){return this.error?e`<p class="tc-danger" role="alert">${this.error}</p>`:this.templates?this.templates.length?e`
|
|
11
|
+
<table class="mb">
|
|
12
|
+
<thead><tr><th>Email</th><th>Template</th><th>Status</th><th></th></tr></thead>
|
|
13
|
+
<tbody>${this.templates.map(t=>e`<tr>
|
|
14
|
+
<td>${t.title||t.name}</td>
|
|
15
|
+
<td class="tc-muted">${t.template}</td>
|
|
16
|
+
<td>${t.overridden?e`<span class="tc-warning">Customized</span>`:e`<span class="tc-muted">Default</span>`}</td>
|
|
17
|
+
<td class="ta-right">
|
|
18
|
+
<a class="btn" href=${`${this.previewUrl}?template=${encodeURIComponent(t.template)}`} target="email-preview">Preview</a>
|
|
19
|
+
<button aria-label="Edit" title="Edit" @click=${()=>this.edit(t.template)}><k-icon name="edit"></k-icon></button>
|
|
20
|
+
</td>
|
|
21
|
+
</tr>`)}</tbody>
|
|
22
|
+
</table>
|
|
23
|
+
${this.renderDialog()}
|
|
24
|
+
`:e`<p class="tc-muted">There are no email templates here.</p>`:e`<k-spinner></k-spinner>`}}customElements.define("k-email-templates",l);
|
|
25
|
+
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\admin\components\EmailTemplates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"src\\admin\\components\\EmailTemplates.js","names":["ShadowComponent","html","nothing","Toast","listEmailTemplates","getEmailTemplate","saveEmailTemplate","resetEmailTemplate","EmailTemplates","static","owner","type","String","previewUrl","attribute","templates","state","error","editing","saving","constructor","super","this","connectedCallback","load","async","data","msg","edit","found","template","updateComplete","renderRoot","querySelector","open","confirmSave","event","keepDialogOpen","save","source","value","close","success","reset","renderDialog","title","name","overridden","render","length","map","encodeURIComponent","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Icon.js';\nimport '/kempo-ui/components/Dialog.js';\nimport '/kempo-ui/components/CodeEditor.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { listEmailTemplates, getEmailTemplate, saveEmailTemplate, resetEmailTemplate } from '/kempo/sdk.js';\n\n/*\n The email templates of one extension, to edit, served at /admin/components/EmailTemplates.js:\n\n <script type=\"module\" src=\"/admin/components/EmailTemplates.js\"></script>\n <k-email-templates owner=\"kempo-commerce\"></k-email-templates>\n\n Without `owner` it lists kempo's own templates (verify-email, reset-password). Each row opens a dialog with the template in\n a code editor. Saving writes the site's own copy to `templates/emails/<owner>/<name>.page.html`, which is used from the next\n email on and survives updates of the extension; **Reset to default** removes it. The packaged template is never changed.\n\n **Preview** opens the template as it would be sent, in one named tab that each preview replaces. By default that is kempo's\n preview, which draws it with no data; an extension that can do better (its own orders as the data) serves its own and gives\n its address as `preview-url`:\n\n <k-email-templates owner=\"kempo-commerce\" preview-url=\"/commerce/api/emails/preview\"></k-email-templates>\n\n The preview is the saved template, not what is being typed in the editor.\n An extension puts this on its settings page; it needs no code of its own.\n*/\nexport default class EmailTemplates extends ShadowComponent {\n static properties = {\n owner: { type: String },\n previewUrl: { type: String, attribute: 'preview-url' },\n templates: { state: true },\n error: { state: true },\n editing: { state: true },\n saving: { state: true },\n };\n\n constructor(){\n super();\n this.owner = '';\n this.previewUrl = '/kempo/api/email-templates/preview';\n this.templates = null;\n this.error = '';\n this.editing = null;\n this.saving = false;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n this.load();\n }\n\n /*\n Actions\n */\n load = async () => {\n const [error, data] = await listEmailTemplates(this.owner);\n if(error){\n this.error = error.msg;\n return;\n }\n this.templates = data.templates;\n };\n\n edit = async template => {\n const [error, found] = await getEmailTemplate(template);\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.editing = found;\n await this.updateComplete;\n this.renderRoot.querySelector('#emailDialog').open();\n };\n\n /* The dialog's Save button: it stays open until the template is saved, so a template that does not render is not lost. */\n confirmSave = event => {\n event.keepDialogOpen = true;\n this.save();\n };\n\n save = async () => {\n this.saving = true;\n const [error] = await saveEmailTemplate({ template: this.editing.template, source: this.renderRoot.querySelector('#emailEditor').value });\n this.saving = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#emailDialog').close();\n Toast.success('Email template saved');\n await this.load();\n };\n\n reset = async () => {\n this.saving = true;\n const [error] = await resetEmailTemplate(this.editing.template);\n this.saving = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#emailDialog').close();\n Toast.success('Email template reset to the default');\n await this.load();\n };\n\n /*\n Rendering\n */\n renderDialog(){\n const template = this.editing;\n if(!template) return nothing;\n return html`\n <k-dialog id=\"emailDialog\" style=\"--width: 60rem;\" confirm-text=${this.saving ? 'Saving…' : 'Save'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmSave}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${template.title || template.name}</h2>\n <div class=\"p\">\n <p class=\"tc-muted mb\">${template.template}. Saving keeps your copy in <strong>templates/emails</strong>, which is used from the next email and is not changed by updates. A template that does not render is not saved.</p>\n <k-code-editor id=\"emailEditor\" class=\"r b mb\" language=\"html\" .value=${template.source} style=\"height: 55vh; min-height: 300px;\"></k-code-editor>\n ${template.overridden ? html`<button type=\"button\" class=\"danger\" ?disabled=${this.saving} @click=${this.reset}>Reset to default</button>` : nothing}\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n if(!this.templates) return html`<k-spinner></k-spinner>`;\n if(!this.templates.length) return html`<p class=\"tc-muted\">There are no email templates here.</p>`;\n return html`\n <table class=\"mb\">\n <thead><tr><th>Email</th><th>Template</th><th>Status</th><th></th></tr></thead>\n <tbody>${this.templates.map(template => html`<tr>\n <td>${template.title || template.name}</td>\n <td class=\"tc-muted\">${template.template}</td>\n <td>${template.overridden ? html`<span class=\"tc-warning\">Customized</span>` : html`<span class=\"tc-muted\">Default</span>`}</td>\n <td class=\"ta-right\">\n <a class=\"btn\" href=${`${this.previewUrl}?template=${encodeURIComponent(template.template)}`} target=\"email-preview\">Preview</a>\n <button aria-label=\"Edit\" title=\"Edit\" @click=${() => this.edit(template.template)}><k-icon name=\"edit\"></k-icon></button>\n </td>\n </tr>`)}</tbody>\n </table>\n ${this.renderDialog()}\n `;\n }\n}\n\ncustomElements.define('k-email-templates', EmailTemplates);\n"],"mappings":"OAAOA,MAAqB,yDACnBC,aAAMC,MAAe,iCACvB,wCACA,qCACA,uCACA,4CACAC,MAAW,6DACTC,sBAAoBC,uBAAkBC,wBAAmBC,MAA0B,+BAqB7E,MAAMC,UAAuBR,EAC1CS,kBAAoB,CAClBC,MAAO,CAAEC,KAAMC,QACfC,WAAY,CAAEF,KAAMC,OAAQE,UAAW,eACvCC,UAAW,CAAEC,OAAO,GACpBC,MAAO,CAAED,OAAO,GAChBE,QAAS,CAAEF,OAAO,GAClBG,OAAQ,CAAEH,OAAO,IAGnB,WAAAI,GACEC,QACAC,KAAKZ,MAAQ,GACbY,KAAKT,WAAa,qCAClBS,KAAKP,UAAY,KACjBO,KAAKL,MAAQ,GACbK,KAAKJ,QAAU,KACfI,KAAKH,QAAS,CAChB,CAKA,iBAAAI,GACEF,MAAME,oBACND,KAAKE,MACP,CAKAA,KAAOC,UACL,MAAOR,EAAOS,SAActB,EAAmBkB,KAAKZ,OACjDO,EACDK,KAAKL,MAAQA,EAAMU,IAGrBL,KAAKP,UAAYW,EAAKX,WAGxBa,KAAOH,UACL,MAAOR,EAAOY,SAAexB,EAAiByB,GAC3Cb,EACDd,EAAMc,MAAMA,EAAMU,MAGpBL,KAAKJ,QAAUW,QACTP,KAAKS,eACXT,KAAKU,WAAWC,cAAc,gBAAgBC,SAIhDC,YAAcC,IACZA,EAAMC,gBAAiB,EACvBf,KAAKgB,QAGPA,KAAOb,UACLH,KAAKH,QAAS,EACd,MAAOF,SAAeX,EAAkB,CAAEwB,SAAUR,KAAKJ,QAAQY,SAAUS,OAAQjB,KAAKU,WAAWC,cAAc,gBAAgBO,QACjIlB,KAAKH,QAAS,EACXF,EACDd,EAAMc,MAAMA,EAAMU,MAGpBL,KAAKU,WAAWC,cAAc,gBAAgBQ,QAC9CtC,EAAMuC,QAAQ,8BACRpB,KAAKE,SAGbmB,MAAQlB,UACNH,KAAKH,QAAS,EACd,MAAOF,SAAeV,EAAmBe,KAAKJ,QAAQY,UACtDR,KAAKH,QAAS,EACXF,EACDd,EAAMc,MAAMA,EAAMU,MAGpBL,KAAKU,WAAWC,cAAc,gBAAgBQ,QAC9CtC,EAAMuC,QAAQ,6CACRpB,KAAKE,SAMb,YAAAoB,GACE,MAAMd,EAAWR,KAAKJ,QACtB,OAAIY,EACG7B,CAAI;wEACyDqB,KAAKH,OAAS,UAAY,2EAA2EG,KAAKa;gDAClIL,EAASe,OAASf,EAASgB;;mCAExChB,EAASA;kFACsCA,EAASS;YAC/ET,EAASiB,WAAa9C,CAAI,kDAAkDqB,KAAKH,iBAAiBG,KAAKqB,kCAAoCzC;;;MAP9HA,CAWvB,CAEA,MAAA8C,GACE,OAAG1B,KAAKL,MAAchB,CAAI,qCAAqCqB,KAAKL,YAChEK,KAAKP,UACLO,KAAKP,UAAUkC,OACZhD,CAAI;;;iBAGEqB,KAAKP,UAAUmC,IAAIpB,GAAY7B,CAAI;gBACpC6B,EAASe,OAASf,EAASgB;iCACVhB,EAASA;gBAC1BA,EAASiB,WAAa9C,CAAI,6CAA+CA,CAAI;;kCAE3D,GAAGqB,KAAKT,uBAAuBsC,mBAAmBrB,EAASA;4DACjC,IAAMR,KAAKM,KAAKE,EAASA;;;;QAI7ER,KAAKsB;MAdyB3C,CAAI,6DADXA,CAAI,yBAiBjC,EAGFmD,eAAeC,OAAO,oBAAqB7C","ignoreList":[]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import t from"/kempo-ui/components/ShadowComponent.js";import{html as s,nothing as e}from"/kempo-ui/lit-all.min.js";import"/kempo-ui/components/Spinner.js";import"/kempo-ui/components/Dialog.js";import a from"/kempo-ui/components/Toast.js";import{listEmails as i,getEmail as o,retryEmail as r,sendTestEmail as l,getAllCurrentUserPermissions as n}from"/kempo/sdk.js";const c={sent:"tc-success",failed:"tc-danger",sending:"tc-warning",queued:"tc-muted"},h=t=>t?new Date(t).toLocaleString():"";export default class d extends t{static properties={emails:{state:!0},total:{state:!0},offset:{state:!0},status:{state:!0},search:{state:!0},loading:{state:!0},error:{state:!0},canManage:{state:!0},open:{state:!0},testTo:{state:!0},busy:{state:!0}};constructor(){super(),this.emails=[],this.total=0,this.offset=0,this.status="",this.search="",this.loading=!0,this.error="",this.canManage=!1,this.open=null,this.testTo="",this.busy=!1}connectedCallback(){super.connectedCallback(),n().then(([,t])=>{this.canManage=(t?.permissions??[]).includes("system:emails:manage")}),this.load()}load=async()=>{this.loading=!0;const[t,s]=await i({status:this.status||void 0,search:this.search||void 0,limit:25,offset:this.offset});this.loading=!1,t?this.error=t.msg||"The emails could not be loaded":(this.error="",this.emails=s.emails,this.total=s.total)};filter=async t=>{Object.assign(this,t,{offset:0}),await this.load()};page=async t=>{this.offset=Math.max(0,this.offset+25*t),await this.load()};show=async t=>{const[s,e]=await o(t);s?a.error(s.msg):(this.open=e,await this.updateComplete,this.renderRoot.querySelector("#emailDialog").open())};retry=async()=>{this.busy=!0;const[t,s]=await r(this.open.id);this.busy=!1,t?a.error(t.msg):s.sent?a.success("The email was sent"):a.error(s.error||"The email could not be sent"),this.renderRoot.querySelector("#emailDialog").close(),await this.load()};openTest=()=>this.renderRoot.querySelector("#testDialog").open();confirmTest=t=>{t.keepDialogOpen=!0,this.sendTest()};sendTest=async()=>{this.busy=!0;const[t,s]=await l(this.testTo.trim());this.busy=!1,t?a.error(t.msg):(this.renderRoot.querySelector("#testDialog").close(),s.sent?a.success(`A test email was sent to ${this.testTo.trim()}`):a.error(s.error||"The test email could not be sent"),await this.load())};renderDetail(){const t=this.open;return t?s`
|
|
2
|
+
<k-dialog id="emailDialog" style="--width: 60rem;" cancel-text="Close">
|
|
3
|
+
<h2 slot="title" class="h5 pyh px m0">${t.subject}</h2>
|
|
4
|
+
<div class="p">
|
|
5
|
+
<p class="mb"><strong class=${c[t.status]??""}>${t.status}</strong>${t.attempts?s` · ${t.attempts} ${1===t.attempts?"try":"tries"}`:e}${t.provider?s` · sent by ${t.provider}`:e}</p>
|
|
6
|
+
<p class="mb">To <strong>${t.recipient}</strong>, from ${t.sender}<br><span class="tc-muted">${t.type} · created ${h(t.createdAt)}${t.sentAt?` · sent ${h(t.sentAt)}`:""}</span></p>
|
|
7
|
+
${t.lastError?s`<p class="tc-danger mb" role="alert">${t.lastError}</p>`:e}
|
|
8
|
+
${this.canManage&&["queued","failed"].includes(t.status)?s`<p class="mb"><button class="primary" ?disabled=${this.busy} @click=${this.retry}>${this.busy?"Trying…":"Try again"}</button></p>`:e}
|
|
9
|
+
<iframe title="The email" sandbox="" .srcdoc=${t.html} class="full b r" height="420"></iframe>
|
|
10
|
+
</div>
|
|
11
|
+
</k-dialog>
|
|
12
|
+
`:e}renderTest(){return s`
|
|
13
|
+
<k-dialog id="testDialog" confirm-text=${this.busy?"Sending…":"Send test email"} confirm-classes="primary ml" cancel-text="Cancel" .confirmAction=${this.confirmTest}>
|
|
14
|
+
<h2 slot="title" class="h5 pyh px m0">Send a test email</h2>
|
|
15
|
+
<div class="p">
|
|
16
|
+
<label class="mb d-b">Send it to<input type="email" .value=${this.testTo} @input=${t=>{this.testTo=t.target.value}}></label>
|
|
17
|
+
<p class="tc-muted">It goes out through the email extension that is installed, and shows in the log below.</p>
|
|
18
|
+
</div>
|
|
19
|
+
</k-dialog>
|
|
20
|
+
`}render(){const t=Math.min(this.offset+25,this.total);return s`
|
|
21
|
+
<div class="d-f mb">
|
|
22
|
+
<label class="mr">Status
|
|
23
|
+
<select @change=${t=>this.filter({status:t.target.value})}>
|
|
24
|
+
${["","queued","sending","sent","failed"].map(t=>s`<option value=${t} ?selected=${this.status===t}>${t||"All"}</option>`)}
|
|
25
|
+
</select>
|
|
26
|
+
</label>
|
|
27
|
+
<label class="mr">Search
|
|
28
|
+
<input type="search" placeholder="Address or subject" .value=${this.search} @change=${t=>this.filter({search:t.target.value})}>
|
|
29
|
+
</label>
|
|
30
|
+
${this.canManage?s`<button class="mb" @click=${this.openTest}>Send a test email</button>`:e}
|
|
31
|
+
</div>
|
|
32
|
+
${this.error?s`<p class="tc-danger" role="alert">${this.error}</p>`:e}
|
|
33
|
+
${this.loading?s`<k-spinner></k-spinner>`:this.emails.length?s`
|
|
34
|
+
<table class="mb">
|
|
35
|
+
<thead><tr><th>Status</th><th>To</th><th>Subject</th><th>Type</th><th>Created</th></tr></thead>
|
|
36
|
+
<tbody>${this.emails.map(t=>s`<tr>
|
|
37
|
+
<td><strong class=${c[t.status]??""}>${t.status}</strong></td>
|
|
38
|
+
<td>${t.recipient}</td>
|
|
39
|
+
<td><a href="#" @click=${s=>{s.preventDefault(),this.show(t.id)}}>${t.subject}</a>${t.lastError?s`<br><span class="tc-danger">${t.lastError}</span>`:e}</td>
|
|
40
|
+
<td class="tc-muted">${t.type}</td>
|
|
41
|
+
<td class="tc-muted">${h(t.createdAt)}</td>
|
|
42
|
+
</tr>`)}</tbody>
|
|
43
|
+
</table>
|
|
44
|
+
<p class="d-f mb"><button ?disabled=${0===this.offset} @click=${()=>this.page(-1)}>Newer</button><span class="tc-muted ph">${this.offset+1}–${t} of ${this.total}</span><button ?disabled=${t>=this.total} @click=${()=>this.page(1)}>Older</button></p>
|
|
45
|
+
`:s`<p class="tc-muted">No emails have been sent yet.</p>`}
|
|
46
|
+
${this.renderDetail()}
|
|
47
|
+
${this.renderTest()}
|
|
48
|
+
`}}customElements.define("k-admin-emails",d);
|
|
49
|
+
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\admin\components\Emails.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"src\\admin\\components\\Emails.js","names":["ShadowComponent","html","nothing","Toast","listEmails","getEmail","retryEmail","sendTestEmail","getAllCurrentUserPermissions","STATUS_CLASS","sent","failed","sending","queued","when","value","Date","toLocaleString","Emails","static","emails","state","total","offset","status","search","loading","error","canManage","open","testTo","busy","constructor","super","this","connectedCallback","then","data","permissions","includes","load","async","undefined","limit","msg","filter","Object","assign","changes","page","Math","max","step","show","found","id","updateComplete","renderRoot","querySelector","retry","result","success","close","openTest","confirmTest","event","keepDialogOpen","sendTest","trim","renderDetail","mail","subject","attempts","provider","recipient","sender","type","createdAt","sentAt","lastError","renderTest","target","render","last","min","map","length","preventDefault","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Dialog.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { listEmails, getEmail, retryEmail, sendTestEmail, getAllCurrentUserPermissions } from '/kempo/sdk.js';\n\n/*\n The log of emails, served at /admin/components/Emails.js:\n\n <k-admin-emails></k-admin-emails>\n\n Every email kempo was asked to send is here, newest first, with how it went: `queued` (waiting for a sender or a retry), `sending`,\n `sent` or `failed`. Click one to read it, with what it said and the reason it did not go. Someone who can manage emails can try a\n waiting or failed one again and send a test email, which is the quickest way to see that an email extension is set up.\n*/\nconst PAGE_SIZE = 25;\nconst STATUS_CLASS = { sent: 'tc-success', failed: 'tc-danger', sending: 'tc-warning', queued: 'tc-muted' };\n\nconst when = value => (value ? new Date(value).toLocaleString() : '');\n\nexport default class Emails extends ShadowComponent {\n static properties = {\n emails: { state: true },\n total: { state: true },\n offset: { state: true },\n status: { state: true },\n search: { state: true },\n loading: { state: true },\n error: { state: true },\n canManage: { state: true },\n open: { state: true },\n testTo: { state: true },\n busy: { state: true },\n };\n\n constructor(){\n super();\n this.emails = [];\n this.total = 0;\n this.offset = 0;\n this.status = '';\n this.search = '';\n this.loading = true;\n this.error = '';\n this.canManage = false;\n this.open = null;\n this.testTo = '';\n this.busy = false;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n getAllCurrentUserPermissions().then(([, data]) => { this.canManage = (data?.permissions ?? []).includes('system:emails:manage'); });\n this.load();\n }\n\n /*\n Loading\n */\n load = async () => {\n this.loading = true;\n const [error, data] = await listEmails({ status: this.status || undefined, search: this.search || undefined, limit: PAGE_SIZE, offset: this.offset });\n this.loading = false;\n if(error){\n this.error = error.msg || 'The emails could not be loaded';\n return;\n }\n this.error = '';\n this.emails = data.emails;\n this.total = data.total;\n };\n\n filter = async changes => {\n Object.assign(this, changes, { offset: 0 });\n await this.load();\n };\n\n page = async step => {\n this.offset = Math.max(0, this.offset + step * PAGE_SIZE);\n await this.load();\n };\n\n /*\n One email\n */\n show = async id => {\n const [error, found] = await getEmail(id);\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.open = found;\n await this.updateComplete;\n this.renderRoot.querySelector('#emailDialog').open();\n };\n\n retry = async () => {\n this.busy = true;\n const [error, result] = await retryEmail(this.open.id);\n this.busy = false;\n if(error) Toast.error(error.msg);\n else if(result.sent) Toast.success('The email was sent');\n else Toast.error(result.error || 'The email could not be sent');\n this.renderRoot.querySelector('#emailDialog').close();\n await this.load();\n };\n\n /*\n Test email\n */\n openTest = () => this.renderRoot.querySelector('#testDialog').open();\n\n confirmTest = event => {\n event.keepDialogOpen = true;\n this.sendTest();\n };\n\n sendTest = async () => {\n this.busy = true;\n const [error, result] = await sendTestEmail(this.testTo.trim());\n this.busy = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.renderRoot.querySelector('#testDialog').close();\n if(result.sent) Toast.success(`A test email was sent to ${this.testTo.trim()}`);\n else Toast.error(result.error || 'The test email could not be sent');\n await this.load();\n };\n\n /*\n Rendering\n */\n renderDetail(){\n const mail = this.open;\n if(!mail) return nothing;\n return html`\n <k-dialog id=\"emailDialog\" style=\"--width: 60rem;\" cancel-text=\"Close\">\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${mail.subject}</h2>\n <div class=\"p\">\n <p class=\"mb\"><strong class=${STATUS_CLASS[mail.status] ?? ''}>${mail.status}</strong>${mail.attempts ? html` · ${mail.attempts} ${mail.attempts === 1 ? 'try' : 'tries'}` : nothing}${mail.provider ? html` · sent by ${mail.provider}` : nothing}</p>\n <p class=\"mb\">To <strong>${mail.recipient}</strong>, from ${mail.sender}<br><span class=\"tc-muted\">${mail.type} · created ${when(mail.createdAt)}${mail.sentAt ? ` · sent ${when(mail.sentAt)}` : ''}</span></p>\n ${mail.lastError ? html`<p class=\"tc-danger mb\" role=\"alert\">${mail.lastError}</p>` : nothing}\n ${this.canManage && ['queued', 'failed'].includes(mail.status) ? html`<p class=\"mb\"><button class=\"primary\" ?disabled=${this.busy} @click=${this.retry}>${this.busy ? 'Trying…' : 'Try again'}</button></p>` : nothing}\n <iframe title=\"The email\" sandbox=\"\" .srcdoc=${mail.html} class=\"full b r\" height=\"420\"></iframe>\n </div>\n </k-dialog>\n `;\n }\n\n renderTest(){\n return html`\n <k-dialog id=\"testDialog\" confirm-text=${this.busy ? 'Sending…' : 'Send test email'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmTest}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">Send a test email</h2>\n <div class=\"p\">\n <label class=\"mb d-b\">Send it to<input type=\"email\" .value=${this.testTo} @input=${event => { this.testTo = event.target.value; }}></label>\n <p class=\"tc-muted\">It goes out through the email extension that is installed, and shows in the log below.</p>\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n const last = Math.min(this.offset + PAGE_SIZE, this.total);\n return html`\n <div class=\"d-f mb\">\n <label class=\"mr\">Status\n <select @change=${event => this.filter({ status: event.target.value })}>\n ${['', 'queued', 'sending', 'sent', 'failed'].map(status => html`<option value=${status} ?selected=${this.status === status}>${status || 'All'}</option>`)}\n </select>\n </label>\n <label class=\"mr\">Search\n <input type=\"search\" placeholder=\"Address or subject\" .value=${this.search} @change=${event => this.filter({ search: event.target.value })}>\n </label>\n ${this.canManage ? html`<button class=\"mb\" @click=${this.openTest}>Send a test email</button>` : nothing}\n </div>\n ${this.error ? html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>` : nothing}\n ${this.loading ? html`<k-spinner></k-spinner>` : this.emails.length ? html`\n <table class=\"mb\">\n <thead><tr><th>Status</th><th>To</th><th>Subject</th><th>Type</th><th>Created</th></tr></thead>\n <tbody>${this.emails.map(mail => html`<tr>\n <td><strong class=${STATUS_CLASS[mail.status] ?? ''}>${mail.status}</strong></td>\n <td>${mail.recipient}</td>\n <td><a href=\"#\" @click=${event => { event.preventDefault(); this.show(mail.id); }}>${mail.subject}</a>${mail.lastError ? html`<br><span class=\"tc-danger\">${mail.lastError}</span>` : nothing}</td>\n <td class=\"tc-muted\">${mail.type}</td>\n <td class=\"tc-muted\">${when(mail.createdAt)}</td>\n </tr>`)}</tbody>\n </table>\n <p class=\"d-f mb\"><button ?disabled=${this.offset === 0} @click=${() => this.page(-1)}>Newer</button><span class=\"tc-muted ph\">${this.offset + 1}–${last} of ${this.total}</span><button ?disabled=${last >= this.total} @click=${() => this.page(1)}>Older</button></p>\n ` : html`<p class=\"tc-muted\">No emails have been sent yet.</p>`}\n ${this.renderDetail()}\n ${this.renderTest()}\n `;\n }\n}\n\ncustomElements.define('k-admin-emails', Emails);\n"],"mappings":"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","ignoreList":[]}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import e from"/kempo-ui/components/ShadowComponent.js";import{html as t,nothing as n,live as a}from"/kempo-ui/lit-all.min.js";import{listMenus as s,deleteMenu as i,assignMenuLocation as l,saveMenuItem as o,deleteMenuItems as d,setMenuItemEnabled as c,reorderMenuItems as m,adoptMenuItem as r,getAllCurrentUserPermissions as h}from"/kempo/sdk.js";import u from"/kempo-ui/components/Dialog.js";import p from"/kempo-ui/components/Toast.js";import"/kempo-ui/components/Icon.js";import"/kempo-ui/components/Sortable.js";import"/kempo-ui/components/Combobox.js";export default class b extends e{static properties={loading:{state:!0},failed:{state:!0},menus:{state:!0},locations:{state:!0},navItems:{state:!0},can:{state:!0}};constructor(){super(),this.loading=!0,this.failed=!1,this.menus=[],this.locations=[],this.navItems=[],this.can={create:!1,update:!1,delete:!1},this.pages=[]}async connectedCallback(){super.connectedCallback();const[,e]=await h(),t=new Set(e?.permissions??[]);this.can={create:t.has("system:globals:create"),update:t.has("system:globals:update"),delete:t.has("system:globals:delete")},await this.load()}load=async()=>{const[e,t]=await s();this.loading=!1,this.failed=Boolean(e),e?p.error(e.msg||"Failed to load menus"):(this.menus=t.menus,this.locations=t.locations,this.navItems=t.navItems,this.pages=t.pages??[])};run=async(e,t)=>{const[n]=await e;return n?(p.error(n.msg||"That did not work"),await this.load(),!1):(t&&p.success(t),await this.load(),!0)};get pageOptions(){return this.pages.map(e=>({value:e.url,label:`${e.title} (${e.url})`}))}iconOf=e=>e.navItem?this.navItems.find(t=>t.key===e.navItem)?.icon:e.icon;iconSrcOf=e=>e.navItem?this.navItems.find(t=>t.key===e.navItem)?.iconSrc:"";menuAt=e=>this.menus.find(t=>t.id===e.menuId);otherPlaces=e=>this.locations.filter(t=>t.menuId&&t.menuId===e.menuId&&t!==e);handleSort=async e=>{const t=e.currentTarget,n=[...t.querySelectorAll("k-sortable-item")].map(e=>e.dataset.id);await this.run(m({menuId:t.dataset.menu,ids:n}))};handleToggle=e=>this.run(c({id:e.id,enabled:e.disabled}));handleDeleteItem=e=>u.confirm(`Delete "${e.label}" from the menu?`,async t=>{t&&await this.run(d([e.id]),"Item deleted")});handleDeleteMenu=e=>u.confirm(`Delete the unused "${e.name}" menu and its ${e.items.length} item${1===e.items.length?"":"s"}? This cannot be undone.`,async t=>{t&&await this.run(i(e.id),"Menu deleted")});handleMenuChange=(e,t)=>this.run(l({location:e.location,menuId:t.target.value}));handleAdopt=(e,t)=>this.run(r({id:t.id,menuName:e.label}),`"${t.label}" can now be reordered and edited here`);showKind=(e,t)=>{const n=this.navItems.find(e=>e.key===t);e.querySelector("#menu-link-fields").hidden=Boolean(n);const a=e.querySelector("#menu-kind-description");a.hidden=!n,a.textContent=n?n.description||`${n.label} from ${n.extension}.`:""};openLinkEditor=(e,a)=>{const s=Boolean(a),i=u.create(t`
|
|
2
|
+
<div class="p">
|
|
3
|
+
${!s&&this.navItems.length?t`
|
|
4
|
+
<div class="mb">
|
|
5
|
+
<label class="d-b mbq" for="menu-kind">What to add<small class="d-b tc-muted">A link to a page or address, or something an installed extension offers, such as a cart.</small></label>
|
|
6
|
+
<select id="menu-kind" class="full" @change=${e=>this.showKind(i,e.target.value)}>
|
|
7
|
+
<option value="">A link</option>
|
|
8
|
+
${[...new Set(this.navItems.map(e=>e.extension))].map(e=>t`
|
|
9
|
+
<optgroup label=${"kempo"===e?"Built in":e}>
|
|
10
|
+
${this.navItems.filter(t=>t.extension===e).map(e=>t`<option value=${e.key}>${e.label}</option>`)}
|
|
11
|
+
</optgroup>
|
|
12
|
+
`)}
|
|
13
|
+
</select>
|
|
14
|
+
</div>
|
|
15
|
+
<p id="menu-kind-description" class="tc-muted mb" hidden></p>
|
|
16
|
+
`:n}
|
|
17
|
+
<div id="menu-link-fields">
|
|
18
|
+
<div class="mb">
|
|
19
|
+
<label class="d-b mbq" for="menu-label">Label</label>
|
|
20
|
+
<input type="text" id="menu-label" class="full" placeholder="About" .value=${a?.label??""}>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="mb">
|
|
23
|
+
<label class="d-b mbq" for="menu-href">Link<small class="d-b tc-muted">Choose one of your pages, or type any address.</small></label>
|
|
24
|
+
<div id="menu-href-wrap"></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="mb">
|
|
27
|
+
<label class="d-b mbq" for="menu-icon">Icon (optional)<small class="d-b tc-muted">The name of an icon, such as <strong>home</strong> or <strong>star</strong>.</small></label>
|
|
28
|
+
<input type="text" id="menu-icon" class="full" .value=${a?.icon??""}>
|
|
29
|
+
</div>
|
|
30
|
+
<label class="checkbox"><input type="checkbox" id="menu-new-tab" ?checked=${a?.newTab}> Open in a new tab</label>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
`,{title:s?"Edit link":`Add an item to ${e.label}`,confirmText:s?"Save":"Add item",cancelText:"Cancel",confirmAction:async()=>{const t=i.querySelector("#menu-href").value,n=i.querySelector("#menu-kind")?.value;await this.run(o(n?{location:e.location,navItem:n}:{id:a?.id,location:e.location,label:i.querySelector("#menu-label").value,href:this.pageOptions.find(e=>e.label===t)?.value??t,icon:i.querySelector("#menu-icon").value.trim(),newTab:i.querySelector("#menu-new-tab").checked}),s?"Link saved":"Item added")&&i.close()}}),l=document.createElement("k-combobox");l.id="menu-href",l.className="full",l.setAttribute("placeholder","/about"),l.setOptions(this.pageOptions),i.querySelector("#menu-href-wrap").appendChild(l),a?.href&&(l.value=a.href)};renderLink(e,a){const s=t`
|
|
34
|
+
<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
35
|
+
<div class="flex">
|
|
36
|
+
<strong class=${a.disabled||!a.available?"tc-muted":""}>${this.iconSrcOf(a)?t`<k-icon src=${this.iconSrcOf(a)}></k-icon> `:this.iconOf(a)?t`<k-icon name=${this.iconOf(a)}></k-icon> `:n}${a.label}</strong>
|
|
37
|
+
<span class="tc-muted"> ${a.navItem?a.navItem.startsWith("kempo:")?"built in":`from ${a.navItem.split(":")[0]}`:a.href}</span>
|
|
38
|
+
${a.available?n:t`<span class="tc-danger"> not on the site: its extension is disabled or removed</span>`}
|
|
39
|
+
${a.newTab?t`<k-icon name="open_in_new"></k-icon>`:n}
|
|
40
|
+
${a.disabled?t`<span class="tc-muted"> (hidden)</span>`:n}
|
|
41
|
+
</div>
|
|
42
|
+
${this.can.update?t`
|
|
43
|
+
<div>
|
|
44
|
+
${a.editable?t`<button class="btn" title="Edit" @click=${()=>this.openLinkEditor(e,a)}><k-icon name="edit"></k-icon></button>`:n}
|
|
45
|
+
<button class="btn" title=${a.disabled?"Show on the site":"Hide from the site"} @click=${()=>this.handleToggle(a)}><k-icon name=${a.disabled?"show":"hide"}></k-icon></button>
|
|
46
|
+
${this.can.delete?t`<button class="btn danger" title="Delete" @click=${()=>this.handleDeleteItem(a)}><k-icon name="delete"></k-icon></button>`:n}
|
|
47
|
+
</div>
|
|
48
|
+
`:n}
|
|
49
|
+
</div>
|
|
50
|
+
`;return this.can.update?t`<k-sortable-item data-id=${a.id}>${s}</k-sortable-item>`:t`<div class="b p mbq">${s}</div>`}renderPlace(e){const s=this.menuAt(e),i=this.otherPlaces(e);return t`
|
|
51
|
+
<div class="mb">
|
|
52
|
+
<div class="d-f mb" style="align-items: center; gap: var(--spacer_h);">
|
|
53
|
+
<div class="flex">
|
|
54
|
+
<h2 class="mb0">${e.label}</h2>
|
|
55
|
+
${e.declared?n:t`<span class="tc-danger">The theme no longer has this place (<strong>${e.location}</strong>), so nothing shows there.</span>`}
|
|
56
|
+
${i.length?t`<span class="tc-muted">Shows the same links as ${i.map(e=>e.label).join(", ")}.</span>`:n}
|
|
57
|
+
</div>
|
|
58
|
+
${this.can.create&&e.declared?t`<button class="primary" @click=${()=>this.openLinkEditor(e)}><k-icon name="add"></k-icon> Add item</button>`:n}
|
|
59
|
+
</div>
|
|
60
|
+
${s?.items.length?t`
|
|
61
|
+
<k-sortable data-menu=${s.id} @sort=${this.handleSort}>
|
|
62
|
+
${s.items.map(t=>this.renderLink(e,t))}
|
|
63
|
+
</k-sortable>
|
|
64
|
+
`:n}
|
|
65
|
+
${e.extensionItems.map(e=>t`
|
|
66
|
+
<div class="b p mbq"><strong>${e.label}</strong> <span class="tc-muted">${e.href} · added by ${e.owner}</span></div>
|
|
67
|
+
`)}
|
|
68
|
+
${e.candidates.map(a=>t`
|
|
69
|
+
<div class="b p mbq d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
70
|
+
<div class="flex"><strong>${a.label}</strong> <span class="tc-muted">${a.href} · added as global content</span></div>
|
|
71
|
+
${this.can.update?t`<button class="btn" @click=${()=>this.handleAdopt(e,a)}>Manage here</button>`:n}
|
|
72
|
+
</div>
|
|
73
|
+
`)}
|
|
74
|
+
${s?.items.length||e.extensionItems.length||e.candidates.length?n:t`<p class="tc-muted">Nothing here yet.</p>`}
|
|
75
|
+
${this.can.update&&this.menus.length>1?t`
|
|
76
|
+
<label class="d-b mtq tc-muted">Show the links of another place here
|
|
77
|
+
<select @change=${t=>this.handleMenuChange(e,t)}>
|
|
78
|
+
<option value="" .selected=${a(!e.menuId)}>This place's own links</option>
|
|
79
|
+
${this.menus.map(n=>t`<option value=${n.id} .selected=${a(e.menuId===n.id)}>${n.name}</option>`)}
|
|
80
|
+
</select>
|
|
81
|
+
</label>
|
|
82
|
+
`:n}
|
|
83
|
+
</div>
|
|
84
|
+
`}renderUnused(){const e=this.menus.filter(e=>!e.locations.length);return e.length?t`
|
|
85
|
+
<h2>Not shown anywhere</h2>
|
|
86
|
+
<p class="tc-muted">These menus are not shown in any place. Pick one in a place's dropdown above to use it again, or delete it.</p>
|
|
87
|
+
${e.map(e=>t`
|
|
88
|
+
<div class="b p mbq d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
89
|
+
<div class="flex"><strong>${e.name}</strong> <span class="tc-muted">${e.items.length} item${1===e.items.length?"":"s"}</span></div>
|
|
90
|
+
${this.can.delete?t`<button class="btn danger" title="Delete menu" @click=${()=>this.handleDeleteMenu(e)}><k-icon name="delete"></k-icon></button>`:n}
|
|
91
|
+
</div>
|
|
92
|
+
`)}
|
|
93
|
+
`:n}render(){return this.loading?t`<p>Loading...</p>`:this.failed?t`<h1>Menus</h1><p class="tc-danger">The menus could not be loaded.</p>`:t`
|
|
94
|
+
<h1>Menus</h1>
|
|
95
|
+
${this.locations.length?t`
|
|
96
|
+
${this.locations.map(e=>this.renderPlace(e))}
|
|
97
|
+
${this.renderUnused()}
|
|
98
|
+
`:t`
|
|
99
|
+
<p>Your theme does not have a place for a menu yet.</p>
|
|
100
|
+
<p class="tc-muted">Mark a place in your navigation, for example <code><location name="main-nav-links" label="Main menu" menu="true" /></code> in your navigation fragment, and it will be listed here.</p>
|
|
101
|
+
`}
|
|
102
|
+
`}}customElements.define("admin-menus",b);
|
|
103
|
+
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\admin\components\Menus.js.map
|