@crombolo/backoffice-host 0.13.1 → 0.14.1

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.
Files changed (53) hide show
  1. package/assets/login.js +89 -2
  2. package/package.json +2 -2
  3. package/plugin/assistant/umbraco-package.json +1 -1
  4. package/plugin/branding/branding.js +9 -14
  5. package/plugin/branding/bundle-builder-labels.js +30 -1
  6. package/plugin/branding/localization-en.js +8 -0
  7. package/plugin/branding/sign-in.js +31 -0
  8. package/plugin/branding/theme.css +2 -0
  9. package/plugin/bundles-created-overview.js +31 -18
  10. package/plugin/bundles-installed.js +127 -61
  11. package/plugin/bundles-marketplace.js +1 -1
  12. package/plugin/changes-dashboard.js +8 -1
  13. package/plugin/download.js +19 -0
  14. package/plugin/form-picker.js +1 -1
  15. package/plugin/forms-client.js +5 -0
  16. package/plugin/forms-entries.js +30 -16
  17. package/plugin/forms-overview.js +55 -33
  18. package/plugin/localizations/ar.js +3 -39
  19. package/plugin/localizations/bs.js +3 -29
  20. package/plugin/localizations/cs.js +3 -24
  21. package/plugin/localizations/cy.js +3 -32
  22. package/plugin/localizations/da.js +3 -41
  23. package/plugin/localizations/de.js +3 -27
  24. package/plugin/localizations/es.js +3 -19
  25. package/plugin/localizations/fr.js +3 -26
  26. package/plugin/localizations/hr.js +3 -29
  27. package/plugin/localizations/it.js +3 -29
  28. package/plugin/localizations/ja.js +3 -11
  29. package/plugin/localizations/ko.js +3 -11
  30. package/plugin/localizations/nb.js +3 -18
  31. package/plugin/localizations/nl.js +3 -29
  32. package/plugin/localizations/pl.js +3 -11
  33. package/plugin/localizations/pt-br.js +0 -10
  34. package/plugin/localizations/pt.js +3 -39
  35. package/plugin/localizations/ru.js +3 -9
  36. package/plugin/localizations/sv.js +3 -20
  37. package/plugin/localizations/tr.js +3 -26
  38. package/plugin/localizations/uk.js +3 -9
  39. package/plugin/localizations/umbraco-package.json +22 -2
  40. package/plugin/localizations/vi.js +3 -42
  41. package/plugin/localizations/zh-tw.js +0 -6
  42. package/plugin/localizations/zh.js +0 -6
  43. package/plugin/seo/umbraco-package.json +1 -1
  44. package/plugin/sign-in/entry.js +20 -0
  45. package/plugin/sign-in/sign-in-dialog.js +127 -0
  46. package/plugin/sign-in/umbraco-package.json +15 -0
  47. package/plugin/two-factor-setup.js +147 -0
  48. package/plugin/umbraco-package.json +30 -30
  49. package/plugin/welcome-dashboard.js +2 -5
  50. package/plugin/workflow-designer.js +298 -187
  51. package/plugin/workflow-requests.js +19 -6
  52. package/src/shell.ts +4 -1
  53. package/plugin/branding/sysinfo.js +0 -89
package/assets/login.js CHANGED
@@ -39,6 +39,8 @@ class CromboloLoginElement extends LitElement {
39
39
  logoImage: { type: String, attribute: 'logo-image-alternative' },
40
40
  usernameIsEmail: { type: Boolean, attribute: 'username-is-email' },
41
41
  allowPasswordReset: { type: Boolean, attribute: 'allow-password-reset' },
42
+ disableLocalLogin: { type: Boolean, attribute: 'disable-local-login' },
43
+ externalProviders: { type: Array, attribute: 'external-providers' },
42
44
  _busy: { state: true },
43
45
  _error: { state: true },
44
46
  _view: { state: true },
@@ -51,6 +53,8 @@ class CromboloLoginElement extends LitElement {
51
53
  this.logoImage = ''
52
54
  this.usernameIsEmail = true
53
55
  this.allowPasswordReset = false
56
+ this.disableLocalLogin = false
57
+ this.externalProviders = []
54
58
  this._busy = false
55
59
  this._error = ''
56
60
  this._notice = ''
@@ -63,6 +67,9 @@ class CromboloLoginElement extends LitElement {
63
67
  ? { kind: 'reset', user: params.get('userId'), code: params.get('resetCode') }
64
68
  : undefined
65
69
  this._view = this._link ? 'checking' : 'login'
70
+ // An identity provider's sign-in that came back refused says why.
71
+ const external = params.get('externalLoginError')
72
+ if (external) this._error = external
66
73
  }
67
74
 
68
75
  connectedCallback() {
@@ -182,7 +189,9 @@ class CromboloLoginElement extends LitElement {
182
189
  return
183
190
  }
184
191
  if (response.status === 402) {
185
- this._error = 'This account requires two-factor authentication, which is not yet supported.'
192
+ const challenge = await response.json().catch(() => ({}))
193
+ this._twoFactorProvider = challenge.enabledTwoFactorProviderNames?.[0]
194
+ this._view = 'two-factor'
186
195
  } else if (response.status === 403) {
187
196
  this._error = 'This account is locked or not approved.'
188
197
  } else if (response.status === 401) {
@@ -197,6 +206,57 @@ class CromboloLoginElement extends LitElement {
197
206
  }
198
207
  }
199
208
 
209
+ async #verifyTwoFactor(event) {
210
+ event.preventDefault()
211
+ if (this._busy) return
212
+ const code = event.target.elements.code.value.trim()
213
+ if (!code) return
214
+ this._busy = true
215
+ this._error = ''
216
+ try {
217
+ const response = await this.#post('/security/back-office/verify-2fa', {
218
+ provider: this._twoFactorProvider,
219
+ code,
220
+ })
221
+ if (response.ok) {
222
+ window.location.href = localTarget(this.returnUrl, '/umbraco')
223
+ return
224
+ }
225
+ if (response.status === 401) {
226
+ this._view = 'login'
227
+ this._error = 'That took too long. Sign in again.'
228
+ } else if (response.status === 403) {
229
+ this._view = 'login'
230
+ this._error = 'This account is locked or not approved.'
231
+ } else {
232
+ this._error = 'That code is not right. Try the next one.'
233
+ }
234
+ } catch {
235
+ this._error = 'Could not reach the server.'
236
+ } finally {
237
+ this._busy = false
238
+ }
239
+ }
240
+
241
+ #twoFactorForm() {
242
+ return html`
243
+ <h1 class="uui-h3">Enter your code</h1>
244
+ <p>Enter the six-digit code from your authenticator app, or one of your recovery codes.</p>
245
+ <form @submit=${this.#verifyTwoFactor}>
246
+ <uui-form-layout-item>
247
+ <uui-label for="code" slot="label" required>Code</uui-label>
248
+ <uui-input id="code" name="code" autocomplete="one-time-code" required ?disabled=${this._busy}></uui-input>
249
+ </uui-form-layout-item>
250
+ ${this.#messages()}
251
+ <uui-button type="submit" look="primary" color="positive" .state=${this._busy ? 'waiting' : undefined} label="Verify"></uui-button>
252
+ <uui-button look="link" label="Back to sign in" @click=${() => {
253
+ this._error = ''
254
+ this._view = 'login'
255
+ }}></uui-button>
256
+ </form>
257
+ `
258
+ }
259
+
200
260
  #messages() {
201
261
  return html`${this._notice ? html`<p id="notice" role="status">${this._notice}</p>` : nothing}
202
262
  ${this._error ? html`<p id="error" role="alert">${this._error}</p>` : nothing}`
@@ -245,7 +305,9 @@ class CromboloLoginElement extends LitElement {
245
305
  ? this.#newPasswordForm()
246
306
  : this._view === 'forgot'
247
307
  ? this.#resetRequestForm()
248
- : this.#loginForm()
308
+ : this._view === 'two-factor'
309
+ ? this.#twoFactorForm()
310
+ : this.#loginForm()
249
311
  return html`
250
312
  <div id="layout">
251
313
  <uui-box id="card">
@@ -256,7 +318,25 @@ class CromboloLoginElement extends LitElement {
256
318
  `
257
319
  }
258
320
 
321
+ /** A button per backoffice identity provider, each returning here signed in. */
322
+ #providers() {
323
+ if (!this.externalProviders?.length) return nothing
324
+ return html`<div id="providers">
325
+ ${this.externalProviders.map(
326
+ (provider) =>
327
+ html`<uui-button look="outline" href=${provider.url} label=${`Sign in with ${provider.name}`}>Sign in with ${provider.name}</uui-button>`,
328
+ )}
329
+ </div>`
330
+ }
331
+
259
332
  #loginForm() {
333
+ // With local sign-in off, the providers are the only way in.
334
+ if (this.disableLocalLogin)
335
+ return html`
336
+ <h1 class="uui-h3">Sign in</h1>
337
+ ${this.#messages()}
338
+ ${this.#providers()}
339
+ `
260
340
  return html`
261
341
  <h1 class="uui-h3">Sign in</h1>
262
342
  <form @submit=${this.#submit}>
@@ -301,6 +381,7 @@ class CromboloLoginElement extends LitElement {
301
381
  : nothing
302
382
  }
303
383
  </form>
384
+ ${this.#providers()}
304
385
  `
305
386
  }
306
387
 
@@ -342,6 +423,12 @@ class CromboloLoginElement extends LitElement {
342
423
  margin: 0;
343
424
  color: var(--uui-color-danger, #d42054);
344
425
  }
426
+ #providers {
427
+ display: flex;
428
+ flex-direction: column;
429
+ gap: var(--uui-size-space-3, 9px);
430
+ margin-top: var(--uui-size-space-5, 18px);
431
+ }
345
432
  #notice {
346
433
  margin: 0;
347
434
  color: var(--uui-color-positive, #0b8152);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/backoffice-host",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Serves the Umbraco backoffice SPA: shell HTML, import map and package manifests.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@crombolo/core": "0.13.1",
34
+ "@crombolo/core": "0.14.1",
35
35
  "@crombolo/backoffice-dist": "18.2.0"
36
36
  }
37
37
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Assistant",
4
4
  "id": "Crombolo.Assistant",
5
- "version": "0.13.1",
5
+ "version": "0.14.1",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -1,25 +1,20 @@
1
1
  /**
2
2
  * The two places the vendored client names the product in code rather than in a
3
- * string table, where the localisation override cannot reach.
3
+ * string table.
4
4
  *
5
- * `view.controller.js` builds the page title with a hardcoded string rather than
6
- * a localisation key, so the dictionary override cannot reach it. Patching the
7
- * vendored file would work until the next `vendor:backoffice`, which is the
8
- * reason nothing here patches vendored files.
9
- *
10
- * So it is corrected as it is written. The suffix is the site's own name, which
11
- * the shell states in `<meta name="application-name">` — so a site called
12
- * something other than Crombolo gets its own name rather than a second hardcoded
13
- * one.
5
+ * `view.controller.js` builds the page title with a hardcoded suffix, which
6
+ * `vendor:backoffice` rebrands to Crombolo. The suffix should be the site's own
7
+ * name, which the shell states in `<meta name="application-name">` — so a site
8
+ * called something other than Crombolo gets its own name rather than ours.
14
9
  *
15
10
  * Not read from `document.title`, although the shell puts the name there too.
16
11
  * This module loads after the client has started, and by then the client may
17
- * have written a title of its own. Taking `Content | Umbraco` for the site's
18
- * name makes every correction end in ` | Umbraco` again: the observer below
12
+ * have written a title of its own. Taking `Content | Crombolo` for the site's
13
+ * name makes every correction end in ` | Crombolo` again: the observer below
19
14
  * then answers its own change, forever, and the page never gets its main thread
20
15
  * back.
21
16
  */
22
- const CLIENT_SUFFIX = ' | Umbraco'
17
+ const CLIENT_SUFFIX = ' | Crombolo'
23
18
 
24
19
  /** The site's name as the shell stated it. */
25
20
  export function brandName(doc = document) {
@@ -70,7 +65,7 @@ function correctLogoLink() {
70
65
  if (!anchor) return false
71
66
  anchor.href = 'https://github.com/kineco-au/crombolo-cms'
72
67
  anchor.textContent = 'Crombolo on GitHub'
73
- const image = logo.shadowRoot.querySelector('img[alt="Umbraco"]')
68
+ const image = logo.shadowRoot.querySelector('img#logo')
74
69
  if (image) image.alt = brandName()
75
70
  return true
76
71
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The two labels Umbraco's bundle builder hard-codes.
2
+ * The two labels and two toasts Umbraco's bundle builder hard-codes.
3
3
  *
4
4
  * The builder workspace is vendored and its copy is English in the template
5
5
  * rather than dictionary keys, so there is no key to override: `Name of the
@@ -21,6 +21,16 @@ export const BUILDER_LABELS = [
21
21
  ['Package Content', 'Bundle content'],
22
22
  ]
23
23
 
24
+ /**
25
+ * The builder's toasts on saving a definition. Shown through the notification
26
+ * context, so they are corrected where every toast passes rather than in the
27
+ * element, whose `peek` call is its own.
28
+ */
29
+ export const BUILDER_TOASTS = [
30
+ ['Package saved', 'Bundle saved'],
31
+ ['Package updated', 'Bundle saved'],
32
+ ]
33
+
24
34
  /** The replacement for a vendored string, or undefined when it is not one of ours. */
25
35
  export function correctionFor(text) {
26
36
  if (typeof text !== 'string') return undefined
@@ -55,7 +65,26 @@ function patch(element) {
55
65
  return true
56
66
  }
57
67
 
68
+ // Loaded here rather than at the top, so the module's pure parts import
69
+ // anywhere — the tests read `BUILDER_LABELS` and `BUILDER_TOASTS` outside a browser.
70
+ async function correctToasts() {
71
+ const { UmbNotificationContext } = await import('@umbraco-cms/backoffice/notification')
72
+ const proto = UmbNotificationContext.prototype
73
+ const peek = proto.peek
74
+ if (typeof peek !== 'function') return
75
+ proto.peek = function correctedPeek(color, options) {
76
+ const message = options?.data?.message
77
+ const replacement = BUILDER_TOASTS.find(([before]) => before === message)?.[1]
78
+ return peek.call(
79
+ this,
80
+ color,
81
+ replacement ? { ...options, data: { ...options.data, message: replacement } } : options,
82
+ )
83
+ }
84
+ }
85
+
58
86
  export const onInit = () => {
87
+ correctToasts().catch(() => {})
59
88
  customElements
60
89
  .whenDefined('umb-workspace-package-builder')
61
90
  .then(patch)
@@ -106,6 +106,14 @@ export default {
106
106
  searchUmbracoIssues: 'Search Crombolo issues',
107
107
  searchUmbracoIssuesOnGithub: 'Search Crombolo issues on GitHub',
108
108
  },
109
+ redirectUrls: {
110
+ // Followed by `CROMBOLO_TRACK_REDIRECTS`, which `vendor:backoffice` puts in
111
+ // place of the .NET setting; it tracks when true, the inverse of Umbraco's.
112
+ disableUrlTrackerInstruction:
113
+ "Redirect URL tracking is set in the site's environment. To turn it off, set this variable to false:",
114
+ enableUrlTrackerInstruction:
115
+ "Redirect URL tracking is set in the site's environment. To turn it on, set this variable to true, or remove it:",
116
+ },
109
117
  defaultdialogs: {
110
118
  linkYourConfirm:
111
119
  'You are about to link your Crombolo and {0} accounts and you will be redirected to {0} to complete the process.',
@@ -66,7 +66,38 @@ class CromboloAuthProviderElement extends LitElement {
66
66
 
67
67
  if (!customElements.get(TAG)) customElements.define(TAG, CromboloAuthProviderElement)
68
68
 
69
+ /**
70
+ * Signing in again after a timeout closes its modal twice.
71
+ *
72
+ * `umb-app-auth-modal` submits when the session reports itself authorised, and
73
+ * `umb-auth-view` calls the same submit as its `onSuccess` once
74
+ * `makeAuthorizationRequest` returns. Whichever runs first closes the modal and
75
+ * destroys its value state, so the second's `value = { success: true }` throws
76
+ * `_subject is undefined` — and the view shows that as a sign-in error, though
77
+ * the editor is signed in. The second assignment has nothing left to say, so it
78
+ * is dropped. Patched on the element's prototype, as `tsx-editors.js` patches
79
+ * upstream, rather than in the vendored bundle.
80
+ */
81
+ async function guardTimedOutSignIn() {
82
+ const Modal = await customElements.whenDefined('umb-app-auth-modal')
83
+ let descriptor
84
+ for (let proto = Modal.prototype; proto && !descriptor; proto = Object.getPrototypeOf(proto))
85
+ descriptor = Object.getOwnPropertyDescriptor(proto, 'value')
86
+ if (!descriptor?.set) return
87
+ const { get, set } = descriptor
88
+ Object.defineProperty(Modal.prototype, 'value', {
89
+ configurable: true,
90
+ get,
91
+ set(value) {
92
+ if (this.__cromboloSubmitted) return
93
+ set.call(this, value)
94
+ this.__cromboloSubmitted = true
95
+ },
96
+ })
97
+ }
98
+
69
99
  export const onInit = (_host, extensionRegistry) => {
100
+ void guardTimedOutSignIn()
70
101
  extensionRegistry.unregister(VENDORED)
71
102
  extensionRegistry.register({
72
103
  type: 'authProvider',
@@ -68,6 +68,8 @@
68
68
  --uui-color-header-surface: var(--crombolo-orange);
69
69
  --uui-color-header-contrast: rgb(84 50 31 / 80%);
70
70
  --uui-color-header-contrast-emphasis: var(--crombolo-cocoa);
71
+ /* The active section's marker, dark so it stands out from the white submenu below. */
72
+ --crombolo-header-current: var(--crombolo-orange-text);
71
73
 
72
74
  /* Pure white panels on a warm off-white page, so cards still separate. */
73
75
  --uui-color-background: var(--crombolo-cream);
@@ -101,7 +101,7 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
101
101
  version 1.0.0.
102
102
  </p>
103
103
  <uui-input
104
- label="Package name"
104
+ label="Name"
105
105
  placeholder=${pkg?.name ?? ''}
106
106
  .value=${this._draft.name}
107
107
  @input=${(event) => (this._draft = { ...this._draft, name: event.target.value })}></uui-input>
@@ -166,38 +166,47 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
166
166
  // look empty on every page change.
167
167
  return html`
168
168
  <uui-button
169
- look="primary"
169
+ color="default"
170
+ look="outline"
170
171
  href=${`${BUILDER_PATH}/create`}
171
- label=${this.localize.term('packager_createPackage')}></uui-button>
172
+ label="Create"></uui-button>
172
173
  ${
173
174
  this._loading
174
175
  ? html`<div id="loader"><uui-loader></uui-loader></div>`
175
176
  : this._items.length === 0
176
177
  ? html`<h2 id="empty">${this.localize.term('packager_noPackagesCreated')}</h2>`
177
178
  : html`
178
- <uui-box headline="Created bundles" style="--uui-box-default-padding:0;">
179
- <uui-ref-list>
179
+ <uui-box headline="Created bundles">
180
+ <uui-table>
181
+ <uui-table-head>
182
+ <uui-table-head-cell>Name</uui-table-head-cell>
183
+ <uui-table-head-cell>Downloads as</uui-table-head-cell>
184
+ <uui-table-head-cell></uui-table-head-cell>
185
+ </uui-table-head>
180
186
  ${repeat(
181
187
  this._items,
182
188
  (item) => item.unique,
183
189
  (item) => html`
184
- <uui-ref-node
185
- name=${item.name}
186
- detail=${this.#detail(item)}
187
- @open=${() => this.#open(item)}>
188
- <uui-action-bar slot="actions">
190
+ <uui-table-row>
191
+ <uui-table-cell>${item.name}</uui-table-cell>
192
+ <uui-table-cell><code>${this.#detail(item)}</code></uui-table-cell>
193
+ <uui-table-cell class="buttons">
194
+ <uui-button look="secondary" label="Edit" @click=${() => this.#open(item)}></uui-button>
189
195
  <uui-button
190
- @click=${() => this.#edit(item)}
191
- label="Package details"></uui-button>
196
+ look="secondary"
197
+ label="Name and version"
198
+ @click=${() => this.#edit(item)}></uui-button>
192
199
  <uui-button
193
- @click=${() => this.#remove(item)}
194
- label=${this.localize.term('general_delete')}></uui-button>
195
- </uui-action-bar>
196
- </uui-ref-node>
197
- ${this.#renderEditor(item)}
200
+ look="secondary"
201
+ color="danger"
202
+ label=${this.localize.term('general_delete')}
203
+ @click=${() => this.#remove(item)}></uui-button>
204
+ </uui-table-cell>
205
+ </uui-table-row>
198
206
  `,
199
207
  )}
200
- </uui-ref-list>
208
+ </uui-table>
209
+ ${this._items.map((item) => this.#renderEditor(item))}
201
210
  </uui-box>
202
211
  `
203
212
  }
@@ -238,6 +247,10 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
238
247
  .problem {
239
248
  color: var(--uui-color-danger);
240
249
  }
250
+ .buttons {
251
+ white-space: nowrap;
252
+ text-align: right;
253
+ }
241
254
  `
242
255
  }
243
256
 
@@ -10,6 +10,9 @@
10
10
  * What it lists instead is the site's bundle dependencies, which is what
11
11
  * "installed" means here: what each one adds, whether its content has been
12
12
  * added to this site, and whether its server features are switched on.
13
+ *
14
+ * Two columns: the bundles on the left, filtered as you type; the one chosen
15
+ * on the right, with its details and the button that uninstalls it.
13
16
  */
14
17
  import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
15
18
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
@@ -41,6 +44,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
41
44
  _busy: { state: true },
42
45
  _notice: { state: true },
43
46
  _installs: { state: true },
47
+ _filter: { state: true },
48
+ _selected: { state: true },
44
49
  }
45
50
 
46
51
  constructor() {
@@ -50,6 +55,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
50
55
  this._busy = ''
51
56
  this._notice = ''
52
57
  this._installs = true
58
+ this._filter = ''
59
+ this._selected = ''
53
60
  }
54
61
 
55
62
  connectedCallback() {
@@ -63,6 +70,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
63
70
  const result = await listInstalled()
64
71
  this._items = result.items ?? []
65
72
  this._installs = result.installs !== false
73
+ if (!this._items.some((item) => item.packageName === this._selected))
74
+ this._selected = this._items[0]?.packageName ?? ''
66
75
  } catch (error) {
67
76
  this._notice = error instanceof Error ? error.message : String(error)
68
77
  } finally {
@@ -83,72 +92,98 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
83
92
  }
84
93
  }
85
94
 
95
+ #pick(item) {
96
+ this._selected = item.packageName
97
+ this.requestUpdate()
98
+ }
99
+
100
+ get #shown() {
101
+ const words = this._filter.trim().toLowerCase()
102
+ if (!words) return this._items
103
+ return this._items.filter((item) =>
104
+ [item.name, item.packageName, item.description].some((text) =>
105
+ text?.toLowerCase().includes(words),
106
+ ),
107
+ )
108
+ }
109
+
86
110
  render() {
87
111
  if (this._loading) return html`<div id="loader"><uui-loader></uui-loader></div>`
88
-
112
+ if (this._items.length === 0)
113
+ return html`
114
+ ${this._notice ? html`<div id="notice">${this._notice}</div>` : nothing}
115
+ <uui-box headline="No bundles installed">
116
+ <p>Install one from the Marketplace tab, and it appears here.</p>
117
+ </uui-box>
118
+ `
119
+ const selected = this._items.find((item) => item.packageName === this._selected)
120
+ const shown = this.#shown
89
121
  return html`
90
122
  ${this._notice ? html`<div id="notice">${this._notice}</div>` : nothing}
123
+ <div id="columns">
124
+ <uui-box headline="Installed bundles" style="--uui-box-default-padding:0;">
125
+ <div id="filter">
126
+ <uui-input
127
+ label="Filter"
128
+ placeholder="Filter"
129
+ .value=${this._filter}
130
+ @input=${(event) => {
131
+ this._filter = event.target.value
132
+ }}>
133
+ <uui-icon name="icon-search" slot="prepend"></uui-icon>
134
+ </uui-input>
135
+ </div>
136
+ ${
137
+ shown.length === 0
138
+ ? html`<p class="empty">No bundle matches.</p>`
139
+ : html`<uui-ref-list>
140
+ ${repeat(
141
+ shown,
142
+ (item) => item.packageName,
143
+ (item) => html`<uui-ref-node
144
+ name=${item.name}
145
+ detail=${`${item.packageName} ${item.version}`}
146
+ selectable
147
+ select-only
148
+ ?selected=${item.packageName === this._selected}
149
+ @selected=${() => this.#pick(item)}
150
+ @deselected=${() => this.#pick(item)}
151
+ @open=${() => this.#pick(item)}></uui-ref-node>`,
152
+ )}
153
+ </uui-ref-list>`
154
+ }
155
+ </uui-box>
156
+ ${selected ? this.#details(selected) : nothing}
157
+ </div>
158
+ `
159
+ }
160
+
161
+ #details(item) {
162
+ return html`<uui-box headline=${item.name}>
163
+ ${item.description ? html`<p>${item.description}</p>` : nothing}
164
+ <dl>
165
+ <dt>npm package</dt>
166
+ <dd><code>${item.packageName}</code></dd>
167
+ <dt>Version</dt>
168
+ <dd>${item.version}</dd>
169
+ <dt>Adds</dt>
170
+ <dd class="carries">${carriesTags(item.carries)}</dd>
171
+ <dt>On this site</dt>
172
+ <dd class="status">${status(item)}</dd>
173
+ <dt>Works with</dt>
174
+ <dd>Crombolo ${item.peerRange}</dd>
175
+ </dl>
91
176
  ${
92
- this._items.length === 0
93
- ? html`
94
- <uui-box headline="No bundles installed">
95
- <p>
96
- A bundle is one of this site's npm dependencies. Install one from the
97
- Marketplace tab, or with <code>bun add</code> in the site directory, and it
98
- appears here.
99
- </p>
100
- </uui-box>
101
- `
102
- : html`
103
- <uui-box headline="Installed bundles">
104
- <uui-table>
105
- <uui-table-head>
106
- <uui-table-head-cell>Name</uui-table-head-cell>
107
- <uui-table-head-cell>npm package</uui-table-head-cell>
108
- <uui-table-head-cell>Version</uui-table-head-cell>
109
- <uui-table-head-cell>Adds</uui-table-head-cell>
110
- <uui-table-head-cell>On this site</uui-table-head-cell>
111
- <uui-table-head-cell></uui-table-head-cell>
112
- </uui-table-head>
113
- ${repeat(
114
- this._items,
115
- (item) => item.packageName,
116
- (item) => html`
117
- <uui-table-row>
118
- <uui-table-cell>${item.name}</uui-table-cell>
119
- <uui-table-cell><code>${item.packageName}</code></uui-table-cell>
120
- <uui-table-cell>${item.version}</uui-table-cell>
121
- <uui-table-cell class="carries">${carriesTags(item.carries)}</uui-table-cell>
122
- <uui-table-cell class="status">${status(item)}</uui-table-cell>
123
- <uui-table-cell>
124
- ${
125
- this._installs
126
- ? html`<uui-button
127
- look="secondary"
128
- color="danger"
129
- label="Uninstall"
130
- .state=${this._busy === item.packageName ? 'waiting' : undefined}
131
- @click=${() => this.#uninstall(item)}></uui-button>`
132
- : nothing
133
- }
134
- </uui-table-cell>
135
- </uui-table-row>
136
- `,
137
- )}
138
- </uui-table>
139
- <p class="note">
140
- ${
141
- this._installs
142
- ? html`An install writes this site's <code>package.json</code> and
143
- <code>bun.lock</code>. Commit both to keep it across deployments.`
144
- : html`Packages are added and removed by changing this site's
145
- <code>package.json</code> and deploying, so every server has the same ones.`
146
- }
147
- </p>
148
- </uui-box>
149
- `
177
+ this._installs
178
+ ? html`<uui-button
179
+ look="primary"
180
+ color="danger"
181
+ label="Uninstall"
182
+ .state=${this._busy === item.packageName ? 'waiting' : undefined}
183
+ @click=${() => this.#uninstall(item)}></uui-button>`
184
+ : html`<p class="note">This site adds and removes bundles with a deploy, so every server has the same ones.</p>`
150
185
  }
151
- `
186
+ </uui-box>`
152
187
  }
153
188
 
154
189
  static styles = css`
@@ -167,10 +202,42 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
167
202
  border-left: 3px solid var(--uui-color-positive, #2bc37c);
168
203
  background: var(--uui-color-surface-alt, #f3f3f5);
169
204
  }
205
+ #columns {
206
+ display: grid;
207
+ grid-template-columns: minmax(240px, 1fr) 2fr;
208
+ gap: var(--uui-size-layout-1, 24px);
209
+ align-items: start;
210
+ }
211
+ @media (max-width: 800px) {
212
+ #columns {
213
+ grid-template-columns: 1fr;
214
+ }
215
+ }
216
+ #filter {
217
+ padding: var(--uui-size-space-4, 12px);
218
+ }
219
+ #filter uui-input {
220
+ width: 100%;
221
+ }
222
+ .empty {
223
+ padding: 0 var(--uui-size-space-4, 12px) var(--uui-size-space-4, 12px);
224
+ }
170
225
  p {
171
226
  max-width: 60ch;
172
227
  line-height: 1.5;
173
228
  }
229
+ dl {
230
+ display: grid;
231
+ grid-template-columns: max-content 1fr;
232
+ gap: var(--uui-size-space-3, 9px) var(--uui-size-space-5, 18px);
233
+ margin: 0 0 var(--uui-size-space-5, 18px);
234
+ }
235
+ dt {
236
+ font-weight: 700;
237
+ }
238
+ dd {
239
+ margin: 0;
240
+ }
174
241
  .carries {
175
242
  display: flex;
176
243
  gap: var(--uui-size-space-2, 6px);
@@ -179,7 +246,6 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
179
246
  display: block;
180
247
  }
181
248
  .note {
182
- margin-top: var(--uui-size-space-5, 18px);
183
249
  opacity: 0.8;
184
250
  font-size: 0.9em;
185
251
  }