@signalk/tracks-plugin 3.1.1 → 3.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/README.md +72 -0
- package/dist/asyncTrackStore.d.ts +26 -2
- package/dist/bin.js +285 -0
- package/dist/e2e.test-utils.d.ts +16 -2
- package/dist/gpx.d.ts +11 -2
- package/dist/gpxExport.test.d.ts +1 -0
- package/dist/gpxImport.test.d.ts +1 -0
- package/dist/import.js +98 -0
- package/dist/importedTracks.d.ts +52 -0
- package/dist/importedTracks.test.d.ts +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +52 -0
- package/dist/index.js +759 -71
- package/dist/index.js.map +1 -1
- package/dist/recycleBin.d.ts +20 -0
- package/dist/recycleBin.test.d.ts +1 -0
- package/dist/recycleBinApi.test.d.ts +1 -0
- package/dist/sqliteStore.d.ts +67 -1
- package/dist/store.d.ts +43 -1
- package/dist/style.css +78 -0
- package/dist/trackApi.d.ts +63 -5
- package/dist/trackProvider.d.ts +22 -1
- package/dist/trackStoreProtocol.d.ts +1 -1
- package/dist/trackStoreWorker.js +393 -1
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/tracks.js +132 -13
- package/dist/types.d.ts +74 -0
- package/dist/upload.d.ts +19 -0
- package/dist/{utils-CGIGp4Xf.js → utils-C0VkiidM.js} +32 -2
- package/dist/{utils-CGIGp4Xf.js.map → utils-C0VkiidM.js.map} +1 -1
- package/dist/utils.d.ts +14 -0
- package/docs/history-and-storage.md +19 -0
- package/package.json +1 -1
- package/public/bin.js +285 -0
- package/public/import.js +98 -0
- package/public/index.html +52 -0
- package/public/style.css +78 -0
- package/public/tracks.js +132 -13
package/public/bin.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deleting tracks, and the recycle bin they go to.
|
|
3
|
+
*
|
|
4
|
+
* Deletes go through the v2 Track API, like every other client's; the bin is
|
|
5
|
+
* this plugin's own, under its router at /plugins/tracks, since the Track API
|
|
6
|
+
* has no restore. Both need an administrator, which the server enforces.
|
|
7
|
+
*
|
|
8
|
+
* Everything that touches the page runs from `start()`, so the pure helpers
|
|
9
|
+
* can be imported and tested without a document.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Relative, because the server mounts this page under /@signalk/tracks-plugin. */
|
|
13
|
+
const TRACKS_URL = '../../signalk/v2/api/tracks'
|
|
14
|
+
const BIN_URL = '../../plugins/tracks/recycle-bin'
|
|
15
|
+
|
|
16
|
+
/** The label for a track, matching `trackLabel()` on the server and `label()` in tracks.js. */
|
|
17
|
+
export function label({ context, contextName, isSelf }) {
|
|
18
|
+
if (isSelf) {
|
|
19
|
+
return 'Own Ship'
|
|
20
|
+
}
|
|
21
|
+
if (contextName) {
|
|
22
|
+
return `AIS ${contextName}`
|
|
23
|
+
}
|
|
24
|
+
const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context ?? '')
|
|
25
|
+
return mmsi ? `AIS ${mmsi[1]}` : (context ?? 'No vessel')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What a bin entry deleted, in the viewer's locale and zone. */
|
|
29
|
+
export function deletedPart({ whole, from, to }) {
|
|
30
|
+
if (whole) {
|
|
31
|
+
return 'The whole track'
|
|
32
|
+
}
|
|
33
|
+
if (from === undefined) {
|
|
34
|
+
return `Everything up to ${when(to)}`
|
|
35
|
+
}
|
|
36
|
+
if (to === undefined) {
|
|
37
|
+
return `Everything from ${when(from)}`
|
|
38
|
+
}
|
|
39
|
+
return `${when(from)} to ${when(to)}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The query string for a delete of part of a track, from two `datetime-local`
|
|
44
|
+
* values; undefined when neither is set.
|
|
45
|
+
*
|
|
46
|
+
* The inputs show whole seconds, so `to` reaches the end of its second: a
|
|
47
|
+
* track ending at 12:00:00.5 shows as ending at 12:00:00, and a span "to
|
|
48
|
+
* 12:00:00" taken literally would keep the last point the user meant to
|
|
49
|
+
* delete.
|
|
50
|
+
*/
|
|
51
|
+
export function spanQuery(from, to) {
|
|
52
|
+
const params = new URLSearchParams()
|
|
53
|
+
if (from) params.set('from', new Date(from).toISOString())
|
|
54
|
+
if (to) params.set('to', new Date(new Date(to).getTime() + 999).toISOString())
|
|
55
|
+
const query = params.toString()
|
|
56
|
+
return query ? `?${query}` : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An ISO time as a `datetime-local` value, in the viewer's zone. */
|
|
60
|
+
export function localInput(iso) {
|
|
61
|
+
const at = new Date(iso)
|
|
62
|
+
if (Number.isNaN(at.getTime())) {
|
|
63
|
+
return ''
|
|
64
|
+
}
|
|
65
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
66
|
+
return (
|
|
67
|
+
`${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` +
|
|
68
|
+
`T${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Why a request was refused, in words a user can act on. */
|
|
73
|
+
export function refusal(status) {
|
|
74
|
+
if (status === 401 || status === 403) {
|
|
75
|
+
return 'This needs an administrator login.'
|
|
76
|
+
}
|
|
77
|
+
if (status === 404) {
|
|
78
|
+
return 'It is no longer there.'
|
|
79
|
+
}
|
|
80
|
+
if (status === 501) {
|
|
81
|
+
return 'The track provider cannot do this.'
|
|
82
|
+
}
|
|
83
|
+
return `The server answered ${status}.`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function when(iso) {
|
|
87
|
+
const at = new Date(iso)
|
|
88
|
+
return Number.isNaN(at.getTime()) ? '—' : at.toLocaleString()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function messageOf(response) {
|
|
92
|
+
try {
|
|
93
|
+
const body = await response.json()
|
|
94
|
+
return body?.error ?? body?.message
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function start() {
|
|
101
|
+
const dialog = document.getElementById('delete-dialog')
|
|
102
|
+
const form = dialog.querySelector('form')
|
|
103
|
+
const error = document.getElementById('delete-error')
|
|
104
|
+
const binStatus = document.getElementById('bin-status')
|
|
105
|
+
const binTable = document.getElementById('bin-table')
|
|
106
|
+
const binBody = binTable.querySelector('tbody')
|
|
107
|
+
let target
|
|
108
|
+
|
|
109
|
+
document.addEventListener('tracks:delete', (event) => {
|
|
110
|
+
target = event.detail
|
|
111
|
+
document.getElementById('delete-title').textContent = `Delete ${target.label}${
|
|
112
|
+
target.name ? ` (imported: ${target.name})` : ''
|
|
113
|
+
}`
|
|
114
|
+
// A recording goes on after it is deleted: what is recorded next is a new
|
|
115
|
+
// track, so "whole" means up to now, and saying so avoids a surprise.
|
|
116
|
+
document.getElementById('delete-whole').textContent = target.id.startsWith(`${target.providerId}:recorded:`)
|
|
117
|
+
? 'The whole track, up to now'
|
|
118
|
+
: 'The whole track'
|
|
119
|
+
form.elements.scope.value = 'whole'
|
|
120
|
+
form.elements.from.value = localInput(target.from)
|
|
121
|
+
form.elements.to.value = localInput(target.to)
|
|
122
|
+
// A track without times has no part to address by time.
|
|
123
|
+
for (const input of form.querySelectorAll('[value="span"], .span input')) {
|
|
124
|
+
input.disabled = target.from === undefined
|
|
125
|
+
}
|
|
126
|
+
error.hidden = true
|
|
127
|
+
dialog.showModal()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
form.addEventListener('submit', async (event) => {
|
|
131
|
+
if (event.submitter?.value !== 'delete') {
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
event.preventDefault()
|
|
135
|
+
const { scope, from, to } = form.elements
|
|
136
|
+
let query = ''
|
|
137
|
+
if (scope.value === 'span') {
|
|
138
|
+
// Without a bound the request would delete the whole track, which is
|
|
139
|
+
// not what choosing a part asked for.
|
|
140
|
+
const span = spanQuery(from.value, to.value)
|
|
141
|
+
if (span === undefined) {
|
|
142
|
+
error.textContent = 'Give the time the part starts, ends, or both.'
|
|
143
|
+
error.hidden = false
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
query = span
|
|
147
|
+
}
|
|
148
|
+
// The server hands out ids already qualified by their provider.
|
|
149
|
+
const id = encodeURIComponent(target.id)
|
|
150
|
+
const url = `${TRACKS_URL}/${id}${query}`
|
|
151
|
+
let response
|
|
152
|
+
try {
|
|
153
|
+
response = await fetch(url, { method: 'DELETE', credentials: 'include' })
|
|
154
|
+
} catch {
|
|
155
|
+
error.textContent = 'Could not reach the server.'
|
|
156
|
+
error.hidden = false
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
error.textContent = (await messageOf(response)) ?? refusal(response.status)
|
|
161
|
+
error.hidden = false
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
dialog.close()
|
|
165
|
+
location.reload()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
async function act(url, method) {
|
|
169
|
+
let response
|
|
170
|
+
try {
|
|
171
|
+
response = await fetch(url, { method, credentials: 'include' })
|
|
172
|
+
} catch {
|
|
173
|
+
showBin('Could not reach the server.', 'error')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
showBin((await messageOf(response)) ?? refusal(response.status), 'error')
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
location.reload()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function showBin(message, state) {
|
|
184
|
+
binStatus.textContent = message
|
|
185
|
+
if (state) {
|
|
186
|
+
binStatus.dataset.state = state
|
|
187
|
+
} else {
|
|
188
|
+
delete binStatus.dataset.state
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function cell(text, className) {
|
|
193
|
+
const td = document.createElement('td')
|
|
194
|
+
td.textContent = String(text)
|
|
195
|
+
if (className) td.className = className
|
|
196
|
+
return td
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function button(text, onClick, className) {
|
|
200
|
+
const b = document.createElement('button')
|
|
201
|
+
b.type = 'button'
|
|
202
|
+
b.textContent = text
|
|
203
|
+
if (className) b.className = className
|
|
204
|
+
b.addEventListener('click', onClick)
|
|
205
|
+
return b
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function binRow(entry) {
|
|
209
|
+
const tr = document.createElement('tr')
|
|
210
|
+
const name = cell(label(entry))
|
|
211
|
+
if (entry.name) {
|
|
212
|
+
const imported = document.createElement('span')
|
|
213
|
+
imported.className = 'imported'
|
|
214
|
+
imported.textContent = ` (imported: ${entry.name})`
|
|
215
|
+
name.append(imported)
|
|
216
|
+
}
|
|
217
|
+
const actions = document.createElement('td')
|
|
218
|
+
const buttons = document.createElement('div')
|
|
219
|
+
buttons.className = 'actions'
|
|
220
|
+
actions.append(buttons)
|
|
221
|
+
buttons.append(
|
|
222
|
+
button('Restore', () => act(`${BIN_URL}/${entry.id}/restore`, 'POST')),
|
|
223
|
+
button(
|
|
224
|
+
'Delete now',
|
|
225
|
+
() => {
|
|
226
|
+
if (confirm('Delete this for good? It cannot be restored afterwards.')) {
|
|
227
|
+
void act(`${BIN_URL}/${entry.id}`, 'DELETE')
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
'danger',
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
tr.append(
|
|
234
|
+
name,
|
|
235
|
+
cell(deletedPart(entry)),
|
|
236
|
+
cell(when(entry.deletedAt)),
|
|
237
|
+
cell(when(entry.purgeAt)),
|
|
238
|
+
cell(entry.pointCount, 'num'),
|
|
239
|
+
actions,
|
|
240
|
+
)
|
|
241
|
+
return tr
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function loadBin() {
|
|
245
|
+
let response
|
|
246
|
+
try {
|
|
247
|
+
response = await fetch(BIN_URL, { credentials: 'include', signal: AbortSignal.timeout(30_000) })
|
|
248
|
+
} catch {
|
|
249
|
+
showBin('Could not reach the server.', 'error')
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
showBin(
|
|
254
|
+
response.status === 401 || response.status === 403
|
|
255
|
+
? 'Sign in as an administrator to see the recycle bin.'
|
|
256
|
+
: refusal(response.status),
|
|
257
|
+
'error',
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
let entries
|
|
262
|
+
try {
|
|
263
|
+
entries = await response.json()
|
|
264
|
+
} catch {
|
|
265
|
+
entries = undefined
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(entries)) {
|
|
268
|
+
showBin('The server sent a response this page could not read.', 'error')
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (entries.length === 0) {
|
|
272
|
+
showBin('The recycle bin is empty.')
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
binBody.replaceChildren(...entries.map(binRow))
|
|
276
|
+
binTable.hidden = false
|
|
277
|
+
showBin(`${entries.length} deleted track${entries.length === 1 ? '' : 's'} or parts, newest first.`)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
void loadBin()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (typeof document !== 'undefined') {
|
|
284
|
+
start()
|
|
285
|
+
}
|
package/public/import.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importing GPX files as tracks.
|
|
3
|
+
*
|
|
4
|
+
* The file goes to this plugin's own route, which reads it with the same GPX
|
|
5
|
+
* parser the export uses: the page ships with no bundler and cannot reach it.
|
|
6
|
+
* Storing needs write access, which the server enforces.
|
|
7
|
+
*/
|
|
8
|
+
import { refusal } from './bin.js'
|
|
9
|
+
|
|
10
|
+
/** Relative, because the server mounts this page under /@signalk/tracks-plugin. */
|
|
11
|
+
const IMPORT_URL = '../../plugins/tracks/imports'
|
|
12
|
+
|
|
13
|
+
/** What an import came to, in words. */
|
|
14
|
+
export function imported({ ids, skippedPoints }) {
|
|
15
|
+
const count = `Imported ${ids.length} track${ids.length === 1 ? '' : 's'}.`
|
|
16
|
+
return skippedPoints > 0
|
|
17
|
+
? `${count} ${skippedPoints} point${skippedPoints === 1 ? '' : 's'} without a time left out.`
|
|
18
|
+
: count
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function messageOf(response) {
|
|
22
|
+
try {
|
|
23
|
+
const body = await response.json()
|
|
24
|
+
return body?.message ?? body?.error
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function start() {
|
|
31
|
+
const form = document.getElementById('import-form')
|
|
32
|
+
const status = document.getElementById('import-status')
|
|
33
|
+
const button = form.querySelector('button[type="submit"]')
|
|
34
|
+
|
|
35
|
+
function show(message, state) {
|
|
36
|
+
// A failed upload can be tried again; a successful one reloads the page.
|
|
37
|
+
if (state === 'error') {
|
|
38
|
+
button.disabled = false
|
|
39
|
+
}
|
|
40
|
+
status.textContent = message
|
|
41
|
+
status.hidden = false
|
|
42
|
+
if (state) {
|
|
43
|
+
status.dataset.state = state
|
|
44
|
+
} else {
|
|
45
|
+
delete status.dataset.state
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
form.addEventListener('submit', async (event) => {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
// Each request stores the file anew, so a second click while one is in
|
|
52
|
+
// flight would import every track in it twice.
|
|
53
|
+
if (button.disabled) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const file = form.elements.file.files[0]
|
|
57
|
+
if (!file) {
|
|
58
|
+
show('Choose a GPX file first.', 'error')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const query = form.elements.self.checked ? '?self=true' : ''
|
|
62
|
+
button.disabled = true
|
|
63
|
+
show('Importing…')
|
|
64
|
+
let response
|
|
65
|
+
try {
|
|
66
|
+
response = await fetch(`${IMPORT_URL}${query}`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
credentials: 'include',
|
|
69
|
+
headers: { 'Content-Type': 'application/gpx+xml' },
|
|
70
|
+
body: file,
|
|
71
|
+
})
|
|
72
|
+
} catch {
|
|
73
|
+
show('Could not reach the server.', 'error')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
show((await messageOf(response)) ?? refusal(response.status), 'error')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
let body
|
|
81
|
+
try {
|
|
82
|
+
body = await response.json()
|
|
83
|
+
} catch {
|
|
84
|
+
body = undefined
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(body?.ids)) {
|
|
87
|
+
show('The server sent a response this page could not read.', 'error')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
show(imported(body))
|
|
91
|
+
// Reloaded so the list, which shows every import, includes this one.
|
|
92
|
+
setTimeout(() => location.reload(), 1500)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof document !== 'undefined') {
|
|
97
|
+
start()
|
|
98
|
+
}
|
package/public/index.html
CHANGED
|
@@ -21,11 +21,63 @@
|
|
|
21
21
|
<th scope="col">To</th>
|
|
22
22
|
<th scope="col" class="num">Points</th>
|
|
23
23
|
<th scope="col">Export</th>
|
|
24
|
+
<th scope="col"><span class="sr-only">Delete</span></th>
|
|
24
25
|
</tr>
|
|
25
26
|
</thead>
|
|
26
27
|
<tbody></tbody>
|
|
27
28
|
</table>
|
|
29
|
+
<section id="import" aria-labelledby="import-title">
|
|
30
|
+
<h2 id="import-title">Import</h2>
|
|
31
|
+
<form id="import-form">
|
|
32
|
+
<label>GPX file <input type="file" name="file" accept=".gpx,application/gpx+xml" required /></label>
|
|
33
|
+
<label><input type="checkbox" name="self" /> Tracks of my own vessel</label>
|
|
34
|
+
<button type="submit">Import</button>
|
|
35
|
+
</form>
|
|
36
|
+
<p id="import-status" role="status" hidden></p>
|
|
37
|
+
</section>
|
|
38
|
+
<section id="bin" aria-labelledby="bin-title">
|
|
39
|
+
<h2 id="bin-title">Recycle bin</h2>
|
|
40
|
+
<p id="bin-status" role="status">Loading…</p>
|
|
41
|
+
<table id="bin-table" hidden>
|
|
42
|
+
<thead>
|
|
43
|
+
<tr>
|
|
44
|
+
<th scope="col">Track</th>
|
|
45
|
+
<th scope="col">Deleted part</th>
|
|
46
|
+
<th scope="col">Deleted</th>
|
|
47
|
+
<th scope="col">Purged</th>
|
|
48
|
+
<th scope="col" class="num">Points</th>
|
|
49
|
+
<th scope="col"><span class="sr-only">Actions</span></th>
|
|
50
|
+
</tr>
|
|
51
|
+
</thead>
|
|
52
|
+
<tbody></tbody>
|
|
53
|
+
</table>
|
|
54
|
+
</section>
|
|
28
55
|
</main>
|
|
56
|
+
<dialog id="delete-dialog">
|
|
57
|
+
<form method="dialog">
|
|
58
|
+
<h2 id="delete-title">Delete track</h2>
|
|
59
|
+
<fieldset>
|
|
60
|
+
<legend>What to delete</legend>
|
|
61
|
+
<label
|
|
62
|
+
><input type="radio" name="scope" value="whole" checked />
|
|
63
|
+
<span id="delete-whole">The whole track</span></label
|
|
64
|
+
>
|
|
65
|
+
<label><input type="radio" name="scope" value="span" /> Only the part recorded</label>
|
|
66
|
+
<div class="span">
|
|
67
|
+
<label>from <input type="datetime-local" name="from" step="1" /></label>
|
|
68
|
+
<label>to <input type="datetime-local" name="to" step="1" /></label>
|
|
69
|
+
</div>
|
|
70
|
+
</fieldset>
|
|
71
|
+
<p>It goes to the recycle bin, where it can be restored until it is purged.</p>
|
|
72
|
+
<p id="delete-error" class="error" role="alert" hidden></p>
|
|
73
|
+
<menu>
|
|
74
|
+
<button value="cancel" formnovalidate>Cancel</button>
|
|
75
|
+
<button value="delete" class="danger">Delete</button>
|
|
76
|
+
</menu>
|
|
77
|
+
</form>
|
|
78
|
+
</dialog>
|
|
29
79
|
<script type="module" src="./tracks.js"></script>
|
|
80
|
+
<script type="module" src="./bin.js"></script>
|
|
81
|
+
<script type="module" src="./import.js"></script>
|
|
30
82
|
</body>
|
|
31
83
|
</html>
|
package/public/style.css
CHANGED
|
@@ -47,3 +47,81 @@ tbody tr:hover {
|
|
|
47
47
|
.self {
|
|
48
48
|
font-weight: 600;
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
.sr-only {
|
|
52
|
+
position: absolute;
|
|
53
|
+
width: 1px;
|
|
54
|
+
height: 1px;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
clip-path: inset(50%);
|
|
57
|
+
white-space: nowrap;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
h2 {
|
|
61
|
+
font-size: 1.1rem;
|
|
62
|
+
margin: 2rem 0 0.25rem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#import-form {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-wrap: wrap;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 0.5rem 1rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#bin-status,
|
|
73
|
+
#import-status {
|
|
74
|
+
color: GrayText;
|
|
75
|
+
margin: 0 0 1rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.error,
|
|
79
|
+
#bin-status[data-state='error'],
|
|
80
|
+
#import-status[data-state='error'] {
|
|
81
|
+
color: crimson;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.imported {
|
|
85
|
+
color: GrayText;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.actions {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-wrap: wrap;
|
|
91
|
+
gap: 0.5rem;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.danger {
|
|
95
|
+
color: crimson;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dialog {
|
|
99
|
+
max-width: min(32rem, calc(100vw - 2rem));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
dialog h2 {
|
|
103
|
+
margin-top: 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
dialog fieldset {
|
|
107
|
+
display: grid;
|
|
108
|
+
gap: 0.5rem;
|
|
109
|
+
border: none;
|
|
110
|
+
padding: 0;
|
|
111
|
+
margin: 0 0 1rem;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
dialog .span {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-wrap: wrap;
|
|
117
|
+
gap: 0.5rem 1rem;
|
|
118
|
+
margin-left: 1.5rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
dialog menu {
|
|
122
|
+
display: flex;
|
|
123
|
+
justify-content: flex-end;
|
|
124
|
+
gap: 0.5rem;
|
|
125
|
+
padding: 0;
|
|
126
|
+
margin: 0;
|
|
127
|
+
}
|