selfstore 1.0.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/LICENSE +21 -0
- package/README.md +364 -0
- package/dist/advanced.d.ts +90 -0
- package/dist/advanced.js +41 -0
- package/dist/advanced.js.map +1 -0
- package/dist/backups.d.ts +182 -0
- package/dist/backups.js +360 -0
- package/dist/backups.js.map +1 -0
- package/dist/cache-De5_sALA.d.ts +129 -0
- package/dist/cache-lock-LZILZNLP.js +23 -0
- package/dist/cache-lock-LZILZNLP.js.map +1 -0
- package/dist/chunk-ATUPHVLG.js +441 -0
- package/dist/chunk-ATUPHVLG.js.map +1 -0
- package/dist/chunk-BJ3RUMGI.js +45 -0
- package/dist/chunk-BJ3RUMGI.js.map +1 -0
- package/dist/chunk-CUWB5EWF.js +732 -0
- package/dist/chunk-CUWB5EWF.js.map +1 -0
- package/dist/chunk-FDPAKHQS.js +230 -0
- package/dist/chunk-FDPAKHQS.js.map +1 -0
- package/dist/chunk-IBIEKK36.js +331 -0
- package/dist/chunk-IBIEKK36.js.map +1 -0
- package/dist/chunk-JB4JHM7V.js +474 -0
- package/dist/chunk-JB4JHM7V.js.map +1 -0
- package/dist/chunk-N4RPMX3Y.js +1908 -0
- package/dist/chunk-N4RPMX3Y.js.map +1 -0
- package/dist/chunk-OWLX6PNT.js +231 -0
- package/dist/chunk-OWLX6PNT.js.map +1 -0
- package/dist/chunk-QBCU7OOP.js +20 -0
- package/dist/chunk-QBCU7OOP.js.map +1 -0
- package/dist/chunk-SGISYMT5.js +46 -0
- package/dist/chunk-SGISYMT5.js.map +1 -0
- package/dist/chunk-VBFMMR7Q.js +29 -0
- package/dist/chunk-VBFMMR7Q.js.map +1 -0
- package/dist/drive-Bv94UM8b.d.ts +222 -0
- package/dist/flows.d.ts +306 -0
- package/dist/flows.js +153 -0
- package/dist/flows.js.map +1 -0
- package/dist/groups.d.ts +39 -0
- package/dist/groups.js +103 -0
- package/dist/groups.js.map +1 -0
- package/dist/households.d.ts +214 -0
- package/dist/households.js +410 -0
- package/dist/households.js.map +1 -0
- package/dist/index.d.ts +205 -0
- package/dist/index.js +249 -0
- package/dist/index.js.map +1 -0
- package/dist/kdf-worker.js +2395 -0
- package/dist/kdf-worker.js.map +1 -0
- package/dist/local-2py_vGGy.d.ts +47 -0
- package/dist/local-AM22RWRR.js +7 -0
- package/dist/local-AM22RWRR.js.map +1 -0
- package/dist/machine-ALMkT3mk.d.ts +17 -0
- package/dist/store-ChIGEOph.d.ts +577 -0
- package/dist/sync.d.ts +107 -0
- package/dist/sync.js +3 -0
- package/dist/sync.js.map +1 -0
- package/dist/widgets.d.ts +426 -0
- package/dist/widgets.js +2579 -0
- package/dist/widgets.js.map +1 -0
- package/llms.txt +405 -0
- package/package.json +99 -0
package/dist/widgets.js
ADDED
|
@@ -0,0 +1,2579 @@
|
|
|
1
|
+
import { connectFlow, shareFlow, joinFlow } from './chunk-ATUPHVLG.js';
|
|
2
|
+
import './chunk-CUWB5EWF.js';
|
|
3
|
+
import './chunk-JB4JHM7V.js';
|
|
4
|
+
import './chunk-SGISYMT5.js';
|
|
5
|
+
import './chunk-OWLX6PNT.js';
|
|
6
|
+
import './chunk-QBCU7OOP.js';
|
|
7
|
+
import './chunk-FDPAKHQS.js';
|
|
8
|
+
import './chunk-VBFMMR7Q.js';
|
|
9
|
+
|
|
10
|
+
// src/widgets/base.ts
|
|
11
|
+
var baseStyles = `
|
|
12
|
+
:host {
|
|
13
|
+
display: block;
|
|
14
|
+
font: inherit;
|
|
15
|
+
color: inherit;
|
|
16
|
+
--_accent: var(--selfstore-accent, #2563eb);
|
|
17
|
+
--_accent-ink: var(--selfstore-accent-contrast, #ffffff);
|
|
18
|
+
--_ink-dim: var(--selfstore-muted, color-mix(in srgb, currentColor 55%, transparent));
|
|
19
|
+
--_border: var(--selfstore-border, color-mix(in srgb, currentColor 16%, transparent));
|
|
20
|
+
--_radius: var(--selfstore-radius, 12px);
|
|
21
|
+
--_gap: var(--selfstore-gap, 0.6rem);
|
|
22
|
+
--_danger: var(--selfstore-danger, #dc2626);
|
|
23
|
+
--_ok: var(--selfstore-ok, #16a34a);
|
|
24
|
+
--_warn: var(--selfstore-warn, #d97706);
|
|
25
|
+
}
|
|
26
|
+
/* Status severity, as a text color the dot inherits (background: currentColor). */
|
|
27
|
+
[part~='sev-ok'] { color: var(--_ok); }
|
|
28
|
+
[part~='sev-info'] { color: var(--_accent); }
|
|
29
|
+
[part~='sev-warn'] { color: var(--_warn); }
|
|
30
|
+
[part~='sev-danger'] { color: var(--_danger); }
|
|
31
|
+
span[part~='status-dot'] {
|
|
32
|
+
display: inline-block;
|
|
33
|
+
width: 0.6em;
|
|
34
|
+
height: 0.6em;
|
|
35
|
+
border-radius: 50%;
|
|
36
|
+
background: currentColor;
|
|
37
|
+
flex-shrink: 0;
|
|
38
|
+
}
|
|
39
|
+
button[part~='dot-button'] {
|
|
40
|
+
background: none;
|
|
41
|
+
border: none;
|
|
42
|
+
padding: 0.2em;
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
line-height: 0;
|
|
45
|
+
}
|
|
46
|
+
[part~='status-row'] [part~='status-action'] { margin-left: auto; }
|
|
47
|
+
[part~='card'] {
|
|
48
|
+
border: 1px solid var(--_border);
|
|
49
|
+
border-radius: var(--_radius);
|
|
50
|
+
padding: 0.8rem 0.9rem;
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: var(--_gap);
|
|
54
|
+
width: 100%;
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
background: transparent;
|
|
57
|
+
text-align: left;
|
|
58
|
+
}
|
|
59
|
+
button[part~='card'] { cursor: pointer; font: inherit; color: inherit; }
|
|
60
|
+
button[part~='card']:hover { border-color: var(--_accent); }
|
|
61
|
+
img[part~='icon'] {
|
|
62
|
+
width: var(--selfstore-icon-size, 2.1em);
|
|
63
|
+
height: var(--selfstore-icon-size, 2.1em);
|
|
64
|
+
object-fit: contain;
|
|
65
|
+
flex-shrink: 0;
|
|
66
|
+
}
|
|
67
|
+
[part~='stack'] { display: flex; flex-direction: column; gap: var(--_gap); container-type: inline-size; }
|
|
68
|
+
[part~='row'] { display: flex; align-items: center; gap: var(--_gap); flex-wrap: wrap; }
|
|
69
|
+
[part~='title'] { font-weight: 600; }
|
|
70
|
+
[part~='sub'], [part~='hint'] { color: var(--_ink-dim); font-size: 0.9em; }
|
|
71
|
+
[part~='tag'] {
|
|
72
|
+
display: inline-block;
|
|
73
|
+
font-size: 0.72em;
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
padding: 0 0.55em;
|
|
77
|
+
margin-left: 0.5em;
|
|
78
|
+
border-radius: 999px;
|
|
79
|
+
background: var(--_accent);
|
|
80
|
+
color: var(--_accent-ink);
|
|
81
|
+
vertical-align: middle;
|
|
82
|
+
}
|
|
83
|
+
[part~='status'] {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: var(--_gap);
|
|
87
|
+
padding: 0.7rem 0.9rem;
|
|
88
|
+
border-radius: var(--_radius);
|
|
89
|
+
background: color-mix(in srgb, var(--_accent) 10%, transparent);
|
|
90
|
+
}
|
|
91
|
+
[part~='status-ok'] { background: color-mix(in srgb, var(--_ok) 12%, transparent); }
|
|
92
|
+
[part~='status-error'] { background: color-mix(in srgb, var(--_danger) 12%, transparent); }
|
|
93
|
+
[part='spinner'] {
|
|
94
|
+
width: 0.7em; height: 0.7em; flex-shrink: 0;
|
|
95
|
+
border-radius: 50%;
|
|
96
|
+
border: 2px solid var(--_accent);
|
|
97
|
+
border-top-color: transparent;
|
|
98
|
+
animation: ss-spin 0.8s linear infinite;
|
|
99
|
+
}
|
|
100
|
+
@keyframes ss-spin { to { transform: rotate(360deg); } }
|
|
101
|
+
button[part~='button'] {
|
|
102
|
+
font: inherit;
|
|
103
|
+
padding: 0.45rem 0.9rem;
|
|
104
|
+
border-radius: calc(var(--_radius) * 0.75);
|
|
105
|
+
border: 1px solid var(--_border);
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: inherit;
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
}
|
|
110
|
+
button[part~='button']:disabled { opacity: 0.55; cursor: default; }
|
|
111
|
+
button[part~='button-primary'] {
|
|
112
|
+
background: var(--_accent);
|
|
113
|
+
border-color: var(--_accent);
|
|
114
|
+
color: var(--_accent-ink);
|
|
115
|
+
}
|
|
116
|
+
button[part~='button-danger'] { color: var(--_danger); }
|
|
117
|
+
button[part~='link'] {
|
|
118
|
+
font: inherit;
|
|
119
|
+
background: none;
|
|
120
|
+
border: none;
|
|
121
|
+
padding: 0;
|
|
122
|
+
color: var(--_ink-dim);
|
|
123
|
+
text-decoration: underline;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
}
|
|
126
|
+
button[part~='link']:disabled { opacity: 0.55; cursor: default; }
|
|
127
|
+
button[part~='link-danger'] { color: var(--_danger); }
|
|
128
|
+
button[part~='advanced-link'] {
|
|
129
|
+
width: fit-content;
|
|
130
|
+
font-size: 0.85em;
|
|
131
|
+
margin-top: 0.1rem;
|
|
132
|
+
}
|
|
133
|
+
[part~='card'] [part~='row'] { margin-top: 0.45rem; }
|
|
134
|
+
input[part~='input'] {
|
|
135
|
+
font: inherit;
|
|
136
|
+
color: inherit;
|
|
137
|
+
background: transparent;
|
|
138
|
+
border: 1px solid var(--_border);
|
|
139
|
+
border-radius: calc(var(--_radius) * 0.75);
|
|
140
|
+
padding: 0.45rem 0.6rem;
|
|
141
|
+
width: 100%;
|
|
142
|
+
box-sizing: border-box;
|
|
143
|
+
}
|
|
144
|
+
input[part~='input']:focus { outline: 2px solid var(--_accent); outline-offset: 1px; }
|
|
145
|
+
/* A text input with a trailing button (the password eye) tucked inside it. */
|
|
146
|
+
[part~='field'] { position: relative; display: flex; align-items: center; width: 100%; }
|
|
147
|
+
[part~='field'] input[part~='input'] { padding-right: 2.4rem; }
|
|
148
|
+
button[part~='eye'] {
|
|
149
|
+
position: absolute;
|
|
150
|
+
right: 0.25rem;
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: center;
|
|
154
|
+
width: 2rem;
|
|
155
|
+
height: 2rem;
|
|
156
|
+
background: none;
|
|
157
|
+
border: none;
|
|
158
|
+
padding: 0;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
color: var(--_ink-dim);
|
|
161
|
+
}
|
|
162
|
+
button[part~='eye']:hover { color: inherit; }
|
|
163
|
+
button[part~='eye'] svg { width: 1.15em; height: 1.15em; display: block; }
|
|
164
|
+
[part~='warn-note'] { color: var(--_warn); }
|
|
165
|
+
button[part~='forgot-link'] { width: fit-content; font-size: 0.85em; margin-top: 0.15rem; }
|
|
166
|
+
[part~='error-note'] { color: var(--_danger); font-size: 0.9em; }
|
|
167
|
+
/* WebDAV/S3 segmented control: a rail with the inactive segments quiet and the
|
|
168
|
+
active one lifted onto the surface. Full-width segments, equal share. */
|
|
169
|
+
[part~='tabs'] {
|
|
170
|
+
display: flex;
|
|
171
|
+
gap: 0.2rem;
|
|
172
|
+
padding: 0.25rem;
|
|
173
|
+
border-radius: calc(var(--_radius) * 0.7);
|
|
174
|
+
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
175
|
+
}
|
|
176
|
+
button[part~='tab'] {
|
|
177
|
+
flex: 1 1 0;
|
|
178
|
+
font: inherit;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
padding: 0.4rem 0.7rem;
|
|
181
|
+
border: none;
|
|
182
|
+
border-radius: calc(var(--_radius) * 0.5);
|
|
183
|
+
background: transparent;
|
|
184
|
+
color: var(--_ink-dim);
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
}
|
|
187
|
+
button[part~='tab']:hover { color: inherit; }
|
|
188
|
+
button[part~='tab-on'] {
|
|
189
|
+
background: Canvas;
|
|
190
|
+
color: inherit;
|
|
191
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
192
|
+
}
|
|
193
|
+
/* WebDAV provider quick-pick chips. */
|
|
194
|
+
[part~='presets'] { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
195
|
+
button[part~='preset'] {
|
|
196
|
+
font: inherit;
|
|
197
|
+
font-size: 0.85em;
|
|
198
|
+
padding: 0.3rem 0.75rem;
|
|
199
|
+
border: 1px solid var(--_border);
|
|
200
|
+
border-radius: 999px;
|
|
201
|
+
background: transparent;
|
|
202
|
+
color: inherit;
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
}
|
|
205
|
+
button[part~='preset']:hover { border-color: var(--_accent); }
|
|
206
|
+
button[part~='preset-on'] {
|
|
207
|
+
border-color: var(--_accent);
|
|
208
|
+
background: color-mix(in srgb, var(--_accent) 12%, transparent);
|
|
209
|
+
}
|
|
210
|
+
ul[part~='list'] { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--_gap); }
|
|
211
|
+
img[part~='qr'] {
|
|
212
|
+
width: var(--selfstore-qr-size, 6.5em);
|
|
213
|
+
height: var(--selfstore-qr-size, 6.5em);
|
|
214
|
+
border-radius: calc(var(--_radius) * 0.5);
|
|
215
|
+
background: #ffffff;
|
|
216
|
+
flex-shrink: 0;
|
|
217
|
+
}
|
|
218
|
+
/* Overflow menu. The trigger is a quiet in-card button; the open menu is a
|
|
219
|
+
dropdown over the trigger's corner on wide screens and a bottom sheet on
|
|
220
|
+
phones. The backdrop catches every outside click or tap (the standard
|
|
221
|
+
dismiss), sitting above the sibling cards but under the panel. */
|
|
222
|
+
button[part~='menu-button'] {
|
|
223
|
+
border: none;
|
|
224
|
+
background: none;
|
|
225
|
+
padding: 0.25rem 0.6rem;
|
|
226
|
+
font-size: 1.1em;
|
|
227
|
+
line-height: 1.3;
|
|
228
|
+
border-radius: calc(var(--_radius) * 0.5);
|
|
229
|
+
color: var(--_ink-dim);
|
|
230
|
+
cursor: pointer;
|
|
231
|
+
}
|
|
232
|
+
button[part~='menu-button']:hover,
|
|
233
|
+
button[part~='menu-button'][aria-expanded='true'] {
|
|
234
|
+
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
235
|
+
color: inherit;
|
|
236
|
+
}
|
|
237
|
+
[part~='card'] > button[part~='menu-button'] { align-self: flex-start; }
|
|
238
|
+
[part~='menu-layer'] { display: contents; }
|
|
239
|
+
[part~='menu-backdrop'] { position: fixed; inset: 0; z-index: 29; background: transparent; }
|
|
240
|
+
[part~='menu'] {
|
|
241
|
+
position: absolute;
|
|
242
|
+
right: 0.4rem;
|
|
243
|
+
top: 0.4rem;
|
|
244
|
+
z-index: 30;
|
|
245
|
+
display: flex;
|
|
246
|
+
flex-direction: column;
|
|
247
|
+
min-width: 11rem;
|
|
248
|
+
padding: 0.3rem 0;
|
|
249
|
+
border: 1px solid var(--_border);
|
|
250
|
+
border-radius: calc(var(--_radius) * 0.75);
|
|
251
|
+
background: Canvas;
|
|
252
|
+
color: CanvasText;
|
|
253
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16);
|
|
254
|
+
outline: none;
|
|
255
|
+
}
|
|
256
|
+
button[part~='menu-item'] {
|
|
257
|
+
font: inherit;
|
|
258
|
+
text-align: left;
|
|
259
|
+
padding: 0.55rem 0.95rem;
|
|
260
|
+
border: none;
|
|
261
|
+
background: none;
|
|
262
|
+
color: inherit;
|
|
263
|
+
cursor: pointer;
|
|
264
|
+
white-space: nowrap;
|
|
265
|
+
}
|
|
266
|
+
button[part~='menu-item']:hover,
|
|
267
|
+
button[part~='menu-item']:focus-visible {
|
|
268
|
+
background: color-mix(in srgb, var(--_accent) 10%, transparent);
|
|
269
|
+
}
|
|
270
|
+
button[part~='menu-item-danger'] { color: var(--_danger); }
|
|
271
|
+
@media (pointer: coarse) {
|
|
272
|
+
button[part~='menu-item'] { padding: 0.8rem 1rem; }
|
|
273
|
+
}
|
|
274
|
+
@media (max-width: 500px) {
|
|
275
|
+
[part~='menu-backdrop'] { background: rgba(0, 0, 0, 0.35); }
|
|
276
|
+
[part~='menu'] {
|
|
277
|
+
position: fixed;
|
|
278
|
+
inset: auto 0 0 0;
|
|
279
|
+
border-radius: var(--_radius) var(--_radius) 0 0;
|
|
280
|
+
border-bottom: none;
|
|
281
|
+
min-width: 0;
|
|
282
|
+
padding: 0.4rem 0 max(0.5rem, env(safe-area-inset-bottom));
|
|
283
|
+
box-shadow: 0 -12px 34px rgba(0, 0, 0, 0.28);
|
|
284
|
+
}
|
|
285
|
+
button[part~='menu-item'] { padding: 0.95rem 1.2rem; }
|
|
286
|
+
}
|
|
287
|
+
/* Responsive by default. The stack div above is the query container, so a
|
|
288
|
+
widget reacts to ITS OWN width (a narrow column on a wide screen counts),
|
|
289
|
+
and the host element's own sizing stays untouched. When the widget is
|
|
290
|
+
narrow, a list card's row (QR + url + copy + revoke) would squeeze the
|
|
291
|
+
text into a sliver: stack the card and centre the QR instead. */
|
|
292
|
+
@container (max-width: 480px) {
|
|
293
|
+
li[part~='card'] {
|
|
294
|
+
flex-direction: column;
|
|
295
|
+
align-items: stretch;
|
|
296
|
+
}
|
|
297
|
+
li[part~='card'] img[part~='qr'] { align-self: center; }
|
|
298
|
+
/* Stacked cards must not push the overflow trigger below the body: it
|
|
299
|
+
stays a top-right kebab (the card is position:relative already). */
|
|
300
|
+
li[part~='card']:has(> button[part~='menu-button']) { padding-right: 2.4rem; }
|
|
301
|
+
li[part~='card'] > button[part~='menu-button'] {
|
|
302
|
+
position: absolute;
|
|
303
|
+
top: 0.4rem;
|
|
304
|
+
right: 0.4rem;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
`;
|
|
308
|
+
function put(el, ...children) {
|
|
309
|
+
for (const c of children) {
|
|
310
|
+
if (c == null) continue;
|
|
311
|
+
el.append(typeof c === "string" ? document.createTextNode(c) : c);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function h(tag, attrs = {}, ...children) {
|
|
315
|
+
const el = document.createElement(tag);
|
|
316
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
317
|
+
if (v == null || v === false) continue;
|
|
318
|
+
if (k.startsWith("on") && typeof v === "function") {
|
|
319
|
+
el.addEventListener(k.slice(2), v);
|
|
320
|
+
} else if (v === true) {
|
|
321
|
+
el.setAttribute(k, "");
|
|
322
|
+
} else {
|
|
323
|
+
el.setAttribute(k, String(v));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const c of children) {
|
|
327
|
+
if (c == null) continue;
|
|
328
|
+
el.append(typeof c === "string" ? document.createTextNode(c) : c);
|
|
329
|
+
}
|
|
330
|
+
return el;
|
|
331
|
+
}
|
|
332
|
+
var FlowWidget = class extends HTMLElement {
|
|
333
|
+
root;
|
|
334
|
+
styleEl;
|
|
335
|
+
body;
|
|
336
|
+
overrides = {};
|
|
337
|
+
unsub = null;
|
|
338
|
+
constructor() {
|
|
339
|
+
super();
|
|
340
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
341
|
+
this.styleEl = document.createElement("style");
|
|
342
|
+
this.styleEl.textContent = baseStyles;
|
|
343
|
+
this.body = document.createElement("div");
|
|
344
|
+
this.body.setAttribute("part", "stack");
|
|
345
|
+
this.root.append(this.styleEl, this.body);
|
|
346
|
+
}
|
|
347
|
+
/** Partial override map, merged over the EN defaults (also the i18n hook). */
|
|
348
|
+
get labels() {
|
|
349
|
+
return this.overrides;
|
|
350
|
+
}
|
|
351
|
+
set labels(map) {
|
|
352
|
+
this.overrides = map ?? {};
|
|
353
|
+
this.rerender();
|
|
354
|
+
}
|
|
355
|
+
t(key) {
|
|
356
|
+
return this.overrides[key] ?? this.defaults()[key] ?? key;
|
|
357
|
+
}
|
|
358
|
+
/** A heading an empty-string label removes: labels = { 'share.title': '' }
|
|
359
|
+
* hides that heading (the host page already says it) without a fork.
|
|
360
|
+
* Append through put(), which skips the null. */
|
|
361
|
+
heading(part, key) {
|
|
362
|
+
const text = this.t(key);
|
|
363
|
+
return text ? h("div", { part }, text) : null;
|
|
364
|
+
}
|
|
365
|
+
/** Map a flow error to copy: labels can override per labelKey
|
|
366
|
+
* ('error.targetUnavailable', ...), with a generic fallback. */
|
|
367
|
+
errorText(labelKey) {
|
|
368
|
+
if (labelKey) {
|
|
369
|
+
const specific = this.overrides[labelKey] ?? this.defaults()[labelKey];
|
|
370
|
+
if (specific) return specific;
|
|
371
|
+
}
|
|
372
|
+
return this.t("error.generic");
|
|
373
|
+
}
|
|
374
|
+
emit(name, detail) {
|
|
375
|
+
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
376
|
+
}
|
|
377
|
+
/** Rebuild the body, keeping the focused input's value and focus across the
|
|
378
|
+
* rebuild (inputs carry data-keep so typing survives a snapshot change). */
|
|
379
|
+
rerender() {
|
|
380
|
+
let active = null;
|
|
381
|
+
try {
|
|
382
|
+
active = this.root.activeElement;
|
|
383
|
+
} catch {
|
|
384
|
+
active = null;
|
|
385
|
+
}
|
|
386
|
+
const keep = active?.getAttribute?.("data-keep") ?? null;
|
|
387
|
+
const value = active?.value;
|
|
388
|
+
this.body.replaceChildren();
|
|
389
|
+
this.view(this.body);
|
|
390
|
+
if (keep != null) {
|
|
391
|
+
const again = this.body.querySelector(`[data-keep="${keep}"]`);
|
|
392
|
+
if (again) {
|
|
393
|
+
if (value != null) again.value = value;
|
|
394
|
+
again.focus();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
disconnectedCallback() {
|
|
399
|
+
this.unsub?.();
|
|
400
|
+
this.unsub = null;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// src/widgets/connect.ts
|
|
405
|
+
var EN = {
|
|
406
|
+
"connect.title": "Where should we save your data?",
|
|
407
|
+
"connect.recommended": "Recommended",
|
|
408
|
+
"connect.drive": "Google Drive",
|
|
409
|
+
"connect.drive.sub": "Available on all your devices",
|
|
410
|
+
"connect.file": "A file on this device",
|
|
411
|
+
"connect.file.sub": "Offline, you keep the file",
|
|
412
|
+
"connect.file.new": "New backup",
|
|
413
|
+
"connect.file.open": "Load an existing file",
|
|
414
|
+
"connect.webdav": "My own server (WebDAV)",
|
|
415
|
+
"connect.webdav.sub": "Nextcloud, ownCloud, yours",
|
|
416
|
+
"connect.s3": "My own bucket (S3)",
|
|
417
|
+
"connect.s3.sub": "Amazon S3, R2, B2, MinIO",
|
|
418
|
+
"connect.server": "My own server",
|
|
419
|
+
"connect.server.sub": "WebDAV or an S3 bucket you control",
|
|
420
|
+
"connect.tab.webdav": "WebDAV",
|
|
421
|
+
"connect.tab.s3": "S3",
|
|
422
|
+
"connect.s3.endpoint": "Endpoint URL",
|
|
423
|
+
"connect.s3.region": "Region",
|
|
424
|
+
"connect.s3.bucket": "Bucket",
|
|
425
|
+
"connect.s3.key": "Object key (path)",
|
|
426
|
+
"connect.s3.accessKeyId": "Access key ID",
|
|
427
|
+
"connect.s3.secret": "Secret access key",
|
|
428
|
+
"connect.s3.submit": "Connect",
|
|
429
|
+
"connect.connecting": "Connecting...",
|
|
430
|
+
"connect.connecting.drive": "Authorise access in the Google window.",
|
|
431
|
+
"connect.cancel": "Cancel",
|
|
432
|
+
"connect.retry": "Try again",
|
|
433
|
+
"connect.password.title": "This backup is protected",
|
|
434
|
+
"connect.password.hint": "Enter its password to open it.",
|
|
435
|
+
"connect.password.placeholder": "Password",
|
|
436
|
+
"connect.password.wrong": "Wrong password. Try again.",
|
|
437
|
+
"connect.password.submit": "Open",
|
|
438
|
+
"connect.password.show": "Show password",
|
|
439
|
+
"connect.password.hide": "Hide password",
|
|
440
|
+
"connect.password.forgot": "Forgot the password?",
|
|
441
|
+
"connect.password.forgot.warn": "Without the password, this backup cannot be opened. You can erase it and start from an empty one. The old backup is lost for good.",
|
|
442
|
+
"connect.password.forgot.confirm": "Overwrite the backup",
|
|
443
|
+
"connect.password.forgot.back": "Back",
|
|
444
|
+
"connect.conflict.title": "This destination already holds a backup",
|
|
445
|
+
"connect.conflict.merge": "Merge both",
|
|
446
|
+
"connect.conflict.merge.sub": "Keep everything from both sides",
|
|
447
|
+
"connect.conflict.resume": "Use the backup",
|
|
448
|
+
"connect.conflict.resume.sub": "This device adopts it",
|
|
449
|
+
"connect.conflict.replace": "Replace the backup",
|
|
450
|
+
"connect.conflict.replace.sub": "This device wins, the backup is overwritten",
|
|
451
|
+
"connect.webdav.url": "Server URL",
|
|
452
|
+
"connect.webdav.user": "Username",
|
|
453
|
+
"connect.webdav.password": "Password",
|
|
454
|
+
"connect.webdav.submit": "Connect",
|
|
455
|
+
"connect.webdav.signup": "Create an account",
|
|
456
|
+
"connect.webdav.help.more": "How?",
|
|
457
|
+
"connect.done": "Connected. Your data is saved.",
|
|
458
|
+
"connect.done.manual": "Download mode: save the file after each change.",
|
|
459
|
+
"error.generic": "That did not work. Check the connection and try again.",
|
|
460
|
+
"error.targetUnavailable": "The destination did not answer. Try again in a moment.",
|
|
461
|
+
"error.authExpired": "Access expired: reconnect to continue.",
|
|
462
|
+
"error.decryptFailed": "This backup could not be opened with that password.",
|
|
463
|
+
"error.badFormat": "This file does not look like a readable backup."
|
|
464
|
+
};
|
|
465
|
+
var EYE_SHOW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3-8 10-8 10 8 10 8-3 8-10 8-10-8-10-8Z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
466
|
+
var EYE_HIDE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 8 10 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><path d="M6.61 6.61A18.45 18.45 0 0 0 2 12s3 8 10 8a9.12 9.12 0 0 0 5.39-1.61"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
|
|
467
|
+
function eyeIcon(visible) {
|
|
468
|
+
const span = document.createElement("span");
|
|
469
|
+
span.innerHTML = visible ? EYE_HIDE : EYE_SHOW;
|
|
470
|
+
return span.firstElementChild;
|
|
471
|
+
}
|
|
472
|
+
var SelfstoreConnectElement = class extends FlowWidget {
|
|
473
|
+
static get observedAttributes() {
|
|
474
|
+
return ["recommended", "advanced"];
|
|
475
|
+
}
|
|
476
|
+
#store = null;
|
|
477
|
+
#targets = null;
|
|
478
|
+
#options = {};
|
|
479
|
+
#flow = null;
|
|
480
|
+
#recommended = null;
|
|
481
|
+
#icons = {};
|
|
482
|
+
#advanced = [];
|
|
483
|
+
#webdavPresets = [];
|
|
484
|
+
#webdavUrl = "";
|
|
485
|
+
#webdavUser = "";
|
|
486
|
+
#webdavPass = "";
|
|
487
|
+
#pickedPreset = null;
|
|
488
|
+
// S3 form fields (kept on the element, like the WebDAV ones, so switching the
|
|
489
|
+
// server tab never loses what was typed).
|
|
490
|
+
#s3Endpoint = "";
|
|
491
|
+
#s3Region = "";
|
|
492
|
+
#s3Bucket = "";
|
|
493
|
+
#s3Key = "";
|
|
494
|
+
#s3AccessKeyId = "";
|
|
495
|
+
#s3Secret = "";
|
|
496
|
+
// Password-step local UI state (never in the flow: purely how this element
|
|
497
|
+
// draws the step). Reset whenever the flow leaves 'password' (see wire()).
|
|
498
|
+
#pwVisible = false;
|
|
499
|
+
#confirmOverwrite = false;
|
|
500
|
+
defaults() {
|
|
501
|
+
return EN;
|
|
502
|
+
}
|
|
503
|
+
/** The simple store (anything exposing `flowHost`), or a hand-built FlowHost. */
|
|
504
|
+
get store() {
|
|
505
|
+
return this.#store;
|
|
506
|
+
}
|
|
507
|
+
set store(v) {
|
|
508
|
+
this.#store = v;
|
|
509
|
+
this.wire();
|
|
510
|
+
}
|
|
511
|
+
get targets() {
|
|
512
|
+
return this.#targets;
|
|
513
|
+
}
|
|
514
|
+
set targets(v) {
|
|
515
|
+
this.#targets = v;
|
|
516
|
+
this.wire();
|
|
517
|
+
}
|
|
518
|
+
get options() {
|
|
519
|
+
return this.#options;
|
|
520
|
+
}
|
|
521
|
+
set options(v) {
|
|
522
|
+
this.#options = v ?? {};
|
|
523
|
+
this.wire();
|
|
524
|
+
}
|
|
525
|
+
/** Badge one destination as the one to pick (part="tag", label
|
|
526
|
+
* 'connect.recommended'). Purely a highlight: the order still follows the
|
|
527
|
+
* targets object. Also the `recommended` attribute. Default: none. */
|
|
528
|
+
get recommended() {
|
|
529
|
+
return this.#recommended;
|
|
530
|
+
}
|
|
531
|
+
set recommended(v) {
|
|
532
|
+
this.#recommended = v === "drive" || v === "file" || v === "webdav" || v === "s3" ? v : null;
|
|
533
|
+
this.rerender();
|
|
534
|
+
}
|
|
535
|
+
/** An optional icon per destination - an image URL or data URI, rendered as
|
|
536
|
+
* part="icon" at the head of that card. Purely decorative and per-instance;
|
|
537
|
+
* omit a kind for no icon. Size it with --selfstore-icon-size. */
|
|
538
|
+
get icons() {
|
|
539
|
+
return this.#icons;
|
|
540
|
+
}
|
|
541
|
+
set icons(v) {
|
|
542
|
+
this.#icons = v ?? {};
|
|
543
|
+
this.rerender();
|
|
544
|
+
}
|
|
545
|
+
/** Destinations to tuck behind a discreet link under the cards instead of a
|
|
546
|
+
* full card - for power-user options (say, WebDAV) that must stay reachable
|
|
547
|
+
* without weighing the everyday choice. Also the `advanced` attribute
|
|
548
|
+
* (comma-separated). The journey after the click is exactly the same. */
|
|
549
|
+
get advanced() {
|
|
550
|
+
return this.#advanced;
|
|
551
|
+
}
|
|
552
|
+
set advanced(v) {
|
|
553
|
+
this.#advanced = (v ?? []).filter(
|
|
554
|
+
(k) => k === "drive" || k === "file" || k === "webdav" || k === "s3"
|
|
555
|
+
);
|
|
556
|
+
this.rerender();
|
|
557
|
+
}
|
|
558
|
+
/** Named WebDAV providers offered as a quick-pick row above the WebDAV form,
|
|
559
|
+
* pre-filling the URL field. The app owns the list (its own host, sovereign
|
|
560
|
+
* providers, ...); selfstore ships none. Purely additive - the blank form
|
|
561
|
+
* still works. */
|
|
562
|
+
get webdavPresets() {
|
|
563
|
+
return this.#webdavPresets;
|
|
564
|
+
}
|
|
565
|
+
set webdavPresets(v) {
|
|
566
|
+
this.#webdavPresets = Array.isArray(v) ? v : [];
|
|
567
|
+
this.rerender();
|
|
568
|
+
}
|
|
569
|
+
/** The underlying flow, for programmatic control (may be null until wired). */
|
|
570
|
+
get flow() {
|
|
571
|
+
return this.#flow;
|
|
572
|
+
}
|
|
573
|
+
attributeChangedCallback(name, _old, value) {
|
|
574
|
+
if (name === "recommended") this.recommended = value;
|
|
575
|
+
if (name === "advanced") {
|
|
576
|
+
this.advanced = (value ?? "").split(",").map((s) => s.trim());
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
connectedCallback() {
|
|
580
|
+
if (this.#recommended === null && this.hasAttribute("recommended")) {
|
|
581
|
+
this.recommended = this.getAttribute("recommended");
|
|
582
|
+
}
|
|
583
|
+
if (this.#advanced.length === 0 && this.hasAttribute("advanced")) {
|
|
584
|
+
this.advanced = (this.getAttribute("advanced") ?? "").split(",").map((s) => s.trim());
|
|
585
|
+
}
|
|
586
|
+
this.wire();
|
|
587
|
+
}
|
|
588
|
+
wire() {
|
|
589
|
+
if (!this.isConnected || !this.#store || !this.#targets) return;
|
|
590
|
+
this.unsub?.();
|
|
591
|
+
this.#flow = connectFlow(this.#store, this.#targets, this.#options);
|
|
592
|
+
let last = null;
|
|
593
|
+
this.unsub = this.#flow.subscribe((snap) => {
|
|
594
|
+
const was = last;
|
|
595
|
+
last = snap;
|
|
596
|
+
if (snap.step === "connected" && was?.step !== "connected") {
|
|
597
|
+
this.emit("selfstore-connected", { outcome: snap.outcome });
|
|
598
|
+
}
|
|
599
|
+
if (snap.step === "error" && was?.step !== "error") {
|
|
600
|
+
this.emit("selfstore-error", { error: snap.error });
|
|
601
|
+
}
|
|
602
|
+
if (snap.step !== "password") {
|
|
603
|
+
this.#pwVisible = false;
|
|
604
|
+
this.#confirmOverwrite = false;
|
|
605
|
+
}
|
|
606
|
+
this.rerender();
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
view(into) {
|
|
610
|
+
const flow = this.#flow;
|
|
611
|
+
if (!flow) return;
|
|
612
|
+
const s = flow.snapshot;
|
|
613
|
+
if (s.step === "choose") {
|
|
614
|
+
put(into, this.heading("title", "connect.title"));
|
|
615
|
+
const serverKinds = ["webdav", "s3"].filter(
|
|
616
|
+
(k) => s.kinds.includes(k) && !this.#advanced.includes(k)
|
|
617
|
+
);
|
|
618
|
+
const groupServer = serverKinds.length >= 2;
|
|
619
|
+
for (const kind of s.kinds) {
|
|
620
|
+
if (this.#advanced.includes(kind)) continue;
|
|
621
|
+
if (groupServer && (kind === "webdav" || kind === "s3")) {
|
|
622
|
+
if (kind === serverKinds[0]) into.append(this.serverCard(serverKinds[0]));
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
into.append(this.destCard(kind));
|
|
626
|
+
}
|
|
627
|
+
for (const kind of s.kinds) {
|
|
628
|
+
if (this.#advanced.includes(kind)) {
|
|
629
|
+
into.append(
|
|
630
|
+
h(
|
|
631
|
+
"button",
|
|
632
|
+
{ part: "link advanced-link", "data-kind": kind, onclick: () => this.#flow?.choose(kind) },
|
|
633
|
+
this.t(`connect.${kind}`)
|
|
634
|
+
)
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (s.step === "form") {
|
|
641
|
+
const kind = s.kind === "s3" ? "s3" : "webdav";
|
|
642
|
+
const tabbed = ["webdav", "s3"].filter((k) => this.#targets?.[k] === true).length >= 2;
|
|
643
|
+
const children = [
|
|
644
|
+
h("div", { part: "title" }, this.t(tabbed ? "connect.server" : `connect.${kind}`))
|
|
645
|
+
];
|
|
646
|
+
if (tabbed) children.push(this.serverTabs(kind));
|
|
647
|
+
children.push(
|
|
648
|
+
...kind === "s3" ? this.s3FormChildren(flow) : this.webdavFormChildren(flow)
|
|
649
|
+
);
|
|
650
|
+
into.append(...children);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (s.step === "authorizing") {
|
|
654
|
+
into.append(
|
|
655
|
+
h(
|
|
656
|
+
"div",
|
|
657
|
+
{ part: "status" },
|
|
658
|
+
h("span", { part: "spinner", "aria-hidden": "true" }),
|
|
659
|
+
h(
|
|
660
|
+
"div",
|
|
661
|
+
{},
|
|
662
|
+
h("div", { part: "title" }, this.t("connect.connecting")),
|
|
663
|
+
s.kind === "drive" ? h("div", { part: "sub" }, this.t("connect.connecting.drive")) : null
|
|
664
|
+
),
|
|
665
|
+
h("button", { part: "button", onclick: () => this.cancel() }, this.t("connect.cancel"))
|
|
666
|
+
)
|
|
667
|
+
);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (s.step === "password") {
|
|
671
|
+
if (this.#confirmOverwrite) {
|
|
672
|
+
put(
|
|
673
|
+
into,
|
|
674
|
+
h("div", { part: "title" }, this.t("connect.password.title")),
|
|
675
|
+
h("div", { part: "hint warn-note" }, this.t("connect.password.forgot.warn")),
|
|
676
|
+
h(
|
|
677
|
+
"div",
|
|
678
|
+
{ part: "row" },
|
|
679
|
+
h(
|
|
680
|
+
"button",
|
|
681
|
+
{ part: "button button-danger", disabled: s.busy, onclick: () => this.doOverwrite() },
|
|
682
|
+
this.t("connect.password.forgot.confirm")
|
|
683
|
+
),
|
|
684
|
+
h(
|
|
685
|
+
"button",
|
|
686
|
+
{ part: "button", disabled: s.busy, onclick: () => this.setConfirmOverwrite(false) },
|
|
687
|
+
this.t("connect.password.forgot.back")
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const field = h(
|
|
694
|
+
"div",
|
|
695
|
+
{ part: "field" },
|
|
696
|
+
h("input", {
|
|
697
|
+
part: "input",
|
|
698
|
+
type: this.#pwVisible ? "text" : "password",
|
|
699
|
+
placeholder: this.t("connect.password.placeholder"),
|
|
700
|
+
"data-keep": "password",
|
|
701
|
+
autocomplete: "current-password"
|
|
702
|
+
}),
|
|
703
|
+
h(
|
|
704
|
+
"button",
|
|
705
|
+
{
|
|
706
|
+
part: "eye",
|
|
707
|
+
type: "button",
|
|
708
|
+
"aria-label": this.t(this.#pwVisible ? "connect.password.hide" : "connect.password.show"),
|
|
709
|
+
"aria-pressed": this.#pwVisible ? "true" : "false",
|
|
710
|
+
onclick: () => this.togglePassword()
|
|
711
|
+
},
|
|
712
|
+
eyeIcon(this.#pwVisible)
|
|
713
|
+
)
|
|
714
|
+
);
|
|
715
|
+
put(
|
|
716
|
+
into,
|
|
717
|
+
h("div", { part: "title" }, this.t("connect.password.title")),
|
|
718
|
+
h("div", { part: "hint" }, this.t("connect.password.hint")),
|
|
719
|
+
field,
|
|
720
|
+
s.passwordError ? h("div", { part: "error-note" }, this.t("connect.password.wrong")) : null,
|
|
721
|
+
h(
|
|
722
|
+
"div",
|
|
723
|
+
{ part: "row" },
|
|
724
|
+
h(
|
|
725
|
+
"button",
|
|
726
|
+
{ part: "button button-primary", disabled: s.busy, onclick: () => this.submitPassword() },
|
|
727
|
+
this.t("connect.password.submit")
|
|
728
|
+
),
|
|
729
|
+
// Cancel is never disabled, even mid-trial-read: a hung leg must
|
|
730
|
+
// always have a working way out. The generation counter makes a
|
|
731
|
+
// late resolution of the abandoned leg a no-op, so leaving is safe.
|
|
732
|
+
h("button", { part: "button", onclick: () => this.cancel() }, this.t("connect.cancel"))
|
|
733
|
+
),
|
|
734
|
+
// The forgotten-password escape: a discreet link into the confirm
|
|
735
|
+
// sub-state above (erasing the backup is never one careless click).
|
|
736
|
+
h(
|
|
737
|
+
"button",
|
|
738
|
+
{ part: "link forgot-link", onclick: () => this.setConfirmOverwrite(true) },
|
|
739
|
+
this.t("connect.password.forgot")
|
|
740
|
+
)
|
|
741
|
+
);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (s.step === "conflict") {
|
|
745
|
+
into.append(h("div", { part: "title" }, this.t("connect.conflict.title")));
|
|
746
|
+
for (const how of ["merge", "resume", "replace"]) {
|
|
747
|
+
into.append(
|
|
748
|
+
h(
|
|
749
|
+
"button",
|
|
750
|
+
{ part: "card", disabled: s.busy, onclick: () => flow.resolveConflict(how) },
|
|
751
|
+
h(
|
|
752
|
+
"div",
|
|
753
|
+
{},
|
|
754
|
+
h("div", { part: "title" }, this.t(`connect.conflict.${how}`)),
|
|
755
|
+
h("div", { part: "sub" }, this.t(`connect.conflict.${how}.sub`))
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
into.append(h("button", { part: "link", onclick: () => this.cancel() }, this.t("connect.cancel")));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (s.step === "connected") {
|
|
764
|
+
into.append(
|
|
765
|
+
h(
|
|
766
|
+
"div",
|
|
767
|
+
{ part: "status status-ok" },
|
|
768
|
+
h("div", { part: "title" }, this.t(s.outcome === "manual" ? "connect.done.manual" : "connect.done"))
|
|
769
|
+
)
|
|
770
|
+
);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
into.append(
|
|
774
|
+
h("div", { part: "status status-error" }, h("div", { part: "title" }, this.errorText(s.error?.labelKey))),
|
|
775
|
+
h(
|
|
776
|
+
"div",
|
|
777
|
+
{ part: "row" },
|
|
778
|
+
h("button", { part: "button button-primary", onclick: () => flow.retry() }, this.t("connect.retry")),
|
|
779
|
+
h("button", { part: "button", onclick: () => this.cancel() }, this.t("connect.cancel"))
|
|
780
|
+
)
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
destCard(kind) {
|
|
784
|
+
const icon = this.#icons[kind];
|
|
785
|
+
const head = icon ? h("img", { part: "icon", src: icon, alt: "" }) : null;
|
|
786
|
+
const title = h(
|
|
787
|
+
"div",
|
|
788
|
+
{ part: "title" },
|
|
789
|
+
this.t(`connect.${kind}`),
|
|
790
|
+
this.#recommended === kind ? h("span", { part: "tag" }, this.t("connect.recommended")) : null
|
|
791
|
+
);
|
|
792
|
+
const sub = h("div", { part: "sub" }, this.t(`connect.${kind}.sub`));
|
|
793
|
+
const fileSpec = kind === "file" ? this.#targets?.file : null;
|
|
794
|
+
if (fileSpec && typeof fileSpec === "object") {
|
|
795
|
+
const openSupported = typeof window !== "undefined" && "showOpenFilePicker" in window;
|
|
796
|
+
const actions = h("div", { part: "row" });
|
|
797
|
+
if (fileSpec.create != null) {
|
|
798
|
+
actions.append(
|
|
799
|
+
h(
|
|
800
|
+
"button",
|
|
801
|
+
{ part: "button button-primary", "data-action": "create", onclick: () => this.#flow?.choose("file", "create") },
|
|
802
|
+
this.t("connect.file.new")
|
|
803
|
+
)
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (fileSpec.open != null && (typeof fileSpec.open === "function" || openSupported)) {
|
|
807
|
+
actions.append(
|
|
808
|
+
h(
|
|
809
|
+
"button",
|
|
810
|
+
{ part: "button", "data-action": "open", onclick: () => this.#flow?.choose("file", "open") },
|
|
811
|
+
this.t("connect.file.open")
|
|
812
|
+
)
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
const body = h("div", {}, title, sub);
|
|
816
|
+
if (actions.childElementCount > 0) body.append(actions);
|
|
817
|
+
return h("div", { part: "card", "data-kind": kind }, head, body);
|
|
818
|
+
}
|
|
819
|
+
return h(
|
|
820
|
+
"button",
|
|
821
|
+
{ part: "card", "data-kind": kind, onclick: () => this.#flow?.choose(kind) },
|
|
822
|
+
head,
|
|
823
|
+
h("div", {}, title, sub)
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
/** The grouped "your own server" card shown when both WebDAV and S3 are
|
|
827
|
+
* offered: one entry into the tabbed form, opening on the first server kind. */
|
|
828
|
+
serverCard(defaultKind) {
|
|
829
|
+
const recommended = this.#recommended === "webdav" || this.#recommended === "s3";
|
|
830
|
+
return h(
|
|
831
|
+
"button",
|
|
832
|
+
{ part: "card", "data-kind": "server", onclick: () => this.#flow?.choose(defaultKind) },
|
|
833
|
+
h(
|
|
834
|
+
"div",
|
|
835
|
+
{},
|
|
836
|
+
h(
|
|
837
|
+
"div",
|
|
838
|
+
{ part: "title" },
|
|
839
|
+
this.t("connect.server"),
|
|
840
|
+
recommended ? h("span", { part: "tag" }, this.t("connect.recommended")) : null
|
|
841
|
+
),
|
|
842
|
+
h("div", { part: "sub" }, this.t("connect.server.sub"))
|
|
843
|
+
)
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
/** WebDAV/S3 segmented toggle. Switching leaves the form and re-enters it on
|
|
847
|
+
* the other kind - no flow surgery - and the typed fields live on the element,
|
|
848
|
+
* so nothing the user wrote is lost across the switch. */
|
|
849
|
+
serverTabs(active) {
|
|
850
|
+
const tab = (k) => h(
|
|
851
|
+
"button",
|
|
852
|
+
{
|
|
853
|
+
part: active === k ? "tab tab-on" : "tab",
|
|
854
|
+
type: "button",
|
|
855
|
+
"data-tab": k,
|
|
856
|
+
"aria-pressed": active === k ? "true" : "false",
|
|
857
|
+
onclick: () => {
|
|
858
|
+
if (active === k) return;
|
|
859
|
+
this.#flow?.cancel();
|
|
860
|
+
this.#flow?.choose(k);
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
this.t(`connect.tab.${k}`)
|
|
864
|
+
);
|
|
865
|
+
return h("div", { part: "tabs" }, tab("webdav"), tab("s3"));
|
|
866
|
+
}
|
|
867
|
+
webdavFormChildren(flow) {
|
|
868
|
+
const picked = this.#pickedPreset;
|
|
869
|
+
const out = [];
|
|
870
|
+
if (this.#webdavPresets.length > 0) {
|
|
871
|
+
out.push(
|
|
872
|
+
h(
|
|
873
|
+
"div",
|
|
874
|
+
{ part: "presets" },
|
|
875
|
+
...this.#webdavPresets.map(
|
|
876
|
+
(p) => h(
|
|
877
|
+
"button",
|
|
878
|
+
{
|
|
879
|
+
part: picked?.id === p.id ? "preset preset-on" : "preset",
|
|
880
|
+
type: "button",
|
|
881
|
+
"data-preset": p.id,
|
|
882
|
+
"aria-pressed": picked?.id === p.id ? "true" : "false",
|
|
883
|
+
title: p.help ?? "",
|
|
884
|
+
onclick: () => this.pickWebdavPreset(p)
|
|
885
|
+
},
|
|
886
|
+
p.label
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
out.push(
|
|
893
|
+
h("input", {
|
|
894
|
+
part: "input",
|
|
895
|
+
type: "url",
|
|
896
|
+
placeholder: this.t("connect.webdav.url"),
|
|
897
|
+
"data-keep": "wd-url",
|
|
898
|
+
value: this.#webdavUrl,
|
|
899
|
+
oninput: (e) => this.#webdavUrl = e.target.value
|
|
900
|
+
}),
|
|
901
|
+
h("input", {
|
|
902
|
+
part: "input",
|
|
903
|
+
type: "text",
|
|
904
|
+
placeholder: this.t("connect.webdav.user"),
|
|
905
|
+
"data-keep": "wd-user",
|
|
906
|
+
value: this.#webdavUser,
|
|
907
|
+
oninput: (e) => this.#webdavUser = e.target.value
|
|
908
|
+
}),
|
|
909
|
+
h("input", {
|
|
910
|
+
part: "input",
|
|
911
|
+
type: "password",
|
|
912
|
+
placeholder: this.t("connect.webdav.password"),
|
|
913
|
+
"data-keep": "wd-pass",
|
|
914
|
+
value: this.#webdavPass,
|
|
915
|
+
oninput: (e) => this.#webdavPass = e.target.value
|
|
916
|
+
})
|
|
917
|
+
);
|
|
918
|
+
if (picked?.help) {
|
|
919
|
+
out.push(
|
|
920
|
+
h(
|
|
921
|
+
"div",
|
|
922
|
+
{ part: "hint webdav-note" },
|
|
923
|
+
picked.help,
|
|
924
|
+
picked.helpUrl ? h(
|
|
925
|
+
"a",
|
|
926
|
+
{ part: "link webdav-help", href: picked.helpUrl, target: "_blank", rel: "noopener noreferrer" },
|
|
927
|
+
` ${this.t("connect.webdav.help.more")}`
|
|
928
|
+
) : null
|
|
929
|
+
)
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
out.push(
|
|
933
|
+
h(
|
|
934
|
+
"div",
|
|
935
|
+
{ part: "row" },
|
|
936
|
+
h("button", { part: "button button-primary", onclick: () => this.submitWebdav() }, this.t("connect.webdav.submit")),
|
|
937
|
+
h("button", { part: "button", onclick: () => flow.cancel() }, this.t("connect.cancel"))
|
|
938
|
+
)
|
|
939
|
+
);
|
|
940
|
+
if (picked?.signupUrl) {
|
|
941
|
+
out.push(
|
|
942
|
+
h(
|
|
943
|
+
"a",
|
|
944
|
+
{ part: "link webdav-signup", href: picked.signupUrl, target: "_blank", rel: "noopener noreferrer" },
|
|
945
|
+
this.t("connect.webdav.signup")
|
|
946
|
+
)
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
return out;
|
|
950
|
+
}
|
|
951
|
+
s3FormChildren(flow) {
|
|
952
|
+
const field = (type, label, keep, get, set) => h("input", {
|
|
953
|
+
part: "input",
|
|
954
|
+
type,
|
|
955
|
+
placeholder: this.t(label),
|
|
956
|
+
"data-keep": keep,
|
|
957
|
+
value: get(),
|
|
958
|
+
oninput: (e) => set(e.target.value)
|
|
959
|
+
});
|
|
960
|
+
return [
|
|
961
|
+
field("url", "connect.s3.endpoint", "s3-endpoint", () => this.#s3Endpoint, (v) => this.#s3Endpoint = v),
|
|
962
|
+
field("text", "connect.s3.region", "s3-region", () => this.#s3Region, (v) => this.#s3Region = v),
|
|
963
|
+
field("text", "connect.s3.bucket", "s3-bucket", () => this.#s3Bucket, (v) => this.#s3Bucket = v),
|
|
964
|
+
field("text", "connect.s3.key", "s3-key", () => this.#s3Key, (v) => this.#s3Key = v),
|
|
965
|
+
field("text", "connect.s3.accessKeyId", "s3-akid", () => this.#s3AccessKeyId, (v) => this.#s3AccessKeyId = v),
|
|
966
|
+
field("password", "connect.s3.secret", "s3-secret", () => this.#s3Secret, (v) => this.#s3Secret = v),
|
|
967
|
+
h(
|
|
968
|
+
"div",
|
|
969
|
+
{ part: "row" },
|
|
970
|
+
h("button", { part: "button button-primary", onclick: () => this.submitS3() }, this.t("connect.s3.submit")),
|
|
971
|
+
h("button", { part: "button", onclick: () => flow.cancel() }, this.t("connect.cancel"))
|
|
972
|
+
)
|
|
973
|
+
];
|
|
974
|
+
}
|
|
975
|
+
submitS3() {
|
|
976
|
+
const endpoint = this.#s3Endpoint.trim();
|
|
977
|
+
const region = this.#s3Region.trim();
|
|
978
|
+
const bucket = this.#s3Bucket.trim();
|
|
979
|
+
const key = this.#s3Key.trim();
|
|
980
|
+
if (!endpoint || !region || !bucket || !key) return;
|
|
981
|
+
const config = {
|
|
982
|
+
endpoint,
|
|
983
|
+
region,
|
|
984
|
+
bucket,
|
|
985
|
+
key,
|
|
986
|
+
accessKeyId: this.#s3AccessKeyId.trim(),
|
|
987
|
+
secretAccessKey: this.#s3Secret
|
|
988
|
+
};
|
|
989
|
+
this.#flow?.submitS3(config);
|
|
990
|
+
}
|
|
991
|
+
cancel() {
|
|
992
|
+
this.#flow?.cancel();
|
|
993
|
+
this.emit("selfstore-cancelled");
|
|
994
|
+
}
|
|
995
|
+
submitPassword() {
|
|
996
|
+
const input = this.root.querySelector('[data-keep="password"]');
|
|
997
|
+
if (input) this.#flow?.submitPassword(input.value);
|
|
998
|
+
}
|
|
999
|
+
/** Flip the password field between hidden and shown IN PLACE - swap the
|
|
1000
|
+
* input type and the eye glyph without a re-render, so what the user has
|
|
1001
|
+
* already typed survives the toggle. */
|
|
1002
|
+
togglePassword() {
|
|
1003
|
+
this.#pwVisible = !this.#pwVisible;
|
|
1004
|
+
const input = this.root.querySelector('[data-keep="password"]');
|
|
1005
|
+
if (input) {
|
|
1006
|
+
input.type = this.#pwVisible ? "text" : "password";
|
|
1007
|
+
input.focus();
|
|
1008
|
+
}
|
|
1009
|
+
const eye = this.root.querySelector('[part~="eye"]');
|
|
1010
|
+
if (eye) {
|
|
1011
|
+
eye.setAttribute(
|
|
1012
|
+
"aria-label",
|
|
1013
|
+
this.t(this.#pwVisible ? "connect.password.hide" : "connect.password.show")
|
|
1014
|
+
);
|
|
1015
|
+
eye.setAttribute("aria-pressed", this.#pwVisible ? "true" : "false");
|
|
1016
|
+
eye.replaceChildren(eyeIcon(this.#pwVisible));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/** Enter or leave the forgotten-password confirmation sub-state. */
|
|
1020
|
+
setConfirmOverwrite(on) {
|
|
1021
|
+
this.#confirmOverwrite = on;
|
|
1022
|
+
this.rerender();
|
|
1023
|
+
}
|
|
1024
|
+
/** Confirmed: abandon the forgotten password and start a fresh backup over
|
|
1025
|
+
* the protected one (the flow makes this device win). */
|
|
1026
|
+
doOverwrite() {
|
|
1027
|
+
this.#flow?.overwrite();
|
|
1028
|
+
}
|
|
1029
|
+
/** Fill the WebDAV URL field from a picked provider preset and move focus on to
|
|
1030
|
+
* the username, so the pick just seeds the form the user then completes. The
|
|
1031
|
+
* pick is remembered so its guidance / sign-up link render and its chip flags
|
|
1032
|
+
* as active; a re-render keeps the already-typed username and password. */
|
|
1033
|
+
pickWebdavPreset(p) {
|
|
1034
|
+
this.#pickedPreset = p;
|
|
1035
|
+
this.#webdavUrl = p.url ?? "";
|
|
1036
|
+
this.rerender();
|
|
1037
|
+
this.root.querySelector('[data-keep="wd-user"]')?.focus();
|
|
1038
|
+
}
|
|
1039
|
+
submitWebdav() {
|
|
1040
|
+
const url = this.#webdavUrl.trim();
|
|
1041
|
+
if (!url) return;
|
|
1042
|
+
const config = {
|
|
1043
|
+
url,
|
|
1044
|
+
username: this.#webdavUser.trim(),
|
|
1045
|
+
password: this.#webdavPass
|
|
1046
|
+
};
|
|
1047
|
+
this.#flow?.submitWebdav(config);
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
// src/widgets/share.ts
|
|
1052
|
+
var EN2 = {
|
|
1053
|
+
"share.title": "Sharing",
|
|
1054
|
+
"share.create.read": "Create a view link",
|
|
1055
|
+
"share.create.write": "Create an edit link",
|
|
1056
|
+
"share.creating": "Creating...",
|
|
1057
|
+
"share.level.read": "Can view",
|
|
1058
|
+
"share.level.write": "Can edit",
|
|
1059
|
+
"share.copy": "Copy",
|
|
1060
|
+
"share.copied": "Copied",
|
|
1061
|
+
"share.revoke": "Revoke",
|
|
1062
|
+
"share.members": "People with access",
|
|
1063
|
+
"share.member.you": "you",
|
|
1064
|
+
"share.member.owner": "owner",
|
|
1065
|
+
"share.remove": "Remove",
|
|
1066
|
+
"share.stop": "Stop sharing",
|
|
1067
|
+
"share.empty": "Nobody else has access yet.",
|
|
1068
|
+
"share.stale": "Connection hiccup: this view may be behind.",
|
|
1069
|
+
"error.generic": "That did not work. Check the connection and try again.",
|
|
1070
|
+
"error.targetUnavailable": "The share service did not answer. Try again in a moment."
|
|
1071
|
+
};
|
|
1072
|
+
var isOff = (v) => v != null && ["off", "false", "no", "0"].includes(v.trim().toLowerCase());
|
|
1073
|
+
var SelfstoreShareElement = class extends FlowWidget {
|
|
1074
|
+
static get observedAttributes() {
|
|
1075
|
+
return ["levels", "with-create", "with-members"];
|
|
1076
|
+
}
|
|
1077
|
+
#engine = null;
|
|
1078
|
+
#flow = null;
|
|
1079
|
+
#options = {};
|
|
1080
|
+
#levels = ["read", "write"];
|
|
1081
|
+
#withCreate = true;
|
|
1082
|
+
#withMembers = true;
|
|
1083
|
+
#confirmAction = null;
|
|
1084
|
+
#qrProvider = null;
|
|
1085
|
+
#qr = /* @__PURE__ */ new Map();
|
|
1086
|
+
#qrPending = /* @__PURE__ */ new Set();
|
|
1087
|
+
#copiedId = null;
|
|
1088
|
+
#creating = null;
|
|
1089
|
+
defaults() {
|
|
1090
|
+
return EN2;
|
|
1091
|
+
}
|
|
1092
|
+
/** The app-side transport of links and memberships (see ShareEngine). */
|
|
1093
|
+
get engine() {
|
|
1094
|
+
return this.#engine;
|
|
1095
|
+
}
|
|
1096
|
+
set engine(v) {
|
|
1097
|
+
this.#engine = v;
|
|
1098
|
+
this.wire();
|
|
1099
|
+
}
|
|
1100
|
+
/** Flow options (deadlineMs), for engines whose legs outgrow the default
|
|
1101
|
+
* 30s guard (a large first upload, a slow relay). */
|
|
1102
|
+
get options() {
|
|
1103
|
+
return this.#options;
|
|
1104
|
+
}
|
|
1105
|
+
set options(v) {
|
|
1106
|
+
this.#options = v ?? {};
|
|
1107
|
+
this.wire();
|
|
1108
|
+
}
|
|
1109
|
+
/** Which levels the panel OFFERS to create (['read', 'write'] by default) -
|
|
1110
|
+
* one create button per level without a live link. Also the `levels`
|
|
1111
|
+
* attribute, comma-separated: levels="read". Links the engine lists
|
|
1112
|
+
* render whatever this says. */
|
|
1113
|
+
get levels() {
|
|
1114
|
+
return [...this.#levels];
|
|
1115
|
+
}
|
|
1116
|
+
set levels(v) {
|
|
1117
|
+
const clean = [...new Set((v ?? []).filter((l) => l === "read" || l === "write"))];
|
|
1118
|
+
this.#levels = clean.length > 0 ? clean : ["read", "write"];
|
|
1119
|
+
this.rerender();
|
|
1120
|
+
}
|
|
1121
|
+
/** Render the create buttons (default). `with-create="off"` hides them: the
|
|
1122
|
+
* panel becomes list-and-revoke only. */
|
|
1123
|
+
get withCreate() {
|
|
1124
|
+
return this.#withCreate;
|
|
1125
|
+
}
|
|
1126
|
+
set withCreate(v) {
|
|
1127
|
+
this.#withCreate = v !== false;
|
|
1128
|
+
this.rerender();
|
|
1129
|
+
}
|
|
1130
|
+
/** Render the members section (default). `with-members="off"` hides it:
|
|
1131
|
+
* a links-only panel. */
|
|
1132
|
+
get withMembers() {
|
|
1133
|
+
return this.#withMembers;
|
|
1134
|
+
}
|
|
1135
|
+
set withMembers(v) {
|
|
1136
|
+
this.#withMembers = v !== false;
|
|
1137
|
+
this.rerender();
|
|
1138
|
+
}
|
|
1139
|
+
/** Host veto over the destructive actions: called before revoke, remove or
|
|
1140
|
+
* stop runs; answer (or resolve) false to keep things as they are -
|
|
1141
|
+
* window.confirm fits as-is. Default: everything proceeds. */
|
|
1142
|
+
get confirmAction() {
|
|
1143
|
+
return this.#confirmAction;
|
|
1144
|
+
}
|
|
1145
|
+
set confirmAction(v) {
|
|
1146
|
+
this.#confirmAction = v ?? null;
|
|
1147
|
+
}
|
|
1148
|
+
/** Turn a link URL into an image source (a data URL fits): when set, each
|
|
1149
|
+
* link card renders it as an img part="qr". Injected so the element ships
|
|
1150
|
+
* no QR dependency. */
|
|
1151
|
+
get qrProvider() {
|
|
1152
|
+
return this.#qrProvider;
|
|
1153
|
+
}
|
|
1154
|
+
set qrProvider(v) {
|
|
1155
|
+
this.#qrProvider = v ?? null;
|
|
1156
|
+
this.#qr.clear();
|
|
1157
|
+
this.#qrPending.clear();
|
|
1158
|
+
this.rerender();
|
|
1159
|
+
}
|
|
1160
|
+
get flow() {
|
|
1161
|
+
return this.#flow;
|
|
1162
|
+
}
|
|
1163
|
+
attributeChangedCallback(name, _old, value) {
|
|
1164
|
+
if (name === "levels") {
|
|
1165
|
+
this.levels = (value ?? "").split(",").map((s) => s.trim());
|
|
1166
|
+
}
|
|
1167
|
+
if (name === "with-create") this.withCreate = value == null || !isOff(value);
|
|
1168
|
+
if (name === "with-members") this.withMembers = value == null || !isOff(value);
|
|
1169
|
+
}
|
|
1170
|
+
connectedCallback() {
|
|
1171
|
+
this.wire();
|
|
1172
|
+
}
|
|
1173
|
+
wire() {
|
|
1174
|
+
if (!this.isConnected || !this.#engine) return;
|
|
1175
|
+
this.unsub?.();
|
|
1176
|
+
this.#flow = shareFlow(this.#engine, this.#options);
|
|
1177
|
+
this.unsub = this.#flow.subscribe(() => this.rerender());
|
|
1178
|
+
}
|
|
1179
|
+
view(into) {
|
|
1180
|
+
const flow = this.#flow;
|
|
1181
|
+
if (!flow) return;
|
|
1182
|
+
const s = flow.snapshot;
|
|
1183
|
+
put(into, this.heading("title", "share.title"));
|
|
1184
|
+
if (s.stale) into.append(h("div", { part: "hint" }, this.t("share.stale")));
|
|
1185
|
+
if (s.error && !s.stale) {
|
|
1186
|
+
into.append(h("div", { part: "error-note" }, this.errorText(s.error.labelKey)));
|
|
1187
|
+
}
|
|
1188
|
+
if (this.#withCreate) {
|
|
1189
|
+
const missing = this.#levels.filter((l) => !s.links.some((k) => k.level === l));
|
|
1190
|
+
if (missing.length > 0) {
|
|
1191
|
+
into.append(
|
|
1192
|
+
h(
|
|
1193
|
+
"div",
|
|
1194
|
+
{ part: "row" },
|
|
1195
|
+
...missing.map(
|
|
1196
|
+
(level) => h(
|
|
1197
|
+
"button",
|
|
1198
|
+
{
|
|
1199
|
+
part: "button button-primary",
|
|
1200
|
+
"data-action": `create-${level}`,
|
|
1201
|
+
disabled: s.busy !== null,
|
|
1202
|
+
onclick: () => this.create(level)
|
|
1203
|
+
},
|
|
1204
|
+
this.t(
|
|
1205
|
+
s.busy === "create" && this.#creating === level ? "share.creating" : `share.create.${level}`
|
|
1206
|
+
)
|
|
1207
|
+
)
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (s.links.length > 0) {
|
|
1214
|
+
const list = h("ul", { part: "list" });
|
|
1215
|
+
for (const link of s.links) {
|
|
1216
|
+
const qr = this.qrFor(link.url);
|
|
1217
|
+
list.append(
|
|
1218
|
+
h(
|
|
1219
|
+
"li",
|
|
1220
|
+
{ part: "card", "data-link": link.id },
|
|
1221
|
+
qr ? h("img", { part: "qr", src: qr, alt: "" }) : null,
|
|
1222
|
+
h(
|
|
1223
|
+
"div",
|
|
1224
|
+
{ style: "flex:1;min-width:0" },
|
|
1225
|
+
h("div", { part: "title" }, this.t(`share.level.${link.level}`)),
|
|
1226
|
+
h("div", { part: "sub", style: "word-break:break-all" }, link.url)
|
|
1227
|
+
),
|
|
1228
|
+
h(
|
|
1229
|
+
"button",
|
|
1230
|
+
{ part: "button", "data-action": "copy", onclick: () => this.copy(link.id, link.url) },
|
|
1231
|
+
this.t(this.#copiedId === link.id ? "share.copied" : "share.copy")
|
|
1232
|
+
),
|
|
1233
|
+
h(
|
|
1234
|
+
"button",
|
|
1235
|
+
{ part: "button button-danger", "data-action": "revoke", disabled: s.busy !== null, onclick: () => this.revoke(link.id) },
|
|
1236
|
+
this.t("share.revoke")
|
|
1237
|
+
)
|
|
1238
|
+
)
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
into.append(list);
|
|
1242
|
+
}
|
|
1243
|
+
if (this.#withMembers) {
|
|
1244
|
+
put(into, this.heading("sub", "share.members"));
|
|
1245
|
+
if (s.members.length === 0) {
|
|
1246
|
+
into.append(h("div", { part: "hint" }, this.t("share.empty")));
|
|
1247
|
+
} else {
|
|
1248
|
+
const members = h("ul", { part: "list" });
|
|
1249
|
+
for (const m of s.members) {
|
|
1250
|
+
const badges = [];
|
|
1251
|
+
if (m.self) badges.push(this.t("share.member.you"));
|
|
1252
|
+
if (m.owner) badges.push(this.t("share.member.owner"));
|
|
1253
|
+
members.append(
|
|
1254
|
+
h(
|
|
1255
|
+
"li",
|
|
1256
|
+
{ part: "card", "data-member": m.id },
|
|
1257
|
+
h(
|
|
1258
|
+
"div",
|
|
1259
|
+
{ style: "flex:1" },
|
|
1260
|
+
h("div", { part: "title" }, m.label || m.id),
|
|
1261
|
+
badges.length ? h("div", { part: "sub" }, badges.join(" - ")) : null
|
|
1262
|
+
),
|
|
1263
|
+
s.canRemoveMembers && !m.self ? h(
|
|
1264
|
+
"button",
|
|
1265
|
+
{ part: "button button-danger", "data-action": "remove", disabled: s.busy !== null, onclick: () => this.removeRow(m.id) },
|
|
1266
|
+
this.t("share.remove")
|
|
1267
|
+
) : null
|
|
1268
|
+
)
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
into.append(members);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
if (s.canRevokeAll && s.members.some((m) => !m.self)) {
|
|
1275
|
+
into.append(
|
|
1276
|
+
h(
|
|
1277
|
+
"div",
|
|
1278
|
+
{ part: "footer", style: "text-align:right" },
|
|
1279
|
+
h(
|
|
1280
|
+
"button",
|
|
1281
|
+
{ part: "link link-danger", "data-action": "stop", disabled: s.busy !== null, onclick: () => this.stop() },
|
|
1282
|
+
this.t("share.stop")
|
|
1283
|
+
)
|
|
1284
|
+
)
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
create(level) {
|
|
1289
|
+
this.#creating = level;
|
|
1290
|
+
void this.#flow?.createLink({ level }).then((link) => {
|
|
1291
|
+
this.#creating = null;
|
|
1292
|
+
if (link) this.emit("selfstore-link-created", { link });
|
|
1293
|
+
this.rerender();
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
/** Run a destructive action through the host's veto (default: proceed).
|
|
1297
|
+
* A throwing hook reads as "no": destruction needs a clear yes. */
|
|
1298
|
+
guarded(action, run) {
|
|
1299
|
+
const gate = this.#confirmAction;
|
|
1300
|
+
if (!gate) {
|
|
1301
|
+
run();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
void Promise.resolve().then(() => gate(action)).then((ok) => {
|
|
1305
|
+
if (ok) run();
|
|
1306
|
+
}).catch(() => void 0);
|
|
1307
|
+
}
|
|
1308
|
+
revoke(id) {
|
|
1309
|
+
this.guarded({ type: "revoke", id }, () => void this.#flow?.revokeLink(id));
|
|
1310
|
+
}
|
|
1311
|
+
removeRow(id) {
|
|
1312
|
+
this.guarded({ type: "remove", id }, () => void this.#flow?.removeMember(id));
|
|
1313
|
+
}
|
|
1314
|
+
stop() {
|
|
1315
|
+
this.guarded({ type: "stop" }, () => {
|
|
1316
|
+
void this.#flow?.revokeAll().then((ok) => {
|
|
1317
|
+
if (ok) this.emit("selfstore-share-stopped");
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
qrFor(url) {
|
|
1322
|
+
const provider = this.#qrProvider;
|
|
1323
|
+
if (!provider) return null;
|
|
1324
|
+
const got = this.#qr.get(url);
|
|
1325
|
+
if (got) return got;
|
|
1326
|
+
if (!this.#qrPending.has(url)) {
|
|
1327
|
+
this.#qrPending.add(url);
|
|
1328
|
+
void provider(url).then((src) => {
|
|
1329
|
+
if (this.#qrProvider !== provider) return;
|
|
1330
|
+
this.#qr.set(url, src);
|
|
1331
|
+
this.rerender();
|
|
1332
|
+
}).catch(() => void 0).finally(() => this.#qrPending.delete(url));
|
|
1333
|
+
}
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
copy(id, url) {
|
|
1337
|
+
this.#copiedId = id;
|
|
1338
|
+
this.emit("selfstore-link-copied", { url });
|
|
1339
|
+
void navigator.clipboard?.writeText(url).catch(() => void 0);
|
|
1340
|
+
this.rerender();
|
|
1341
|
+
setTimeout(() => {
|
|
1342
|
+
if (this.#copiedId !== id) return;
|
|
1343
|
+
this.#copiedId = null;
|
|
1344
|
+
this.rerender();
|
|
1345
|
+
}, 1500);
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
// src/widgets/join.ts
|
|
1350
|
+
var EN3 = {
|
|
1351
|
+
"join.previewing": "Reading the invitation...",
|
|
1352
|
+
"join.title": "You are invited",
|
|
1353
|
+
"join.from": "Shared by {from}",
|
|
1354
|
+
"join.level.read": "You will be able to view it.",
|
|
1355
|
+
"join.level.write": "You will be able to view and edit it.",
|
|
1356
|
+
"join.accept": "Join",
|
|
1357
|
+
"join.joining": "Joining...",
|
|
1358
|
+
"join.joined": "You are in. The shared data now syncs on this device.",
|
|
1359
|
+
"join.mismatch": "This device already follows another share. Leave it first, or use another profile.",
|
|
1360
|
+
"join.noInvite": "This invitation is spent, or meant for another account.",
|
|
1361
|
+
"join.switchAccount": "Use another account",
|
|
1362
|
+
"join.retry": "Try again",
|
|
1363
|
+
"error.generic": "That did not work. Check the connection and try again.",
|
|
1364
|
+
"error.targetUnavailable": "The invitation could not be read. Try again in a moment."
|
|
1365
|
+
};
|
|
1366
|
+
var SelfstoreJoinElement = class extends FlowWidget {
|
|
1367
|
+
static get observedAttributes() {
|
|
1368
|
+
return ["link", "variant"];
|
|
1369
|
+
}
|
|
1370
|
+
#engine = null;
|
|
1371
|
+
#link = null;
|
|
1372
|
+
#flow = null;
|
|
1373
|
+
#variant = "card";
|
|
1374
|
+
#options = {};
|
|
1375
|
+
defaults() {
|
|
1376
|
+
return EN3;
|
|
1377
|
+
}
|
|
1378
|
+
get engine() {
|
|
1379
|
+
return this.#engine;
|
|
1380
|
+
}
|
|
1381
|
+
set engine(v) {
|
|
1382
|
+
this.#engine = v;
|
|
1383
|
+
this.wire();
|
|
1384
|
+
}
|
|
1385
|
+
/** The invitation. Also settable as the `link` attribute. */
|
|
1386
|
+
get link() {
|
|
1387
|
+
return this.#link;
|
|
1388
|
+
}
|
|
1389
|
+
set link(v) {
|
|
1390
|
+
this.#link = v;
|
|
1391
|
+
this.wire();
|
|
1392
|
+
}
|
|
1393
|
+
/** Flow options (deadlineMs). Set it when the engine's join opens a popup:
|
|
1394
|
+
* a generous deadline keeps the network guard without timing out a human
|
|
1395
|
+
* who is reading an account chooser. */
|
|
1396
|
+
get options() {
|
|
1397
|
+
return this.#options;
|
|
1398
|
+
}
|
|
1399
|
+
set options(v) {
|
|
1400
|
+
this.#options = v ?? {};
|
|
1401
|
+
this.wire();
|
|
1402
|
+
}
|
|
1403
|
+
/** 'card' (default): the full invitation card - who shared, what level.
|
|
1404
|
+
* 'banner': one line - label, join, account switch - for a host page that
|
|
1405
|
+
* renders its own preview around the widget. Also the `variant` attribute. */
|
|
1406
|
+
get variant() {
|
|
1407
|
+
return this.#variant;
|
|
1408
|
+
}
|
|
1409
|
+
set variant(v) {
|
|
1410
|
+
this.#variant = v === "banner" ? "banner" : "card";
|
|
1411
|
+
this.rerender();
|
|
1412
|
+
}
|
|
1413
|
+
get flow() {
|
|
1414
|
+
return this.#flow;
|
|
1415
|
+
}
|
|
1416
|
+
attributeChangedCallback(name, _old, value) {
|
|
1417
|
+
if (name === "link") this.link = value;
|
|
1418
|
+
if (name === "variant") this.variant = value ?? "card";
|
|
1419
|
+
}
|
|
1420
|
+
connectedCallback() {
|
|
1421
|
+
if (!this.#link && this.hasAttribute("link")) this.#link = this.getAttribute("link");
|
|
1422
|
+
if (this.hasAttribute("variant")) {
|
|
1423
|
+
this.#variant = this.getAttribute("variant") === "banner" ? "banner" : "card";
|
|
1424
|
+
}
|
|
1425
|
+
this.wire();
|
|
1426
|
+
}
|
|
1427
|
+
wire() {
|
|
1428
|
+
if (!this.isConnected || !this.#engine || !this.#link) return;
|
|
1429
|
+
this.unsub?.();
|
|
1430
|
+
this.#flow = joinFlow(this.#link, this.#engine, this.#options);
|
|
1431
|
+
let lastStep = null;
|
|
1432
|
+
this.unsub = this.#flow.subscribe((snap) => {
|
|
1433
|
+
if (snap.step !== lastStep) {
|
|
1434
|
+
if (snap.step === "joined") this.emit("selfstore-joined", { outcome: "joined" });
|
|
1435
|
+
if (snap.step === "mismatch" || snap.step === "no-invite") {
|
|
1436
|
+
this.emit("selfstore-join-refused", { outcome: snap.step });
|
|
1437
|
+
}
|
|
1438
|
+
if (snap.step === "ready" && lastStep === "joining") {
|
|
1439
|
+
this.emit("selfstore-cancelled");
|
|
1440
|
+
}
|
|
1441
|
+
lastStep = snap.step;
|
|
1442
|
+
}
|
|
1443
|
+
this.rerender();
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
view(into) {
|
|
1447
|
+
const flow = this.#flow;
|
|
1448
|
+
if (!flow) return;
|
|
1449
|
+
const s = flow.snapshot;
|
|
1450
|
+
if (s.step === "previewing" || s.step === "joining") {
|
|
1451
|
+
into.append(
|
|
1452
|
+
h(
|
|
1453
|
+
"div",
|
|
1454
|
+
{ part: "status" },
|
|
1455
|
+
h("span", { part: "spinner", "aria-hidden": "true" }),
|
|
1456
|
+
h("div", { part: "title" }, this.t(s.step === "joining" ? "join.joining" : "join.previewing"))
|
|
1457
|
+
)
|
|
1458
|
+
);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (s.step === "ready") {
|
|
1462
|
+
const p = s.preview;
|
|
1463
|
+
if (this.#variant === "banner") {
|
|
1464
|
+
put(
|
|
1465
|
+
into,
|
|
1466
|
+
h(
|
|
1467
|
+
"div",
|
|
1468
|
+
{ part: "row banner" },
|
|
1469
|
+
h("div", { part: "title", style: "flex:1;min-width:0" }, p?.label || this.t("join.title")),
|
|
1470
|
+
h("button", { part: "button button-primary", "data-action": "accept", onclick: () => flow.accept() }, this.t("join.accept")),
|
|
1471
|
+
s.canSwitchAccount ? h("button", { part: "link", "data-action": "switch", onclick: () => flow.switchAccount() }, this.t("join.switchAccount")) : null
|
|
1472
|
+
)
|
|
1473
|
+
);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
put(
|
|
1477
|
+
into,
|
|
1478
|
+
h("div", { part: "title" }, p?.label || this.t("join.title")),
|
|
1479
|
+
p?.from ? h("div", { part: "sub" }, this.t("join.from").replace("{from}", p.from)) : null,
|
|
1480
|
+
p?.level ? h("div", { part: "hint" }, this.t(`join.level.${p.level}`)) : null,
|
|
1481
|
+
h(
|
|
1482
|
+
"div",
|
|
1483
|
+
{ part: "row" },
|
|
1484
|
+
h("button", { part: "button button-primary", "data-action": "accept", onclick: () => flow.accept() }, this.t("join.accept")),
|
|
1485
|
+
s.canSwitchAccount ? h("button", { part: "link", "data-action": "switch", onclick: () => flow.switchAccount() }, this.t("join.switchAccount")) : null
|
|
1486
|
+
)
|
|
1487
|
+
);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (s.step === "joined") {
|
|
1491
|
+
into.append(h("div", { part: "status status-ok" }, h("div", { part: "title" }, this.t("join.joined"))));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (s.step === "mismatch" || s.step === "no-invite") {
|
|
1495
|
+
into.append(
|
|
1496
|
+
h("div", { part: "status" }, h("div", { part: "title" }, this.t(s.step === "mismatch" ? "join.mismatch" : "join.noInvite")))
|
|
1497
|
+
);
|
|
1498
|
+
if (s.step === "no-invite" && s.canSwitchAccount) {
|
|
1499
|
+
into.append(
|
|
1500
|
+
h("div", { part: "row" }, h("button", { part: "button", "data-action": "switch", onclick: () => flow.switchAccount() }, this.t("join.switchAccount")))
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
into.append(
|
|
1506
|
+
h("div", { part: "status status-error" }, h("div", { part: "title" }, this.errorText(s.error?.labelKey))),
|
|
1507
|
+
h(
|
|
1508
|
+
"div",
|
|
1509
|
+
{ part: "row" },
|
|
1510
|
+
h("button", { part: "button", onclick: () => flow.retry() }, this.t("join.retry")),
|
|
1511
|
+
s.canSwitchAccount ? h("button", { part: "link", "data-action": "switch", onclick: () => flow.switchAccount() }, this.t("join.switchAccount")) : null
|
|
1512
|
+
)
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// src/widgets/status.ts
|
|
1518
|
+
var EN4 = {
|
|
1519
|
+
"status.ephemeral": "Nothing is saved",
|
|
1520
|
+
"status.cacheOnly": "Only on this device",
|
|
1521
|
+
"status.saving": "Saving...",
|
|
1522
|
+
"status.saved": "Saved",
|
|
1523
|
+
"status.needsAttention": "Reconnect to continue",
|
|
1524
|
+
"status.locked": "Locked",
|
|
1525
|
+
"status.pendingDownload": "Changes to download",
|
|
1526
|
+
"status.action.choose-destination": "Choose a destination",
|
|
1527
|
+
"status.action.download": "Download",
|
|
1528
|
+
"status.action.reconnect": "Reconnect",
|
|
1529
|
+
"status.action.unlock": "Unlock"
|
|
1530
|
+
};
|
|
1531
|
+
var SelfstoreStatusElement = class extends FlowWidget {
|
|
1532
|
+
static get observedAttributes() {
|
|
1533
|
+
return ["variant"];
|
|
1534
|
+
}
|
|
1535
|
+
#store = null;
|
|
1536
|
+
#variant = "row";
|
|
1537
|
+
#icons = {};
|
|
1538
|
+
defaults() {
|
|
1539
|
+
return EN4;
|
|
1540
|
+
}
|
|
1541
|
+
/** The simple store (anything exposing `flowHost`), or a hand-built FlowHost. */
|
|
1542
|
+
get store() {
|
|
1543
|
+
return this.#store;
|
|
1544
|
+
}
|
|
1545
|
+
set store(v) {
|
|
1546
|
+
this.#store = v;
|
|
1547
|
+
this.wire();
|
|
1548
|
+
}
|
|
1549
|
+
/** 'row' (dot + text + action) or 'dot' (the dot alone). Also the attribute. */
|
|
1550
|
+
get variant() {
|
|
1551
|
+
return this.#variant;
|
|
1552
|
+
}
|
|
1553
|
+
set variant(v) {
|
|
1554
|
+
this.#variant = v === "dot" ? "dot" : "row";
|
|
1555
|
+
this.rerender();
|
|
1556
|
+
}
|
|
1557
|
+
/** Optional icon per target kind (e.g. { drive: url, file: url }), shown
|
|
1558
|
+
* before the text in the row variant. Same contract as the connect cards. */
|
|
1559
|
+
get icons() {
|
|
1560
|
+
return this.#icons;
|
|
1561
|
+
}
|
|
1562
|
+
set icons(v) {
|
|
1563
|
+
this.#icons = v ?? {};
|
|
1564
|
+
this.rerender();
|
|
1565
|
+
}
|
|
1566
|
+
attributeChangedCallback(name, _old, value) {
|
|
1567
|
+
if (name === "variant") this.variant = value;
|
|
1568
|
+
}
|
|
1569
|
+
connectedCallback() {
|
|
1570
|
+
if (this.hasAttribute("variant")) this.variant = this.getAttribute("variant");
|
|
1571
|
+
this.wire();
|
|
1572
|
+
}
|
|
1573
|
+
host() {
|
|
1574
|
+
const s = this.#store;
|
|
1575
|
+
if (!s) return null;
|
|
1576
|
+
return "flowHost" in s ? s.flowHost : s;
|
|
1577
|
+
}
|
|
1578
|
+
wire() {
|
|
1579
|
+
if (!this.isConnected) return;
|
|
1580
|
+
this.unsub?.();
|
|
1581
|
+
this.unsub = null;
|
|
1582
|
+
const host = this.host();
|
|
1583
|
+
if (!host) return;
|
|
1584
|
+
this.unsub = host.engine.subscribe(() => this.rerender());
|
|
1585
|
+
this.rerender();
|
|
1586
|
+
}
|
|
1587
|
+
view(into) {
|
|
1588
|
+
const host = this.host();
|
|
1589
|
+
if (!host) return;
|
|
1590
|
+
const { status, targetKind, label } = host.engine.state;
|
|
1591
|
+
const text = this.t(status.labelKey);
|
|
1592
|
+
const dot = h("span", {
|
|
1593
|
+
part: `status-dot sev-${status.severity}`,
|
|
1594
|
+
"aria-hidden": "true"
|
|
1595
|
+
});
|
|
1596
|
+
if (this.#variant === "dot") {
|
|
1597
|
+
into.append(
|
|
1598
|
+
h(
|
|
1599
|
+
"button",
|
|
1600
|
+
{
|
|
1601
|
+
part: "dot-button",
|
|
1602
|
+
"aria-label": text,
|
|
1603
|
+
title: text,
|
|
1604
|
+
onclick: () => this.emit("selfstore-status-action", { action: status.action ?? null })
|
|
1605
|
+
},
|
|
1606
|
+
dot
|
|
1607
|
+
)
|
|
1608
|
+
);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const icon = this.#icons[targetKind];
|
|
1612
|
+
const where = this.t(`status.where.${targetKind}`);
|
|
1613
|
+
const sub = where !== `status.where.${targetKind}` ? where : label ?? "";
|
|
1614
|
+
put(
|
|
1615
|
+
into,
|
|
1616
|
+
h(
|
|
1617
|
+
"div",
|
|
1618
|
+
{ part: "row status-row" },
|
|
1619
|
+
dot,
|
|
1620
|
+
icon ? h("img", { part: "icon", src: icon, alt: "" }) : null,
|
|
1621
|
+
h(
|
|
1622
|
+
"div",
|
|
1623
|
+
{},
|
|
1624
|
+
h("div", { part: "title" }, text),
|
|
1625
|
+
sub ? h("div", { part: "sub" }, sub) : null
|
|
1626
|
+
),
|
|
1627
|
+
status.actionable && status.action ? h(
|
|
1628
|
+
"button",
|
|
1629
|
+
{
|
|
1630
|
+
part: "button status-action",
|
|
1631
|
+
onclick: () => this.emit("selfstore-status-action", { action: status.action })
|
|
1632
|
+
},
|
|
1633
|
+
this.t(`status.action.${status.action}`)
|
|
1634
|
+
) : null
|
|
1635
|
+
)
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
// src/widgets/backups.ts
|
|
1641
|
+
var EN5 = {
|
|
1642
|
+
"backups.mine.heading": "My backups",
|
|
1643
|
+
"backups.mine.personal": "Main backup",
|
|
1644
|
+
"backups.active": "Active",
|
|
1645
|
+
"backups.pill.encrypted": "Encrypted",
|
|
1646
|
+
"backups.pill.plain": "Not encrypted",
|
|
1647
|
+
"backups.pill.shared": "Shared",
|
|
1648
|
+
"backups.pill.private": "Not shared",
|
|
1649
|
+
"backups.modified": "modified {when}",
|
|
1650
|
+
"backups.replaceHint": "Opening replaces the data on this device.",
|
|
1651
|
+
"backups.loading": "Opening...",
|
|
1652
|
+
"backups.menu": "Backup actions",
|
|
1653
|
+
"backups.open": "Open",
|
|
1654
|
+
"backups.encrypt": "Encrypt",
|
|
1655
|
+
"backups.share": "Share",
|
|
1656
|
+
"backups.rename": "Rename",
|
|
1657
|
+
"backups.delete": "Delete",
|
|
1658
|
+
"backups.new": "+ New backup",
|
|
1659
|
+
"backups.newTitle": "New backup",
|
|
1660
|
+
"backups.startsBlank": "It starts empty.",
|
|
1661
|
+
"backups.namePh": "Its name",
|
|
1662
|
+
"backups.willBe": "File: {file}",
|
|
1663
|
+
"backups.taken": "This name is already taken.",
|
|
1664
|
+
"backups.create": "Create",
|
|
1665
|
+
"backups.renameTitle": "Rename this backup",
|
|
1666
|
+
"backups.pwHint": "This backup is protected: enter its password.",
|
|
1667
|
+
"backups.pwShow": "Show the password",
|
|
1668
|
+
"backups.pwHide": "Hide the password",
|
|
1669
|
+
"backups.cancel": "Cancel",
|
|
1670
|
+
"backups.shared.heading": "Shared with me",
|
|
1671
|
+
"backups.shared.by": "Shared by {who}",
|
|
1672
|
+
"backups.shared.leave": "Leave",
|
|
1673
|
+
"backups.replica.add": "Backup copy",
|
|
1674
|
+
"backups.replica.title": "Backup copy",
|
|
1675
|
+
"backups.replica.intro": "The same encrypted file, also written to a second destination. If one fails, the other remains.",
|
|
1676
|
+
"backups.replica.dest.drive": "Google Drive",
|
|
1677
|
+
"backups.replica.dest.file": "A file on this device",
|
|
1678
|
+
"backups.replica.dest.webdav": "A WebDAV server",
|
|
1679
|
+
"backups.replica.dest.s3": "An S3 bucket",
|
|
1680
|
+
"backups.replica.card": "Backup copy - {label}",
|
|
1681
|
+
"backups.replica.uptodate": "Up to date, {when}",
|
|
1682
|
+
"backups.replica.pending": "First copy at the next save",
|
|
1683
|
+
"backups.replica.error": "Unreachable - will retry at the next save",
|
|
1684
|
+
"backups.replica.remove": "Remove",
|
|
1685
|
+
"backups.replica.save": "Add the copy",
|
|
1686
|
+
"backups.replica.webdav.url": "File URL on the server (https://host:8443/dav/file.zip)",
|
|
1687
|
+
"backups.replica.webdav.help": "The full URL of the backup file, including a custom port if any. The server must allow this app to reach it (CORS) - some hosted drives do not, and will not connect from a browser.",
|
|
1688
|
+
"backups.replica.webdav.user": "Username",
|
|
1689
|
+
"backups.replica.webdav.password": "Password",
|
|
1690
|
+
"backups.replica.s3.endpoint": "Endpoint URL",
|
|
1691
|
+
"backups.replica.s3.region": "Region",
|
|
1692
|
+
"backups.replica.s3.bucket": "Bucket",
|
|
1693
|
+
"backups.replica.s3.key": "Object key (file name)",
|
|
1694
|
+
"backups.replica.s3.accessKeyId": "Access key ID",
|
|
1695
|
+
"backups.replica.s3.secret": "Secret access key",
|
|
1696
|
+
"error.generic": "That did not work. Check the connection and try again."
|
|
1697
|
+
};
|
|
1698
|
+
var isOff2 = (v) => v != null && ["off", "false", "no", "0"].includes(v.trim().toLowerCase());
|
|
1699
|
+
var EYE_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
1700
|
+
var EYE_SHUT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 3 18 18"/><path d="M10.6 5.1A10.9 10.9 0 0 1 12 5c6.5 0 10 7 10 7a17.4 17.4 0 0 1-3.4 4.4M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10.7 10.7 0 0 0 5.4-1.4"/></svg>';
|
|
1701
|
+
var SelfstoreBackupsElement = class extends FlowWidget {
|
|
1702
|
+
static get observedAttributes() {
|
|
1703
|
+
return ["with-create", "with-rename", "with-delete", "with-open", "with-encrypt", "with-share", "with-shared"];
|
|
1704
|
+
}
|
|
1705
|
+
#manager = null;
|
|
1706
|
+
#rows = null;
|
|
1707
|
+
#lastActive = void 0;
|
|
1708
|
+
#listing = false;
|
|
1709
|
+
#busy = false;
|
|
1710
|
+
#openBusy = null;
|
|
1711
|
+
#menuFor = null;
|
|
1712
|
+
#pwFor = null;
|
|
1713
|
+
#pw = "";
|
|
1714
|
+
#pwShown = false;
|
|
1715
|
+
#renameFor = null;
|
|
1716
|
+
#renameLabel = "";
|
|
1717
|
+
#createOpen = false;
|
|
1718
|
+
#createLabel = "";
|
|
1719
|
+
#members = [];
|
|
1720
|
+
#replicaFlow = null;
|
|
1721
|
+
#replicaSnap = null;
|
|
1722
|
+
#repWebdav = { url: "", username: "", password: "" };
|
|
1723
|
+
#repS3 = { endpoint: "", region: "", bucket: "", key: "", accessKeyId: "", secretAccessKey: "" };
|
|
1724
|
+
#activeEncrypted = null;
|
|
1725
|
+
#activeShared = null;
|
|
1726
|
+
#confirmAction = null;
|
|
1727
|
+
#withCreate = true;
|
|
1728
|
+
#withRename = true;
|
|
1729
|
+
#withDelete = true;
|
|
1730
|
+
#withOpen = true;
|
|
1731
|
+
#withEncrypt = true;
|
|
1732
|
+
#withShare = true;
|
|
1733
|
+
#withShared = true;
|
|
1734
|
+
defaults() {
|
|
1735
|
+
return EN5;
|
|
1736
|
+
}
|
|
1737
|
+
/** The headless manager this panel drives (see 'selfstore/backups'). */
|
|
1738
|
+
get manager() {
|
|
1739
|
+
return this.#manager;
|
|
1740
|
+
}
|
|
1741
|
+
set manager(v) {
|
|
1742
|
+
this.#manager = v;
|
|
1743
|
+
this.wire();
|
|
1744
|
+
}
|
|
1745
|
+
/** The households this device is a member of (one entry per share, or a
|
|
1746
|
+
* single object for one): each renders in "Shared with me" labeled by who
|
|
1747
|
+
* shares it, removal = leaving (host performs it on
|
|
1748
|
+
* 'selfstore-backups-leave', whose detail names the entry's fileId). With
|
|
1749
|
+
* `fileId` (the share's dedicated wallet), that registry row IS the
|
|
1750
|
+
* member row: active it wears the badge, inactive it opens like any
|
|
1751
|
+
* shared silo - and its one menu action is leaving, never a mere forget. */
|
|
1752
|
+
get member() {
|
|
1753
|
+
return this.#members.length > 0 ? this.#members : null;
|
|
1754
|
+
}
|
|
1755
|
+
set member(v) {
|
|
1756
|
+
this.#members = v == null ? [] : Array.isArray(v) ? v : [v];
|
|
1757
|
+
this.rerender();
|
|
1758
|
+
}
|
|
1759
|
+
/** Live state of the ATTACHED backup, from the host's store: the active
|
|
1760
|
+
* row's pills prefer these over the learned memories (which may lag). */
|
|
1761
|
+
get activeEncrypted() {
|
|
1762
|
+
return this.#activeEncrypted;
|
|
1763
|
+
}
|
|
1764
|
+
set activeEncrypted(v) {
|
|
1765
|
+
this.#activeEncrypted = v;
|
|
1766
|
+
this.rerender();
|
|
1767
|
+
}
|
|
1768
|
+
get activeShared() {
|
|
1769
|
+
return this.#activeShared;
|
|
1770
|
+
}
|
|
1771
|
+
set activeShared(v) {
|
|
1772
|
+
this.#activeShared = v;
|
|
1773
|
+
this.rerender();
|
|
1774
|
+
}
|
|
1775
|
+
/** Host veto over the destructive gestures (delete / forget / leave):
|
|
1776
|
+
* answer (or resolve) false to keep things as they are - window.confirm
|
|
1777
|
+
* fits as-is. Default: everything proceeds. */
|
|
1778
|
+
get confirmAction() {
|
|
1779
|
+
return this.#confirmAction;
|
|
1780
|
+
}
|
|
1781
|
+
set confirmAction(v) {
|
|
1782
|
+
this.#confirmAction = v ?? null;
|
|
1783
|
+
}
|
|
1784
|
+
get withCreate() {
|
|
1785
|
+
return this.#withCreate;
|
|
1786
|
+
}
|
|
1787
|
+
set withCreate(v) {
|
|
1788
|
+
this.#withCreate = v !== false;
|
|
1789
|
+
this.rerender();
|
|
1790
|
+
}
|
|
1791
|
+
get withRename() {
|
|
1792
|
+
return this.#withRename;
|
|
1793
|
+
}
|
|
1794
|
+
set withRename(v) {
|
|
1795
|
+
this.#withRename = v !== false;
|
|
1796
|
+
this.rerender();
|
|
1797
|
+
}
|
|
1798
|
+
get withDelete() {
|
|
1799
|
+
return this.#withDelete;
|
|
1800
|
+
}
|
|
1801
|
+
set withDelete(v) {
|
|
1802
|
+
this.#withDelete = v !== false;
|
|
1803
|
+
this.rerender();
|
|
1804
|
+
}
|
|
1805
|
+
get withOpen() {
|
|
1806
|
+
return this.#withOpen;
|
|
1807
|
+
}
|
|
1808
|
+
set withOpen(v) {
|
|
1809
|
+
this.#withOpen = v !== false;
|
|
1810
|
+
this.rerender();
|
|
1811
|
+
}
|
|
1812
|
+
get withEncrypt() {
|
|
1813
|
+
return this.#withEncrypt;
|
|
1814
|
+
}
|
|
1815
|
+
set withEncrypt(v) {
|
|
1816
|
+
this.#withEncrypt = v !== false;
|
|
1817
|
+
this.rerender();
|
|
1818
|
+
}
|
|
1819
|
+
get withShare() {
|
|
1820
|
+
return this.#withShare;
|
|
1821
|
+
}
|
|
1822
|
+
set withShare(v) {
|
|
1823
|
+
this.#withShare = v !== false;
|
|
1824
|
+
this.rerender();
|
|
1825
|
+
}
|
|
1826
|
+
get withShared() {
|
|
1827
|
+
return this.#withShared;
|
|
1828
|
+
}
|
|
1829
|
+
set withShared(v) {
|
|
1830
|
+
this.#withShared = v !== false;
|
|
1831
|
+
this.rerender();
|
|
1832
|
+
}
|
|
1833
|
+
attributeChangedCallback(name, _old, value) {
|
|
1834
|
+
const on = value == null || !isOff2(value);
|
|
1835
|
+
if (name === "with-create") this.withCreate = on;
|
|
1836
|
+
if (name === "with-rename") this.withRename = on;
|
|
1837
|
+
if (name === "with-delete") this.withDelete = on;
|
|
1838
|
+
if (name === "with-open") this.withOpen = on;
|
|
1839
|
+
if (name === "with-encrypt") this.withEncrypt = on;
|
|
1840
|
+
if (name === "with-share") this.withShare = on;
|
|
1841
|
+
if (name === "with-shared") this.withShared = on;
|
|
1842
|
+
}
|
|
1843
|
+
connectedCallback() {
|
|
1844
|
+
this.wire();
|
|
1845
|
+
}
|
|
1846
|
+
/** Re-list the rows from the destination (the host calls this after a
|
|
1847
|
+
* gesture of its own moved things - a leave, an external change). */
|
|
1848
|
+
refresh() {
|
|
1849
|
+
void this.loadRows();
|
|
1850
|
+
}
|
|
1851
|
+
wire() {
|
|
1852
|
+
if (!this.isConnected || !this.#manager) return;
|
|
1853
|
+
this.unsub?.();
|
|
1854
|
+
const manager = this.#manager;
|
|
1855
|
+
const unsubManager = manager.subscribe((snap) => {
|
|
1856
|
+
if (snap.activeFileId !== this.#lastActive) {
|
|
1857
|
+
this.#lastActive = snap.activeFileId;
|
|
1858
|
+
void this.loadRows();
|
|
1859
|
+
}
|
|
1860
|
+
this.rerender();
|
|
1861
|
+
});
|
|
1862
|
+
const unsubReplica = this.#replicaFlow?.subscribe((snap) => {
|
|
1863
|
+
this.#replicaSnap = snap;
|
|
1864
|
+
this.rerender();
|
|
1865
|
+
});
|
|
1866
|
+
this.unsub = () => {
|
|
1867
|
+
unsubManager();
|
|
1868
|
+
unsubReplica?.();
|
|
1869
|
+
};
|
|
1870
|
+
void manager.hydrate().then(() => this.loadRows());
|
|
1871
|
+
}
|
|
1872
|
+
/** The backup-copy journey (see 'selfstore/flows' replicaFlow). Setting it
|
|
1873
|
+
* adds a menu entry on the active backup's card and renders the copy
|
|
1874
|
+
* cards under it; without it the panel is byte-identical to before. The
|
|
1875
|
+
* widget restores a recorded copy by itself. */
|
|
1876
|
+
get replica() {
|
|
1877
|
+
return this.#replicaFlow;
|
|
1878
|
+
}
|
|
1879
|
+
set replica(v) {
|
|
1880
|
+
this.#replicaFlow = v;
|
|
1881
|
+
this.#replicaSnap = v?.snapshot ?? null;
|
|
1882
|
+
if (v) void v.restore();
|
|
1883
|
+
this.wire();
|
|
1884
|
+
}
|
|
1885
|
+
/** One destination listing, then the lazy encryption probes (each learned
|
|
1886
|
+
* bit repaints its row). A transient failure keeps the last good list. */
|
|
1887
|
+
async loadRows() {
|
|
1888
|
+
const manager = this.#manager;
|
|
1889
|
+
if (!manager || this.#listing) return;
|
|
1890
|
+
this.#listing = true;
|
|
1891
|
+
try {
|
|
1892
|
+
this.#rows = await manager.list();
|
|
1893
|
+
this.rerender();
|
|
1894
|
+
for (const r of this.#rows ?? []) {
|
|
1895
|
+
if (r.encrypted !== null) continue;
|
|
1896
|
+
const enc = await manager.probeEncryption(r.fileId);
|
|
1897
|
+
if (enc === null || this.#rows === null) continue;
|
|
1898
|
+
this.#rows = this.#rows.map((x) => x.fileId === r.fileId ? { ...x, encrypted: enc } : x);
|
|
1899
|
+
this.rerender();
|
|
1900
|
+
}
|
|
1901
|
+
} catch {
|
|
1902
|
+
this.#rows = this.#rows ?? [];
|
|
1903
|
+
this.rerender();
|
|
1904
|
+
} finally {
|
|
1905
|
+
this.#listing = false;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
/** t() with {placeholders}. */
|
|
1909
|
+
ts(key, params) {
|
|
1910
|
+
return this.t(key).replace(/\{(\w+)\}/g, (_, k) => params[k] ?? "");
|
|
1911
|
+
}
|
|
1912
|
+
label(row) {
|
|
1913
|
+
return row.label ?? this.t("backups.mine.personal");
|
|
1914
|
+
}
|
|
1915
|
+
view(into) {
|
|
1916
|
+
const manager = this.#manager;
|
|
1917
|
+
if (!manager) return;
|
|
1918
|
+
const snap = manager.snapshot;
|
|
1919
|
+
const rows = this.#rows ?? [];
|
|
1920
|
+
const active = snap.activeFileId;
|
|
1921
|
+
const mine = snap.joined ? rows.filter((r) => r.fileId !== active) : rows;
|
|
1922
|
+
if (this.#rows !== null) put(into, this.heading("sub heading", "backups.mine.heading"));
|
|
1923
|
+
const list = h("ul", { part: "list" });
|
|
1924
|
+
for (const r of mine) {
|
|
1925
|
+
const isActive = r.fileId === active;
|
|
1926
|
+
if (this.#pwFor === r.fileId) list.append(this.passwordCard(r));
|
|
1927
|
+
else if (this.#renameFor?.fileId === r.fileId) list.append(this.renameCard(r));
|
|
1928
|
+
else list.append(this.rowCard(r, isActive, isActive ? this.replicaAttachedLine() : null));
|
|
1929
|
+
if (isActive) for (const el of this.replicaSetup()) list.append(el);
|
|
1930
|
+
}
|
|
1931
|
+
if (list.childElementCount > 0) into.append(list);
|
|
1932
|
+
if (this.#withCreate) {
|
|
1933
|
+
if (this.#createOpen) into.append(this.createCard());
|
|
1934
|
+
else {
|
|
1935
|
+
into.append(
|
|
1936
|
+
h(
|
|
1937
|
+
"button",
|
|
1938
|
+
{
|
|
1939
|
+
part: "button new-button",
|
|
1940
|
+
"data-action": "new",
|
|
1941
|
+
disabled: this.#busy,
|
|
1942
|
+
onclick: () => {
|
|
1943
|
+
this.#createOpen = true;
|
|
1944
|
+
this.#createLabel = "";
|
|
1945
|
+
this.rerender();
|
|
1946
|
+
}
|
|
1947
|
+
},
|
|
1948
|
+
this.t("backups.new")
|
|
1949
|
+
)
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
if (!this.#withShared) return;
|
|
1954
|
+
const members = this.#members;
|
|
1955
|
+
const badge = members.find((m) => m.fileId != null ? m.fileId === active : true) ?? null;
|
|
1956
|
+
const memberWhos = new Set(members.map((m) => m.by));
|
|
1957
|
+
const others = [];
|
|
1958
|
+
const seenForget = /* @__PURE__ */ new Set();
|
|
1959
|
+
for (const s of snap.registry.shared) {
|
|
1960
|
+
if (s.fileId === active && !snap.joined) continue;
|
|
1961
|
+
const member = members.some((m) => m.fileId === s.fileId);
|
|
1962
|
+
const who = s.ownerEmail ?? s.ownerName ?? "?";
|
|
1963
|
+
if (!member) {
|
|
1964
|
+
if (memberWhos.has(who) || seenForget.has(who)) continue;
|
|
1965
|
+
seenForget.add(who);
|
|
1966
|
+
}
|
|
1967
|
+
others.push({ fileId: s.fileId, who, member });
|
|
1968
|
+
}
|
|
1969
|
+
if (members.length === 0 && others.length === 0) return;
|
|
1970
|
+
put(into, this.heading("sub heading", "backups.shared.heading"));
|
|
1971
|
+
const shared = h("ul", { part: "list" });
|
|
1972
|
+
if (badge) {
|
|
1973
|
+
const activeRow = rows.find((r) => r.fileId === active) ?? null;
|
|
1974
|
+
const leaveAction = { type: "leave", fileId: badge.fileId ?? null, who: badge.by };
|
|
1975
|
+
shared.append(
|
|
1976
|
+
h(
|
|
1977
|
+
"li",
|
|
1978
|
+
{ part: "card card-active", style: "position:relative;border-color:var(--_ok)", "data-member": "" },
|
|
1979
|
+
h(
|
|
1980
|
+
"div",
|
|
1981
|
+
{ style: "flex:1;min-width:0" },
|
|
1982
|
+
h(
|
|
1983
|
+
"div",
|
|
1984
|
+
{ part: "title" },
|
|
1985
|
+
badge.fileId != null || !activeRow ? this.ts("backups.shared.by", { who: badge.by }) : this.label(activeRow),
|
|
1986
|
+
h("span", { part: "tag" }, this.t("backups.active"))
|
|
1987
|
+
),
|
|
1988
|
+
badge.fileId == null && activeRow ? h("div", { part: "sub" }, this.ts("backups.shared.by", { who: badge.by })) : null
|
|
1989
|
+
),
|
|
1990
|
+
this.menuButton("member"),
|
|
1991
|
+
this.#menuFor === "member" ? this.menu(
|
|
1992
|
+
this.#withDelete ? this.menuItem(
|
|
1993
|
+
"leave",
|
|
1994
|
+
this.t("backups.shared.leave"),
|
|
1995
|
+
true,
|
|
1996
|
+
() => this.guarded(
|
|
1997
|
+
leaveAction,
|
|
1998
|
+
() => this.emit("selfstore-backups-leave", { fileId: leaveAction.fileId })
|
|
1999
|
+
)
|
|
2000
|
+
) : null
|
|
2001
|
+
) : null
|
|
2002
|
+
)
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
for (const s of others) {
|
|
2006
|
+
if (badge && s.fileId === active) continue;
|
|
2007
|
+
const memberEntry = s.member;
|
|
2008
|
+
shared.append(
|
|
2009
|
+
h(
|
|
2010
|
+
"li",
|
|
2011
|
+
{ part: "card", style: "position:relative", "data-shared": s.fileId },
|
|
2012
|
+
this.#withOpen ? h(
|
|
2013
|
+
"button",
|
|
2014
|
+
{ part: "link open-shared", style: "flex:1;min-width:0;text-align:left;text-decoration:none", "data-action": "open-shared", disabled: this.#busy, onclick: () => this.open(s.fileId) },
|
|
2015
|
+
h("div", { part: "title" }, this.ts("backups.shared.by", { who: s.who })),
|
|
2016
|
+
h("div", { part: "sub" }, this.#openBusy === s.fileId ? this.t("backups.loading") : this.t("backups.replaceHint"))
|
|
2017
|
+
) : h(
|
|
2018
|
+
"div",
|
|
2019
|
+
{ style: "flex:1;min-width:0" },
|
|
2020
|
+
h("div", { part: "title" }, this.ts("backups.shared.by", { who: s.who })),
|
|
2021
|
+
h("div", { part: "sub" }, this.t("backups.replaceHint"))
|
|
2022
|
+
),
|
|
2023
|
+
this.menuButton(s.fileId),
|
|
2024
|
+
this.#menuFor === s.fileId ? this.menu(
|
|
2025
|
+
this.#withDelete ? memberEntry ? this.menuItem(
|
|
2026
|
+
"leave",
|
|
2027
|
+
this.t("backups.shared.leave"),
|
|
2028
|
+
true,
|
|
2029
|
+
() => this.guarded(
|
|
2030
|
+
{ type: "leave", fileId: s.fileId, who: s.who },
|
|
2031
|
+
() => this.emit("selfstore-backups-leave", { fileId: s.fileId })
|
|
2032
|
+
)
|
|
2033
|
+
) : this.menuItem(
|
|
2034
|
+
"forget",
|
|
2035
|
+
this.t("backups.delete"),
|
|
2036
|
+
true,
|
|
2037
|
+
() => this.guarded({ type: "forget", fileId: s.fileId, who: s.who }, () => void this.forget(s.fileId))
|
|
2038
|
+
) : null
|
|
2039
|
+
) : null
|
|
2040
|
+
)
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
if (shared.childElementCount > 0) into.append(shared);
|
|
2044
|
+
}
|
|
2045
|
+
// --- Cards ----------------------------------------------------------------
|
|
2046
|
+
pills(r, active) {
|
|
2047
|
+
const enc = active && this.#activeEncrypted !== null ? this.#activeEncrypted : r.encrypted;
|
|
2048
|
+
const shr = active && this.#activeShared !== null ? this.#activeShared : r.shared;
|
|
2049
|
+
const bits = [];
|
|
2050
|
+
if (enc !== null) {
|
|
2051
|
+
bits.push(
|
|
2052
|
+
h("span", { part: enc ? "pill pill-encrypted sev-ok" : "pill pill-plain", style: pillStyle }, this.t(enc ? "backups.pill.encrypted" : "backups.pill.plain"))
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
if (shr !== null) {
|
|
2056
|
+
bits.push(
|
|
2057
|
+
h("span", { part: shr ? "pill pill-shared sev-warn" : "pill pill-private", style: pillStyle }, this.t(shr ? "backups.pill.shared" : "backups.pill.private"))
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
if (r.modifiedAt) {
|
|
2061
|
+
bits.push(h("span", { part: "sub" }, this.ts("backups.modified", { when: new Date(r.modifiedAt).toLocaleDateString() })));
|
|
2062
|
+
}
|
|
2063
|
+
return bits;
|
|
2064
|
+
}
|
|
2065
|
+
rowCard(r, active, footer = null) {
|
|
2066
|
+
const body = h(
|
|
2067
|
+
"div",
|
|
2068
|
+
{ style: "flex:1;min-width:0" },
|
|
2069
|
+
h("div", { part: "title" }, this.label(r), active ? h("span", { part: "tag" }, this.t("backups.active")) : null),
|
|
2070
|
+
h("div", { part: "row", style: "margin-top:0.2rem" }, ...this.pills(r, active)),
|
|
2071
|
+
footer
|
|
2072
|
+
);
|
|
2073
|
+
const opens = this.#withOpen && !active;
|
|
2074
|
+
const face = opens ? h(
|
|
2075
|
+
"button",
|
|
2076
|
+
{ part: "link open-row", style: "flex:1;min-width:0;text-align:left;text-decoration:none", "data-action": "open", disabled: this.#busy, onclick: () => this.open(r.fileId) },
|
|
2077
|
+
this.#openBusy === r.fileId ? h("div", { part: "sub" }, this.t("backups.loading")) : body
|
|
2078
|
+
) : body;
|
|
2079
|
+
return h(
|
|
2080
|
+
"li",
|
|
2081
|
+
{ part: active ? "card card-active" : "card", style: active ? "position:relative;border-color:var(--_ok)" : "position:relative", "data-row": r.fileId },
|
|
2082
|
+
face,
|
|
2083
|
+
this.menuButton(r.fileId),
|
|
2084
|
+
this.#menuFor === r.fileId ? this.menu(
|
|
2085
|
+
opens ? this.menuItem("open", this.t("backups.open"), false, () => this.open(r.fileId)) : null,
|
|
2086
|
+
this.#withEncrypt ? this.menuItem("encrypt", this.t("backups.encrypt"), false, () => this.emit("selfstore-backups-encrypt", { fileId: r.fileId, label: r.label, active })) : null,
|
|
2087
|
+
this.#withShare ? this.menuItem("share", this.t("backups.share"), false, () => this.emit("selfstore-backups-share", { fileId: r.fileId, label: r.label, active })) : null,
|
|
2088
|
+
active && this.#replicaFlow && this.#replicaSnap?.step === "idle" && !this.#replicaSnap.replica ? this.menuItem("replica-add", this.t("backups.replica.add"), false, () => this.#replicaFlow?.open()) : null,
|
|
2089
|
+
this.#withRename ? this.menuItem("rename", this.t("backups.rename"), false, () => {
|
|
2090
|
+
this.#renameFor = r;
|
|
2091
|
+
this.#renameLabel = r.label ?? "";
|
|
2092
|
+
this.rerender();
|
|
2093
|
+
}) : null,
|
|
2094
|
+
this.#withDelete ? this.menuItem(
|
|
2095
|
+
"delete",
|
|
2096
|
+
this.t("backups.delete"),
|
|
2097
|
+
true,
|
|
2098
|
+
() => this.guarded({ type: "delete", fileId: r.fileId, name: r.name, active }, () => void this.deleteRow(r.fileId))
|
|
2099
|
+
) : null
|
|
2100
|
+
) : null
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
passwordCard(r) {
|
|
2104
|
+
const submit = () => {
|
|
2105
|
+
if (this.#pw.length > 0) void this.open(r.fileId, this.#pw);
|
|
2106
|
+
};
|
|
2107
|
+
return h(
|
|
2108
|
+
"li",
|
|
2109
|
+
{ part: "card", "data-password": r.fileId },
|
|
2110
|
+
h(
|
|
2111
|
+
"div",
|
|
2112
|
+
{ style: "flex:1;min-width:0" },
|
|
2113
|
+
h("div", { part: "title" }, this.label(r)),
|
|
2114
|
+
h("div", { part: "sub" }, this.t("backups.pwHint")),
|
|
2115
|
+
h(
|
|
2116
|
+
"div",
|
|
2117
|
+
{ part: "row", style: "margin-top:0.45rem" },
|
|
2118
|
+
h(
|
|
2119
|
+
"span",
|
|
2120
|
+
{ part: "field", style: "flex:1;min-width:9rem" },
|
|
2121
|
+
h("input", {
|
|
2122
|
+
part: "input",
|
|
2123
|
+
type: this.#pwShown ? "text" : "password",
|
|
2124
|
+
autocomplete: "current-password",
|
|
2125
|
+
"data-keep": "backup-pw",
|
|
2126
|
+
disabled: this.#busy,
|
|
2127
|
+
oninput: (e) => this.#pw = e.target.value,
|
|
2128
|
+
onkeydown: (e) => {
|
|
2129
|
+
if (e.key === "Enter") submit();
|
|
2130
|
+
}
|
|
2131
|
+
}),
|
|
2132
|
+
this.eye()
|
|
2133
|
+
),
|
|
2134
|
+
h("button", { part: "button button-primary", "data-action": "pw-open", disabled: this.#busy, onclick: submit }, this.t("backups.open")),
|
|
2135
|
+
h("button", { part: "button", "data-action": "pw-cancel", disabled: this.#busy, onclick: () => this.closePw() }, this.t("backups.cancel"))
|
|
2136
|
+
)
|
|
2137
|
+
)
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
renameCard(r) {
|
|
2141
|
+
const taken = this.nameTaken(this.#renameLabel, r.fileId);
|
|
2142
|
+
const clean = this.#renameLabel.trim();
|
|
2143
|
+
return h(
|
|
2144
|
+
"li",
|
|
2145
|
+
{ part: "card", "data-rename": r.fileId },
|
|
2146
|
+
h(
|
|
2147
|
+
"div",
|
|
2148
|
+
{ style: "flex:1;min-width:0" },
|
|
2149
|
+
h("div", { part: "title" }, this.t("backups.renameTitle")),
|
|
2150
|
+
h("input", {
|
|
2151
|
+
part: "input",
|
|
2152
|
+
style: "margin-top:0.45rem",
|
|
2153
|
+
"data-keep": "rename-label",
|
|
2154
|
+
maxlength: "40",
|
|
2155
|
+
disabled: this.#busy,
|
|
2156
|
+
value: this.#renameLabel,
|
|
2157
|
+
oninput: (e) => {
|
|
2158
|
+
this.#renameLabel = e.target.value;
|
|
2159
|
+
this.rerender();
|
|
2160
|
+
}
|
|
2161
|
+
}),
|
|
2162
|
+
taken ? h("div", { part: "error-note" }, this.t("backups.taken")) : clean ? h("div", { part: "sub" }, this.ts("backups.willBe", { file: this.#manager?.fileNameFor(clean) ?? "" })) : null,
|
|
2163
|
+
h(
|
|
2164
|
+
"div",
|
|
2165
|
+
{ part: "row", style: "margin-top:0.45rem" },
|
|
2166
|
+
h("button", { part: "button button-primary", "data-action": "rename-save", disabled: this.#busy || !clean || taken, onclick: () => void this.rename(r) }, this.t("backups.rename")),
|
|
2167
|
+
h(
|
|
2168
|
+
"button",
|
|
2169
|
+
{ part: "button", "data-action": "rename-cancel", disabled: this.#busy, onclick: () => {
|
|
2170
|
+
this.#renameFor = null;
|
|
2171
|
+
this.rerender();
|
|
2172
|
+
} },
|
|
2173
|
+
this.t("backups.cancel")
|
|
2174
|
+
)
|
|
2175
|
+
)
|
|
2176
|
+
)
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
createCard() {
|
|
2180
|
+
const taken = this.nameTaken(this.#createLabel, null);
|
|
2181
|
+
const clean = this.#createLabel.trim();
|
|
2182
|
+
return h(
|
|
2183
|
+
"div",
|
|
2184
|
+
{ part: "card", "data-create": "" },
|
|
2185
|
+
h(
|
|
2186
|
+
"div",
|
|
2187
|
+
{ style: "flex:1;min-width:0" },
|
|
2188
|
+
h("div", { part: "title" }, this.t("backups.newTitle")),
|
|
2189
|
+
h("div", { part: "sub" }, this.t("backups.startsBlank")),
|
|
2190
|
+
h("input", {
|
|
2191
|
+
part: "input",
|
|
2192
|
+
style: "margin-top:0.45rem",
|
|
2193
|
+
placeholder: this.t("backups.namePh"),
|
|
2194
|
+
"data-keep": "create-label",
|
|
2195
|
+
maxlength: "40",
|
|
2196
|
+
disabled: this.#busy,
|
|
2197
|
+
oninput: (e) => {
|
|
2198
|
+
this.#createLabel = e.target.value;
|
|
2199
|
+
this.rerender();
|
|
2200
|
+
}
|
|
2201
|
+
}),
|
|
2202
|
+
taken ? h("div", { part: "error-note" }, this.t("backups.taken")) : clean ? h("div", { part: "sub" }, this.ts("backups.willBe", { file: this.#manager?.fileNameFor(clean) ?? "" })) : null,
|
|
2203
|
+
h(
|
|
2204
|
+
"div",
|
|
2205
|
+
{ part: "row", style: "margin-top:0.45rem" },
|
|
2206
|
+
h("button", { part: "button button-primary", "data-action": "create", disabled: this.#busy || !clean || taken, onclick: () => void this.create() }, this.t("backups.create")),
|
|
2207
|
+
h(
|
|
2208
|
+
"button",
|
|
2209
|
+
{ part: "button", "data-action": "create-cancel", disabled: this.#busy, onclick: () => {
|
|
2210
|
+
this.#createOpen = false;
|
|
2211
|
+
this.rerender();
|
|
2212
|
+
} },
|
|
2213
|
+
this.t("backups.cancel")
|
|
2214
|
+
)
|
|
2215
|
+
)
|
|
2216
|
+
)
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
// --- Backup copy (replica) -------------------------------------------------
|
|
2220
|
+
/** The transient setup UI under the active card: the destination choice or a
|
|
2221
|
+
* config form. The ATTACHED copy is not here - it rides inside the active
|
|
2222
|
+
* card (replicaAttachedLine). Idle renders nothing - the entry point lives
|
|
2223
|
+
* in the active card's menu. */
|
|
2224
|
+
replicaSetup() {
|
|
2225
|
+
const snap = this.#replicaSnap;
|
|
2226
|
+
if (!this.#replicaFlow || !snap || snap.replica) return [];
|
|
2227
|
+
if (snap.step === "choose") return [this.replicaPicker(snap)];
|
|
2228
|
+
if (snap.step === "form-webdav") return [this.replicaWebdavForm(snap)];
|
|
2229
|
+
if (snap.step === "form-s3") return [this.replicaS3Form(snap)];
|
|
2230
|
+
return [];
|
|
2231
|
+
}
|
|
2232
|
+
/** The attached copy is not a backup of its own: it renders as ONE line
|
|
2233
|
+
* INSIDE the active backup's card, under its pills (label, freshness, an
|
|
2234
|
+
* inline "remove") - never a card or a block of its own. Null when no copy
|
|
2235
|
+
* is attached. */
|
|
2236
|
+
replicaAttachedLine() {
|
|
2237
|
+
const snap = this.#replicaSnap;
|
|
2238
|
+
if (!this.#replicaFlow || !snap || !snap.replica) return null;
|
|
2239
|
+
const rep = snap.replica;
|
|
2240
|
+
const label = rep.label;
|
|
2241
|
+
const status = rep.lastError ? h("span", { part: "sub replica-error" }, this.t("backups.replica.error")) : rep.lastPublishAt ? h(
|
|
2242
|
+
"span",
|
|
2243
|
+
{ part: "sub replica-ok" },
|
|
2244
|
+
this.ts("backups.replica.uptodate", {
|
|
2245
|
+
when: new Date(rep.lastPublishAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
2246
|
+
})
|
|
2247
|
+
) : h("span", { part: "sub" }, this.t("backups.replica.pending"));
|
|
2248
|
+
return h(
|
|
2249
|
+
"div",
|
|
2250
|
+
{
|
|
2251
|
+
part: "sub replica-line",
|
|
2252
|
+
"data-replica": "",
|
|
2253
|
+
style: "display:flex;flex-wrap:wrap;align-items:baseline;gap:0.1rem 0.5rem;margin-top:0.35rem"
|
|
2254
|
+
},
|
|
2255
|
+
h("span", { part: "replica-label" }, this.ts("backups.replica.card", { label })),
|
|
2256
|
+
status,
|
|
2257
|
+
h(
|
|
2258
|
+
"button",
|
|
2259
|
+
{
|
|
2260
|
+
part: "link replica-remove",
|
|
2261
|
+
type: "button",
|
|
2262
|
+
"data-action": "replica-remove",
|
|
2263
|
+
onclick: () => this.guarded({ type: "replica-remove", label }, () => void this.#replicaFlow?.remove())
|
|
2264
|
+
},
|
|
2265
|
+
this.t("backups.replica.remove")
|
|
2266
|
+
)
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
replicaPicker(snap) {
|
|
2270
|
+
return h(
|
|
2271
|
+
"li",
|
|
2272
|
+
{ part: "card replica-picker", style: "display:block" },
|
|
2273
|
+
h("div", { part: "title" }, this.t("backups.replica.title")),
|
|
2274
|
+
h("div", { part: "sub", style: "margin:0.2rem 0 0.6rem" }, this.t("backups.replica.intro")),
|
|
2275
|
+
...snap.kinds.map(
|
|
2276
|
+
(kind) => h(
|
|
2277
|
+
"button",
|
|
2278
|
+
{
|
|
2279
|
+
part: "button replica-dest",
|
|
2280
|
+
style: "display:block;width:100%;text-align:left;margin-bottom:0.4rem",
|
|
2281
|
+
"data-kind": kind,
|
|
2282
|
+
disabled: snap.busy,
|
|
2283
|
+
onclick: () => this.#replicaFlow?.pick(kind)
|
|
2284
|
+
},
|
|
2285
|
+
this.t(`backups.replica.dest.${kind}`)
|
|
2286
|
+
)
|
|
2287
|
+
),
|
|
2288
|
+
snap.error ? h("div", { part: "error-note" }, this.t("error.generic")) : null,
|
|
2289
|
+
h(
|
|
2290
|
+
"button",
|
|
2291
|
+
{ part: "button", "data-action": "replica-cancel", disabled: snap.busy, onclick: () => this.#replicaFlow?.cancel() },
|
|
2292
|
+
this.t("backups.cancel")
|
|
2293
|
+
)
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
/** A config field: mutates state on input, no repaint (validated on submit). */
|
|
2297
|
+
replicaField(key, type, value, oninput) {
|
|
2298
|
+
return h("input", {
|
|
2299
|
+
part: "input",
|
|
2300
|
+
style: "display:block;width:100%;margin-bottom:0.4rem",
|
|
2301
|
+
type,
|
|
2302
|
+
placeholder: this.t(key),
|
|
2303
|
+
"aria-label": this.t(key),
|
|
2304
|
+
value,
|
|
2305
|
+
oninput: (e) => oninput(e.target.value)
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
replicaWebdavForm(snap) {
|
|
2309
|
+
const c = this.#repWebdav;
|
|
2310
|
+
const submit = () => {
|
|
2311
|
+
if (!c.url.trim()) return;
|
|
2312
|
+
this.#replicaFlow?.submitWebdav({ url: c.url.trim(), username: c.username.trim(), password: c.password });
|
|
2313
|
+
};
|
|
2314
|
+
return h(
|
|
2315
|
+
"li",
|
|
2316
|
+
{ part: "card replica-form", style: "display:block" },
|
|
2317
|
+
h("div", { part: "title", style: "margin-bottom:0.3rem" }, this.t("backups.replica.title")),
|
|
2318
|
+
h("div", { part: "sub", style: "margin-bottom:0.6rem" }, this.t("backups.replica.webdav.help")),
|
|
2319
|
+
this.replicaField("backups.replica.webdav.url", "url", c.url, (v) => c.url = v),
|
|
2320
|
+
this.replicaField("backups.replica.webdav.user", "text", c.username, (v) => c.username = v),
|
|
2321
|
+
this.replicaField("backups.replica.webdav.password", "password", c.password, (v) => c.password = v),
|
|
2322
|
+
snap.error ? h("div", { part: "error-note" }, this.t("error.generic")) : null,
|
|
2323
|
+
h(
|
|
2324
|
+
"div",
|
|
2325
|
+
{ part: "row", style: "margin-top:0.45rem" },
|
|
2326
|
+
h("button", { part: "button button-primary", "data-action": "replica-webdav", disabled: snap.busy, onclick: submit }, this.t("backups.replica.save")),
|
|
2327
|
+
h("button", { part: "button", disabled: snap.busy, onclick: () => this.#replicaFlow?.cancel() }, this.t("backups.cancel"))
|
|
2328
|
+
)
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
replicaS3Form(snap) {
|
|
2332
|
+
const c = this.#repS3;
|
|
2333
|
+
const submit = () => {
|
|
2334
|
+
const config = {
|
|
2335
|
+
endpoint: c.endpoint.trim(),
|
|
2336
|
+
region: c.region.trim(),
|
|
2337
|
+
bucket: c.bucket.trim(),
|
|
2338
|
+
key: c.key.trim(),
|
|
2339
|
+
accessKeyId: c.accessKeyId.trim(),
|
|
2340
|
+
secretAccessKey: c.secretAccessKey
|
|
2341
|
+
};
|
|
2342
|
+
if (!config.endpoint || !config.region || !config.bucket || !config.key) return;
|
|
2343
|
+
if (!config.accessKeyId || !config.secretAccessKey) return;
|
|
2344
|
+
this.#replicaFlow?.submitS3(config);
|
|
2345
|
+
};
|
|
2346
|
+
return h(
|
|
2347
|
+
"li",
|
|
2348
|
+
{ part: "card replica-form", style: "display:block" },
|
|
2349
|
+
h("div", { part: "title", style: "margin-bottom:0.5rem" }, this.t("backups.replica.title")),
|
|
2350
|
+
this.replicaField("backups.replica.s3.endpoint", "url", c.endpoint, (v) => c.endpoint = v),
|
|
2351
|
+
this.replicaField("backups.replica.s3.region", "text", c.region, (v) => c.region = v),
|
|
2352
|
+
this.replicaField("backups.replica.s3.bucket", "text", c.bucket, (v) => c.bucket = v),
|
|
2353
|
+
this.replicaField("backups.replica.s3.key", "text", c.key, (v) => c.key = v),
|
|
2354
|
+
this.replicaField("backups.replica.s3.accessKeyId", "text", c.accessKeyId, (v) => c.accessKeyId = v),
|
|
2355
|
+
this.replicaField("backups.replica.s3.secret", "password", c.secretAccessKey, (v) => c.secretAccessKey = v),
|
|
2356
|
+
snap.error ? h("div", { part: "error-note" }, this.t("error.generic")) : null,
|
|
2357
|
+
h(
|
|
2358
|
+
"div",
|
|
2359
|
+
{ part: "row", style: "margin-top:0.45rem" },
|
|
2360
|
+
h("button", { part: "button button-primary", "data-action": "replica-s3", disabled: snap.busy, onclick: submit }, this.t("backups.replica.save")),
|
|
2361
|
+
h("button", { part: "button", disabled: snap.busy, onclick: () => this.#replicaFlow?.cancel() }, this.t("backups.cancel"))
|
|
2362
|
+
)
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
// --- Menu -----------------------------------------------------------------
|
|
2366
|
+
menuButton(key) {
|
|
2367
|
+
return h(
|
|
2368
|
+
"button",
|
|
2369
|
+
{
|
|
2370
|
+
part: "button menu-button",
|
|
2371
|
+
"aria-label": this.t("backups.menu"),
|
|
2372
|
+
"aria-haspopup": "menu",
|
|
2373
|
+
"aria-expanded": this.#menuFor === key ? "true" : "false",
|
|
2374
|
+
"data-action": "menu",
|
|
2375
|
+
"data-menu-key": key,
|
|
2376
|
+
disabled: this.#busy,
|
|
2377
|
+
onclick: () => {
|
|
2378
|
+
this.#menuFor = this.#menuFor === key ? null : key;
|
|
2379
|
+
this.rerender();
|
|
2380
|
+
this.root.querySelector('[part~="menu"]')?.focus();
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
"\u22EF"
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
/** The open overflow menu: a backdrop closes it on any outside click or
|
|
2387
|
+
* tap, Escape closes it and hands focus back to its trigger. The panel is
|
|
2388
|
+
* a dropdown over the trigger's corner on wide screens and a bottom sheet
|
|
2389
|
+
* on phones - both styled in the base sheet. */
|
|
2390
|
+
menu(...items) {
|
|
2391
|
+
const key = this.#menuFor;
|
|
2392
|
+
const close = () => {
|
|
2393
|
+
this.#menuFor = null;
|
|
2394
|
+
this.rerender();
|
|
2395
|
+
this.root.querySelector(`[data-menu-key="${key}"]`)?.focus();
|
|
2396
|
+
};
|
|
2397
|
+
return h(
|
|
2398
|
+
"div",
|
|
2399
|
+
{ part: "menu-layer" },
|
|
2400
|
+
h("div", { part: "menu-backdrop", "data-action": "menu-close", onclick: close }),
|
|
2401
|
+
h(
|
|
2402
|
+
"div",
|
|
2403
|
+
{
|
|
2404
|
+
part: "menu",
|
|
2405
|
+
role: "menu",
|
|
2406
|
+
tabindex: "-1",
|
|
2407
|
+
onkeydown: (e) => {
|
|
2408
|
+
if (e.key === "Escape") close();
|
|
2409
|
+
}
|
|
2410
|
+
},
|
|
2411
|
+
...items.filter((i) => i !== null)
|
|
2412
|
+
)
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
menuItem(action, text, danger, run) {
|
|
2416
|
+
return h(
|
|
2417
|
+
"button",
|
|
2418
|
+
{
|
|
2419
|
+
part: danger ? "menu-item menu-item-danger" : "menu-item",
|
|
2420
|
+
type: "button",
|
|
2421
|
+
role: "menuitem",
|
|
2422
|
+
"data-action": action,
|
|
2423
|
+
onclick: () => {
|
|
2424
|
+
this.#menuFor = null;
|
|
2425
|
+
run();
|
|
2426
|
+
}
|
|
2427
|
+
},
|
|
2428
|
+
text
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
eye() {
|
|
2432
|
+
const btn = h("button", {
|
|
2433
|
+
part: "eye",
|
|
2434
|
+
type: "button",
|
|
2435
|
+
"aria-label": this.t(this.#pwShown ? "backups.pwHide" : "backups.pwShow"),
|
|
2436
|
+
onclick: () => {
|
|
2437
|
+
this.#pwShown = !this.#pwShown;
|
|
2438
|
+
this.rerender();
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
btn.innerHTML = this.#pwShown ? EYE_SHUT : EYE_OPEN;
|
|
2442
|
+
return btn;
|
|
2443
|
+
}
|
|
2444
|
+
// --- Gestures --------------------------------------------------------------
|
|
2445
|
+
nameTaken(label, exceptFileId) {
|
|
2446
|
+
const clean = label.trim().toLowerCase();
|
|
2447
|
+
if (!clean || this.#busy) return false;
|
|
2448
|
+
return (this.#rows ?? []).some(
|
|
2449
|
+
(r) => r.fileId !== exceptFileId && r.label !== null && r.label.trim().toLowerCase() === clean
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
/** Run a destructive action through the host's veto (default: proceed).
|
|
2453
|
+
* A throwing hook reads as "no": destruction needs a clear yes. */
|
|
2454
|
+
guarded(action, run) {
|
|
2455
|
+
const gate = this.#confirmAction;
|
|
2456
|
+
if (!gate) {
|
|
2457
|
+
run();
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
void Promise.resolve().then(() => gate(action)).then((ok) => {
|
|
2461
|
+
if (ok) run();
|
|
2462
|
+
}).catch(() => void 0);
|
|
2463
|
+
}
|
|
2464
|
+
/** Open a row programmatically - the same gesture as tapping it, password
|
|
2465
|
+
* card included. Lets a host chain "open this backup, then act on it"
|
|
2466
|
+
* (its own sub-views) without re-implementing the passphrase step. */
|
|
2467
|
+
async open(fileId, passphrase) {
|
|
2468
|
+
const manager = this.#manager;
|
|
2469
|
+
if (!manager || this.#busy) return;
|
|
2470
|
+
this.#busy = true;
|
|
2471
|
+
this.#openBusy = fileId;
|
|
2472
|
+
this.rerender();
|
|
2473
|
+
try {
|
|
2474
|
+
const r = await manager.openBackup(fileId, passphrase ?? null);
|
|
2475
|
+
if (r === "encrypted") {
|
|
2476
|
+
this.#pwFor = fileId;
|
|
2477
|
+
if (passphrase) this.emit("selfstore-backups-wrong-password", { fileId });
|
|
2478
|
+
} else if (r === "ok") {
|
|
2479
|
+
this.closePw();
|
|
2480
|
+
this.emit("selfstore-backups-opened", { fileId });
|
|
2481
|
+
void this.loadRows();
|
|
2482
|
+
} else {
|
|
2483
|
+
this.emit("selfstore-backups-error", { code: manager.snapshot.lastError ?? "failed" });
|
|
2484
|
+
void this.loadRows();
|
|
2485
|
+
}
|
|
2486
|
+
} finally {
|
|
2487
|
+
this.#busy = false;
|
|
2488
|
+
this.#openBusy = null;
|
|
2489
|
+
this.rerender();
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
async create() {
|
|
2493
|
+
const manager = this.#manager;
|
|
2494
|
+
const label = this.#createLabel.trim();
|
|
2495
|
+
if (!manager || !label || this.#busy) return;
|
|
2496
|
+
this.#busy = true;
|
|
2497
|
+
this.rerender();
|
|
2498
|
+
try {
|
|
2499
|
+
const r = await manager.createNamed(label);
|
|
2500
|
+
if (r === "ok") {
|
|
2501
|
+
this.#createOpen = false;
|
|
2502
|
+
this.#createLabel = "";
|
|
2503
|
+
this.emit("selfstore-backups-created", { label });
|
|
2504
|
+
void this.loadRows();
|
|
2505
|
+
} else {
|
|
2506
|
+
this.emit("selfstore-backups-error", { code: manager.snapshot.lastError ?? "failed" });
|
|
2507
|
+
}
|
|
2508
|
+
} finally {
|
|
2509
|
+
this.#busy = false;
|
|
2510
|
+
this.rerender();
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
async rename(row) {
|
|
2514
|
+
const manager = this.#manager;
|
|
2515
|
+
const label = this.#renameLabel.trim();
|
|
2516
|
+
if (!manager || !label || this.#busy) return;
|
|
2517
|
+
this.#busy = true;
|
|
2518
|
+
this.rerender();
|
|
2519
|
+
try {
|
|
2520
|
+
const r = await manager.renameBackup(row.fileId, label);
|
|
2521
|
+
if (r === "ok") {
|
|
2522
|
+
this.#renameFor = null;
|
|
2523
|
+
this.emit("selfstore-backups-renamed", { fileId: row.fileId, label });
|
|
2524
|
+
void this.loadRows();
|
|
2525
|
+
} else {
|
|
2526
|
+
this.emit("selfstore-backups-error", { code: manager.snapshot.lastError ?? "failed" });
|
|
2527
|
+
}
|
|
2528
|
+
} finally {
|
|
2529
|
+
this.#busy = false;
|
|
2530
|
+
this.rerender();
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
async deleteRow(fileId) {
|
|
2534
|
+
const manager = this.#manager;
|
|
2535
|
+
if (!manager || this.#busy) return;
|
|
2536
|
+
this.#busy = true;
|
|
2537
|
+
this.rerender();
|
|
2538
|
+
try {
|
|
2539
|
+
if (await manager.deleteBackup(fileId)) {
|
|
2540
|
+
this.#rows = (this.#rows ?? []).filter((r) => r.fileId !== fileId);
|
|
2541
|
+
this.emit("selfstore-backups-deleted", { fileId });
|
|
2542
|
+
} else {
|
|
2543
|
+
this.emit("selfstore-backups-error", { code: manager.snapshot.lastError ?? "failed" });
|
|
2544
|
+
}
|
|
2545
|
+
} finally {
|
|
2546
|
+
this.#busy = false;
|
|
2547
|
+
this.rerender();
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
async forget(fileId) {
|
|
2551
|
+
const manager = this.#manager;
|
|
2552
|
+
if (!manager) return;
|
|
2553
|
+
await manager.forgetShared(fileId);
|
|
2554
|
+
this.emit("selfstore-backups-forgotten", { fileId });
|
|
2555
|
+
this.rerender();
|
|
2556
|
+
}
|
|
2557
|
+
closePw() {
|
|
2558
|
+
this.#pwFor = null;
|
|
2559
|
+
this.#pw = "";
|
|
2560
|
+
this.#pwShown = false;
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
var pillStyle = "display:inline-block;font-size:0.72em;font-weight:600;line-height:1.6;padding:0 0.55em;border-radius:999px;border:1px solid var(--_border)";
|
|
2564
|
+
|
|
2565
|
+
// src/entries/widgets.ts
|
|
2566
|
+
function defineSelfstoreWidgets(prefix = "selfstore") {
|
|
2567
|
+
const define = (name, ctor) => {
|
|
2568
|
+
if (!customElements.get(name)) customElements.define(name, ctor);
|
|
2569
|
+
};
|
|
2570
|
+
define(`${prefix}-connect`, SelfstoreConnectElement);
|
|
2571
|
+
define(`${prefix}-share`, SelfstoreShareElement);
|
|
2572
|
+
define(`${prefix}-join`, SelfstoreJoinElement);
|
|
2573
|
+
define(`${prefix}-status`, SelfstoreStatusElement);
|
|
2574
|
+
define(`${prefix}-backups`, SelfstoreBackupsElement);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
export { FlowWidget, SelfstoreBackupsElement, SelfstoreConnectElement, SelfstoreJoinElement, SelfstoreShareElement, SelfstoreStatusElement, defineSelfstoreWidgets };
|
|
2578
|
+
//# sourceMappingURL=widgets.js.map
|
|
2579
|
+
//# sourceMappingURL=widgets.js.map
|