@crombolo/backoffice-host 0.0.0-stage → 0.9.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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/assets/login.js +352 -0
- package/package.json +35 -4
- package/plugin/assistant/client.js +53 -0
- package/plugin/assistant/drawer.js +865 -0
- package/plugin/assistant/umbraco-package.json +16 -0
- package/plugin/branding/backdrop.svg +1 -0
- package/plugin/branding/branding.js +100 -0
- package/plugin/branding/bundle-builder-labels.js +63 -0
- package/plugin/branding/client-credentials.js +49 -0
- package/plugin/branding/components-tree.js +40 -0
- package/plugin/branding/content-type-labels.js +27 -0
- package/plugin/branding/favicon.svg +1 -0
- package/plugin/branding/help-menu.js +46 -0
- package/plugin/branding/icon-mark.js +15 -0
- package/plugin/branding/icon-registry.js +40 -0
- package/plugin/branding/localization-en.js +115 -0
- package/plugin/branding/log-viewer-menu.js +70 -0
- package/plugin/branding/logo-header.svg +1 -0
- package/plugin/branding/logo-surface.svg +1 -0
- package/plugin/branding/query-builder.js +28 -0
- package/plugin/branding/sign-in.js +80 -0
- package/plugin/branding/sysinfo.js +89 -0
- package/plugin/branding/theme.css +102 -0
- package/plugin/bundles-client.js +34 -0
- package/plugin/bundles-created-overview.js +150 -0
- package/plugin/bundles-created.js +70 -0
- package/plugin/bundles-installed.js +147 -0
- package/plugin/bundles-marketplace.js +199 -0
- package/plugin/changes-dashboard.js +132 -0
- package/plugin/element-type-hint.js +58 -0
- package/plugin/form-picker.js +116 -0
- package/plugin/forms-client.js +52 -0
- package/plugin/forms-entries.js +244 -0
- package/plugin/forms-overview.js +380 -0
- package/plugin/localizations/ar.js +97 -0
- package/plugin/localizations/bs.js +86 -0
- package/plugin/localizations/cs.js +73 -0
- package/plugin/localizations/cy.js +92 -0
- package/plugin/localizations/da.js +101 -0
- package/plugin/localizations/de.js +79 -0
- package/plugin/localizations/en.js +71 -0
- package/plugin/localizations/es.js +52 -0
- package/plugin/localizations/fr.js +78 -0
- package/plugin/localizations/he.js +24 -0
- package/plugin/localizations/hr.js +89 -0
- package/plugin/localizations/it.js +86 -0
- package/plugin/localizations/ja.js +43 -0
- package/plugin/localizations/ko.js +24 -0
- package/plugin/localizations/nb.js +44 -0
- package/plugin/localizations/nl.js +85 -0
- package/plugin/localizations/pl.js +44 -0
- package/plugin/localizations/pt-br.js +25 -0
- package/plugin/localizations/pt.js +100 -0
- package/plugin/localizations/ro.js +9 -0
- package/plugin/localizations/ru.js +46 -0
- package/plugin/localizations/sv.js +46 -0
- package/plugin/localizations/tr.js +80 -0
- package/plugin/localizations/uk.js +46 -0
- package/plugin/localizations/umbraco-package.json +279 -0
- package/plugin/localizations/vi.js +104 -0
- package/plugin/localizations/zh-tw.js +38 -0
- package/plugin/localizations/zh.js +38 -0
- package/plugin/read-only-banner.js +48 -0
- package/plugin/report-client.js +13 -0
- package/plugin/seo/client.js +35 -0
- package/plugin/seo/dashboard.js +242 -0
- package/plugin/seo/document-view.js +161 -0
- package/plugin/seo/type-hint.js +45 -0
- package/plugin/seo/umbraco-package.json +61 -0
- package/plugin/settings-dashboard.js +123 -0
- package/plugin/tsx-editors.js +337 -0
- package/plugin/tsx-snippets.js +63 -0
- package/plugin/umbraco-package.json +298 -0
- package/plugin/welcome-dashboard.js +106 -0
- package/src/branding.ts +59 -0
- package/src/extensions.ts +228 -0
- package/src/graphics.ts +87 -0
- package/src/index.ts +7 -0
- package/src/login-assets.ts +18 -0
- package/src/manifests.ts +154 -0
- package/src/paths.ts +117 -0
- package/src/shell.ts +139 -0
- package/src/static.ts +107 -0
- package/src/text-imports.d.ts +23 -0
- package/README.md +0 -3
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant's review surface: a near-fullscreen workspace with the proposals
|
|
3
|
+
* on the left and the selected one open on the right, editable.
|
|
4
|
+
*
|
|
5
|
+
* It is laid out like an editor because that is the job. A proposal is a change
|
|
6
|
+
* to code or to content, approving it is the only thing that reaches the CMS, and
|
|
7
|
+
* a one-line summary is not enough to approve on. So: pick a change, read it,
|
|
8
|
+
* change it if it is nearly right, then approve it.
|
|
9
|
+
*
|
|
10
|
+
* What the right pane shows follows what the change actually is, rather than one
|
|
11
|
+
* shape for everything: a template is TSX in a code editor, a page is its property
|
|
12
|
+
* values as markup, a type is the prepared request as JSON.
|
|
13
|
+
*
|
|
14
|
+
* Registered as a `headerApp`, whose manifest the server omits entirely when the
|
|
15
|
+
* assistant is not configured.
|
|
16
|
+
*/
|
|
17
|
+
import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
|
|
18
|
+
import { approve, ask, change, changesets, diff, discard, edit, status } from './client.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The language each kind of change is edited in. `tsx` is crombolo's own, added
|
|
22
|
+
* to the code editor by the `tsx-editors` entry point; plain `typescript` would
|
|
23
|
+
* read a view's markup as a stream of syntax errors.
|
|
24
|
+
*/
|
|
25
|
+
const LANGUAGE = {
|
|
26
|
+
template: 'tsx',
|
|
27
|
+
'template-create': 'tsx',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const KIND_LABEL = {
|
|
31
|
+
document: 'Page',
|
|
32
|
+
'document-create': 'New page',
|
|
33
|
+
'document-type': 'Content type',
|
|
34
|
+
'document-type-create': 'New content type',
|
|
35
|
+
'data-type': 'Data type',
|
|
36
|
+
'data-type-create': 'New data type',
|
|
37
|
+
template: 'Template',
|
|
38
|
+
'template-create': 'New template',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default class CromboloAssistantDrawerElement extends LitElement {
|
|
42
|
+
static properties = {
|
|
43
|
+
_open: { state: true },
|
|
44
|
+
_busy: { state: true },
|
|
45
|
+
_turns: { state: true },
|
|
46
|
+
_changes: { state: true },
|
|
47
|
+
_selected: { state: true },
|
|
48
|
+
_detail: { state: true },
|
|
49
|
+
_review: { state: true },
|
|
50
|
+
_dirty: { state: true },
|
|
51
|
+
_status: { state: true },
|
|
52
|
+
_error: { state: true },
|
|
53
|
+
_split: { state: true },
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
constructor() {
|
|
57
|
+
super()
|
|
58
|
+
this._open = false
|
|
59
|
+
this._busy = false
|
|
60
|
+
this._turns = []
|
|
61
|
+
this._changes = []
|
|
62
|
+
this._selected = undefined
|
|
63
|
+
this._detail = undefined
|
|
64
|
+
this._review = undefined
|
|
65
|
+
this._dirty = false
|
|
66
|
+
this._status = undefined
|
|
67
|
+
this._error = undefined
|
|
68
|
+
// Percentage width of the left pane; dragged by the splitter.
|
|
69
|
+
this._split = 32
|
|
70
|
+
this._changesetKey = undefined
|
|
71
|
+
this._history = []
|
|
72
|
+
this._pending = undefined
|
|
73
|
+
this._pendingText = undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A field rather than a method: the window listener needs a stable bound
|
|
78
|
+
* reference to add and remove, and a private method cannot be reassigned.
|
|
79
|
+
*/
|
|
80
|
+
#onKey = (event) => {
|
|
81
|
+
if (event.key === 'Escape' && this._open) this.#toggle()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
disconnectedCallback() {
|
|
85
|
+
super.disconnectedCallback()
|
|
86
|
+
window.removeEventListener('keydown', this.#onKey)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async #toggle() {
|
|
90
|
+
this._open = !this._open
|
|
91
|
+
if (this._open) {
|
|
92
|
+
window.addEventListener('keydown', this.#onKey)
|
|
93
|
+
await Promise.all([this.#refresh(), this.#readStatus()])
|
|
94
|
+
} else {
|
|
95
|
+
window.removeEventListener('keydown', this.#onKey)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read on opening, so a signed-out AWS session is visible before anything is
|
|
101
|
+
* typed rather than as a failed message afterwards.
|
|
102
|
+
*/
|
|
103
|
+
async #readStatus(refresh = false) {
|
|
104
|
+
try {
|
|
105
|
+
this._status = await status(refresh)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this._status = { ok: false, detail: error.message }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Does not clear `_error`: a refused approval reports through it and then calls
|
|
113
|
+
* this, so clearing here would wipe the message before it was read.
|
|
114
|
+
*
|
|
115
|
+
* Anything still awaiting review is listed however old it is, proposals raised
|
|
116
|
+
* over MCP in another process included. Settled items are shown only for this
|
|
117
|
+
* conversation, so the list does not grow for ever.
|
|
118
|
+
*/
|
|
119
|
+
async #refresh() {
|
|
120
|
+
try {
|
|
121
|
+
const sets = await changesets()
|
|
122
|
+
this._changes = sets
|
|
123
|
+
.flatMap((set) =>
|
|
124
|
+
set.changes.map((item) => ({ ...item, origin: set.origin, changeset: set.key })),
|
|
125
|
+
)
|
|
126
|
+
.filter((item) => item.status === 'proposed' || item.changeset === this._changesetKey)
|
|
127
|
+
if (this._selected && !this._changes.some((item) => item.key === this._selected)) {
|
|
128
|
+
await this.#select(undefined)
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this._error = error.message
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async #select(key) {
|
|
136
|
+
this._selected = key
|
|
137
|
+
this._detail = undefined
|
|
138
|
+
this._review = undefined
|
|
139
|
+
this._dirty = false
|
|
140
|
+
this._pending = undefined
|
|
141
|
+
this._pendingText = undefined
|
|
142
|
+
if (!key) return
|
|
143
|
+
try {
|
|
144
|
+
const [detail, review] = await Promise.all([change(key), diff(key).catch(() => undefined)])
|
|
145
|
+
this._detail = detail
|
|
146
|
+
this._review = review
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this._error = error.message
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async #submit(event) {
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
const input = this.renderRoot.querySelector('textarea.prompt')
|
|
155
|
+
const message = input?.value?.trim()
|
|
156
|
+
if (!message || this._busy) return
|
|
157
|
+
input.value = ''
|
|
158
|
+
this._turns = [...this._turns, { role: 'user', text: message }]
|
|
159
|
+
this._busy = true
|
|
160
|
+
this._error = undefined
|
|
161
|
+
try {
|
|
162
|
+
const answer = await ask(message, {
|
|
163
|
+
changesetKey: this._changesetKey,
|
|
164
|
+
history: this._history,
|
|
165
|
+
viewing: this.#viewing(),
|
|
166
|
+
})
|
|
167
|
+
this._changesetKey = answer.changesetKey
|
|
168
|
+
this._history = answer.history ?? []
|
|
169
|
+
this._turns = [...this._turns, { role: 'assistant', text: answer.text }]
|
|
170
|
+
await this.#refresh()
|
|
171
|
+
// Open the first thing it proposed, so the work is in front of you.
|
|
172
|
+
const first = this._changes.find((item) => item.status === 'proposed')
|
|
173
|
+
if (first && !this._selected) await this.#select(first.key)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this._error = error.message
|
|
176
|
+
} finally {
|
|
177
|
+
this._busy = false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A hint for the model about where the user is, taken from the address bar. */
|
|
182
|
+
#viewing() {
|
|
183
|
+
const match = /\/section\/([^/]+)/.exec(window.location.pathname)
|
|
184
|
+
return match ? `the ${match[1]} section (${window.location.pathname})` : undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async #settle(item, action) {
|
|
188
|
+
this._busy = true
|
|
189
|
+
this._error = undefined
|
|
190
|
+
try {
|
|
191
|
+
await (action === 'approve' ? approve(item.key) : discard(item.key))
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this._error = error.message
|
|
194
|
+
} finally {
|
|
195
|
+
this._busy = false
|
|
196
|
+
await this.#refresh()
|
|
197
|
+
if (this._selected === item.key) await this.#select(item.key)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Saves the edited body back to the proposal, and re-reads what it now says. */
|
|
202
|
+
async #save(body) {
|
|
203
|
+
if (body === undefined) return
|
|
204
|
+
this._busy = true
|
|
205
|
+
this._error = undefined
|
|
206
|
+
try {
|
|
207
|
+
await edit(this._selected, body)
|
|
208
|
+
this._dirty = false
|
|
209
|
+
await this.#refresh()
|
|
210
|
+
await this.#select(this._selected)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
this._error = error.message
|
|
213
|
+
} finally {
|
|
214
|
+
this._busy = false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#startDrag(event) {
|
|
219
|
+
event.preventDefault()
|
|
220
|
+
const panel = this.renderRoot.querySelector('.panes')
|
|
221
|
+
if (!panel) return
|
|
222
|
+
const move = (moved) => {
|
|
223
|
+
const bounds = panel.getBoundingClientRect()
|
|
224
|
+
const percent = ((moved.clientX - bounds.left) / bounds.width) * 100
|
|
225
|
+
// Neither pane is allowed to collapse to nothing.
|
|
226
|
+
this._split = Math.min(60, Math.max(18, percent))
|
|
227
|
+
}
|
|
228
|
+
const stop = () => {
|
|
229
|
+
window.removeEventListener('pointermove', move)
|
|
230
|
+
window.removeEventListener('pointerup', stop)
|
|
231
|
+
}
|
|
232
|
+
window.addEventListener('pointermove', move)
|
|
233
|
+
window.addEventListener('pointerup', stop)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
render() {
|
|
237
|
+
return html`
|
|
238
|
+
<uui-button
|
|
239
|
+
class="launcher"
|
|
240
|
+
look="primary"
|
|
241
|
+
compact
|
|
242
|
+
label="Assistant"
|
|
243
|
+
title="Ask the assistant to draft content and schema changes for you to approve"
|
|
244
|
+
@click=${() => this.#toggle()}
|
|
245
|
+
>
|
|
246
|
+
<umb-icon name="icon-wand"></umb-icon>
|
|
247
|
+
</uui-button>
|
|
248
|
+
${this._open ? this.#renderWorkspace() : nothing}
|
|
249
|
+
`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#renderWorkspace() {
|
|
253
|
+
const awaiting = this._changes.filter((item) => item.status === 'proposed')
|
|
254
|
+
return html`
|
|
255
|
+
<div class="scrim" @click=${() => this.#toggle()}></div>
|
|
256
|
+
<section class="workspace" role="dialog" aria-label="Assistant">
|
|
257
|
+
<header>
|
|
258
|
+
<strong>Assistant</strong>
|
|
259
|
+
<span class="overview">${this.#overview(awaiting)}</span>
|
|
260
|
+
<uui-button compact look="default" label="Close" @click=${() => this.#toggle()}>
|
|
261
|
+
<umb-icon name="icon-delete"></umb-icon>
|
|
262
|
+
</uui-button>
|
|
263
|
+
</header>
|
|
264
|
+
|
|
265
|
+
${this.#renderStatus()}
|
|
266
|
+
${this._error ? html`<p class="error bar">${this._error}</p>` : nothing}
|
|
267
|
+
|
|
268
|
+
<div class="panes">
|
|
269
|
+
<div class="left" style="width: ${this._split}%">${this.#renderList(awaiting)}</div>
|
|
270
|
+
<div
|
|
271
|
+
class="splitter"
|
|
272
|
+
role="separator"
|
|
273
|
+
aria-orientation="vertical"
|
|
274
|
+
@pointerdown=${(event) => this.#startDrag(event)}
|
|
275
|
+
></div>
|
|
276
|
+
<div class="right">${this.#renderDetail()}</div>
|
|
277
|
+
</div>
|
|
278
|
+
</section>
|
|
279
|
+
`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The explanatory text: what the assistant said it was doing, and what is
|
|
284
|
+
* actually waiting. The prose is the useful half; the count keeps it honest.
|
|
285
|
+
*/
|
|
286
|
+
#overview(awaiting) {
|
|
287
|
+
const said = [...this._turns].reverse().find((turn) => turn.role === 'assistant')?.text
|
|
288
|
+
if (awaiting.length === 0) {
|
|
289
|
+
return said ?? 'Nothing is waiting for you. Ask for a change and it will appear here.'
|
|
290
|
+
}
|
|
291
|
+
const kinds = new Map()
|
|
292
|
+
for (const item of awaiting) {
|
|
293
|
+
const label = KIND_LABEL[item.kind] ?? item.kind
|
|
294
|
+
kinds.set(label, (kinds.get(label) ?? 0) + 1)
|
|
295
|
+
}
|
|
296
|
+
const parts = [...kinds].map(([label, count]) => `${count} ${label.toLowerCase()}`)
|
|
297
|
+
const counted = `${awaiting.length} proposal${awaiting.length === 1 ? '' : 's'} waiting — ${parts.join(', ')}. Nothing has changed yet.`
|
|
298
|
+
return said ? `${said} · ${counted}` : counted
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#renderList(awaiting) {
|
|
302
|
+
return html`
|
|
303
|
+
<div class="list">
|
|
304
|
+
${
|
|
305
|
+
this._changes.length === 0
|
|
306
|
+
? html`<p class="hint">
|
|
307
|
+
Proposals appear here. Approving one is the only thing that changes the CMS.
|
|
308
|
+
</p>`
|
|
309
|
+
: html`<ul>
|
|
310
|
+
${this._changes.map((item) => this.#renderListItem(item))}
|
|
311
|
+
</ul>`
|
|
312
|
+
}
|
|
313
|
+
${awaiting.length > 1 ? html`<p class="hint">Each is approved on its own.</p>` : nothing}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div class="conversation">
|
|
317
|
+
${this._turns.map((turn) => html`<div class="turn ${turn.role}">${turn.text}</div>`)}
|
|
318
|
+
${this._busy ? html`<div class="turn assistant">Thinking…</div>` : nothing}
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<form @submit=${(event) => this.#submit(event)}>
|
|
322
|
+
<textarea
|
|
323
|
+
class="prompt"
|
|
324
|
+
rows="4"
|
|
325
|
+
placeholder="Draft a landing page about…"
|
|
326
|
+
@keydown=${(event) => {
|
|
327
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) this.#submit(event)
|
|
328
|
+
}}
|
|
329
|
+
></textarea>
|
|
330
|
+
<uui-button type="submit" look="primary" label="Send" .disabled=${this._busy}></uui-button>
|
|
331
|
+
</form>
|
|
332
|
+
`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#renderListItem(item) {
|
|
336
|
+
const blocked = item.problems?.length > 0
|
|
337
|
+
return html`<li>
|
|
338
|
+
<button
|
|
339
|
+
class="entry ${item.key === this._selected ? 'selected' : ''} ${item.status}"
|
|
340
|
+
@click=${() => this.#select(item.key)}
|
|
341
|
+
>
|
|
342
|
+
<span class="kind">${KIND_LABEL[item.kind] ?? item.kind}</span>
|
|
343
|
+
<span class="summary">${item.summary}</span>
|
|
344
|
+
<span class="tags">
|
|
345
|
+
${
|
|
346
|
+
item.status !== 'proposed'
|
|
347
|
+
? html`<uui-tag look="secondary">${item.status}</uui-tag>`
|
|
348
|
+
: nothing
|
|
349
|
+
}
|
|
350
|
+
${item.origin === 'mcp' ? html`<uui-tag look="secondary">MCP</uui-tag>` : nothing}
|
|
351
|
+
${blocked ? html`<uui-tag color="danger" look="secondary">blocked</uui-tag>` : nothing}
|
|
352
|
+
</span>
|
|
353
|
+
</button>
|
|
354
|
+
</li>`
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#renderDetail() {
|
|
358
|
+
if (!this._selected) {
|
|
359
|
+
return html`<div class="empty">
|
|
360
|
+
<p>Select a proposal to read it, change it, or approve it.</p>
|
|
361
|
+
</div>`
|
|
362
|
+
}
|
|
363
|
+
if (!this._detail) return html`<div class="empty"><p>Loading…</p></div>`
|
|
364
|
+
|
|
365
|
+
const item = this._detail
|
|
366
|
+
const settled = item.status !== 'proposed'
|
|
367
|
+
const blocked = item.problems?.length > 0
|
|
368
|
+
return html`
|
|
369
|
+
<div class="detail">
|
|
370
|
+
<div class="detail-head">
|
|
371
|
+
<div>
|
|
372
|
+
<strong>${item.summary}</strong>
|
|
373
|
+
<p class="effect">${item.effect}</p>
|
|
374
|
+
</div>
|
|
375
|
+
<div class="actions">
|
|
376
|
+
${
|
|
377
|
+
settled
|
|
378
|
+
? html`<uui-tag look="secondary">${item.status}</uui-tag>`
|
|
379
|
+
: html`
|
|
380
|
+
<uui-button
|
|
381
|
+
look="primary"
|
|
382
|
+
color="positive"
|
|
383
|
+
label="Approve"
|
|
384
|
+
.disabled=${this._busy || blocked || this._dirty}
|
|
385
|
+
@click=${() => this.#settle(item, 'approve')}
|
|
386
|
+
></uui-button>
|
|
387
|
+
<uui-button
|
|
388
|
+
look="secondary"
|
|
389
|
+
label="Discard"
|
|
390
|
+
.disabled=${this._busy}
|
|
391
|
+
@click=${() => this.#settle(item, 'discard')}
|
|
392
|
+
></uui-button>
|
|
393
|
+
`
|
|
394
|
+
}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
${
|
|
399
|
+
blocked
|
|
400
|
+
? html`<ul class="problems">
|
|
401
|
+
${item.problems.map((problem) => html`<li>${problem.message}</li>`)}
|
|
402
|
+
</ul>`
|
|
403
|
+
: nothing
|
|
404
|
+
}
|
|
405
|
+
${
|
|
406
|
+
this._review?.stale
|
|
407
|
+
? html`<p class="error bar">
|
|
408
|
+
This changed after it was proposed. Ask for it again rather than approving it.
|
|
409
|
+
</p>`
|
|
410
|
+
: nothing
|
|
411
|
+
}
|
|
412
|
+
${this._dirty ? html`<p class="hint bar">Edited — save before approving.</p>` : nothing}
|
|
413
|
+
|
|
414
|
+
${this.#renderDescription()}
|
|
415
|
+
${this.#renderEditor(item)}
|
|
416
|
+
</div>
|
|
417
|
+
`
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* What the proposal does, in words. The parts a person cannot edit — which type
|
|
422
|
+
* a page is, which parent it sits under — are still theirs to review, and the
|
|
423
|
+
* prepared request was never a readable way to show them.
|
|
424
|
+
*/
|
|
425
|
+
#renderDescription() {
|
|
426
|
+
const lines = this._review?.lines
|
|
427
|
+
if (!lines?.length) return nothing
|
|
428
|
+
return html`<div class="described">
|
|
429
|
+
<ul>
|
|
430
|
+
${lines.map((line) => html`<li>${line}</li>`)}
|
|
431
|
+
</ul>
|
|
432
|
+
${
|
|
433
|
+
this._review?.editable
|
|
434
|
+
? html`<p class="hint">You can change ${this._review.editable}; the rest is fixed.</p>`
|
|
435
|
+
: html`<p class="hint">Nothing here is editable \u2014 approve it or discard it.</p>`
|
|
436
|
+
}
|
|
437
|
+
</div>`
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#renderEditor(item) {
|
|
441
|
+
// Settled: the description above already says what it did, and the prepared
|
|
442
|
+
// request is not something to read. Templates and files are still worth
|
|
443
|
+
// showing, because the text is the change.
|
|
444
|
+
if (item.status !== 'proposed') {
|
|
445
|
+
if (item.kind.startsWith('template')) {
|
|
446
|
+
return html`<pre class="readonly">${item.body?.content ?? ''}</pre>`
|
|
447
|
+
}
|
|
448
|
+
if (typeof item.body?.toml === 'string') {
|
|
449
|
+
return html`<pre class="readonly">${item.body.toml}</pre>`
|
|
450
|
+
}
|
|
451
|
+
return nothing
|
|
452
|
+
}
|
|
453
|
+
if (item.kind.startsWith('template')) return this.#renderTemplateEditor(item)
|
|
454
|
+
if (item.kind.startsWith('document-type') || item.kind.startsWith('data-type')) {
|
|
455
|
+
return this.#renderTomlEditor(item)
|
|
456
|
+
}
|
|
457
|
+
return this.#renderValuesEditor(item)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A template is TSX, so it gets a code editor and the file's own language. */
|
|
461
|
+
#renderTemplateEditor(item) {
|
|
462
|
+
const source = typeof item.body?.content === 'string' ? item.body.content : ''
|
|
463
|
+
return html`
|
|
464
|
+
<div class="editor">
|
|
465
|
+
<umb-code-editor
|
|
466
|
+
language=${LANGUAGE[item.kind] ?? 'tsx'}
|
|
467
|
+
.code=${source}
|
|
468
|
+
@input=${(event) => {
|
|
469
|
+
// Only the field this kind is edited through; the server refuses the rest.
|
|
470
|
+
this._pending = { content: event.target.code ?? event.target.value }
|
|
471
|
+
this._dirty = true
|
|
472
|
+
}}
|
|
473
|
+
></umb-code-editor>
|
|
474
|
+
</div>
|
|
475
|
+
${this.#renderSave(() => this._pending)}
|
|
476
|
+
`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** A page is its property values; each one is markup, so each gets an editor. */
|
|
480
|
+
#renderValuesEditor(item) {
|
|
481
|
+
const values = Array.isArray(item.body?.values) ? item.body.values : []
|
|
482
|
+
if (values.length === 0) {
|
|
483
|
+
return html`<p class="hint pad">This proposal sets no property values.</p>`
|
|
484
|
+
}
|
|
485
|
+
return html`
|
|
486
|
+
<div class="values">
|
|
487
|
+
${values.map(
|
|
488
|
+
(value, index) => html`<label class="value">
|
|
489
|
+
<span>
|
|
490
|
+
${value.alias}${value.culture ? html` <small>${value.culture}</small>` : nothing}
|
|
491
|
+
</span>
|
|
492
|
+
<textarea
|
|
493
|
+
rows="6"
|
|
494
|
+
.value=${
|
|
495
|
+
typeof value.value === 'string' ? value.value : JSON.stringify(value.value ?? '')
|
|
496
|
+
}
|
|
497
|
+
@input=${(event) => this.#editValue(item, index, event.target.value)}
|
|
498
|
+
></textarea>
|
|
499
|
+
</label>`,
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
${this.#renderSave(() => this._pending)}
|
|
503
|
+
`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#editValue(item, index, next) {
|
|
507
|
+
const values = (this._pending?.values ?? item.body.values).map((value, at) =>
|
|
508
|
+
at === index ? { ...value, value: next } : value,
|
|
509
|
+
)
|
|
510
|
+
this._pending = { values }
|
|
511
|
+
this._dirty = true
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* A type is a TOML file, and that file is what gets committed — so the file is
|
|
516
|
+
* what is shown and edited, aliases and all, rather than a request body of keys.
|
|
517
|
+
*
|
|
518
|
+
* `ini` rather than a TOML mode: Umbraco's editor has no TOML language, and
|
|
519
|
+
* ini's highlighting of `[table]` headers and `key = value` is right for it.
|
|
520
|
+
*/
|
|
521
|
+
#renderTomlEditor(item) {
|
|
522
|
+
// A proposal raised before types were files has a request body and no TOML.
|
|
523
|
+
// Showing it as an empty editor would invite someone to approve nothing, so it
|
|
524
|
+
// is shown as what it is and says to ask again.
|
|
525
|
+
if (typeof item.body?.toml !== 'string') {
|
|
526
|
+
return html`
|
|
527
|
+
<p class="hint pad">
|
|
528
|
+
This was proposed before types were files, so there is no file to show or edit.
|
|
529
|
+
Discard it and ask again.
|
|
530
|
+
</p>
|
|
531
|
+
`
|
|
532
|
+
}
|
|
533
|
+
const toml = item.body.toml
|
|
534
|
+
return html`
|
|
535
|
+
<p class="hint pad">${item.body?.file ?? 'schema file'}</p>
|
|
536
|
+
<div class="editor">
|
|
537
|
+
<umb-code-editor
|
|
538
|
+
language="ini"
|
|
539
|
+
.code=${toml}
|
|
540
|
+
@input=${(event) => {
|
|
541
|
+
this._pending = { toml: event.target.code ?? event.target.value }
|
|
542
|
+
this._dirty = true
|
|
543
|
+
}}
|
|
544
|
+
></umb-code-editor>
|
|
545
|
+
</div>
|
|
546
|
+
${this.#renderSave(() => this._pending)}
|
|
547
|
+
`
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#renderSave(collect) {
|
|
551
|
+
return html`<div class="save">
|
|
552
|
+
<uui-button
|
|
553
|
+
look="secondary"
|
|
554
|
+
label="Save changes"
|
|
555
|
+
.disabled=${this._busy || !this._dirty}
|
|
556
|
+
@click=${() => this.#save(collect())}
|
|
557
|
+
></uui-button>
|
|
558
|
+
<span class="hint">Saving re-checks it. Approving is still separate.</span>
|
|
559
|
+
</div>`
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Only says anything when something is wrong, or when a session is running out. */
|
|
563
|
+
#renderStatus() {
|
|
564
|
+
const state = this._status
|
|
565
|
+
if (!state || (state.ok && !state.expires)) return nothing
|
|
566
|
+
if (state.ok) {
|
|
567
|
+
return html`<p class="hint bar">
|
|
568
|
+
${state.detail}. Session expires ${new Date(state.expires).toLocaleTimeString()}.
|
|
569
|
+
</p>`
|
|
570
|
+
}
|
|
571
|
+
return html`<div class="unavailable bar">
|
|
572
|
+
<strong>The assistant cannot reach its model.</strong>
|
|
573
|
+
<p>${state.detail}</p>
|
|
574
|
+
${state.remedy ? html`<p class="remedy">${state.remedy}</p>` : nothing}
|
|
575
|
+
${
|
|
576
|
+
// Asking again is a real call to the model, so the server allows it only
|
|
577
|
+
// to whoever could act on the answer. No button for anyone else: one that
|
|
578
|
+
// quietly changes nothing is worse than none.
|
|
579
|
+
state.canRefresh === false
|
|
580
|
+
? nothing
|
|
581
|
+
: html`<uui-button
|
|
582
|
+
look="secondary"
|
|
583
|
+
label="Re-check"
|
|
584
|
+
.disabled=${this._busy}
|
|
585
|
+
@click=${() => this.#readStatus(true)}
|
|
586
|
+
></uui-button>`
|
|
587
|
+
}
|
|
588
|
+
</div>`
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
static styles = css`
|
|
592
|
+
:host {
|
|
593
|
+
display: flex;
|
|
594
|
+
align-items: center;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* As Umbraco's own header apps are built (UmbHeaderAppButtonElement): an
|
|
598
|
+
* \`umb-icon\`, because that is what resolves a registry icon name — \`uui-icon\`
|
|
599
|
+
* renders an empty box for one — and \`look="primary"\` with the background
|
|
600
|
+
* overridden, which is what gives contrast against the dark header.
|
|
601
|
+
*/
|
|
602
|
+
.launcher {
|
|
603
|
+
font-size: 18px;
|
|
604
|
+
--uui-button-background-color: transparent;
|
|
605
|
+
--uui-button-background-color-hover: var(--uui-color-emphasis);
|
|
606
|
+
}
|
|
607
|
+
.scrim {
|
|
608
|
+
position: fixed;
|
|
609
|
+
inset: 0;
|
|
610
|
+
background: rgba(0, 0, 0, 0.45);
|
|
611
|
+
z-index: 9998;
|
|
612
|
+
}
|
|
613
|
+
.workspace {
|
|
614
|
+
position: fixed;
|
|
615
|
+
inset: 4vh 3vw;
|
|
616
|
+
display: flex;
|
|
617
|
+
flex-direction: column;
|
|
618
|
+
background: var(--uui-color-surface, #fff);
|
|
619
|
+
border-radius: var(--uui-border-radius, 3px);
|
|
620
|
+
box-shadow: var(--uui-shadow-depth-5, 0 20px 60px rgba(0, 0, 0, 0.35));
|
|
621
|
+
z-index: 9999;
|
|
622
|
+
overflow: hidden;
|
|
623
|
+
}
|
|
624
|
+
header {
|
|
625
|
+
display: flex;
|
|
626
|
+
align-items: center;
|
|
627
|
+
gap: var(--uui-size-space-4, 12px);
|
|
628
|
+
padding: var(--uui-size-space-3, 9px) var(--uui-size-space-4, 12px);
|
|
629
|
+
border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
|
|
630
|
+
}
|
|
631
|
+
header .overview {
|
|
632
|
+
flex: 1;
|
|
633
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
634
|
+
color: var(--uui-color-text-alt, #515054);
|
|
635
|
+
}
|
|
636
|
+
.bar {
|
|
637
|
+
margin: 0;
|
|
638
|
+
padding: var(--uui-size-space-2, 6px) var(--uui-size-space-4, 12px);
|
|
639
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
640
|
+
border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
|
|
641
|
+
}
|
|
642
|
+
.pad {
|
|
643
|
+
padding: var(--uui-size-space-4, 12px);
|
|
644
|
+
}
|
|
645
|
+
.panes {
|
|
646
|
+
flex: 1;
|
|
647
|
+
display: flex;
|
|
648
|
+
min-height: 0;
|
|
649
|
+
}
|
|
650
|
+
.left {
|
|
651
|
+
display: flex;
|
|
652
|
+
flex-direction: column;
|
|
653
|
+
min-width: 0;
|
|
654
|
+
border-right: 1px solid var(--uui-color-border, #d8d7d9);
|
|
655
|
+
}
|
|
656
|
+
.splitter {
|
|
657
|
+
width: 6px;
|
|
658
|
+
cursor: col-resize;
|
|
659
|
+
background: var(--uui-color-divider, #f3f3f5);
|
|
660
|
+
flex: none;
|
|
661
|
+
}
|
|
662
|
+
.splitter:hover {
|
|
663
|
+
background: var(--uui-color-emphasis, #d8d7d9);
|
|
664
|
+
}
|
|
665
|
+
.right {
|
|
666
|
+
flex: 1;
|
|
667
|
+
min-width: 0;
|
|
668
|
+
display: flex;
|
|
669
|
+
overflow: auto;
|
|
670
|
+
}
|
|
671
|
+
.list {
|
|
672
|
+
flex: 1;
|
|
673
|
+
overflow: auto;
|
|
674
|
+
padding: var(--uui-size-space-3, 9px);
|
|
675
|
+
min-height: 0;
|
|
676
|
+
}
|
|
677
|
+
.list ul {
|
|
678
|
+
list-style: none;
|
|
679
|
+
margin: 0;
|
|
680
|
+
padding: 0;
|
|
681
|
+
display: flex;
|
|
682
|
+
flex-direction: column;
|
|
683
|
+
gap: var(--uui-size-space-2, 6px);
|
|
684
|
+
}
|
|
685
|
+
.entry {
|
|
686
|
+
width: 100%;
|
|
687
|
+
text-align: left;
|
|
688
|
+
font: inherit;
|
|
689
|
+
cursor: pointer;
|
|
690
|
+
background: none;
|
|
691
|
+
padding: var(--uui-size-space-3, 9px);
|
|
692
|
+
border: 1px solid var(--uui-color-border, #d8d7d9);
|
|
693
|
+
border-radius: var(--uui-border-radius, 3px);
|
|
694
|
+
display: flex;
|
|
695
|
+
flex-direction: column;
|
|
696
|
+
gap: 2px;
|
|
697
|
+
}
|
|
698
|
+
.entry:hover {
|
|
699
|
+
border-color: var(--uui-color-emphasis, #a1a1a1);
|
|
700
|
+
}
|
|
701
|
+
.entry.selected {
|
|
702
|
+
border-color: var(--uui-color-selected, #3544b1);
|
|
703
|
+
background: var(--uui-color-surface-alt, #f3f3f5);
|
|
704
|
+
}
|
|
705
|
+
.entry.applied,
|
|
706
|
+
.entry.discarded {
|
|
707
|
+
opacity: 0.55;
|
|
708
|
+
}
|
|
709
|
+
.entry .kind {
|
|
710
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
711
|
+
color: var(--uui-color-text-alt, #515054);
|
|
712
|
+
}
|
|
713
|
+
.entry .summary {
|
|
714
|
+
font-weight: bold;
|
|
715
|
+
overflow-wrap: anywhere;
|
|
716
|
+
}
|
|
717
|
+
.entry .tags {
|
|
718
|
+
display: flex;
|
|
719
|
+
gap: 4px;
|
|
720
|
+
}
|
|
721
|
+
.conversation {
|
|
722
|
+
max-height: 34%;
|
|
723
|
+
overflow: auto;
|
|
724
|
+
padding: var(--uui-size-space-3, 9px);
|
|
725
|
+
border-top: 1px solid var(--uui-color-border, #d8d7d9);
|
|
726
|
+
display: flex;
|
|
727
|
+
flex-direction: column;
|
|
728
|
+
gap: var(--uui-size-space-2, 6px);
|
|
729
|
+
}
|
|
730
|
+
.turn {
|
|
731
|
+
padding: var(--uui-size-space-2, 6px) var(--uui-size-space-3, 9px);
|
|
732
|
+
border-radius: var(--uui-border-radius, 3px);
|
|
733
|
+
white-space: pre-wrap;
|
|
734
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
735
|
+
}
|
|
736
|
+
.turn.user {
|
|
737
|
+
background: var(--uui-color-surface-alt, #f3f3f5);
|
|
738
|
+
}
|
|
739
|
+
.turn.assistant {
|
|
740
|
+
background: var(--uui-color-divider, #f3f3f5);
|
|
741
|
+
}
|
|
742
|
+
form {
|
|
743
|
+
display: flex;
|
|
744
|
+
flex-direction: column;
|
|
745
|
+
gap: var(--uui-size-space-2, 6px);
|
|
746
|
+
padding: var(--uui-size-space-3, 9px);
|
|
747
|
+
border-top: 1px solid var(--uui-color-border, #d8d7d9);
|
|
748
|
+
}
|
|
749
|
+
textarea {
|
|
750
|
+
font: inherit;
|
|
751
|
+
padding: var(--uui-size-space-2, 6px);
|
|
752
|
+
border: 1px solid var(--uui-color-border, #d8d7d9);
|
|
753
|
+
border-radius: var(--uui-border-radius, 3px);
|
|
754
|
+
resize: vertical;
|
|
755
|
+
}
|
|
756
|
+
.detail,
|
|
757
|
+
.empty {
|
|
758
|
+
flex: 1;
|
|
759
|
+
min-width: 0;
|
|
760
|
+
display: flex;
|
|
761
|
+
flex-direction: column;
|
|
762
|
+
}
|
|
763
|
+
.empty {
|
|
764
|
+
align-items: center;
|
|
765
|
+
justify-content: center;
|
|
766
|
+
color: var(--uui-color-text-alt, #515054);
|
|
767
|
+
}
|
|
768
|
+
.detail-head {
|
|
769
|
+
display: flex;
|
|
770
|
+
align-items: flex-start;
|
|
771
|
+
justify-content: space-between;
|
|
772
|
+
gap: var(--uui-size-space-4, 12px);
|
|
773
|
+
padding: var(--uui-size-space-4, 12px);
|
|
774
|
+
border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
|
|
775
|
+
}
|
|
776
|
+
.actions {
|
|
777
|
+
display: flex;
|
|
778
|
+
gap: var(--uui-size-space-2, 6px);
|
|
779
|
+
flex: none;
|
|
780
|
+
}
|
|
781
|
+
.effect,
|
|
782
|
+
.hint {
|
|
783
|
+
margin: 4px 0 0;
|
|
784
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
785
|
+
color: var(--uui-color-text-alt, #515054);
|
|
786
|
+
}
|
|
787
|
+
.editor {
|
|
788
|
+
flex: 1;
|
|
789
|
+
min-height: 320px;
|
|
790
|
+
display: flex;
|
|
791
|
+
}
|
|
792
|
+
umb-code-editor {
|
|
793
|
+
flex: 1;
|
|
794
|
+
min-height: 320px;
|
|
795
|
+
}
|
|
796
|
+
.values {
|
|
797
|
+
flex: 1;
|
|
798
|
+
overflow: auto;
|
|
799
|
+
padding: var(--uui-size-space-4, 12px);
|
|
800
|
+
display: flex;
|
|
801
|
+
flex-direction: column;
|
|
802
|
+
gap: var(--uui-size-space-3, 9px);
|
|
803
|
+
}
|
|
804
|
+
.value {
|
|
805
|
+
display: flex;
|
|
806
|
+
flex-direction: column;
|
|
807
|
+
gap: 4px;
|
|
808
|
+
}
|
|
809
|
+
.value span {
|
|
810
|
+
font-weight: bold;
|
|
811
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
812
|
+
}
|
|
813
|
+
.described {
|
|
814
|
+
padding: var(--uui-size-space-4, 12px);
|
|
815
|
+
border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
|
|
816
|
+
}
|
|
817
|
+
.described ul {
|
|
818
|
+
margin: 0;
|
|
819
|
+
padding-left: 1.1rem;
|
|
820
|
+
display: flex;
|
|
821
|
+
flex-direction: column;
|
|
822
|
+
gap: 2px;
|
|
823
|
+
}
|
|
824
|
+
.described li {
|
|
825
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
826
|
+
}
|
|
827
|
+
.readonly {
|
|
828
|
+
flex: 1;
|
|
829
|
+
margin: 0;
|
|
830
|
+
padding: var(--uui-size-space-4, 12px);
|
|
831
|
+
overflow: auto;
|
|
832
|
+
background: var(--uui-color-surface-alt, #f3f3f5);
|
|
833
|
+
white-space: pre-wrap;
|
|
834
|
+
}
|
|
835
|
+
.save {
|
|
836
|
+
display: flex;
|
|
837
|
+
align-items: center;
|
|
838
|
+
gap: var(--uui-size-space-3, 9px);
|
|
839
|
+
padding: var(--uui-size-space-3, 9px) var(--uui-size-space-4, 12px);
|
|
840
|
+
border-top: 1px solid var(--uui-color-border, #d8d7d9);
|
|
841
|
+
}
|
|
842
|
+
.problems {
|
|
843
|
+
margin: 0;
|
|
844
|
+
padding: var(--uui-size-space-3, 9px) var(--uui-size-space-4, 12px)
|
|
845
|
+
var(--uui-size-space-3, 9px) calc(var(--uui-size-space-4, 12px) + 16px);
|
|
846
|
+
color: var(--uui-color-danger, #d42054);
|
|
847
|
+
font-size: var(--uui-type-small-size, 12px);
|
|
848
|
+
}
|
|
849
|
+
.error {
|
|
850
|
+
color: var(--uui-color-danger, #d42054);
|
|
851
|
+
}
|
|
852
|
+
.unavailable {
|
|
853
|
+
border-bottom: 1px solid var(--uui-color-danger, #d42054);
|
|
854
|
+
}
|
|
855
|
+
.unavailable p {
|
|
856
|
+
margin: var(--uui-size-space-2, 6px) 0;
|
|
857
|
+
}
|
|
858
|
+
.unavailable .remedy {
|
|
859
|
+
font-family: var(--uui-font-monospace, monospace);
|
|
860
|
+
overflow-wrap: anywhere;
|
|
861
|
+
}
|
|
862
|
+
`
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
customElements.define('crombolo-assistant-drawer', CromboloAssistantDrawerElement)
|