node-red-contrib-room-tools 1.8.2 → 1.8.3
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/CONTROL_PANEL_AI_GUIDE.md +55 -220
- package/README.md +10 -148
- package/assets/panel-login-bg.png +0 -0
- package/control-bound-protocol.js +97 -0
- package/control-catalog.js +97 -0
- package/control-panel.html +82 -86
- package/control-panel.js +237 -10
- package/control-policy.js +267 -7
- package/control-type-contract.json +17 -0
- package/control-type-model.js +63 -0
- package/gateway.cjs +77 -5
- package/managed-bridge-admin.js +90 -0
- package/managed-bridge-revision.js +32 -0
- package/managed-native-bridge.js +124 -0
- package/native-binding-plan.js +177 -0
- package/package.json +27 -5
- package/panel-binding-editor.js +771 -0
- package/panel-editor-auth.js +151 -0
- package/panel-template-instrumentation.js +267 -0
- package/panel-visual-apply.js +127 -0
- package/panel-visual-bindings.js +95 -0
- package/room-gateway.html +0 -21
- package/room-gateway.js +0 -57
- package/room-message.html +0 -56
- package/room-message.js +0 -43
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
function initializeVisualBinding () {
|
|
2
|
+
const component = this
|
|
3
|
+
const config = this.__boundPanelConfig()?.visual
|
|
4
|
+
if (!config || !this.$el || typeof document === 'undefined') return
|
|
5
|
+
const root = this.$el
|
|
6
|
+
const base = config.editorEndpoint
|
|
7
|
+
const saved = new Map(Object.entries(config.bindings || {}))
|
|
8
|
+
const attached = new WeakSet()
|
|
9
|
+
const feedbackModels = new Map()
|
|
10
|
+
const pendingText = new Map()
|
|
11
|
+
const cleanup = []
|
|
12
|
+
let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
|
|
13
|
+
let editSession = null
|
|
14
|
+
let snapshot = null
|
|
15
|
+
let draft = []
|
|
16
|
+
let devices = []
|
|
17
|
+
let selecting = false
|
|
18
|
+
let observer
|
|
19
|
+
let toolbar
|
|
20
|
+
let overlay
|
|
21
|
+
let status
|
|
22
|
+
let loginScreen
|
|
23
|
+
|
|
24
|
+
function element (tag, text, className) {
|
|
25
|
+
const node = document.createElement(tag)
|
|
26
|
+
if (text !== undefined && text !== null) node.textContent = String(text)
|
|
27
|
+
if (className) node.className = className
|
|
28
|
+
return node
|
|
29
|
+
}
|
|
30
|
+
function clear (node) { while (node.firstChild) node.removeChild(node.firstChild) }
|
|
31
|
+
function field (form, label, input) {
|
|
32
|
+
const row = element('label', null, 'cpv-field')
|
|
33
|
+
row.append(element('span', label), input)
|
|
34
|
+
form.append(row)
|
|
35
|
+
return input
|
|
36
|
+
}
|
|
37
|
+
function button (label, action, className) {
|
|
38
|
+
const node = element('button', label, className)
|
|
39
|
+
node.type = 'button'
|
|
40
|
+
node.addEventListener('click', action)
|
|
41
|
+
return node
|
|
42
|
+
}
|
|
43
|
+
function inform (message, error = false) {
|
|
44
|
+
if (status) {
|
|
45
|
+
status.textContent = message || ''
|
|
46
|
+
status.dataset.error = error ? 'true' : 'false'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function request (path, method = 'GET', body) {
|
|
50
|
+
const response = await fetch(base + path, {
|
|
51
|
+
method, credentials: 'same-origin',
|
|
52
|
+
headers: method === 'POST'
|
|
53
|
+
? { 'Content-Type': 'application/json', 'X-Panel-CSRF': editSession?.csrf || '' }
|
|
54
|
+
: undefined,
|
|
55
|
+
body: method === 'POST' ? JSON.stringify(body || {}) : undefined,
|
|
56
|
+
signal: AbortSignal.timeout(12000)
|
|
57
|
+
})
|
|
58
|
+
const result = await response.json().catch(() => ({}))
|
|
59
|
+
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
function family (target) {
|
|
63
|
+
if (target.controlSchemaVersion !== config.contract.schemaVersion) return null
|
|
64
|
+
const known = config.contract.types[String(target.controlType || '').toLowerCase()]
|
|
65
|
+
if (!known) return null
|
|
66
|
+
const type = target.type
|
|
67
|
+
if (known.family === 'number' && ['integer', 'float'].includes(type)) return 'number'
|
|
68
|
+
if (known.family === 'boolean' && (type === 'boolean' ||
|
|
69
|
+
(type === 'integer' && target.booleanEncoding === 'integer01' &&
|
|
70
|
+
target.min === 0 && target.max === 1))) return 'boolean'
|
|
71
|
+
if (known.family === 'text' && ['string', 'dstring'].includes(type)) return 'text'
|
|
72
|
+
if (known.family === 'enum' && ['enum', 'integer', 'string'].includes(type)) return 'enum'
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
function compatible (adapter, target, direction, candidate) {
|
|
76
|
+
const families = {
|
|
77
|
+
range: ['number'], switch: ['boolean'], meter: ['number'],
|
|
78
|
+
'text-display': ['number', 'boolean', 'text', 'enum'],
|
|
79
|
+
'text-input': ['text'], select: ['enum']
|
|
80
|
+
}
|
|
81
|
+
if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
|
|
82
|
+
if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
|
|
83
|
+
if (candidate?.unit && candidate.unit !== target.unit) return false
|
|
84
|
+
return direction === 'write' ? ['wo', 'rw'].includes(target.access) : ['ro', 'rw'].includes(target.access)
|
|
85
|
+
}
|
|
86
|
+
function numericRange (binding, node) {
|
|
87
|
+
if (binding.uiRange) return binding.uiRange
|
|
88
|
+
const tag = node.tagName?.toLowerCase()
|
|
89
|
+
const type = node.type?.toLowerCase()
|
|
90
|
+
const defaultMax = tag === 'meter' || tag === 'progress' ? 1 : 100
|
|
91
|
+
const min = node.min === '' || node.min == null ? (type === 'number' ? undefined : 0) : Number(node.min)
|
|
92
|
+
const max = node.max === '' || node.max == null ? (type === 'number' ? undefined : defaultMax) : Number(node.max)
|
|
93
|
+
return { min, max, step: node.step || (tag === 'meter' || tag === 'progress' ? 'any' : '1') }
|
|
94
|
+
}
|
|
95
|
+
function mappedValue (value, source, target, integer = false) {
|
|
96
|
+
if (!Number.isFinite(value)) return value
|
|
97
|
+
if (!Number.isFinite(source?.min) || !Number.isFinite(source?.max) || source.max <= source.min ||
|
|
98
|
+
!Number.isFinite(target?.min) || !Number.isFinite(target?.max) || target.max <= target.min) return value
|
|
99
|
+
const clamped = Math.min(source.max, Math.max(source.min, value))
|
|
100
|
+
let result = target.min + (clamped - source.min) * (target.max - target.min) / (source.max - source.min)
|
|
101
|
+
if (integer) result = Math.round(result)
|
|
102
|
+
else if (target.step && target.step !== 'any') {
|
|
103
|
+
const step = Number(target.step)
|
|
104
|
+
if (Number.isFinite(step) && step > 0) {
|
|
105
|
+
const precision = String(target.step).split('.')[1]?.length || 0
|
|
106
|
+
result = Number((target.min + Math.round((result - target.min) / step) * step).toFixed(Math.min(precision, 12)))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return Math.min(target.max, Math.max(target.min, result))
|
|
110
|
+
}
|
|
111
|
+
function buttonStateAttribute (node) {
|
|
112
|
+
return node.getAttribute('role') === 'switch' && !node.hasAttribute('aria-pressed')
|
|
113
|
+
? 'aria-checked' : 'aria-pressed'
|
|
114
|
+
}
|
|
115
|
+
function valueFor (binding, node) {
|
|
116
|
+
if (binding.adapter === 'switch') {
|
|
117
|
+
if (node.tagName === 'BUTTON') return node.getAttribute(buttonStateAttribute(node)) !== 'true'
|
|
118
|
+
return Boolean(node.checked)
|
|
119
|
+
}
|
|
120
|
+
if (binding.adapter === 'range') {
|
|
121
|
+
return mappedValue(Number(node.value), numericRange(binding, node), binding.writeMeta,
|
|
122
|
+
binding.writeMeta?.type === 'integer')
|
|
123
|
+
}
|
|
124
|
+
if (binding.adapter === 'select' && ['integer', 'float'].includes(binding.writeMeta?.type)) {
|
|
125
|
+
return Number(node.value)
|
|
126
|
+
}
|
|
127
|
+
return String(node.value)
|
|
128
|
+
}
|
|
129
|
+
function showFeedback () {
|
|
130
|
+
for (const [id, binding] of saved) {
|
|
131
|
+
if (binding.mode === 'write') continue
|
|
132
|
+
const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
|
|
133
|
+
if (matches.length !== 1) continue
|
|
134
|
+
if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
|
|
135
|
+
const value = component.boundDisplayValue(id, component.boundState[id])
|
|
136
|
+
const node = matches[0]
|
|
137
|
+
const displayValue = ['range', 'meter'].includes(binding.adapter)
|
|
138
|
+
? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
|
|
139
|
+
numericRange(binding, node)) : value
|
|
140
|
+
if (binding.model) {
|
|
141
|
+
if (binding.component && binding.adapter === 'text-input' &&
|
|
142
|
+
(pendingText.has(id) || node.contains?.(document.activeElement))) continue
|
|
143
|
+
if (binding.model in component && !component.$options?.computed?.[binding.model]) {
|
|
144
|
+
if (!Object.is(component[binding.model], displayValue)) {
|
|
145
|
+
pendingText.delete(id)
|
|
146
|
+
if (binding.component) feedbackModels.set(id, displayValue)
|
|
147
|
+
component[binding.model] = displayValue
|
|
148
|
+
if (binding.component && component.$nextTick) {
|
|
149
|
+
void component.$nextTick(() => {
|
|
150
|
+
if (Object.is(feedbackModels.get(id), displayValue)) feedbackModels.delete(id)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else console.error('ControlPanel display field is unavailable: ' + binding.model)
|
|
155
|
+
}
|
|
156
|
+
else if (binding.adapter === 'switch') {
|
|
157
|
+
if (node.tagName === 'BUTTON') {
|
|
158
|
+
const state = value === true ? 'true' : 'false'
|
|
159
|
+
node.setAttribute(buttonStateAttribute(node), state)
|
|
160
|
+
if (node.getAttribute('role') === 'switch' && node.hasAttribute('aria-pressed')) node.setAttribute('aria-checked', state)
|
|
161
|
+
} else node.checked = value === true
|
|
162
|
+
}
|
|
163
|
+
else if (binding.adapter === 'text-display') {
|
|
164
|
+
if (node.textContent !== String(value)) node.textContent = String(value)
|
|
165
|
+
}
|
|
166
|
+
else if (binding.adapter === 'meter') node.value = Number(displayValue)
|
|
167
|
+
else if (document.activeElement !== node || binding.adapter === 'range') node.value = String(displayValue)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function attachControls () {
|
|
171
|
+
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
172
|
+
if (attached.has(node)) continue
|
|
173
|
+
attached.add(node)
|
|
174
|
+
const locator = node.getAttribute('data-cp-auto')
|
|
175
|
+
const matched = [...saved].find(([, entry]) => entry.locator === locator)
|
|
176
|
+
if (!matched) continue
|
|
177
|
+
const [id, binding] = matched
|
|
178
|
+
if (binding.mode === 'read') continue
|
|
179
|
+
if (binding.component) {
|
|
180
|
+
if (binding.adapter === 'text-input') {
|
|
181
|
+
const commit = () => {
|
|
182
|
+
if (!pendingText.has(id) || editing || !component.boundAuthenticated) return
|
|
183
|
+
const value = pendingText.get(id)
|
|
184
|
+
pendingText.delete(id)
|
|
185
|
+
try { component.sendBoundControl({ id, value, final: true }) } catch (error) {
|
|
186
|
+
console.error('ControlPanel binding command failed', error)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const afterModelUpdate = () => {
|
|
190
|
+
if (component.$nextTick) void component.$nextTick(commit)
|
|
191
|
+
else commit()
|
|
192
|
+
}
|
|
193
|
+
node.addEventListener('change', afterModelUpdate)
|
|
194
|
+
node.addEventListener('focusout', afterModelUpdate)
|
|
195
|
+
if (binding.component === 'v-text-field') {
|
|
196
|
+
node.addEventListener('keydown', event => {
|
|
197
|
+
if (event.key === 'Enter') afterModelUpdate()
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const send = final => {
|
|
204
|
+
if (editing || !component.boundAuthenticated) return
|
|
205
|
+
try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
|
|
206
|
+
console.error('ControlPanel binding command failed', error)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (binding.adapter === 'range') {
|
|
210
|
+
node.addEventListener('input', () => send(false), true)
|
|
211
|
+
node.addEventListener('change', () => send(true), true)
|
|
212
|
+
} else if (binding.adapter === 'switch' && node.tagName === 'BUTTON') {
|
|
213
|
+
node.addEventListener('click', event => {
|
|
214
|
+
event.preventDefault()
|
|
215
|
+
send(true)
|
|
216
|
+
})
|
|
217
|
+
} else node.addEventListener('change', () => send(true))
|
|
218
|
+
}
|
|
219
|
+
showFeedback()
|
|
220
|
+
}
|
|
221
|
+
function attachModelControls () {
|
|
222
|
+
for (const [id, binding] of saved) {
|
|
223
|
+
if (!binding.component || !binding.model || binding.mode === 'read') continue
|
|
224
|
+
let finishTimer
|
|
225
|
+
const send = (value, final) => {
|
|
226
|
+
if (editing || !component.boundAuthenticated) return
|
|
227
|
+
try {
|
|
228
|
+
let commandValue
|
|
229
|
+
if (binding.adapter === 'range') {
|
|
230
|
+
const numeric = Number(value)
|
|
231
|
+
if (!Number.isFinite(numeric)) return
|
|
232
|
+
commandValue = mappedValue(numeric, binding.uiRange, binding.writeMeta,
|
|
233
|
+
binding.writeMeta?.type === 'integer')
|
|
234
|
+
} else if (binding.adapter === 'switch') {
|
|
235
|
+
if (typeof value !== 'boolean') return
|
|
236
|
+
commandValue = value
|
|
237
|
+
} else if (binding.adapter === 'select') {
|
|
238
|
+
if (typeof value !== 'string' &&
|
|
239
|
+
!(typeof value === 'number' && Number.isFinite(value))) return
|
|
240
|
+
if (['integer', 'float'].includes(binding.writeMeta?.type)) {
|
|
241
|
+
commandValue = Number(value)
|
|
242
|
+
if (!Number.isFinite(commandValue)) return
|
|
243
|
+
} else commandValue = value
|
|
244
|
+
const options = binding.writeMeta?.enums
|
|
245
|
+
if (Array.isArray(options) && options.length &&
|
|
246
|
+
!options.some(option => Object.is(option, commandValue) ||
|
|
247
|
+
Object.is(option?.value, commandValue))) {
|
|
248
|
+
console.error('ControlPanel selection is not a device option: ' + id)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
} else return
|
|
252
|
+
component.sendBoundControl({ id, value: commandValue, final })
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error('ControlPanel binding command failed', error)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const unwatch = component.$watch(binding.model, value => {
|
|
258
|
+
if (feedbackModels.has(id)) {
|
|
259
|
+
const feedbackValue = feedbackModels.get(id)
|
|
260
|
+
feedbackModels.delete(id)
|
|
261
|
+
if (Object.is(value, feedbackValue)) return
|
|
262
|
+
}
|
|
263
|
+
if (editing || !component.boundAuthenticated) return
|
|
264
|
+
if (binding.adapter === 'text-input') {
|
|
265
|
+
if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if (binding.adapter === 'range') {
|
|
269
|
+
send(value, false)
|
|
270
|
+
clearTimeout(finishTimer)
|
|
271
|
+
finishTimer = setTimeout(() => send(value, true), 160)
|
|
272
|
+
} else send(value, true)
|
|
273
|
+
})
|
|
274
|
+
cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function closeOverlay () { overlay?.remove(); overlay = null }
|
|
278
|
+
function updateBindingMarkers () {
|
|
279
|
+
for (const node of root.querySelectorAll('[data-cpv-bound="true"]')) {
|
|
280
|
+
node.removeAttribute('data-cpv-bound')
|
|
281
|
+
node.removeAttribute('data-cpv-binding-id')
|
|
282
|
+
}
|
|
283
|
+
if (!editing || !editSession) return
|
|
284
|
+
const bindings = new Map(draft.map(entry => [entry.locator, entry]))
|
|
285
|
+
for (const node of root.querySelectorAll('[data-cp-auto]')) {
|
|
286
|
+
const entry = bindings.get(node.getAttribute('data-cp-auto'))
|
|
287
|
+
if (!entry) continue
|
|
288
|
+
node.setAttribute('data-cpv-bound', 'true')
|
|
289
|
+
node.setAttribute('data-cpv-binding-id', entry.id)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function contextMenu (candidate, x, y) {
|
|
293
|
+
closeOverlay()
|
|
294
|
+
overlay = element('div', null, 'cpv-menu')
|
|
295
|
+
overlay.setAttribute('role', 'menu')
|
|
296
|
+
const existing = draft.find(entry => entry.locator === candidate.locator)
|
|
297
|
+
overlay.append(button(existing ? 'Edit binding' : 'Bind control', () => bindingDialog(candidate)))
|
|
298
|
+
if (existing) overlay.append(button('Remove binding', () => {
|
|
299
|
+
draft = draft.filter(entry => entry.locator !== candidate.locator)
|
|
300
|
+
updateBindingMarkers()
|
|
301
|
+
closeOverlay()
|
|
302
|
+
inform('Binding removed from draft')
|
|
303
|
+
}))
|
|
304
|
+
overlay.style.left = Math.min(x, window.innerWidth - 180) + 'px'
|
|
305
|
+
overlay.style.top = Math.min(y, window.innerHeight - 90) + 'px'
|
|
306
|
+
document.body.append(overlay)
|
|
307
|
+
overlay.querySelector('button')?.focus()
|
|
308
|
+
const outside = event => {
|
|
309
|
+
if (!overlay?.contains(event.target)) {
|
|
310
|
+
closeOverlay()
|
|
311
|
+
document.removeEventListener('pointerdown', outside, true)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0)
|
|
315
|
+
cleanup.push(() => {
|
|
316
|
+
clearTimeout(timer)
|
|
317
|
+
document.removeEventListener('pointerdown', outside, true)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
function showDialog (title) {
|
|
321
|
+
closeOverlay()
|
|
322
|
+
overlay = element('div', null, 'cpv-scrim')
|
|
323
|
+
const dialog = element('section', null, 'cpv-dialog')
|
|
324
|
+
dialog.setAttribute('role', 'dialog')
|
|
325
|
+
dialog.setAttribute('aria-modal', 'true')
|
|
326
|
+
dialog.setAttribute('aria-label', title)
|
|
327
|
+
dialog.append(element('h2', title))
|
|
328
|
+
overlay.append(dialog)
|
|
329
|
+
document.body.append(overlay)
|
|
330
|
+
return dialog
|
|
331
|
+
}
|
|
332
|
+
function coverPage (title, mode, state = 'loading') {
|
|
333
|
+
loginScreen?.remove()
|
|
334
|
+
loginScreen = element('div', null, 'cpv-login-screen')
|
|
335
|
+
loginScreen.setAttribute('role', 'dialog')
|
|
336
|
+
loginScreen.setAttribute('aria-modal', 'true')
|
|
337
|
+
loginScreen.setAttribute('aria-label', title)
|
|
338
|
+
loginScreen.dataset.mode = mode
|
|
339
|
+
loginScreen.dataset.state = state
|
|
340
|
+
const marks = element('div', null, 'cpv-brand-row')
|
|
341
|
+
marks.append(element('strong', 'BSS'), element('strong', 'OMNI'))
|
|
342
|
+
if (mode === 'panel') {
|
|
343
|
+
const brand = element('div', null, 'cpv-login-brand')
|
|
344
|
+
brand.append(marks)
|
|
345
|
+
brand.append(element('span', 'The Unified Audio, Video & Control Platform', 'cpv-brand-tagline'))
|
|
346
|
+
loginScreen.append(brand)
|
|
347
|
+
}
|
|
348
|
+
const content = element('section', null, 'cpv-login-content')
|
|
349
|
+
if (mode === 'editor') content.append(marks)
|
|
350
|
+
content.append(element('h1', title))
|
|
351
|
+
loginScreen.append(content)
|
|
352
|
+
document.body.append(loginScreen)
|
|
353
|
+
return content
|
|
354
|
+
}
|
|
355
|
+
function closeLoginScreen () { loginScreen?.remove(); loginScreen = null }
|
|
356
|
+
function showLoginForm (title, login, editor = false) {
|
|
357
|
+
const content = coverPage(title, editor ? 'editor' : 'panel', 'login')
|
|
358
|
+
const form = element('form')
|
|
359
|
+
const user = field(form, editor ? 'Editor username' : 'Username', element('input'))
|
|
360
|
+
user.name = editor ? 'cp-editor-username' : 'cp-panel-username'
|
|
361
|
+
user.autocomplete = editor ? 'section-cp-editor username' : 'section-cp-panel username'
|
|
362
|
+
const password = field(form, editor ? 'Editor password' : 'Password', element('input'))
|
|
363
|
+
password.type = 'password'
|
|
364
|
+
password.name = editor ? 'cp-editor-password' : 'cp-panel-password'
|
|
365
|
+
password.autocomplete = editor ? 'section-cp-editor current-password' : 'section-cp-panel current-password'
|
|
366
|
+
const message = element('p', '', 'cpv-message')
|
|
367
|
+
message.setAttribute('role', 'status')
|
|
368
|
+
form.append(message)
|
|
369
|
+
const submit = element('button', 'Log in')
|
|
370
|
+
submit.type = 'submit'
|
|
371
|
+
form.append(submit)
|
|
372
|
+
form.addEventListener('submit', async event => {
|
|
373
|
+
event.preventDefault()
|
|
374
|
+
submit.disabled = true
|
|
375
|
+
submit.textContent = editor ? 'Loading...' : 'Signing in...'
|
|
376
|
+
submit.setAttribute('aria-busy', 'true')
|
|
377
|
+
message.textContent = ''
|
|
378
|
+
try {
|
|
379
|
+
await login(user.value, password.value)
|
|
380
|
+
closeLoginScreen()
|
|
381
|
+
} catch (error) {
|
|
382
|
+
message.textContent = error.message
|
|
383
|
+
submit.textContent = 'Log in'
|
|
384
|
+
submit.removeAttribute('aria-busy')
|
|
385
|
+
submit.disabled = false
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
content.append(form)
|
|
389
|
+
user.focus()
|
|
390
|
+
}
|
|
391
|
+
function loginView () {
|
|
392
|
+
showLoginForm('ControlPanel editor', async (username, password) => {
|
|
393
|
+
const response = await fetch(base + '/login', {
|
|
394
|
+
method: 'POST', credentials: 'same-origin',
|
|
395
|
+
headers: { 'Content-Type': 'application/json' },
|
|
396
|
+
body: JSON.stringify({ username, password })
|
|
397
|
+
})
|
|
398
|
+
const result = await response.json()
|
|
399
|
+
if (!response.ok) throw new Error(result.error || 'Login failed')
|
|
400
|
+
editSession = result
|
|
401
|
+
await loadEditor()
|
|
402
|
+
}, true)
|
|
403
|
+
}
|
|
404
|
+
function normalLoginView () {
|
|
405
|
+
showLoginForm('Sign in', (username, password) => component.loginBoundPanel(username, password))
|
|
406
|
+
}
|
|
407
|
+
async function loadEditor () {
|
|
408
|
+
snapshot = await request('/config')
|
|
409
|
+
const response = await request('/devices')
|
|
410
|
+
devices = response.devices
|
|
411
|
+
draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
|
|
412
|
+
closeOverlay()
|
|
413
|
+
selecting = true
|
|
414
|
+
renderToolbar()
|
|
415
|
+
inform('Choose a control on the page')
|
|
416
|
+
updateBindingMarkers()
|
|
417
|
+
}
|
|
418
|
+
function choose (label, options, selected) {
|
|
419
|
+
const select = element('select')
|
|
420
|
+
select.setAttribute('aria-label', label)
|
|
421
|
+
select.append(new Option('Choose...', ''))
|
|
422
|
+
for (const item of options) select.append(new Option(item.text, item.value))
|
|
423
|
+
select.value = selected || ''
|
|
424
|
+
return select
|
|
425
|
+
}
|
|
426
|
+
function numberingMode (binding) {
|
|
427
|
+
for (const value of [binding?.onebased, binding?.writeMeta?.onebased, binding?.readMeta?.onebased]) {
|
|
428
|
+
if (typeof value === 'boolean') return value
|
|
429
|
+
}
|
|
430
|
+
return false
|
|
431
|
+
}
|
|
432
|
+
function finishBindingEdit (returnToBindings) {
|
|
433
|
+
if (returnToBindings) showBindingsDialog()
|
|
434
|
+
else closeOverlay()
|
|
435
|
+
}
|
|
436
|
+
async function bindingDialog (candidate, options = {}) {
|
|
437
|
+
const returnToBindings = options.returnToBindings === true
|
|
438
|
+
const existing = draft.find(entry => entry.locator === candidate.locator)
|
|
439
|
+
const dialog = showDialog(existing ? 'Edit binding' : 'Bind control')
|
|
440
|
+
const form = element('form')
|
|
441
|
+
const name = field(form, 'Name', element('input'))
|
|
442
|
+
name.value = existing?.label || candidate.label
|
|
443
|
+
name.maxLength = 80
|
|
444
|
+
const id = field(form, 'Control ID', element('input'))
|
|
445
|
+
id.value = existing?.id || 'control.' + candidate.locator.slice(-12)
|
|
446
|
+
const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
|
|
447
|
+
? ['read'] : ['both', 'write', 'read']
|
|
448
|
+
const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
|
|
449
|
+
const device = field(form, 'Device', choose('Device', devices.filter(item => item.online)
|
|
450
|
+
.map(item => ({ value: item.id, text: item.id })), existing?.device))
|
|
451
|
+
const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
|
|
452
|
+
const read = field(form, 'Feedback point', choose('Feedback point', [], existing?.readPath || existing?.writePath))
|
|
453
|
+
const numbering = field(form, 'Numbering', choose('Numbering', [
|
|
454
|
+
{ value: 'false', text: 'Zero-based' }, { value: 'true', text: 'One-based' }
|
|
455
|
+
], String(numberingMode(existing))))
|
|
456
|
+
const message = element('p', '', 'cpv-message')
|
|
457
|
+
form.append(message)
|
|
458
|
+
let targets = []
|
|
459
|
+
function updateTargets () {
|
|
460
|
+
const oldWrite = write.value || existing?.writePath
|
|
461
|
+
const oldRead = read.value || existing?.readPath || existing?.writePath
|
|
462
|
+
clear(write); clear(read)
|
|
463
|
+
write.append(new Option('Choose...', ''))
|
|
464
|
+
read.append(new Option('Choose...', ''))
|
|
465
|
+
let writeCount = 0
|
|
466
|
+
let readCount = 0
|
|
467
|
+
for (const target of targets) {
|
|
468
|
+
const label = `${target.path} (${target.type})`
|
|
469
|
+
if (compatible(candidate.adapter, target, 'write', candidate)) {
|
|
470
|
+
write.append(new Option(label, target.path))
|
|
471
|
+
writeCount++
|
|
472
|
+
}
|
|
473
|
+
if (compatible(candidate.adapter, target, 'read', candidate)) {
|
|
474
|
+
read.append(new Option(label, target.path))
|
|
475
|
+
readCount++
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
write.value = oldWrite || ''
|
|
479
|
+
read.value = oldRead || ''
|
|
480
|
+
write.closest('label').hidden = mode.value === 'read'
|
|
481
|
+
read.closest('label').hidden = mode.value === 'write'
|
|
482
|
+
const available = mode.value === 'read' ? readCount
|
|
483
|
+
: mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
|
|
484
|
+
message.textContent = targets.length && !available ? 'No compatible points for this control'
|
|
485
|
+
: targets.length ? '' : 'No points available'
|
|
486
|
+
}
|
|
487
|
+
device.addEventListener('change', async () => {
|
|
488
|
+
message.textContent = 'Loading points...'
|
|
489
|
+
try {
|
|
490
|
+
targets = device.value ? (await request('/targets?device=' + encodeURIComponent(device.value))).targets : []
|
|
491
|
+
updateTargets()
|
|
492
|
+
} catch (error) { targets = []; updateTargets(); message.textContent = error.message }
|
|
493
|
+
})
|
|
494
|
+
write.addEventListener('change', () => { read.value = write.value })
|
|
495
|
+
mode.addEventListener('change', updateTargets)
|
|
496
|
+
if (device.value) device.dispatchEvent(new Event('change'))
|
|
497
|
+
const actions = element('div', null, 'cpv-actions')
|
|
498
|
+
actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
|
|
499
|
+
if (existing) actions.append(button('Remove', () => {
|
|
500
|
+
draft = draft.filter(entry => entry.locator !== candidate.locator)
|
|
501
|
+
finishBindingEdit(returnToBindings)
|
|
502
|
+
inform('Binding removed from draft')
|
|
503
|
+
}))
|
|
504
|
+
const save = element('button', 'Save draft')
|
|
505
|
+
save.type = 'submit'
|
|
506
|
+
actions.append(save)
|
|
507
|
+
form.append(actions)
|
|
508
|
+
form.addEventListener('submit', event => {
|
|
509
|
+
event.preventDefault()
|
|
510
|
+
if (!/^[A-Za-z][A-Za-z0-9._/-]{0,127}$/.test(id.value) ||
|
|
511
|
+
draft.some(entry => entry.id === id.value && entry.locator !== candidate.locator)) {
|
|
512
|
+
message.textContent = 'Control ID must be unique and valid'; return
|
|
513
|
+
}
|
|
514
|
+
if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
|
|
515
|
+
(mode.value !== 'write' && !read.value) || !numbering.value) {
|
|
516
|
+
message.textContent = 'Complete the binding fields'; return
|
|
517
|
+
}
|
|
518
|
+
const entry = {
|
|
519
|
+
id: id.value, locator: candidate.locator, adapter: candidate.adapter,
|
|
520
|
+
...(candidate.model ? { model: candidate.model } : {}),
|
|
521
|
+
label: name.value.trim(), mode: mode.value, device: device.value,
|
|
522
|
+
writePath: mode.value === 'read' ? '' : write.value,
|
|
523
|
+
readPath: mode.value === 'write' ? '' : read.value,
|
|
524
|
+
onebased: numbering.value === 'true'
|
|
525
|
+
}
|
|
526
|
+
draft = draft.filter(item => item.locator !== candidate.locator)
|
|
527
|
+
draft.push(entry)
|
|
528
|
+
updateBindingMarkers()
|
|
529
|
+
finishBindingEdit(returnToBindings)
|
|
530
|
+
inform('Binding saved in draft')
|
|
531
|
+
})
|
|
532
|
+
dialog.append(form)
|
|
533
|
+
name.focus()
|
|
534
|
+
}
|
|
535
|
+
function renderToolbar () {
|
|
536
|
+
toolbar?.remove()
|
|
537
|
+
toolbar = element('div', null, 'cpv-toolbar')
|
|
538
|
+
const select = button('Select', () => {
|
|
539
|
+
selecting = !selecting
|
|
540
|
+
select.setAttribute('aria-pressed', String(selecting))
|
|
541
|
+
inform(selecting ? 'Choose a control on the page' : '')
|
|
542
|
+
})
|
|
543
|
+
select.setAttribute('aria-pressed', String(selecting))
|
|
544
|
+
toolbar.append(select)
|
|
545
|
+
toolbar.append(button('Bindings', showBindingsDialog))
|
|
546
|
+
const apply = button('Apply', async () => {
|
|
547
|
+
if (apply.disabled) return
|
|
548
|
+
apply.disabled = true
|
|
549
|
+
apply.setAttribute('aria-busy', 'true')
|
|
550
|
+
inform('Applying bindings...')
|
|
551
|
+
try {
|
|
552
|
+
await request('/apply', 'POST', {
|
|
553
|
+
rev: snapshot.rev, templateHash: snapshot.templateHash, entries: draft
|
|
554
|
+
})
|
|
555
|
+
window.location.reload()
|
|
556
|
+
} catch (error) {
|
|
557
|
+
inform(error.message, true)
|
|
558
|
+
} finally {
|
|
559
|
+
apply.disabled = false
|
|
560
|
+
apply.removeAttribute('aria-busy')
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
toolbar.append(apply)
|
|
564
|
+
toolbar.append(button('Exit', async () => {
|
|
565
|
+
try { await request('/logout', 'POST') } catch (_) {}
|
|
566
|
+
try { await component.logoutBoundPanel() } catch (_) {}
|
|
567
|
+
returnToPanel()
|
|
568
|
+
}))
|
|
569
|
+
status = element('span', '', 'cpv-status')
|
|
570
|
+
status.setAttribute('role', 'status')
|
|
571
|
+
toolbar.append(status)
|
|
572
|
+
document.body.append(toolbar)
|
|
573
|
+
}
|
|
574
|
+
function sortedDraftEntries () {
|
|
575
|
+
const order = new Map((snapshot?.candidates || []).map((candidate, index) => [candidate.locator, index]))
|
|
576
|
+
return [...draft].sort((a, b) => {
|
|
577
|
+
const position = (order.get(a.locator) ?? Number.MAX_SAFE_INTEGER) -
|
|
578
|
+
(order.get(b.locator) ?? Number.MAX_SAFE_INTEGER)
|
|
579
|
+
if (position) return position
|
|
580
|
+
return String(a.id).localeCompare(String(b.id))
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
function showBindingsDialog () {
|
|
584
|
+
const dialog = showDialog('Bindings')
|
|
585
|
+
if (!draft.length) dialog.append(element('p', 'No bindings'))
|
|
586
|
+
for (const entry of sortedDraftEntries()) {
|
|
587
|
+
const row = element('div', null, 'cpv-binding-row')
|
|
588
|
+
const path = entry.writePath || entry.readPath || '(no target)'
|
|
589
|
+
const details = element('div', null, 'cpv-binding-details')
|
|
590
|
+
details.append(element('span', entry.label, 'cpv-binding-label'))
|
|
591
|
+
details.append(element('span', path, 'cpv-binding-path'))
|
|
592
|
+
row.append(details)
|
|
593
|
+
const actions = element('div', null, 'cpv-binding-actions')
|
|
594
|
+
const candidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
|
|
595
|
+
const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
|
|
596
|
+
edit.disabled = !candidate
|
|
597
|
+
if (!candidate) edit.title = 'This control is no longer available on the page'
|
|
598
|
+
actions.append(edit)
|
|
599
|
+
actions.append(button('Remove', () => {
|
|
600
|
+
draft = draft.filter(item => item.id !== entry.id)
|
|
601
|
+
updateBindingMarkers()
|
|
602
|
+
row.remove()
|
|
603
|
+
inform('Binding removed from draft')
|
|
604
|
+
}))
|
|
605
|
+
row.append(actions)
|
|
606
|
+
dialog.append(row)
|
|
607
|
+
}
|
|
608
|
+
dialog.append(button('Close', closeOverlay))
|
|
609
|
+
}
|
|
610
|
+
function returnToPanel () {
|
|
611
|
+
const url = new URL(window.location.href)
|
|
612
|
+
url.searchParams.delete('cp-edit')
|
|
613
|
+
window.location.replace(url.href)
|
|
614
|
+
}
|
|
615
|
+
function candidateFromEvent (event) {
|
|
616
|
+
const node = event.target?.closest?.('[data-cp-auto]')
|
|
617
|
+
if (!node || !root.contains(node) || !snapshot) return null
|
|
618
|
+
const candidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
|
|
619
|
+
if (!candidate?.component || !candidate.model) return candidate
|
|
620
|
+
const value = component[candidate.model]
|
|
621
|
+
if (value == null) return candidate
|
|
622
|
+
if (candidate.adapter === 'switch' && typeof value !== 'boolean') {
|
|
623
|
+
return { ...candidate, supported: false, reason: 'This Vuetify switch needs a boolean v-model field' }
|
|
624
|
+
}
|
|
625
|
+
if (candidate.adapter === 'range' && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
626
|
+
return { ...candidate, supported: false, reason: 'This Vuetify slider needs a numeric v-model field' }
|
|
627
|
+
}
|
|
628
|
+
if (candidate.adapter === 'meter' && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
629
|
+
return { ...candidate, supported: false, reason: 'This Vuetify progress needs a numeric model field' }
|
|
630
|
+
}
|
|
631
|
+
if (candidate.adapter === 'select' && typeof value !== 'string' &&
|
|
632
|
+
!(typeof value === 'number' && Number.isFinite(value))) {
|
|
633
|
+
return { ...candidate, supported: false, reason: 'This Vuetify selection needs one scalar model field' }
|
|
634
|
+
}
|
|
635
|
+
if (candidate.adapter === 'text-input' && typeof value !== 'string') {
|
|
636
|
+
return { ...candidate, supported: false, reason: 'This Vuetify text field needs a string model field' }
|
|
637
|
+
}
|
|
638
|
+
return candidate
|
|
639
|
+
}
|
|
640
|
+
function chooseCandidate (event) {
|
|
641
|
+
if (!editing || !editSession) return
|
|
642
|
+
const candidate = candidateFromEvent(event)
|
|
643
|
+
if (!candidate) return
|
|
644
|
+
event.preventDefault()
|
|
645
|
+
event.stopImmediatePropagation()
|
|
646
|
+
if (!candidate.supported) { inform(candidate.reason, true); return }
|
|
647
|
+
contextMenu(candidate, event.clientX || 12, event.clientY || 12)
|
|
648
|
+
}
|
|
649
|
+
root.addEventListener('contextmenu', chooseCandidate, true)
|
|
650
|
+
for (const name of ['input', 'change']) {
|
|
651
|
+
root.addEventListener(name, event => {
|
|
652
|
+
if (!editing || !event.target?.closest?.('[data-cp-auto]')) return
|
|
653
|
+
event.preventDefault()
|
|
654
|
+
event.stopImmediatePropagation()
|
|
655
|
+
}, true)
|
|
656
|
+
}
|
|
657
|
+
root.addEventListener('click', event => { if (selecting) chooseCandidate(event) }, true)
|
|
658
|
+
root.addEventListener('keydown', event => {
|
|
659
|
+
if (event.key === 'Enter' && selecting) chooseCandidate(event)
|
|
660
|
+
}, true)
|
|
661
|
+
let longPress
|
|
662
|
+
root.addEventListener('pointerdown', event => {
|
|
663
|
+
if (!editing || event.pointerType === 'mouse') return
|
|
664
|
+
const candidate = candidateFromEvent(event)
|
|
665
|
+
if (candidate) longPress = setTimeout(() => chooseCandidate(event), 550)
|
|
666
|
+
}, true)
|
|
667
|
+
for (const name of ['pointerup', 'pointercancel', 'pointermove']) {
|
|
668
|
+
root.addEventListener(name, () => clearTimeout(longPress), true)
|
|
669
|
+
}
|
|
670
|
+
attachControls()
|
|
671
|
+
attachModelControls()
|
|
672
|
+
observer = new MutationObserver(() => {
|
|
673
|
+
attachControls()
|
|
674
|
+
updateBindingMarkers()
|
|
675
|
+
})
|
|
676
|
+
observer.observe(root, { childList: true, subtree: true })
|
|
677
|
+
const unwatch = this.$watch('boundState', showFeedback)
|
|
678
|
+
cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
|
|
679
|
+
() => closeOverlay(), () => closeLoginScreen())
|
|
680
|
+
this.__visualBindingCleanup = cleanup
|
|
681
|
+
|
|
682
|
+
const style = element('style')
|
|
683
|
+
style.textContent = `
|
|
684
|
+
.cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
|
|
685
|
+
.cpv-toolbar{position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003;max-width:calc(100vw - 24px)}
|
|
686
|
+
.cpv-toolbar button,.cpv-dialog button{padding:7px 11px;border:1px solid #809296;background:#fff;color:#182224;border-radius:4px;cursor:pointer;font:inherit}
|
|
687
|
+
.cpv-toolbar button[aria-pressed=true],.cpv-dialog button[type=submit]{background:#8b1ec4;color:white;border-color:#8b1ec4}
|
|
688
|
+
.cpv-toolbar button[aria-busy=true]{cursor:wait}
|
|
689
|
+
.cpv-toolbar button[aria-busy=true]::before{content:'';display:inline-block;width:14px;height:14px;margin-right:8px;vertical-align:-2px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:cpv-spin .8s linear infinite}
|
|
690
|
+
.cpv-status{max-width:260px;font-size:12px}.cpv-status[data-error=true],.cpv-message{color:#a52a35}
|
|
691
|
+
.cpv-login-screen{position:fixed;inset:0;z-index:2147483003;display:grid;box-sizing:border-box;min-height:100dvh;overflow:auto;font:16px/1.45 system-ui,sans-serif}
|
|
692
|
+
.cpv-login-content{position:relative;z-index:1;box-sizing:border-box;width:min(440px,100%)}
|
|
693
|
+
.cpv-login-content h1{font-size:30px;line-height:1.15;margin:0 0 28px;font-weight:650}
|
|
694
|
+
.cpv-login-content .cpv-field{margin:0 0 18px}.cpv-login-content .cpv-field span{font-size:13px;margin-bottom:7px}
|
|
695
|
+
.cpv-login-content input{box-sizing:border-box;width:100%;min-height:48px;padding:11px 14px;border-radius:4px;font:inherit}
|
|
696
|
+
.cpv-login-content button{width:100%;min-height:50px;padding:10px 16px;border:1px solid #008d35;border-radius:4px;background:#008d35;color:#fff;font:inherit;font-weight:650;cursor:pointer}
|
|
697
|
+
.cpv-login-content button:hover:not(:disabled){background:#067a32;border-color:#067a32}
|
|
698
|
+
.cpv-login-content button:disabled{opacity:.8;cursor:wait}
|
|
699
|
+
.cpv-login-content button[aria-busy=true]::before{content:'';display:inline-block;width:14px;height:14px;margin-right:10px;vertical-align:-2px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:cpv-spin .8s linear infinite}
|
|
700
|
+
.cpv-login-content .cpv-message{min-height:20px;margin:0 0 8px;font-size:13px}
|
|
701
|
+
.cpv-login-screen[data-state=loading] .cpv-login-content::before{content:'';display:block;width:30px;height:30px;margin-bottom:22px;border:3px solid #a9dcc0;border-top-color:#008d35;border-radius:50%;animation:cpv-spin .8s linear infinite}
|
|
702
|
+
@keyframes cpv-spin{to{transform:rotate(360deg)}}
|
|
703
|
+
.cpv-login-screen[data-mode=panel]{grid-template-columns:minmax(0,1fr) minmax(360px,42%);align-items:stretch;gap:clamp(24px,5vw,96px);padding:clamp(24px,4vw,64px);background:linear-gradient(90deg,#03170bc9 0%,#03170b70 42%,#03170be8 100%),url('/control-panel-assets/panel-login-bg.png') center/cover,#071a11;color:#f6fff7}
|
|
704
|
+
.cpv-login-brand{grid-column:1;grid-row:1;display:flex;flex-direction:column;justify-content:flex-end;gap:14px;min-width:0;padding-bottom:3vh}
|
|
705
|
+
.cpv-brand-row{display:flex;align-items:baseline;gap:clamp(12px,2vw,24px);white-space:nowrap;font-size:clamp(44px,5.2vw,82px);line-height:1;font-weight:800}
|
|
706
|
+
.cpv-brand-row strong{font:inherit}
|
|
707
|
+
.cpv-brand-tagline{max-width:610px;padding-top:14px;border-top:1px solid #b6d6bb;font-size:22px}
|
|
708
|
+
.cpv-login-screen[data-mode=panel] .cpv-login-content{grid-column:2;grid-row:1;align-self:center;justify-self:center;border-left:1px solid #71c88780;padding-left:clamp(24px,4vw,64px)}
|
|
709
|
+
.cpv-login-screen[data-mode=panel] .cpv-field span{color:#d1e5d5}
|
|
710
|
+
.cpv-login-screen[data-mode=panel] input{border:1px solid #73a780;background:#092516d9;color:#fff}
|
|
711
|
+
.cpv-login-screen[data-mode=panel] input:focus-visible{outline:2px solid #35c964;outline-offset:2px}
|
|
712
|
+
.cpv-login-screen[data-mode=panel] .cpv-message{color:#ffb9b1}
|
|
713
|
+
.cpv-login-screen[data-mode=editor]{grid-template-columns:minmax(320px,45%) minmax(0,1fr);align-items:stretch;background:#f7f8f6;color:#111a13}
|
|
714
|
+
.cpv-login-screen[data-mode=editor]::after{content:'';grid-column:2;grid-row:1;min-height:100dvh;background:linear-gradient(90deg,#f7f8f640 0%,#03211015 20%),url('/control-panel-assets/panel-login-bg.png') center/cover,#082313}
|
|
715
|
+
.cpv-login-screen[data-mode=editor] .cpv-login-content{grid-column:1;grid-row:1;align-self:center;justify-self:center;padding:48px;width:min(520px,100%)}
|
|
716
|
+
.cpv-login-screen[data-mode=editor] .cpv-brand-row{margin-bottom:40px;color:#0b110d}
|
|
717
|
+
.cpv-login-screen[data-mode=editor] .cpv-brand-row strong:first-child{color:#008d35}
|
|
718
|
+
.cpv-login-screen[data-mode=editor] .cpv-login-content h1{font-size:26px}
|
|
719
|
+
.cpv-login-screen[data-mode=editor] input{border:1px solid #a8b5aa;background:#fff;color:#111a13}
|
|
720
|
+
.cpv-login-screen[data-mode=editor] input:focus-visible{outline:2px solid #008d35;outline-offset:2px}
|
|
721
|
+
.cpv-login-screen[data-mode=editor] .cpv-message{color:#a52635}
|
|
722
|
+
@media(max-width:800px){
|
|
723
|
+
.cpv-login-screen[data-mode=panel]{grid-template-columns:1fr;grid-template-rows:auto auto;align-content:center;gap:32px;min-height:100dvh;padding:28px;background-image:linear-gradient(180deg,#03170bab 0%,#03170bf0 68%),url('/control-panel-assets/panel-login-bg.png');background-position:25% center}
|
|
724
|
+
.cpv-login-brand{grid-column:1;grid-row:1;gap:8px;padding:0}.cpv-brand-row{font-size:clamp(40px,8vw,56px)}.cpv-brand-tagline{font-size:16px}
|
|
725
|
+
.cpv-login-screen[data-mode=panel] .cpv-login-content{grid-column:1;grid-row:2;justify-self:start;width:min(390px,100%);border-left:0;padding-left:0}
|
|
726
|
+
.cpv-login-screen[data-mode=editor]{grid-template-columns:1fr}
|
|
727
|
+
.cpv-login-screen[data-mode=editor]::after{display:none}
|
|
728
|
+
.cpv-login-screen[data-mode=editor] .cpv-login-content{grid-column:1;grid-row:1;width:min(480px,100%);padding:32px 24px}
|
|
729
|
+
}
|
|
730
|
+
.cpv-scrim{position:fixed;inset:0;z-index:2147483001;background:#0008;display:flex;align-items:center;justify-content:center;padding:16px}
|
|
731
|
+
.cpv-dialog{width:min(460px,100%);max-height:min(90vh,720px);overflow:auto;padding:28px 18px 18px;border-radius:6px;box-shadow:0 12px 36px #0005}
|
|
732
|
+
.cpv-dialog h2{font-size:18px;margin:0 0 20px}.cpv-field{display:block;margin:10px 0}.cpv-field span{display:block;font-size:12px;margin-bottom:4px}
|
|
733
|
+
.cpv-field input,.cpv-field select{box-sizing:border-box;width:100%;padding:8px;border:1px solid #86999c;border-radius:4px;background:white;color:#182224;font:inherit}
|
|
734
|
+
.cpv-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.cpv-dialog>button:last-child{margin-top:20px}.cpv-message{min-height:18px;font-size:12px}
|
|
735
|
+
.cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
|
|
736
|
+
.cpv-menu button{display:block;width:100%;padding:8px;text-align:left;border:0;background:white;color:#182224;font:inherit;cursor:pointer}
|
|
737
|
+
.cpv-menu button:hover,.cpv-menu button:focus{background:#f1e2ff}
|
|
738
|
+
.cpv-binding-row{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #d3dedd}
|
|
739
|
+
.cpv-binding-details{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
740
|
+
.cpv-binding-details span{min-width:0;overflow-wrap:anywhere}.cpv-binding-path{color:#526467}
|
|
741
|
+
.cpv-binding-actions{display:flex;gap:6px;flex-shrink:0}
|
|
742
|
+
body.cpv-editing [data-cpv-bound="true"]{outline:2px solid #b526f2 !important;outline-offset:3px;box-shadow:0 0 0 4px #b526f255 !important}
|
|
743
|
+
body.cpv-editing [data-cp-auto]:hover{outline:2px solid #b526f2;outline-offset:2px}
|
|
744
|
+
`
|
|
745
|
+
document.head.append(style)
|
|
746
|
+
cleanup.push(() => style.remove())
|
|
747
|
+
if (editing) {
|
|
748
|
+
document.body.classList.add('cpv-editing')
|
|
749
|
+
cleanup.push(() => document.body.classList.remove('cpv-editing'))
|
|
750
|
+
coverPage('Loading...', 'editor')
|
|
751
|
+
request('/session').then(session => { editSession = session; return loadEditor() })
|
|
752
|
+
.then(closeLoginScreen).catch(() => loginView())
|
|
753
|
+
} else {
|
|
754
|
+
coverPage('Loading...', 'panel')
|
|
755
|
+
cleanup.push(this.$watch('boundAuthenticated', authenticated => {
|
|
756
|
+
if (authenticated) closeLoginScreen()
|
|
757
|
+
else if (!loginScreen) normalLoginView()
|
|
758
|
+
}))
|
|
759
|
+
component.restoreBoundPanelSession().then(session => {
|
|
760
|
+
if (session || component.boundAuthenticated) closeLoginScreen()
|
|
761
|
+
else normalLoginView()
|
|
762
|
+
}).catch(() => normalLoginView())
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function disposeVisualBinding () {
|
|
767
|
+
for (const stop of this.__visualBindingCleanup || []) stop()
|
|
768
|
+
this.__visualBindingCleanup = null
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
module.exports = { initializeVisualBinding, disposeVisualBinding }
|