@voxgig/build 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.ts +23 -0
- package/dist/build.d.ts +34 -1
- package/dist/build.js +19 -1
- package/dist/build.js.map +1 -1
- package/dist/env/env_gen.d.ts +17 -0
- package/dist/env/env_gen.js +154 -0
- package/dist/env/env_gen.js.map +1 -0
- package/dist/env/lambda/generate.d.ts +3 -2
- package/dist/env/lambda/generate.js +13 -9
- package/dist/env/lambda/generate.js.map +1 -1
- package/dist/env/web/web_gen.d.ts +19 -0
- package/dist/env/web/web_gen.js +93 -0
- package/dist/env/web/web_gen.js.map +1 -0
- package/env/env_gen.ts +179 -0
- package/env/lambda/generate.ts +15 -8
- package/env/web/web_gen.ts +115 -0
- package/package.json +1 -1
- package/tm/env/aws/README.md.frag +9 -0
- package/tm/env/aws/lambda.entry.ts.frag +89 -0
- package/tm/env/aws/serverless.yml.frag +23 -0
- package/tm/env/azure/README.md.frag +7 -0
- package/tm/env/azure/entry.ts.frag +49 -0
- package/tm/env/azure/host.json.frag +8 -0
- package/tm/env/basic/README.md.frag +7 -0
- package/tm/env/basic/backend.service.frag +14 -0
- package/tm/env/basic/deploy.sh.frag +13 -0
- package/tm/env/cloudflare/README.md.frag +7 -0
- package/tm/env/cloudflare/worker.ts.frag +13 -0
- package/tm/env/cloudflare/wrangler.toml.frag +7 -0
- package/tm/env/docker/Dockerfile.frag +10 -0
- package/tm/env/docker/README.md.frag +7 -0
- package/tm/env/docker/compose.yml.frag +10 -0
- package/tm/env/local/README.md.frag +7 -0
- package/tm/env/local/run.sh.frag +6 -0
- package/tm/env/server.entry.ts.frag +48 -0
- package/tm/env/vm/README.md.frag +5 -0
- package/tm/env/vm/cloud-init.yaml.frag +23 -0
- package/tm/web/backend/env/web/web.ts.frag +120 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +4 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +5 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +6 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +14 -0
- package/tm/web/backend/srv/auth/signout_user.ts.frag +5 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +12 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +25 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +9 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +21 -0
- package/tm/web/web/index.html.frag +13 -0
- package/tm/web/web/package.json.frag +17 -0
- package/tm/web/web/playwright.config.js.frag +36 -0
- package/tm/web/web/src/bus.js.frag +61 -0
- package/tm/web/web/src/cmp/admin.js.frag +254 -0
- package/tm/web/web/src/cmp/app.js.frag +30 -0
- package/tm/web/web/src/cmp/auth.js.frag +97 -0
- package/tm/web/web/src/main.js.frag +10 -0
- package/tm/web/web/src/style.css.frag +26 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Entity admin: a django-admin-style generic CRUD UI, driven entirely by
|
|
2
|
+
// the compiled model (fetched from /model.json). The entity_admin plugin
|
|
3
|
+
// owns the model + per-entity message mapping (state); the
|
|
4
|
+
// <vg-entity-admin> component renders nav / list / form and talks to the
|
|
5
|
+
// plugin (and through it, the backend) only via bus messages.
|
|
6
|
+
|
|
7
|
+
import { bus, emit } from '../bus.js'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
// Fields managed by the system, not the form.
|
|
11
|
+
const SYS_FIELDS = ['id', 'owner_id', 't_c', 't_m', 't_mh', 't_ch']
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
bus.use(function entity_admin() {
|
|
15
|
+
const seneca = this
|
|
16
|
+
|
|
17
|
+
let model = null
|
|
18
|
+
|
|
19
|
+
// Entities from the model: [{ zone, name, canon, fields }]
|
|
20
|
+
function entities() {
|
|
21
|
+
const out = []
|
|
22
|
+
const ent = (model && model.main.ent) || {}
|
|
23
|
+
for (const zone of Object.keys(ent)) {
|
|
24
|
+
if ('sys' === zone) {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
for (const name of Object.keys(ent[zone])) {
|
|
28
|
+
out.push({
|
|
29
|
+
zone,
|
|
30
|
+
name,
|
|
31
|
+
canon: zone + '/' + name,
|
|
32
|
+
fields: ent[zone][name].field || {},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Message mapping convention: entity zone/name -> aim:<zone>,<verb>:<name>.
|
|
40
|
+
function msgFor(canon, verb) {
|
|
41
|
+
const [zone, name] = canon.split('/')
|
|
42
|
+
return { aim: zone, [verb]: name }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
seneca.add('cmp:entity_admin,load:model', function (msg, reply) {
|
|
46
|
+
if (model) {
|
|
47
|
+
return reply({ ok: true, entities: entities() })
|
|
48
|
+
}
|
|
49
|
+
fetch('/model.json').then((r) => r.json()).then((m) => {
|
|
50
|
+
model = m
|
|
51
|
+
reply({ ok: true, entities: entities() })
|
|
52
|
+
}).catch((e) => reply({ ok: false, why: String(e) }))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
seneca.add('cmp:entity_admin,list:item', function (msg, reply) {
|
|
56
|
+
this.act(msgFor(msg.canon, 'list'), function (err, out) {
|
|
57
|
+
reply(err ? { ok: false, why: err.message } : out)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
seneca.add('cmp:entity_admin,save:item', function (msg, reply) {
|
|
62
|
+
this.act({ ...msgFor(msg.canon, 'save'), item: msg.item },
|
|
63
|
+
function (err, out) {
|
|
64
|
+
if (!err && out.ok) {
|
|
65
|
+
emit('entity', { canon: msg.canon })
|
|
66
|
+
}
|
|
67
|
+
reply(err ? { ok: false, why: err.message } : out)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
seneca.add('cmp:entity_admin,remove:item', function (msg, reply) {
|
|
72
|
+
this.act({ ...msgFor(msg.canon, 'remove'), id: msg.id },
|
|
73
|
+
function (err, out) {
|
|
74
|
+
if (!err && out.ok) {
|
|
75
|
+
emit('entity', { canon: msg.canon })
|
|
76
|
+
}
|
|
77
|
+
reply(err ? { ok: false, why: err.message } : out)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
function inputFor(fname, fdef, value) {
|
|
84
|
+
const kind = (fdef && fdef.kind) || 'String'
|
|
85
|
+
const val = null == value ? '' : value
|
|
86
|
+
if ('Boolean' === kind) {
|
|
87
|
+
return `<input name="${fname}" type="checkbox" ${true === value ? 'checked' : ''} />`
|
|
88
|
+
}
|
|
89
|
+
if ('Number' === kind) {
|
|
90
|
+
return `<input name="${fname}" type="number" value="${val}" />`
|
|
91
|
+
}
|
|
92
|
+
return `<input name="${fname}" type="text" value="${String(val).replace(/"/g, '"')}" />`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class VgEntityAdmin extends HTMLElement {
|
|
97
|
+
constructor() {
|
|
98
|
+
super()
|
|
99
|
+
this.canon = null
|
|
100
|
+
this.editing = null // item being edited, or {} for new
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
connectedCallback() {
|
|
104
|
+
this.render()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async render() {
|
|
108
|
+
const res = await bus.post('cmp:entity_admin,load:model')
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
this.innerHTML = `<p>model load failed: ${res.why}</p>`
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
this.entities = res.entities
|
|
114
|
+
if (null == this.canon && 0 < res.entities.length) {
|
|
115
|
+
this.canon = res.entities[0].canon
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nav = res.entities.map((e) =>
|
|
119
|
+
`<a href="#" class="vg-nav ${e.canon === this.canon ? 'sel' : ''}"
|
|
120
|
+
data-canon="${e.canon}">${e.canon}</a>`).join(' ')
|
|
121
|
+
|
|
122
|
+
this.innerHTML = `
|
|
123
|
+
<div class="vg-admin">
|
|
124
|
+
<nav>${nav}</nav>
|
|
125
|
+
<div id="vg-list"></div>
|
|
126
|
+
<div id="vg-form"></div>
|
|
127
|
+
</div>`
|
|
128
|
+
|
|
129
|
+
for (const a of this.querySelectorAll('.vg-nav')) {
|
|
130
|
+
a.onclick = (ev) => {
|
|
131
|
+
ev.preventDefault()
|
|
132
|
+
this.canon = a.dataset.canon
|
|
133
|
+
this.editing = null
|
|
134
|
+
this.render()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await this.renderList()
|
|
139
|
+
this.renderForm()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
entdef() {
|
|
143
|
+
return this.entities.find((e) => e.canon === this.canon)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async renderList() {
|
|
147
|
+
const out = await bus.post('cmp:entity_admin,list:item',
|
|
148
|
+
{ canon: this.canon })
|
|
149
|
+
const items = (out.ok && out.list) || []
|
|
150
|
+
const fields = Object.keys(this.entdef().fields)
|
|
151
|
+
.filter((f) => !SYS_FIELDS.includes(f))
|
|
152
|
+
|
|
153
|
+
const head = fields.map((f) => `<th>${f}</th>`).join('')
|
|
154
|
+
const rows = items.map((item) => `
|
|
155
|
+
<tr data-id="${item.id}">
|
|
156
|
+
${fields.map((f) => `<td>${null == item[f] ? '' : item[f]}</td>`).join('')}
|
|
157
|
+
<td>
|
|
158
|
+
<button class="vg-edit" data-id="${item.id}">edit</button>
|
|
159
|
+
<button class="vg-del" data-id="${item.id}">delete</button>
|
|
160
|
+
</td>
|
|
161
|
+
</tr>`).join('')
|
|
162
|
+
|
|
163
|
+
this.querySelector('#vg-list').innerHTML = `
|
|
164
|
+
<h2>${this.canon} <button id="vg-new">new</button></h2>
|
|
165
|
+
<table class="vg-table">
|
|
166
|
+
<thead><tr>${head}<th></th></tr></thead>
|
|
167
|
+
<tbody>${rows}</tbody>
|
|
168
|
+
</table>
|
|
169
|
+
<p id="vg-count">${items.length} item${1 === items.length ? '' : 's'}</p>`
|
|
170
|
+
|
|
171
|
+
this.items = items
|
|
172
|
+
this.querySelector('#vg-new').onclick = () => {
|
|
173
|
+
this.editing = {}
|
|
174
|
+
this.renderForm()
|
|
175
|
+
}
|
|
176
|
+
for (const b of this.querySelectorAll('.vg-edit')) {
|
|
177
|
+
b.onclick = () => {
|
|
178
|
+
this.editing = this.items.find((i) => i.id === b.dataset.id)
|
|
179
|
+
this.renderForm()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const b of this.querySelectorAll('.vg-del')) {
|
|
183
|
+
b.onclick = async () => {
|
|
184
|
+
await bus.post('cmp:entity_admin,remove:item',
|
|
185
|
+
{ canon: this.canon, id: b.dataset.id })
|
|
186
|
+
this.editing = null
|
|
187
|
+
await this.renderList()
|
|
188
|
+
this.renderForm()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
renderForm() {
|
|
194
|
+
const box = this.querySelector('#vg-form')
|
|
195
|
+
if (null == this.editing) {
|
|
196
|
+
box.innerHTML = ''
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const def = this.entdef()
|
|
201
|
+
const fields = Object.keys(def.fields)
|
|
202
|
+
.filter((f) => !SYS_FIELDS.includes(f))
|
|
203
|
+
|
|
204
|
+
box.innerHTML = `
|
|
205
|
+
<form class="vg-entity-form">
|
|
206
|
+
<h3>${this.editing.id ? 'Edit' : 'New'} ${this.canon}</h3>
|
|
207
|
+
${fields.map((f) => `
|
|
208
|
+
<label>${(def.fields[f] && def.fields[f].label) || f}
|
|
209
|
+
${inputFor(f, def.fields[f], this.editing[f])}
|
|
210
|
+
</label>`).join('')}
|
|
211
|
+
<button type="submit">Save</button>
|
|
212
|
+
<button type="button" id="vg-cancel">Cancel</button>
|
|
213
|
+
<div id="vg-form-err"></div>
|
|
214
|
+
</form>`
|
|
215
|
+
|
|
216
|
+
box.querySelector('#vg-cancel').onclick = () => {
|
|
217
|
+
this.editing = null
|
|
218
|
+
this.renderForm()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
box.querySelector('form').onsubmit = async (ev) => {
|
|
222
|
+
ev.preventDefault()
|
|
223
|
+
const item = { ...(this.editing.id ? { id: this.editing.id } : {}) }
|
|
224
|
+
for (const f of fields) {
|
|
225
|
+
const input = box.querySelector(`[name="${f}"]`)
|
|
226
|
+
const kind = (def.fields[f] && def.fields[f].kind) || 'String'
|
|
227
|
+
if ('Boolean' === kind) {
|
|
228
|
+
item[f] = input.checked
|
|
229
|
+
}
|
|
230
|
+
else if ('' === input.value) {
|
|
231
|
+
// Blank optional field: leave unset (don't send an empty string;
|
|
232
|
+
// an optional/Skip field rejects '').
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
item[f] = 'Number' === kind ? Number(input.value) : input.value
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const res = await bus.post('cmp:entity_admin,save:item',
|
|
240
|
+
{ canon: this.canon, item })
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
box.querySelector('#vg-form-err').textContent =
|
|
243
|
+
'save failed: ' + (res.why || '')
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
this.editing = null
|
|
247
|
+
await this.renderList()
|
|
248
|
+
this.renderForm()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
customElements.define('vg-entity-admin', VgEntityAdmin)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// App shell: shows the auth component always; the entity admin only when
|
|
2
|
+
// signed in. Reacts to bus auth events.
|
|
3
|
+
|
|
4
|
+
import { bus, onEvent } from '../bus.js'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VgApp extends HTMLElement {
|
|
8
|
+
connectedCallback() {
|
|
9
|
+
this.innerHTML = `
|
|
10
|
+
<header><h1>$$Name$$</h1><vg-auth></vg-auth></header>
|
|
11
|
+
<main id="vg-main"></main>`
|
|
12
|
+
|
|
13
|
+
onEvent('auth', ({ user }) => this.setMain(user))
|
|
14
|
+
|
|
15
|
+
// Resolve the cookie session on load.
|
|
16
|
+
bus.post('cmp:auth,load:state')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
setMain(user) {
|
|
20
|
+
const main = this.querySelector('#vg-main')
|
|
21
|
+
if (user) {
|
|
22
|
+
main.innerHTML = '<vg-entity-admin></vg-entity-admin>'
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
main.innerHTML = '<p class="vg-hint">Sign in to manage your data.</p>'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
customElements.define('vg-app', VgApp)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Auth: a seneca plugin holding the signed-in principal, and the
|
|
2
|
+
// <vg-auth> web component (sign-in form / signed-in bar) driving it
|
|
3
|
+
// purely through bus messages.
|
|
4
|
+
|
|
5
|
+
import { bus, emit } from '../bus.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
bus.use(function auth_cmp() {
|
|
9
|
+
const seneca = this
|
|
10
|
+
|
|
11
|
+
let user = null
|
|
12
|
+
|
|
13
|
+
seneca.add('cmp:auth,get:state', function (msg, reply) {
|
|
14
|
+
reply({ ok: true, user })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Resolve current auth from the backend (cookie session).
|
|
18
|
+
seneca.add('cmp:auth,load:state', function (msg, reply) {
|
|
19
|
+
this.act('aim:req,on:auth,load:auth', function (err, out) {
|
|
20
|
+
user = (!err && out.ok && 'signedin' === out.state) ? out.user : null
|
|
21
|
+
emit('auth', { user })
|
|
22
|
+
reply({ ok: true, user })
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
seneca.add('cmp:auth,signin:user', function (msg, reply) {
|
|
27
|
+
this.act('aim:req,on:auth,signin:user', {
|
|
28
|
+
email: msg.email,
|
|
29
|
+
password: msg.password,
|
|
30
|
+
}, function (err, out) {
|
|
31
|
+
if (err || !out.ok) {
|
|
32
|
+
return reply({ ok: false, why: (out && out.why) || 'signin-failed' })
|
|
33
|
+
}
|
|
34
|
+
user = out.user
|
|
35
|
+
emit('auth', { user })
|
|
36
|
+
reply({ ok: true, user })
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
seneca.add('cmp:auth,signout:user', function (msg, reply) {
|
|
41
|
+
this.act('aim:req,on:auth,signout:user', function () {
|
|
42
|
+
user = null
|
|
43
|
+
emit('auth', { user })
|
|
44
|
+
reply({ ok: true })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VgAuth extends HTMLElement {
|
|
51
|
+
connectedCallback() {
|
|
52
|
+
this.render()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async render() {
|
|
56
|
+
const state = await bus.post('cmp:auth,get:state')
|
|
57
|
+
|
|
58
|
+
if (state.user) {
|
|
59
|
+
this.innerHTML = `
|
|
60
|
+
<div class="vg-auth-bar">
|
|
61
|
+
<span>Signed in: <b>${state.user.email}</b></span>
|
|
62
|
+
<button id="vg-signout">Sign out</button>
|
|
63
|
+
</div>`
|
|
64
|
+
this.querySelector('#vg-signout').onclick = async () => {
|
|
65
|
+
await bus.post('cmp:auth,signout:user')
|
|
66
|
+
this.render()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.innerHTML = `
|
|
71
|
+
<form class="vg-auth-form">
|
|
72
|
+
<h2>Sign in</h2>
|
|
73
|
+
<label>Email <input name="email" type="email" required /></label>
|
|
74
|
+
<label>Password <input name="password" type="password" required /></label>
|
|
75
|
+
<button type="submit">Sign in</button>
|
|
76
|
+
<div class="vg-auth-err" id="vg-auth-err"></div>
|
|
77
|
+
</form>`
|
|
78
|
+
this.querySelector('form').onsubmit = async (ev) => {
|
|
79
|
+
ev.preventDefault()
|
|
80
|
+
const fd = new FormData(ev.target)
|
|
81
|
+
const res = await bus.post('cmp:auth,signin:user', {
|
|
82
|
+
email: fd.get('email'),
|
|
83
|
+
password: fd.get('password'),
|
|
84
|
+
})
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
this.querySelector('#vg-auth-err').textContent =
|
|
87
|
+
'Sign in failed: ' + res.why
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
this.render()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
customElements.define('vg-auth', VgAuth)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Todo SPA entry: web components on a Seneca service bus (experimental).
|
|
2
|
+
// No framework: each component is a custom element backed by a seneca
|
|
3
|
+
// plugin (state), and all data flows are bus messages - aim:* messages
|
|
4
|
+
// travel to the backend gateway via the seneca-browser fetch transport.
|
|
5
|
+
|
|
6
|
+
import './style.css'
|
|
7
|
+
import './bus.js'
|
|
8
|
+
import './cmp/auth.js'
|
|
9
|
+
import './cmp/admin.js'
|
|
10
|
+
import './cmp/app.js'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
* { box-sizing: border-box; }
|
|
2
|
+
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; color: #1c2733; }
|
|
3
|
+
header { display: flex; align-items: center; justify-content: space-between;
|
|
4
|
+
padding: 0.6rem 1.2rem; border-bottom: 2px solid #e3e8ee; }
|
|
5
|
+
header h1 { font-size: 1.2rem; margin: 0; }
|
|
6
|
+
main { padding: 1.2rem; max-width: 60rem; }
|
|
7
|
+
|
|
8
|
+
.vg-auth-bar { display: flex; gap: 0.8rem; align-items: center; }
|
|
9
|
+
.vg-auth-form { max-width: 22rem; display: grid; gap: 0.6rem; }
|
|
10
|
+
.vg-auth-form label { display: grid; gap: 0.2rem; }
|
|
11
|
+
.vg-auth-err { color: #b3261e; min-height: 1.2em; }
|
|
12
|
+
|
|
13
|
+
.vg-admin nav { margin-bottom: 1rem; }
|
|
14
|
+
.vg-admin nav a { margin-right: 0.8rem; text-decoration: none; }
|
|
15
|
+
.vg-admin nav a.sel { font-weight: 700; border-bottom: 2px solid #1c2733; }
|
|
16
|
+
|
|
17
|
+
.vg-table { border-collapse: collapse; width: 100%; }
|
|
18
|
+
.vg-table th, .vg-table td { text-align: left; padding: 0.35rem 0.6rem;
|
|
19
|
+
border-bottom: 1px solid #e3e8ee; }
|
|
20
|
+
|
|
21
|
+
.vg-entity-form { margin-top: 1.2rem; max-width: 26rem; display: grid;
|
|
22
|
+
gap: 0.6rem; padding: 1rem; border: 1px solid #e3e8ee; border-radius: 6px; }
|
|
23
|
+
.vg-entity-form label { display: grid; gap: 0.2rem; }
|
|
24
|
+
|
|
25
|
+
button { cursor: pointer; padding: 0.25rem 0.7rem; }
|
|
26
|
+
.vg-hint { color: #5b6b7b; }
|